socle

watch.go

package main

// Config file watching: a 5s stat poll as the reliable backstop,
// plus inotify on the config file's directory for snappy pickup.
//
// The directory (not the file) is watched because editors and sync tools
// typically replace files via write-to-temp + rename,
// which orphans a watch placed on the file itself.
//
// Both mechanisms funnel into one stat gate (mtime+size comparison),
// so onChange fires only when the file content plausibly changed,
// regardless of which mechanism noticed first.
// inotify is strictly best-effort: any setup or read failure only logs,
// and the poll keeps the watcher correct on its own.
//
// This file assumes Linux (inotify via the stdlib syscall package;
// chosen over x/sys/unix and fsnotify because it adds zero dependencies
// and the three calls we need are stable there).
//
// This is an adapted copy of vitrine's watch.go (log prefixes aside, identical);
// a fix found in either copy is worth carrying to the other.

import (
	"bytes"
	"encoding/binary"
	"log"
	"os"
	"path/filepath"
	"syscall"
	"time"
)

// watchConfig watches the config file at path and calls onChange
// (from a single goroutine, serially) each time the file changes.
// It captures the file's current signature BEFORE returning
// and only then spawns its goroutines:
// a caller thus knows that any config write after watchConfig returns will be detected
// (no happens-after race between baseline capture and the first edit).
// onChange is responsible for loading and applying the new config,
// including deciding what to do when the new config is bad.
func watchConfig(path string, pollInterval time.Duration, onChange func()) {
	last := statSig(path)
	nudge := startInotify(path)
	go watchLoop(path, pollInterval, last, nudge, onChange)
}

// watchLoop is the poll loop and stat gate; nudge (which may be nil)
// carries best-effort hints that a check is worth doing right now.
func watchLoop(path string, pollInterval time.Duration, last fileSig, nudge <-chan struct{}, onChange func()) {
	for {
		select {
		case <-time.After(pollInterval):
		case <-nudge:
		}
		cur := statSig(path)
		// A zero sig means the file is currently unstattable
		// (most likely mid-rename-swap); hold the previous state
		// and a later check catches the settled file.
		if cur == (fileSig{}) || cur == last {
			continue
		}
		last = cur
		onChange()
	}
}

// fileSig is the change-detection signature of the config file.
// The inode number is included so a rename-swap is detected
// even when the replacement file has identical size and an mtime
// within the same coarse-clock granule.
// The zero value doubles as "file not stattable".
type fileSig struct {
	mtimeNS int64
	size    int64
	ino     uint64
}

func statSig(path string) fileSig {
	info, err := os.Stat(path)
	if err != nil {
		return fileSig{}
	}
	sig := fileSig{mtimeNS: info.ModTime().UnixNano(), size: info.Size()}
	if st, ok := info.Sys().(*syscall.Stat_t); ok { // always a Stat_t on Linux
		sig.ino = st.Ino
	}
	return sig
}

// startInotify watches the directory containing cfgPath
// and returns a channel that gets a (coalesced) nudge
// whenever an event names the config file.
// Returns nil if inotify can't be set up (e.g. watch limit exhaustion);
// that only costs latency, never correctness, so it logs and moves on.
func startInotify(cfgPath string) <-chan struct{} {
	dir := filepath.Dir(cfgPath)
	base := filepath.Base(cfgPath)

	fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC)
	if err != nil {
		log.Printf("socle: config watch: inotify unavailable, poll only: %v", err)
		return nil
	}
	// CLOSE_WRITE catches in-place edits; MOVED_TO catches rename-swaps;
	// CREATE catches tools that link or O_TMPFILE the new file into place.
	const mask = syscall.IN_CLOSE_WRITE | syscall.IN_MOVED_TO | syscall.IN_CREATE
	if _, err := syscall.InotifyAddWatch(fd, dir, mask); err != nil {
		syscall.Close(fd)
		log.Printf("socle: config watch: inotify watch on %s failed, poll only: %v", dir, err)
		return nil
	}

	nudge := make(chan struct{}, 1)
	go func() {
		defer syscall.Close(fd)
		buf := make([]byte, 4096)
		for {
			n, err := syscall.Read(fd, buf)
			if err == syscall.EINTR {
				continue
			}
			if err != nil || n < syscall.SizeofInotifyEvent {
				log.Printf("socle: config watch: inotify read failed, poll only from here on: %v", err)
				return
			}
			if inotifyEventsMention(buf[:n], base) {
				select {
				case nudge <- struct{}{}:
				default: // a nudge is already pending; one check covers both
				}
			}
		}
	}()
	return nudge
}

// inotifyEventsMention reports whether the raw event buffer contains
// an event naming the given file, or a queue overflow
// (after an overflow anything may have happened, so it counts as a mention).
func inotifyEventsMention(buf []byte, base string) bool {
	for off := 0; off+syscall.SizeofInotifyEvent <= len(buf); {
		// struct inotify_event layout: wd(4) mask(4) cookie(4) len(4) name(len).
		mask := binary.NativeEndian.Uint32(buf[off+4:])
		nameLen := int(binary.NativeEndian.Uint32(buf[off+12:]))
		name := string(bytes.TrimRight(buf[off+syscall.SizeofInotifyEvent:off+syscall.SizeofInotifyEvent+nameLen], "\x00"))
		if mask&syscall.IN_Q_OVERFLOW != 0 || name == base {
			return true
		}
		off += syscall.SizeofInotifyEvent + nameLen
	}
	return false
}

watch.go · 5.2 KiB · f748a0a942