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
+12 -3
View File
@@ -12,13 +12,22 @@ import (
"khosra/internal/render"
)
// Current returns the site as it is right now.
//
// A function rather than a pointer, so a background poller can swap what it returns and a request still sees one
// coherent index instead of one being rebuilt underneath it (ADR-0022).
type Current func() *content.Site
// Fixed is a Current for a site that never changes, which is every caller that does not watch for changes.
func Fixed(site *content.Site) Current { return func() *content.Site { return site } }
// Handler serves a site.
//
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
func Handler(site *content.Site, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
func Handler(current Current, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
serve(w, req, site, r, siteFS, settings)
serve(w, req, current(), r, siteFS, settings)
})
// Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases: no
// bundle can own them, since a key always sits under a section.
@@ -26,7 +35,7 @@ func Handler(site *content.Site, r *render.Renderer, siteFS, derivedFS fs.FS, se
serveRobots(w, req, siteFS, settings.Base)
})
mux.HandleFunc("GET "+sitemapPath, func(w http.ResponseWriter, req *http.Request) {
serveSitemap(w, req, site, settings.Base)
serveSitemap(w, req, current(), settings.Base)
})
if siteFS != nil {
if sub, err := fs.Sub(siteFS, "static"); err == nil {