socle

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