Files
khosra/internal/ext/watch/watch_test.go
T
Claude Opus 5andbdeshi 9100ce4876 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.
2026-07-31 14:00:53 +06:00

104 lines
3.2 KiB
Go

package watch
import (
"os"
"path/filepath"
"testing"
"testing/fstest"
"time"
)
func TestFingerprintChangesOnlyForContent(t *testing.T) {
base := fstest.MapFS{
"content/posts/one.md": {Data: []byte("x"), ModTime: time.Unix(100, 0)},
"templates/page.html": {Data: []byte("y"), ModTime: time.Unix(100, 0)},
"site.yaml": {Data: []byte("base: x"), ModTime: time.Unix(100, 0)},
}
before := Fingerprint(base)
// The same tree twice is the same fingerprint, or every tick would look like a change.
if Fingerprint(base) != before {
t.Fatal("fingerprint is not stable for an unchanged tree")
}
for name, change := range map[string]fstest.MapFS{
"edited a bundle": {"content/posts/one.md": {Data: []byte("xx"), ModTime: time.Unix(200, 0)}},
"added a bundle": {"content/posts/two.md": {Data: []byte("z"), ModTime: time.Unix(100, 0)}},
"edited a template": {"templates/page.html": {Data: []byte("yy"), ModTime: time.Unix(200, 0)}},
"edited site.yaml": {"site.yaml": {Data: []byte("base: y"), ModTime: time.Unix(200, 0)}},
} {
next := fstest.MapFS{}
for k, v := range base {
next[k] = v
}
for k, v := range change {
next[k] = v
}
if Fingerprint(next) == before {
t.Errorf("%s went unnoticed", name)
}
}
// Editor droppings are not content: saving in vim writes several of these, and each would look like a change.
noise := fstest.MapFS{}
for k, v := range base {
noise[k] = v
}
for _, dropping := range []string{
"content/posts/.one.md.swp", "content/posts/one.md~", "content/posts/4913",
"content/posts/one.md.tmp", "content/.DS_Store",
} {
noise[dropping] = &fstest.MapFile{Data: []byte("noise"), ModTime: time.Unix(300, 0)}
}
if Fingerprint(noise) != before {
t.Error("editor droppings changed the fingerprint")
}
}
func TestWatchFiresOnceAfterAChangeSettles(t *testing.T) {
// A real directory, because the point is the timing of repeated writes, which a static MapFS cannot show.
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "content"), 0o755); err != nil {
t.Fatal(err)
}
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(dir, "content", name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("one.md", "first")
// Poll faster than a person could type, so the test measures the settle behaviour rather than the clock.
Interval, Settle = 20*time.Millisecond, 40*time.Millisecond
defer func() { Interval, Settle = 2*time.Second, time.Second }()
fsys := os.DirFS(dir)
stop := make(chan struct{})
defer close(stop)
changes := make(chan struct{}, 8)
go Watch(fsys, stop, func() { changes <- struct{}{} })
// Nothing has changed, so nothing should fire.
select {
case <-changes:
t.Fatal("fired without a change")
case <-time.After(Interval + Settle):
}
// Several writes in quick succession are one change, not three: that is what the settle window is for.
write("one.md", "second")
write("two.md", "third")
write("one.md", "fourth")
select {
case <-changes:
case <-time.After(4 * (Interval + Settle)):
t.Fatal("a change never arrived")
}
// And it does not keep firing once things are still.
select {
case <-changes:
t.Error("fired twice for one settled change")
case <-time.After(2 * Interval):
}
}