Skip to content

refactor(proxy/tui): deepen http_wire, slim handleConnection, guard held edits - #7

Open
vandot wants to merge 8 commits into
mainfrom
refactor/chunked-pump
Open

refactor(proxy/tui): deepen http_wire, slim handleConnection, guard held edits#7
vandot wants to merge 8 commits into
mainfrom
refactor/chunked-pump

Conversation

@vandot

@vandot vandot commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Architecture deepening pass: turn shallow/duplicated code into deep modules behind small interfaces, with the new interfaces as the test surface. Each commit is behavior-preserving (two exceptions noted below, both bug fixes). No zig build test step — suites run per file.

Commits

  1. pumpChunkedbufferChunkedBody/forwardChunkedBody were near-identical drives of the chunkedStep state machine. Collapsed into one deep http_wire.pumpChunked over an anytype src/sink, keeping the module socket-free. Adds drop-based truncation reporting.
  2. forwardHeaders — three hand-rolled header skip-lists (+ a duplicated, fragile Set-Cookie Domain rewrite) collapse into one http_wire.forwardHeaders(headers, sink, policy, domain). Cookie rewrite moves to writeCookieWithDomain, now tested.
  3. handleConnection decomposition (603 → 370 lines) — extracted connectUpstream (folds 7 duplicated connect-error exits into one), streamBody (collapses 4 non-chunked capture loops), forwardRequest (request emit, byte-for-byte tested), and readResponseHead.
  4. EditableHold — the TUI editor drove the held-entry edit protocol with a raw ring index and four loose calls, writing unconditionally. Now requests.editHeld(logical) ?EditableHold bundles index + phase; commits re-check under the lock that the entry is still held and no-op otherwise. No raw ring index leaves requests.zig.
  5. jsonfmt — three copies of the detect→parse→re-emit JSON core collapse into a pure, std-only module formatting into a caller-owned buffer.

Bug fixes surfaced by the new test surfaces

  • Headerless-response panic (readResponseHead): a response whose status line is immediately followed by CRLFCRLF (e.g. a bare 204) produced an invalid header slice buf[n+2..n] that panicked the proxy thread. The original inline code had the same latent bug.
  • Body-pane clobber (jsonfmt): the detail view formatted both request and response bodies into one shared json_pretty_buf per frame, so a JSON request + JSON response made the REQUEST BODY pane render the response's bytes. Each pane now formats into its own buffer.

Behavior notes (non-bug, deliberate)

  • Header/request-emit write failures are now swallowed by the sinks (consistent with the existing response-direction behavior); the subsequent read detects a dead connection.
  • Truncated request body now aborts before any bytes reach upstream, instead of after a partial emit.
  • Connect-error path duration is now elapsed-time uniformly rather than 0 in a few branches.

Tests

All per-file suites green: jsonfmt 7, http_wire 46, proxy 113, requests 54, intercept 20, dns 16, har 67. zig build clean. New pure functions are tested through their interfaces with fake readers + capturing sinks — no sockets, no TLS, no vaxis.

Note: src/jsonfmt.zig is a new test target not yet listed in CLAUDE.md's test commands.

vandot added 8 commits June 12, 2026 07:11
bufferChunkedBody and forwardChunkedBody were near-identical drives of
the chunkedStep state machine, differing only in whether each read was
also written to the client. Collapse both into a single deep function in
http_wire, keeping the module pure: the transport enters via an anytype
src (read) and sink (write), so http_wire still has no sockets or TLS.

The sink receives the raw chunked framing verbatim while body[] gets the
decoded payload; proxy passes SslSink to forward, NullSink to buffer-only.

chunkedStep now reports a dropped payload byte via a truncated out-param,
which pumpChunked surfaces in PumpResult. The chunked response paths use
it to set resp_body_truncated precisely, replacing the imprecise
captured >= max_body_len heuristic and closing a capture-parity gap with
the content-length path.

Tested via slice source + capturing sink: raw forwarding, prefix+stream
split, truncation flag, and parse_error propagation.
…ders

Three header-forwarding loops (request->upstream, response->client, and
the buffered intercepted response) each re-derived their own skip-list
with startsWithIgnoreCase and, for two of them, a duplicated Set-Cookie
Domain rewrite. Drift between the three was invisible.

Collapse them into http_wire.forwardHeaders(headers, sink, policy, domain):
each call site passes a ForwardPolicy naming the header prefixes to drop
and whether to rewrite Set-Cookie Domain. The fragile cookie-domain
rewrite moves to http_wire.writeCookieWithDomain, now unit-tested
(leading-dot stripping, no-Domain passthrough, and the domain= in-value
case the first-semicolon scan is meant to guard against).

The module stays pure: transport enters through an anytype sink, so the
whole forwarding policy is exercised with a capturing sink and no socket.
proxy gains an UpstreamSink alongside SslSink; both swallow write errors,
making the request direction consistent with the response direction.
handleConnection inlined ~100 lines of upstream setup — DNS resolve / IP
parse, socket, connect, and the external TLS handshake — with seven
near-identical error-exit blocks, each repeating
"if (was_intercepted) finishEntry(502); sslSendError(502); return".

