From 3470aa658e5927118b433016cad2d36535afdfdc Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:34:48 +0800 Subject: [PATCH] fix(agent): don't peek past a completed stream on a half-open socket (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSE reader's post-line reader.peekByte() ran BEFORE the terminal-event check, which then breaks on [DONE]/response.completed. So on the final line the peek's result was discarded anyway, but its fill could block forever on a half-open socket (server gone, no FIN) that the idle-stall watchdog doesn't guard — hanging an already-complete turn. That is part of #56's "reconnect doesn't reconnect". Move the peek to AFTER the terminal-event break, matching the loop's documented intent ("stop instead of waiting for the socket to close"). A completed response now breaks before peeking; the non-terminal path is byte-for-byte unchanged (peek -> if (!more) break -> toss the buffered '\n'). Safe by inspection: on a terminal line the loop already broke at the isStreamEnd check, so `more` was never read there — skipping the peek only removes a useless, hang-prone fill. Verified: zig build test green + the streaming PTY suite (test-pty-codex-ws/repl/markdown/spinner) green — no regression in the hot read path. Scope: this closes the post-completion subcase. The mid-stream half-open subcase (peekByte blocking on a NON-terminal line) still needs a watchdog-guarded peek plus a half-open-socket mock harness to verify — left as a follow-up. Fix-B (retryable stalls via watchdogError, #134) is already done. Pre-existing zig-fmt debt in this file (unrelated one-liners) left untouched to avoid churn. Refs #56 Co-Authored-By: blackfloofie <265516171+blackfloofie@users.noreply.github.com> --- src/agent_stream.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/agent_stream.zig b/src/agent_stream.zig index 43aa81e9..1b832e8e 100644 --- a/src/agent_stream.zig +++ b/src/agent_stream.zig @@ -398,9 +398,6 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons }, } } - // End of stream leaves the reader empty; otherwise the '\n' is - // still buffered (and consumed below, after the line is handled). - const more = if (reader.peekByte()) |_| true else |_| false; try full.writer.writeAll(line.writer.buffered()); try full.writer.writeByte('\n'); got_body = true; // #134: response bytes are in `full`; a later read error is a clean close @@ -434,6 +431,13 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons if (req.connection) |conn| conn.closing = true; return error.Interrupted; } + // "Is there more?" — peek the next byte, then consume the buffered '\n'. + // Deliberately AFTER the terminal-event break above (#56): once + // [DONE]/response.completed has landed the turn is complete and we break + // there, so this peek never runs on a finished response. Peeking then would + // fill-block forever on a half-open socket (server gone, no FIN) and hang + // the completed turn — the exact wedge the idle-stall watchdog can't see. + const more = if (reader.peekByte()) |_| true else |_| false; if (!more) break; reader.toss(1); }