notice content changes and rebuild without a restart

Polling lives in internal/ext/watch, per the human's call to keep core under its
ceiling rather than raise it a second time — which is what ADR-0041 said a second
raise would mean. It is a poller, deletable without trace, and core stayed at
2671/2800.

A settled change calls the same `rebuilder` that startup calls, because a reload
path that differs from the startup path is a reload path that drifts. The index is
an atomic.Pointer swapped whole, so a request reads the site that was current when
it arrived instead of one being rebuilt underneath it — the alternative, mutating in
place, is a data race with every in-flight request.

Names, sizes and modification times, not contents: reading every file to detect a
change costs more than the rebuild it triggers. Editor droppings are excluded,
because saving in vim writes a swap file, a backup and the number 4913, and each
would otherwise look like a change. A change must hold still for a moment first,
since one save is often several operations.

Verified against the running binary: a page 404s, the file appears, and five seconds
later it serves — one "site root changed" in the log. Then three droppings written
at once produced no rebuild at all.

Two warnings fired and were fixed rather than silenced: `runServe` gave up the
rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to
`record`, where three exclusions read as a list instead of as nesting.

The Dockerfile ships the binary alone. The site root arrives as a volume and is
never copied in — it is somebody's content repository with its own history
(ADR-0011), so the image is the same for every site.
This commit is contained in:
Claude Opus 5
2026-07-31 14:00:53 +06:00
committed by bdeshi
parent 669a94a26a
commit 9100ce4876
17 changed files with 381 additions and 38 deletions
+129
View File
@@ -0,0 +1,129 @@
package watch
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"hash"
"io/fs"
"log/slog"
"path"
"strings"
"time"
)
// Interval is how often the site root is looked at, and Settle is how long it must hold still afterwards.
//
// Polled rather than watched: notifications need a dependency and per-platform code, while a stat of a personal
// site's content costs almost nothing (ADR-0022). The settle window exists because saving a file is rarely one
// operation — an editor may write, rename and chmod — and rebuilding halfway through that reads a half-written
// bundle.
// Variables rather than constants for the same reason clock.go holds a variable: a test that has to wait out
// two-second polls is a test nobody runs. Nothing but a test assigns them.
var (
Interval = 2 * time.Second
Settle = time.Second
)
// Changed is called once per settled change, with the reason for the log.
type Changed func()
// Watch polls fsys until stop is closed, calling onChange after each settled change.
//
// A goroutine's worth of work, which `conventions.md` allows outside the render path: nothing here runs while a
// request is being served, and the only shared state is whatever onChange swaps.
func Watch(fsys fs.FS, stop <-chan struct{}, onChange Changed) {
previous := Fingerprint(fsys)
pending := ""
ticker := time.NewTicker(Interval)
defer ticker.Stop()
settling := time.NewTimer(Settle)
settling.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
current := Fingerprint(fsys)
switch {
case current == previous:
// Nothing moved. If something was pending, its settle timer is still running.
case current == pending:
// Still the same as last tick: the write has stopped, so let the timer finish.
default:
// Something changed, or changed again — restart the settle window.
pending = current
settling.Reset(Settle)
}
case <-settling.C:
if pending == "" || pending == previous {
continue
}
previous = pending
pending = ""
slog.Info("site root changed, rebuilding")
onChange()
}
}
}
// Fingerprint is one string standing for the current state of the content.
//
// Names, sizes and modification times — not contents: reading every file to detect a change would cost more
// than the rebuild it triggers. Editor droppings are excluded, or saving a file in vim would look like three
// changes and a deletion.
func Fingerprint(fsys fs.FS) string {
sum := sha256.New()
for _, root := range []string{"content", "templates"} {
_ = fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
return record(sum, p, d, err)
})
}
// site.yaml is part of the site's state, and editing it should not need a restart.
if info, err := fs.Stat(fsys, "site.yaml"); err == nil {
sum.Write([]byte("site.yaml"))
_ = binary.Write(sum, binary.LittleEndian, info.ModTime().UnixNano())
}
return hex.EncodeToString(sum.Sum(nil))
}
// record folds one entry into the running sum, skipping anything that is not content.
//
// Its own function so the walk stays flat: an error, a directory and a dropping are three early returns, which
// read as a list of exclusions rather than as nesting.
func record(sum hash.Hash, p string, d fs.DirEntry, err error) error {
switch {
case err != nil:
return nil
case d.IsDir() && dropping(d.Name()):
return fs.SkipDir
case d.IsDir(), dropping(d.Name()):
return nil
}
info, statErr := d.Info()
if statErr != nil {
return nil
}
sum.Write([]byte(p))
_ = binary.Write(sum, binary.LittleEndian, info.Size())
_ = binary.Write(sum, binary.LittleEndian, info.ModTime().UnixNano())
return nil
}
// dropping reports whether a name is an editor's leftovers rather than content.
//
// Every one of these is something a real editor writes beside the file it is saving: swap files, backups,
// atomic-write temporaries, and the number vim touches to test whether a directory is writable.
func dropping(name string) bool {
switch {
case strings.HasPrefix(name, "."), strings.HasSuffix(name, "~"):
return true
case name == "4913", name == "__pycache__":
return true
}
switch strings.ToLower(path.Ext(name)) {
case ".swp", ".swo", ".swx", ".tmp", ".bak", ".orig":
return true
}
return false
}