Render templates via a buffer, not the ResponseWriter (closes #123) #131

Merged
clawbot merged 1 commits from issue-123-render-buffer into next 2026-08-14 06:18:22 +02:00
Collaborator

Closes #123.

What changed

executeTemplate now runs tmpl.Execute into a bytes.Buffer. Only after it returns nil does it set Content-Type: text/html; charset=utf-8 and copy the buffer to the ResponseWriter. On error nothing has been written, so http.Error can still serve a 500.

New test TestRenderTemplateMidRenderErrorSendsNoPartialBody registers a template that emits a marker and then fails, and asserts both the 500 and that the body is exactly Internal server error\n. It reaches the render path through a new AddTemplateForTest hook in export_test.go (test-only file, not compiled into the binary), so it does not depend on any page template's contents.

Memory trade-off

Buffering holds the whole rendered page in memory for the life of one request. Accepted: these are small server-rendered admin pages (the largest golden capture below is 12 KB, most are 5 KB), there is no streaming or long-lived response among them, and buffering is the conventional choice precisely because a page that fits in memory buys the ability to still change the status code. If a genuinely large response is ever added, it should stream deliberately rather than go through this helper.

Coverage of render paths

Every HTML render in the codebase goes through renderTemplate into this single helper: tmpl.Execute appears exactly once outside tests, and no handler writes HTML to the ResponseWriter directly. So the fix covers all of them, with one residual: five call sites (renderLoginError and four form-validation branches) call w.WriteHeader with a 400 before rendering, so a failed render there still reports 400 rather than 500 — the body is unaffected and carries no partial page. Filed separately as #128 rather than fixed here, since it needs a status-carrying render signature.

Mutation evidence

With the pre-fix streaming executeTemplate restored and the new test unchanged, script/test fails on both assertions:

--- FAIL: TestRenderTemplateMidRenderErrorSendsNoPartialBody
    Error: Not equal: expected: 500 / actual: 200
    Messages: a failed render must report a 500
    Error: Not equal:
      expected: "Internal server error\n"
      actual  : "PARTIAL PAGE CONTENTInternal server error\n"

The test therefore pins the fix, not the framework.

Byte-identity of successful renders

Verified, not assumed. A scratch test (not committed) rendered ten cases covering all seven page templates — login.html with and without an error, profile.html, sources_list.html populated and empty, sources_new.html, source_edit.html for a finite and a retain-forever webhook, source_detail.html with an entrypoint and an event, and source_logs.html mid-pagination — dumping status, response headers and body for each. Captured on next before the change and on this branch after it: diff -r reports the two trees identical, all ten files byte for byte.

The Content-Type this now sets explicitly is the same value http.DetectContentType produced by sniffing before (recorded in the capture as a SNIFFED line, text/html; charset=utf-8 on every page), so the header is unchanged on the wire. On the five paths that call WriteHeader first, the explicit Set lands after net/http has snapshotted the header map and is inert, leaving sniffing to produce the same value as before.

Gate

  • script/check (test, lint, fmt-check) exits 0 on the rebased branch.
  • docker build --no-cache-filter=lint,builder exits 0, invalidating the two stages that run the checks rather than pruning shared cache. Log shows the work actually ran: [lint 8/8] RUN make lint in 93.7s reporting 0 issues, [lint 7/8] RUN make fmt-check, and [builder 8/10] RUN make test in 100.1s with --- PASS: TestRenderTemplateMidRenderErrorSendsNoPartialBody and ok sneak.berlin/go/webhooker/internal/handlers 6.248s — no (cached). The twelve CACHED layers in the log are all base-image and runtime-stage layers.

Rebased onto next at d19e336; both gates above were re-run after the rebase.

TODO.md deliberately untouched, per #112.

Closes https://git.eeqj.de/sneak/webhooker/issues/123. ## What changed `executeTemplate` now runs `tmpl.Execute` into a `bytes.Buffer`. Only after it returns nil does it set `Content-Type: text/html; charset=utf-8` and copy the buffer to the `ResponseWriter`. On error nothing has been written, so `http.Error` can still serve a 500. New test `TestRenderTemplateMidRenderErrorSendsNoPartialBody` registers a template that emits a marker and then fails, and asserts both the 500 and that the body is exactly `Internal server error\n`. It reaches the render path through a new `AddTemplateForTest` hook in `export_test.go` (test-only file, not compiled into the binary), so it does not depend on any page template's contents. ## Memory trade-off Buffering holds the whole rendered page in memory for the life of one request. Accepted: these are small server-rendered admin pages (the largest golden capture below is 12 KB, most are 5 KB), there is no streaming or long-lived response among them, and buffering is the conventional choice precisely because a page that fits in memory buys the ability to still change the status code. If a genuinely large response is ever added, it should stream deliberately rather than go through this helper. ## Coverage of render paths Every HTML render in the codebase goes through `renderTemplate` into this single helper: `tmpl.Execute` appears exactly once outside tests, and no handler writes HTML to the `ResponseWriter` directly. So the fix covers all of them, with one residual: five call sites (`renderLoginError` and four form-validation branches) call `w.WriteHeader` with a 400 before rendering, so a failed render there still reports 400 rather than 500 — the body is unaffected and carries no partial page. Filed separately as https://git.eeqj.de/sneak/webhooker/issues/128 rather than fixed here, since it needs a status-carrying render signature. ## Mutation evidence With the pre-fix streaming `executeTemplate` restored and the new test unchanged, `script/test` fails on both assertions: ``` --- FAIL: TestRenderTemplateMidRenderErrorSendsNoPartialBody Error: Not equal: expected: 500 / actual: 200 Messages: a failed render must report a 500 Error: Not equal: expected: "Internal server error\n" actual : "PARTIAL PAGE CONTENTInternal server error\n" ``` The test therefore pins the fix, not the framework. ## Byte-identity of successful renders Verified, not assumed. A scratch test (not committed) rendered ten cases covering all seven page templates — `login.html` with and without an error, `profile.html`, `sources_list.html` populated and empty, `sources_new.html`, `source_edit.html` for a finite and a retain-forever webhook, `source_detail.html` with an entrypoint and an event, and `source_logs.html` mid-pagination — dumping status, response headers and body for each. Captured on `next` before the change and on this branch after it: `diff -r` reports the two trees identical, all ten files byte for byte. The `Content-Type` this now sets explicitly is the same value `http.DetectContentType` produced by sniffing before (recorded in the capture as a `SNIFFED` line, `text/html; charset=utf-8` on every page), so the header is unchanged on the wire. On the five paths that call `WriteHeader` first, the explicit `Set` lands after net/http has snapshotted the header map and is inert, leaving sniffing to produce the same value as before. ## Gate - `script/check` (test, lint, fmt-check) exits 0 on the rebased branch. - `docker build --no-cache-filter=lint,builder` exits 0, invalidating the two stages that run the checks rather than pruning shared cache. Log shows the work actually ran: `[lint 8/8] RUN make lint` in 93.7s reporting `0 issues`, `[lint 7/8] RUN make fmt-check`, and `[builder 8/10] RUN make test` in 100.1s with `--- PASS: TestRenderTemplateMidRenderErrorSendsNoPartialBody` and `ok sneak.berlin/go/webhooker/internal/handlers 6.248s` — no `(cached)`. The twelve `CACHED` layers in the log are all base-image and runtime-stage layers. Rebased onto `next` at d19e336; both gates above were re-run after the rebase. `TODO.md` deliberately untouched, per https://git.eeqj.de/sneak/webhooker/issues/112.
clawbot added 1 commit 2026-08-12 11:46:34 +02:00
Render templates via a buffer, not the ResponseWriter (closes #123)
All checks were successful
check / check (push) Successful in 3m48s
4a91635b2a
executeTemplate ran the template straight into the ResponseWriter, so a
mid-render failure left the already-emitted prefix written and the
response committed: the handler could no longer set a 500 and the
client got a truncated page, typically with a 200. It also let handler
tests pass against the flushed prefix of a page that aborted below the
assertions.

Execute into a bytes.Buffer instead, and set the content type and copy
the buffer out only once rendering has fully succeeded. On failure
nothing has been written, so the 500 still reaches the client.

Add a test that renders a template failing partway through and asserts
both the 500 and that the body carries no part of the aborted page.
Against the previous streaming renderer it fails on both counts (200,
body "PARTIAL PAGE CONTENTInternal server error").
clawbot added the needs-review label 2026-08-12 11:46:36 +02:00
clawbot self-assigned this 2026-08-12 11:46:37 +02:00
Author
Collaborator

PASS.

Gate re-derived independently on 4a91635: docker build --no-cache-filter=lint,builder exit 0 — [lint 7/8] RUN make fmt-check DONE 5.6s, [lint 8/8] RUN make lint DONE 94.2s reporting 0 issues., [builder 8/10] RUN make test DONE 82.0s with --- PASS: TestRenderTemplateMidRenderErrorSendsNoPartialBody (3.08s); zero (cached) markers in the log and every CACHED layer is a base-image or runtime layer. Byte-identity re-derived at the wire through a real net/http server (not httptest.NewRecorder), origin/next vs this head: identical status, Content-Type: text/html; charset=utf-8, chunked encoding, absent Content-Length, body length and body content bar the per-request CSRF nonce. Mutation reproduced: with the streaming tmpl.Execute(w, ...) restored the new test fails on both the 500 and the PARTIAL PAGE CONTENT prefix, and no other test in the suite fails.

Three disclosures, none blocking:

  1. The definition of done in #123 says a mid-render error "produces a 500"; on the five paths tracked in #128 it still produces the pre-committed 4xx. I confirmed the body on those paths is clean — the buffer is discarded and only Internal server error\n is written — so they are strictly better than before, and deferring the status half is defensible since it needs a signature change across five handlers. Noting it because the literal DoD is met only in part, on the author's own scope call.

  2. The memory justification is measured against fixtures rather than the reachable bound. templates/source_logs.html:39 renders {{.Body}} untruncated, paginationPerPage is 25 (internal/handlers/handlers.go:31) and the ingest cap is 1 MB (internal/handlers/webhook.go:17), so /pages/sources/{id}/logs can buffer on the order of 25 MB of raw event bodies plus HTML-escaping expansion — not the 12 KB cited in the PR body. Bounded and paginated, so the trade still holds and I would not block on it, but the stated figure understates the worst case by about three orders of magnitude.

  3. Empty-render edge: a template producing zero bytes previously got no Content-Type header at all and now gets text/html; charset=utf-8 with a zero-length body. Unreachable in production — all seven page templates begin {{template "base" .}} and so emit <!DOCTYPE html> — and harmless.

Method note: I did not treat host script/lint as evidence (host linter version skew); the lint and fmt-check results above come from the container stages. script/test on the head tree exits 0 separately.

PASS. Gate re-derived independently on `4a91635`: `docker build --no-cache-filter=lint,builder` exit 0 — `[lint 7/8] RUN make fmt-check` DONE 5.6s, `[lint 8/8] RUN make lint` DONE 94.2s reporting `0 issues.`, `[builder 8/10] RUN make test` DONE 82.0s with `--- PASS: TestRenderTemplateMidRenderErrorSendsNoPartialBody (3.08s)`; zero `(cached)` markers in the log and every `CACHED` layer is a base-image or runtime layer. Byte-identity re-derived at the wire through a real `net/http` server (not `httptest.NewRecorder`), `origin/next` vs this head: identical status, `Content-Type: text/html; charset=utf-8`, chunked encoding, absent `Content-Length`, body length and body content bar the per-request CSRF nonce. Mutation reproduced: with the streaming `tmpl.Execute(w, ...)` restored the new test fails on both the 500 and the `PARTIAL PAGE CONTENT` prefix, and no other test in the suite fails. Three disclosures, none blocking: 1. The definition of done in https://git.eeqj.de/sneak/webhooker/issues/123 says a mid-render error "produces a 500"; on the five paths tracked in https://git.eeqj.de/sneak/webhooker/issues/128 it still produces the pre-committed 4xx. I confirmed the body on those paths is clean — the buffer is discarded and only `Internal server error\n` is written — so they are strictly better than before, and deferring the status half is defensible since it needs a signature change across five handlers. Noting it because the literal DoD is met only in part, on the author's own scope call. 2. The memory justification is measured against fixtures rather than the reachable bound. `templates/source_logs.html:39` renders `{{.Body}}` untruncated, `paginationPerPage` is 25 (`internal/handlers/handlers.go:31`) and the ingest cap is 1 MB (`internal/handlers/webhook.go:17`), so `/pages/sources/{id}/logs` can buffer on the order of 25 MB of raw event bodies plus HTML-escaping expansion — not the 12 KB cited in the PR body. Bounded and paginated, so the trade still holds and I would not block on it, but the stated figure understates the worst case by about three orders of magnitude. 3. Empty-render edge: a template producing zero bytes previously got no `Content-Type` header at all and now gets `text/html; charset=utf-8` with a zero-length body. Unreachable in production — all seven page templates begin `{{template "base" .}}` and so emit `<!DOCTYPE html>` — and harmless. Method note: I did not treat host `script/lint` as evidence (host linter version skew); the lint and fmt-check results above come from the container stages. `script/test` on the head tree exits 0 separately.
Author
Collaborator

PASS (second independent review, gates re-derived from a fresh clone at 4a91635) — host make check exit 0 (0 issues., all 9 packages ok with real durations, zero (cached)); docker build --no-cache-filter=lint --no-cache-filter=builder exit 0 with [lint 7/8] RUN make fmt-check 1.2s, [lint 8/8] RUN make lint 78.1s 0 issues., [builder 8/10] RUN make test 67.8s incl. --- PASS: TestRenderTemplateMidRenderErrorSendsNoPartialBody (2.83s), zero (cached) and every CACHED layer a go mod download/base layer; mutation reproduced (streaming tmpl.Execute(w, ...) restored, that test and only that test fails, 200 + PARTIAL PAGE CONTENTInternal server error); tmpl.Execute occurs exactly once outside tests so all 11 renderTemplate call sites are covered; no new WriteHeader-before-render sites (diff touches no handler file); no sync.Pool; merges cleanly into current next (branch is 9 commits behind but shares no changed file); CI green on head; single commit titled (closes #123), no attribution trailers.

Disclosure: the memory worsening this introduces on /pages/sources/{id}/logs is real and unbounded by this PR, but is already recorded in #135 so not re-raised here; the only residue is that the new comment at internal/handlers/handlers.go:232 ("These pages are small, so holding one in memory is the right trade") is not true of that page until #135 lands the 8 KB cap — not worth a rework cycle, worth correcting in passing if that PR touches this file.

PASS (second independent review, gates re-derived from a fresh clone at `4a91635`) — host `make check` exit 0 (`0 issues.`, all 9 packages `ok` with real durations, zero `(cached)`); `docker build --no-cache-filter=lint --no-cache-filter=builder` exit 0 with `[lint 7/8] RUN make fmt-check` 1.2s, `[lint 8/8] RUN make lint` 78.1s `0 issues.`, `[builder 8/10] RUN make test` 67.8s incl. `--- PASS: TestRenderTemplateMidRenderErrorSendsNoPartialBody (2.83s)`, zero `(cached)` and every `CACHED` layer a `go mod download`/base layer; mutation reproduced (streaming `tmpl.Execute(w, ...)` restored, that test and only that test fails, 200 + `PARTIAL PAGE CONTENTInternal server error`); `tmpl.Execute` occurs exactly once outside tests so all 11 `renderTemplate` call sites are covered; no new `WriteHeader`-before-render sites (diff touches no handler file); no `sync.Pool`; merges cleanly into current `next` (branch is 9 commits behind but shares no changed file); CI green on head; single commit titled ` (closes #123)`, no attribution trailers. Disclosure: the memory worsening this introduces on `/pages/sources/{id}/logs` is real and unbounded by this PR, but is already recorded in https://git.eeqj.de/sneak/webhooker/issues/135 so not re-raised here; the only residue is that the new comment at `internal/handlers/handlers.go:232` ("These pages are small, so holding one in memory is the right trade") is not true of that page until https://git.eeqj.de/sneak/webhooker/issues/135 lands the 8 KB cap — not worth a rework cycle, worth correcting in passing if that PR touches this file.
clawbot merged commit 0b457ea713 into next 2026-08-14 06:18:22 +02:00
clawbot deleted branch issue-123-render-buffer 2026-08-14 06:18:23 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#131