Carved out of #175 (maintainer ruling 2026-06-07: descope the speculative hijack-faithfulness machinery; ship the stale-Content-Length fix + bodyless-status quieting #134 needs, track the hijack distinction here for when a hijacking handler actually exists).
Trigger / scope. Entirely latent today — grep confirms no handler in the codebase calls Hijack() (every reference is a comment). A hijacking handler is WebSocket/SSE-upgrade territory, not on the current roadmap; #134's ServeContent/FileServer mounts do not hijack. This issue exists so the faithfulness work isn't rediscovered from scratch when one lands.
What #175 shipped instead (the coarse behavior to replace). The gzip middleware's deferred gz.Close() failure log ("response likely truncated") is suppressed on errors.Is(err, http.ErrHijacked) — a single coarse carve-out that treats every hijack as "not a truncation" and stays silent. It does NOT distinguish a clean pristine takeover from one that abandoned already-committed output. That's the gap below.
The spec for a proper fix — three rounds of adversarial review against #175**'s branch, all probe-reproduced (probes lived at /tmp/gzprobe), kept here as the behavioral contract:**
- hijack-after-write / hijack-after-flush (silent corruption). Handler writes/flushes through the gzip wrapper (compressed bytes buffered or partially on the wire), then hijacks and the takeover dies. Client gets
Content-Encoding: gzip with an undecodable / unterminated body (unexpected EOF); every handler call returned nil; coarse carve-out logs nothing. The close log is, per its own comment, "the only server-side signal of the real corruption class" — and it's silent here.
- commit-then-hijack (silent corruption — the subtle one). Handler commits
WriteHeader(200) (or behind a bodyless 304/HEAD), writes nothing, then hijacks. stdlib's Hijack() flushes the committed headers via its own cw.flush() (net/http server.go:2206) BEFORE handing over the conn — bypassing any wrapper byte-counter. So "no bytes through the wrapper" is FALSE as a proxy for "nothing on the wire": a fully-formed head-response went out, and the hijacker's bytes land in the next-response slot on the kept-alive connection — keep-alive response-queue smuggling, zero log. The bodyless/HEAD twins are the worst case (the flushed response is complete, so the takeover's bytes are unambiguously a smuggled second response).
- false-truncation log (trust erosion). Any verdict keyed on "handler attempted output" rather than "bytes reached the wire" mislabels intact wires: a stray write after a clean hijack, or a flush behind a bonafide bodyless status, can make the log claim "client received a truncated body" / "likely truncated" when the wire is provably intact (or, worse, smuggled — not truncated).
The discriminator the proper fix needs (identified in round 3): the close-time error CLASS plus genuine wire-truth — bytes net/http actually flushed to the conn, not bytes the wrapper accepted, and not the committed-status latch alone. The bodyless/HEAD skip arms in particular must consult hijack state (or let gz.Close() run and inspect errors.Is(err, http.ErrHijacked) vs http.ErrBodyNotAllowed) before deciding to stay quiet, rather than returning on a "nothing written" premise that Hijack()'s header flush violates.
Acceptance: a handler that hijacks after committing/writing/flushing produces a distinct, accurate server-side log naming the real shape (committed-flushed-no-stream / truncated-after-output / smuggled-after-bodyless); a clean pristine takeover stays quiet; no intact wire is ever labeled truncated. Pin each shape end-to-end with a real conn hijack (the round-1/2/3 probes are the template). Do this against a real hijacking handler if one exists by then — real wire shapes beat synthesized ones.
🤖 Generated with Claude Code
Carved out of #175 (maintainer ruling 2026-06-07: descope the speculative hijack-faithfulness machinery; ship the stale-Content-Length fix + bodyless-status quieting #134 needs, track the hijack distinction here for when a hijacking handler actually exists).
Trigger / scope. Entirely latent today — grep confirms no handler in the codebase calls
Hijack()(every reference is a comment). A hijacking handler is WebSocket/SSE-upgrade territory, not on the current roadmap; #134'sServeContent/FileServermounts do not hijack. This issue exists so the faithfulness work isn't rediscovered from scratch when one lands.What #175 shipped instead (the coarse behavior to replace). The gzip middleware's deferred
gz.Close()failure log ("response likely truncated") is suppressed onerrors.Is(err, http.ErrHijacked)— a single coarse carve-out that treats every hijack as "not a truncation" and stays silent. It does NOT distinguish a clean pristine takeover from one that abandoned already-committed output. That's the gap below.The spec for a proper fix — three rounds of adversarial review against #175**'s branch, all probe-reproduced (probes lived at /tmp/gzprobe), kept here as the behavioral contract:**
Content-Encoding: gzipwith an undecodable / unterminated body (unexpected EOF); every handler call returned nil; coarse carve-out logs nothing. The close log is, per its own comment, "the only server-side signal of the real corruption class" — and it's silent here.WriteHeader(200)(or behind a bodyless 304/HEAD), writes nothing, then hijacks. stdlib'sHijack()flushes the committed headers via its owncw.flush()(net/http server.go:2206) BEFORE handing over the conn — bypassing any wrapper byte-counter. So "no bytes through the wrapper" is FALSE as a proxy for "nothing on the wire": a fully-formed head-response went out, and the hijacker's bytes land in the next-response slot on the kept-alive connection — keep-alive response-queue smuggling, zero log. The bodyless/HEAD twins are the worst case (the flushed response is complete, so the takeover's bytes are unambiguously a smuggled second response).The discriminator the proper fix needs (identified in round 3): the close-time error CLASS plus genuine wire-truth — bytes net/http actually flushed to the conn, not bytes the wrapper accepted, and not the committed-status latch alone. The bodyless/HEAD skip arms in particular must consult hijack state (or let
gz.Close()run and inspecterrors.Is(err, http.ErrHijacked)vshttp.ErrBodyNotAllowed) before deciding to stay quiet, rather than returning on a "nothing written" premise thatHijack()'s header flush violates.Acceptance: a handler that hijacks after committing/writing/flushing produces a distinct, accurate server-side log naming the real shape (committed-flushed-no-stream / truncated-after-output / smuggled-after-bodyless); a clean pristine takeover stays quiet; no intact wire is ever labeled truncated. Pin each shape end-to-end with a real conn hijack (the round-1/2/3 probes are the template). Do this against a real hijacking handler if one exists by then — real wire shapes beat synthesized ones.
🤖 Generated with Claude Code