Files
khosra/internal/web/bench_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

92 lines
2.2 KiB
Go

package web
import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"github.com/yuin/goldmark"
"khosra/internal/content"
"khosra/internal/ext/shortcodes"
"khosra/internal/render"
)
// photo is a real encoded JPEG, so a benchmark measures decoding rather than a stub.
func photo(b *testing.B, width int) []byte {
b.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, width*2/3))
for x := range width {
for y := range width * 2 / 3 {
img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 90, 255})
}
}
var out bytes.Buffer
if err := jpeg.Encode(&out, img, nil); err != nil {
b.Fatal(err)
}
return out.Bytes()
}
func benchHandler(b *testing.B, pictures int) http.Handler {
b.Helper()
fsys := fstest.MapFS{
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\ndate: 2026-01-01\n---\nJust prose, several words of it.\n")},
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\ndate: 2026-01-02\n---\n{{< gallery >}}\n")},
}
shot := photo(b, 1600)
for i := range pictures {
fsys[fmt.Sprintf("content/art/set/%02d.jpg", i)] = &fstest.MapFile{Data: shot}
}
bundles, err := content.Scan(fsys)
if err != nil {
b.Fatal(err)
}
r, err := render.New(fsys, content.Settings{}, func(p render.Partial) []goldmark.Extender {
return []goldmark.Extender{shortcodes.New(p)}
})
if err != nil {
b.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func serveOnce(b *testing.B, h http.Handler, path string) {
b.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
b.Fatalf("GET %s = %d", path, rec.Code)
}
}
func BenchmarkPlainPage(b *testing.B) {
h := benchHandler(b, 0)
for b.Loop() {
serveOnce(b, h, "/posts/plain/")
}
}
func BenchmarkGalleryPage(b *testing.B) {
for _, n := range []int{1, 6, 12} {
b.Run(fmt.Sprintf("pictures=%d", n), func(b *testing.B) {
h := benchHandler(b, n)
for b.Loop() {
serveOnce(b, h, "/art/set/")
}
})
}
}
func BenchmarkListing(b *testing.B) {
h := benchHandler(b, 0)
for b.Loop() {
serveOnce(b, h, "/posts/")
}
}