config hot-reload: 5s stat poll plus best-effort inotify
commit ac8aaa4b709a55b31d4521b4e9a70aea0a1a8388 · parent 0cee357d4a
· browse this tree
Ximianth, 2026-07-28 15:43:05 UTC
Config changes now apply without a restart. watch.go gates both mechanisms through one stat signature (mtime+size+inode; inode catches rename-swaps inside one mtime granule). inotify watches the config's *directory*, because editors and sync tools replace files via write-temp+rename, which orphans a file-level watch; its failure only logs, the poll alone stays correct (Linux-only, stdlib syscall, zero new dependencies). Runtime reload is fail-stable: a config that fails to parse or an exhibit dir that fails to open logs a REJECTED line and the previous exhibits keep serving; startup stays fail-loud. Accepted reloads log one line with the exhibit count. watchConfig captures the baseline signature before returning, so tests (and any caller) get a happens-after guarantee: writes after watchConfig returns are always detected. Tests gate on observed server behavior (or a reload-attempt counter for the rejected case), never on wall-clock waits; each was verified to fail under targeted sabotage of the code under test.
diff --git a/main.go b/main.go index 8356003..a8ad6d4 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "flag" "log" "net/http" + "time" ) func main() { @@ -31,6 +32,31 @@ func main() { if err != nil { log.Fatal(err) } + + // Config changes apply live: reloads are fail-stable + // (a bad config logs loudly and the old exhibits keep serving), + // in contrast to the fail-loud startup above. + watchConfig(*configPath, 5*time.Second, func() { + reloadConfig(srv, *configPath) + }) + log.Printf("vitrine listening on %s", *addr) log.Fatal(http.ListenAndServe(*addr, srv)) } + +// reloadConfig loads the config file and swaps the server's exhibits to it. +// Any failure (parse, validation, unopenable exhibit dir) logs loudly +// and leaves the previously served exhibits untouched: +// a fleet of vitrines must not go dark from one typo mid-edit. +func reloadConfig(srv *server, path string) { + cfg, err := loadConfig(path) + if err != nil { + log.Printf("config reload REJECTED, keeping previous exhibits: %v", err) + return + } + if err := srv.setExhibits(cfg.Exhibits); err != nil { + log.Printf("config reload REJECTED, keeping previous exhibits: %v", err) + return + } + log.Printf("config reloaded: %d exhibits", len(cfg.Exhibits)) +} diff --git a/watch.go b/watch.go new file mode 100644 index 0000000..0c05b94 --- /dev/null +++ b/watch.go @@ -0,0 +1,149 @@ +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 +} diff --git a/watch_test.go b/watch_test.go new file mode 100644 index 0000000..5e6740b --- /dev/null +++ b/watch_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "net/http" + "os" + "os/exec" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +// watchFixture is a server plus a watched config file in a temp dir, +// wired the same way main() wires them (watchConfig -> reloadConfig), +// with a fast poll and a reload-attempt counter for happens-after gating. +type watchFixture struct { + srv *server + dir string // temp dir holding the config and exhibit trees + cfgPath string + checked atomic.Int64 // completed reload attempts (accepted or rejected) +} + +// newWatchFixture creates exhibit dir "one" (containing f.md) and "two" (containing g.md), +// writes an initial config mounting "one", starts the server and watcher, and returns the fixture. +func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture { + t.Helper() + dir := t.TempDir() + for _, f := range []string{"one/f.md", "two/g.md"} { + p := filepath.Join(dir, filepath.FromSlash(f)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("# hi\n"), 0o644); err != nil { + t.Fatal(err) + } + } + fx := &watchFixture{dir: dir, cfgPath: filepath.Join(dir, "vitrine.cfg")} + fx.writeConfig(t, "exhibits:\n one:\n path: one\n") + cfg, err := loadConfig(fx.cfgPath) + if err != nil { + t.Fatal(err) + } + fx.srv, err = newServer(cfg.Exhibits) + if err != nil { + t.Fatal(err) + } + watchConfig(fx.cfgPath, pollInterval, func() { + reloadConfig(fx.srv, fx.cfgPath) + fx.checked.Add(1) + }) + return fx +} + +func (fx *watchFixture) writeConfig(t *testing.T, text string) { + t.Helper() + if err := os.WriteFile(fx.cfgPath, []byte(text), 0o644); err != nil { + t.Fatal(err) + } +} + +func (fx *watchFixture) status(t *testing.T, url string) int { + t.Helper() + return get(t, fx.srv, url).Code +} + +// waitFor polls cond until it holds. +// The timeout is damage control only; a passing test never leans on it. +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for: %s", what) + } + time.Sleep(time.Millisecond) + } +} + +func TestHotSwapServesNewMountsAndDropsOld(t *testing.T) { + fx := newWatchFixture(t, 2*time.Millisecond) + if fx.status(t, "/one/f.md") != http.StatusOK { + t.Fatal("initial mount not serving") + } + fx.writeConfig(t, "exhibits:\n two:\n path: two\n# swapped\n") + waitFor(t, "new mount to serve", func() bool { + return fx.status(t, "/two/g.md") == http.StatusOK + }) + if code := fx.status(t, "/one/f.md"); code != http.StatusNotFound { + t.Errorf("old mount still serving after swap: status = %d", code) + } +} + +func TestRejectedReloadKeepsOldConfigServing(t *testing.T) { + // Two flavors of bad: YAML that doesn't parse, + // and a clean parse whose exhibit dir fails to open (the setExhibits path). + for name, bad := range map[string]string{ + "parse-error": "exhibits: [unclosed\n", + "unopenable-dir": "exhibits:\n ghost:\n path: no/such/dir\n", + } { + t.Run(name, func(t *testing.T) { + fx := newWatchFixture(t, 2*time.Millisecond) + before := fx.checked.Load() + fx.writeConfig(t, bad) + // Happens-after gate: wait for a reload attempt to complete, + // then assert it changed nothing. + waitFor(t, "a reload attempt on the bad config", func() bool { + return fx.checked.Load() > before + }) + if code := fx.status(t, "/one/f.md"); code != http.StatusOK { + t.Errorf("old config stopped serving after rejected reload: status = %d", code) + } + if code := fx.status(t, "/ghost/"); code != http.StatusNotFound { + t.Errorf("rejected config partially applied: /ghost/ status = %d", code) + } + }) + } +} + +func TestRenameSwapPickedUpByInotify(t *testing.T) { + // The editor/sync-tool pattern: write a temp file, rename it over the config. + // The poll interval is set absurdly long so that ONLY inotify can pick this up: + // this test deliberately exercises the directory watch + // (a file-level watch would be orphaned by the rename). + fx := newWatchFixture(t, time.Hour) + tmp := fx.cfgPath + ".tmp" + if err := os.WriteFile(tmp, []byte("exhibits:\n two:\n path: two\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, fx.cfgPath); err != nil { + t.Fatal(err) + } + waitFor(t, "rename-swapped config to apply", func() bool { + return fx.status(t, "/two/g.md") == http.StatusOK + }) + if code := fx.status(t, "/one/f.md"); code != http.StatusNotFound { + t.Errorf("old mount still serving after swap: status = %d", code) + } +} + +func TestReloadRunsUpdateServerInfoForExposeGit(t *testing.T) { + fx := newWatchFixture(t, 2*time.Millisecond) + // Turn exhibit "one" into a real git repo; it is currently mounted WITHOUT expose-git, + // so no update-server-info has run and .git/info/refs does not exist. + repo := filepath.Join(fx.dir, "one") + for _, args := range [][]string{ + {"init", "-q"}, + {"-c", "user.name=t", "-c", "user.email=t@t", "add", "."}, + {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + infoRefs := filepath.Join(repo, ".git/info/refs") + if _, err := os.Stat(infoRefs); err == nil { + t.Fatal("info/refs exists before expose-git reload; test premise broken") + } + fx.writeConfig(t, "exhibits:\n one:\n path: one\n expose-git: true\n") + waitFor(t, "update-server-info to run on reload", func() bool { + _, err := os.Stat(infoRefs) + return err == nil + }) + // And the exhibit actually serves the freshly generated refs. + waitFor(t, ".git/info/refs to be reachable over HTTP", func() bool { + return fx.status(t, "/one/.git/info/refs?fmt=raw") == http.StatusOK + }) +} + +func TestInotifyEventBufferParsing(t *testing.T) { + // Build a raw two-event buffer by hand and check name matching and overflow handling. + mkEvent := func(mask uint32, name string) []byte { + nameLen := (len(name)/16 + 1) * 16 // kernel pads names to alignment; emulate + buf := make([]byte, 16+nameLen) + putU32 := func(off int, v uint32) { + buf[off], buf[off+1], buf[off+2], buf[off+3] = byte(v), byte(v>>8), byte(v>>16), byte(v>>24) + } + putU32(4, mask) + putU32(12, uint32(nameLen)) + copy(buf[16:], name) + return buf + } + for name, tc := range map[string]struct { + buf []byte + want bool + }{ + "match": {mkEvent(0x80, "vitrine.cfg"), true}, + "other-file": {mkEvent(0x80, "unrelated.txt"), false}, + "second-event": {append(mkEvent(0x80, "unrelated.txt"), mkEvent(0x80, "vitrine.cfg")...), true}, + "overflow": {mkEvent(0x4000, ""), true}, + } { + if got := inotifyEventsMention(tc.buf, "vitrine.cfg"); got != tc.want { + t.Errorf("%s: got %v, want %v", name, got, tc.want) + } + } +}