Move all of it into connectUpstream() !UpstreamConn: it logs the specific
cause, owns the resolved AddressList for its own lifetime, sets the
socket timeouts, and returns error.UpstreamUnavailable on any failure.
The caller maps that to a single 502 handler. handleConnection drops from
603 to 510 lines.

Behavior-preserving: every log line is kept and the 502 path is
unchanged; the only difference is error-path duration is now the elapsed
time uniformly rather than 0 in a few of the old blocks.
…Body

The non-chunked response body was captured by four near-identical loops:
content-length and read-until-close, each duplicated between the
response-intercept buffering path and the normal streaming path. They
differed only in whether each read was also relayed to the client.

Extract http_wire.streamBody(initial, src, sink, content_length, body):
it relays each read to the sink verbatim (SslSink to stream, NullSink to
buffer-only) while capturing up to body.len into the entry, reporting
captured + truncated. Both response paths now read as a clean pair —
pumpChunked for chunked, streamBody otherwise.

Truncation is drop-based (consistent with pumpChunked) rather than the
old declared-length heuristic; content-length still does not trim an
over-read, matching prior behavior. handleConnection drops to 439 lines.
Tested with a slice source + capturing sink covering content-length,
read-until-close, initial-slice, and truncation.
The request-emit block — request line, forwarded headers, external Host
rewrite, the proxy's X-* headers, recomputed Content-Length, and body —
was ~60 inline lines in handleConnection, each write a bare
`upstream.writeAll(...) catch return`.

Extract forwardRequest(sink, entry, ctx) over an anytype sink, the same
seam pumpChunked/streamBody/forwardHeaders use. proxy passes UpstreamSink;
tests pass a capturing sink and assert the exact bytes for local and
external routes, including that Content-Length is recomputed from the
captured body rather than echoed from the original headers.

The req_body_truncated 413 check moves ahead of the call, so a truncated
request now aborts before any bytes reach upstream instead of after a
partial emit. Request-emit write failures are swallowed by UpstreamSink
(consistent with the header/response directions); the response read that
follows still detects a dead upstream. handleConnection drops to 395 lines.
The upstream response head — read-until-CRLFCRLF, status-line parse, and
slicing out the header block and leftover body — was inline in
handleConnection. Move it to http_wire.readResponseHead(src, buf)
?ResponseHead over an anytype reader, the last socket-bound piece of the
response path. handleConnection drops to 370 lines (from 603).

This makes the whole upstream-response path testable end to end with a
fake reader: tests drive readResponseHead -> streamBody and
readResponseHead -> pumpChunked, asserting client output and entry
capture with no socket — the in-memory upstream adapter the per-phase
seams were built for.

Fix: a response whose status line is immediately followed by the
terminating CRLFCRLF (no headers, e.g. a bare 204) made
first_line_end == hdr_end, so the header slice was buf[n+2..n] — an
invalid range that panicked the proxy thread. The original inline code
had the same latent bug; readResponseHead now yields an empty header
block instead, with a regression test.
The TUI editor carried a raw ring backing index and drove the held-entry
edit protocol as four loose calls — logicalToBackingIndex, phaseOf,
snapshotByBackingIndex, then applyRequestEdit/applyResponseEdit — with
nothing checking the entry was still held when the write landed.
applyRequestEdit wrote entries_backing[idx] unconditionally, and the
response editor took a second lock via statusOf to read its fallback.

Introduce requests.editHeld(logical) ?EditableHold: it resolves the
index and classifies the phase under one lock, returning null for a
missing entry or a settled response. The handle bundles the index with
the opened phase and exposes snapshot / currentStatus / commitRequest /
commitResponse / accept. Both commits re-check, under the lock, that the
entry is still .intercepted in the expected phase, and no-op (returning
false) if the hold was resolved meanwhile — so a stale editor can no
longer scribble onto whatever now occupies the slot. The TUI accepts the
intercept only when the write actually applied.

applyRequestEdit, applyResponseEdit, and statusOf were used only by the
editor; their bodies move into the guarded handle methods and the
standalone functions are gone. EditState now holds an ?EditableHold
instead of a usize, so a raw ring index no longer leaves requests.zig.
The detail view formatted both the request and response bodies into one
module-level json_pretty_buf within a single frame, then rendered the
accumulated DetailLines together. When both bodies were JSON, formatting
the response overwrote the buffer the request body's lines still pointed
into — so the REQUEST BODY pane rendered the response's bytes.

Extract the JSON reserialization (detect, parse, re-emit into a
caller-owned buffer) into a std-only jsonfmt module: pretty for display,
compact for forwarding an edited body. tui's three copies of that core
(EditState.prettyPrintJson, EditState.compactJson, and the inline pretty
print in splitBodyLines) collapse onto it.

splitBodyLines now takes a caller-owned scratch buffer; the detail view
passes a separate one for each pane, so the two bodies no longer share
storage. Because jsonfmt is pure, the formatting — including the
regression that caused the clobber — is now unit-tested without the TUI.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant