live config reload: edit the config, the repo set follows within ~5s
commit ef8eddc0cad13bd2f747cd2906918e1128e3c578 · parent d3c2758b67
· browse this tree
Ximianth, 2026-10-27 00:55:25 UTC
The watcher is an adapted copy of vitrine's: inotify on the config's directory for snappy pickup plus a 5-second stat poll as the reliable backstop, both funneling into one (mtime, size, inode) gate. Reloads are fail-stable where startup stays fail-closed: a config that does not parse, does not validate, or yields not one openable repo logs a loud REJECTED and the previous set keeps serving. Per-repo open failures warn and skip, exactly as at startup. Tests follow vitrine's watch-test standard: happens-after gating on a reload-attempt counter, no load-bearing sleeps. The in-flight test pins the snapshot contract end to end: a fetch RPC dispatched before a swap completes against the set it started with, sequenced by gating the swap on the request's first body read. Config rewrites in tests append a size-changing comment: an in-place same-size rewrite within one coarse-mtime granule is legitimately invisible to the stat gate, and tests must not lean on mtime granularity.
diff --git a/README.md b/README.md index c7ea40b..cedc064 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,13 @@ is a startup error: socle refuses to run rather than serve the wrong thing. A configured directory that's absent or not a git repository is only a warning and a skip, so one config can describe repos that only some hosts happen to have. +Config changes apply live: socle watches the config file +(inotify for immediate pickup, plus a 5-second stat poll as backstop, +so a change lands within ~5s worst case, usually instantly). +Edit freely; a typo mid-edit logs a rejection and leaves the running server untouched, +as does a reload in which not one configured repo can be opened. +Requests in flight during a reload finish against the repo set they started with. + Browsing -------- diff --git a/main.go b/main.go index bac54ec..bf0cd42 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "os" + "time" "vitrine/display" ) @@ -36,6 +37,38 @@ func main() { for _, w := range warnings { log.Print("socle: ", w) } + srv := newServer(repos, display.NewTheme(seed)) + + // Config changes apply live: reloads are fail-stable + // (any problem logs loudly and the old repo set keeps serving), + // while startup above stays fail-closed as ever. + watchConfig(*configPath, 5*time.Second, func() { + reloadConfig(srv, *configPath) + }) + log.Printf("socle: serving %d repos on %s", len(repos), *listen) - log.Fatal(http.ListenAndServe(*listen, newServer(repos, display.NewTheme(seed)))) + log.Fatal(http.ListenAndServe(*listen, srv)) +} + +// reloadConfig loads the config file and swaps the server's repo set to it. +// Fail-stable, unlike startup: a config that cannot be loaded (parse or validation +// failure) or that yields not one openable repo is rejected loudly and the previous +// set keeps serving -- a showcase must not go dark from one typo mid-edit. +// Per-repo open failures are the same warning-and-skip as at startup. +func reloadConfig(srv *server, path string) { + cfg, err := loadConfig(path) + if err != nil { + log.Printf("socle: config reload REJECTED, keeping previous repos: %v", err) + return + } + repos, warnings := openAll(cfg) + for _, w := range warnings { + log.Print("socle: ", w) + } + if len(repos) == 0 { + log.Printf("socle: config reload REJECTED, keeping previous repos: no configured repo could be opened") + return + } + srv.setRepos(repos) + log.Printf("socle: config reloaded: %d repos", len(repos)) } diff --git a/watch.go b/watch.go new file mode 100644 index 0000000..9fdc8ec --- /dev/null +++ b/watch.go @@ -0,0 +1,152 @@ +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 +} diff --git a/watch_test.go b/watch_test.go new file mode 100644 index 0000000..f5d1887 --- /dev/null +++ b/watch_test.go @@ -0,0 +1,265 @@ +package main + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "vitrine/display" +) + +// 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. +// Repo "one" is the standard hermetic fixture repo (so its shas are the fixtureSha* consts); +// repo "two" is a second trivial repo, initially not mounted. +type watchFixture struct { + srv *server + cfgPath string + repoOne string // worktree of the standard fixture repo + repoTwo string // worktree of the second repo + checked atomic.Int64 // completed reload attempts (accepted or rejected) +} + +func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture { + t.Helper() + fx := &watchFixture{ + repoOne: buildFixtureRepo(t), + repoTwo: t.TempDir(), + cfgPath: filepath.Join(t.TempDir(), "socle.cfg"), + } + gitFixture(t, fx.repoTwo, "init", "-q", "-b", "main", ".") + writeFixtureFile(t, fx.repoTwo, "README.md", "# Two\n") + gitFixture(t, fx.repoTwo, "add", "-A") + gitFixture(t, fx.repoTwo, "commit", "-q", "-m", "inaugurate repo two") + + fx.writeConfig(t, fx.configNaming("one")) + cfg, err := loadConfig(fx.cfgPath) + if err != nil { + t.Fatal(err) + } + repos, warnings := openAll(cfg) + if len(warnings) > 0 { + t.Fatalf("fixture repos should all open: %v", warnings) + } + fx.srv = newServer(repos, display.NewTheme("test")) + watchConfig(fx.cfgPath, pollInterval, func() { + reloadConfig(fx.srv, fx.cfgPath) + fx.checked.Add(1) + }) + return fx +} + +// configNaming renders a config body mounting the given fixture repos +// ("one" and/or "two") at those names, by absolute path. +// Rewrites of the config after fixture setup append a size-changing comment: +// the watcher's stat gate compares (mtime, size, inode), and an in-place rewrite +// of identical size within one coarse-mtime granule is legitimately invisible to it +// (tests must not lean on mtime granularity; that is timing, and timing flakes). +func (fx *watchFixture) configNaming(names ...string) string { + paths := map[string]string{"one": fx.repoOne, "two": fx.repoTwo} + var b strings.Builder + b.WriteString("repos:\n") + for _, n := range names { + fmt.Fprintf(&b, " %s:\n path: %s\n", n, paths[n]) + } + return b.String() +} + +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(target string) int { + return do(fx.srv, target, nil, nil).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 TestHotSwapServesNewReposAndDropsOld(t *testing.T) { + fx := newWatchFixture(t, 2*time.Millisecond) + if fx.status("/one/") != http.StatusOK { + t.Fatal("initial repo not serving") + } + fx.writeConfig(t, fx.configNaming("two")+"# swapped\n") + waitFor(t, "new repo to serve", func() bool { + return fx.status("/two/") == http.StatusOK + }) + if code := fx.status("/one/"); code != http.StatusNotFound { + t.Errorf("old repo still serving after swap: status = %d", code) + } +} + +func TestRejectedReloadKeepsOldReposServing(t *testing.T) { + // A malformed config (here: YAML that does not parse) is rejected wholesale, + // and the previously served repos keep serving untouched. + fx := newWatchFixture(t, 2*time.Millisecond) + before := fx.checked.Load() + fx.writeConfig(t, "repos: [unclosed\n") + // 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("/one/"); code != http.StatusOK { + t.Errorf("old config stopped serving after rejected reload: status = %d", code) + } +} + +func TestAllReposVanishedKeepsOldReposServing(t *testing.T) { + // A well-formed config whose every repo fails to open (here: a directory + // that is not a git repository) is rejected the same way a parse error is: + // per-repo skips are startup-compatible leniency, but an empty swap would + // take the whole showcase dark, so it is refused. + fx := newWatchFixture(t, 2*time.Millisecond) + before := fx.checked.Load() + fx.writeConfig(t, "repos:\n ghost:\n path: "+t.TempDir()+"\n") + waitFor(t, "a reload attempt on the all-vanished config", func() bool { + return fx.checked.Load() > before + }) + if code := fx.status("/one/"); code != http.StatusOK { + t.Errorf("old config stopped serving after all-vanished reload: 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(fx.configNaming("two")+"# swapped\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("/two/") == http.StatusOK + }) + if code := fx.status("/one/"); code != http.StatusNotFound { + t.Errorf("old repo still serving after swap: status = %d", code) + } +} + +// firstReadSignaler closes done when its Read is first called. +// In a request flow, the handler dispatches (and loads its repo-set snapshot) +// before anything reads the body, so the close is a happens-after signal +// that the in-flight request already holds its snapshot. +type firstReadSignaler struct { + r io.Reader + done chan struct{} + once atomic.Bool +} + +func (f *firstReadSignaler) Read(p []byte) (int, error) { + if f.once.CompareAndSwap(false, true) { + close(f.done) + } + return f.r.Read(p) +} + +func TestInFlightRequestKeepsItsSnapshotAcrossSwap(t *testing.T) { + // The swap is atomic from a request's point of view: a fetch RPC that has + // already dispatched keeps the repo set it started with, and completes + // normally even though a reload dropped its repo from the config mid-request. + fx := newWatchFixture(t, 2*time.Millisecond) + + // The body is declared gzipped so the handler reads from it (for the gzip header) + // after dispatch but before the fetch RPC starts: the first-read signal thus + // brackets the swap between this request's dispatch and its actual git work. + pr, pw := io.Pipe() + body := &firstReadSignaler{r: pr, done: make(chan struct{})} + req := httptest.NewRequest(http.MethodPost, "http://socle.example/one/git-upload-pack", body) + req.Header.Set("Content-Encoding", "gzip") + rec := httptest.NewRecorder() + served := make(chan struct{}) + go func() { + defer close(served) + fx.srv.ServeHTTP(rec, req) + }() + + <-body.done // the request is dispatched and holds its snapshot + fx.writeConfig(t, fx.configNaming("two")+"# swapped\n") + waitFor(t, "the swap to apply to fresh requests", func() bool { + return fx.status("/one/") == http.StatusNotFound + }) + + // Only now feed the fetch request: want the tip, no haves, done. + var fetch bytes.Buffer + pktline(&fetch, "want "+fixtureShaMain+"\n") + fetch.WriteString("0000") + pktline(&fetch, "done\n") + var zipped bytes.Buffer + zw := gzip.NewWriter(&zipped) + if _, err := zw.Write(fetch.Bytes()); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if _, err := pw.Write(zipped.Bytes()); err != nil { + t.Fatal(err) + } + pw.Close() + <-served + + if rec.Code != http.StatusOK { + t.Fatalf("in-flight fetch failed after swap: status = %d", rec.Code) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("PACK")) { + t.Error("in-flight fetch response carries no pack data") + } +} + +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, "socle.cfg"), true}, + "other-file": {mkEvent(0x80, "unrelated.txt"), false}, + "second-event": {append(mkEvent(0x80, "unrelated.txt"), mkEvent(0x80, "socle.cfg")...), true}, + "overflow": {mkEvent(0x4000, ""), true}, + } { + if got := inotifyEventsMention(tc.buf, "socle.cfg"); got != tc.want { + t.Errorf("%s: got %v, want %v", name, got, tc.want) + } + } +}