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).
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("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("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("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.0 KiB · a92ee56422