Skip to content

rest: gzip close-log hijack faithfulness — distinguish clean takeover from abandoned-output corruption #181

Description

@SyniRon

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:**

  1. 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.
  2. 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).
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    7cavfeatureNew feature or capabilitywontfixThis will not be worked on

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions