vitrine

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)
+		}
+	}
+}