Summary
graff's streaming reader (postStream) could hang forever at "thinking..." when a connection went half-open mid-stream (the HttpConnectionClosing / WriteFailed / truncated-TLS case): the retry re-entered postStream, the spinner restarted, then the resumed read wedged - "reconnect doesn't reconnect."
Root cause (confirmed against the code)
The 2026-06-17 idle-stall watchdog (racing streamStallTask against the line read via Io.Select) guarded the SSE line read but left sibling blocking reads unguarded, and surfaced stalls as a non-retryable error:
reader.peekByte() (the post-line "is there more?" probe, ~main.zig:8638) ran after the watchdog was cancelled. On a half-open socket (server gone, no FIN/RST) it blocks forever. (Primary hang.)
- Watchdog stalls returned
error.Interrupted - which request()'s retry loop deliberately does NOT retry (Esc semantics) - while the non-streaming postWatched correctly returns retryable error.HungRequest. So even a detected stall ended the turn instead of reconnecting.
Done (release/0.0.16)
- Guarded
peekByte with the same stall watchdog (new streamPeekTask select-arm).
- Watchdog deadline stalls now return
error.HungRequest (retryable -> request() reconnects + redials a fresh connection); real Esc still maps to error.Interrupted.
zig build + zig build test green.
Net: a dead/half-open stream now trips the watchdog (<= stream_stall_ms = 120s), prints [network error: HungRequest - retrying], and redials (up to 3 attempts) instead of hanging at "thinking".
Remaining - the broader watchdog work
receiveHead: guard the HTTP response-header read. Before the line/peek loop, postStream reads the response head. A server that accepts the TCP connection but never sends headers (or sends them then dies) hangs there, before the first byte - same "stuck at thinking", not yet covered. Race receiveHead (and/or the request send) against the stall/deadline watchdog so a pre-stream hang also becomes a retryable HungRequest.
- Fail-safe the concurrency-starved fallbacks. The
Io.Select arms fall back to bare blocking reads when the pool has no spare slot (8606 / 8615, and the new peek equivalent). Under subagent fan-out this can still wedge. Poison the connection + return HungRequest instead of blocking.
- Consider a shorter stall threshold for the between-lines peek (bytes were flowing, then stopped) vs. the before-first-token case (a legit long reasoning pause).
Refs
postStream read loop + the now-guarded peek: src/main.zig ~8597-8672
- watchdog infra:
streamStallTask / WatchdogFired / stream_stall_ms; retryable mapping in postWatched
- retry loop:
request() (error.Interrupted not retried; error.HungRequest retried)
Summary
graff's streaming reader (
postStream) could hang forever at "thinking..." when a connection went half-open mid-stream (theHttpConnectionClosing/WriteFailed/ truncated-TLS case): the retry re-enteredpostStream, the spinner restarted, then the resumed read wedged - "reconnect doesn't reconnect."Root cause (confirmed against the code)
The 2026-06-17 idle-stall watchdog (racing
streamStallTaskagainst the line read viaIo.Select) guarded the SSE line read but left sibling blocking reads unguarded, and surfaced stalls as a non-retryable error:reader.peekByte()(the post-line "is there more?" probe, ~main.zig:8638) ran after the watchdog was cancelled. On a half-open socket (server gone, no FIN/RST) it blocks forever. (Primary hang.)error.Interrupted- whichrequest()'s retry loop deliberately does NOT retry (Esc semantics) - while the non-streamingpostWatchedcorrectly returns retryableerror.HungRequest. So even a detected stall ended the turn instead of reconnecting.Done (release/0.0.16)
peekBytewith the same stall watchdog (newstreamPeekTaskselect-arm).error.HungRequest(retryable ->request()reconnects + redials a fresh connection); real Esc still maps toerror.Interrupted.zig build+zig build testgreen.Net: a dead/half-open stream now trips the watchdog (<=
stream_stall_ms= 120s), prints[network error: HungRequest - retrying], and redials (up to 3 attempts) instead of hanging at "thinking".Remaining - the broader watchdog work
receiveHead: guard the HTTP response-header read. Before the line/peek loop,postStreamreads the response head. A server that accepts the TCP connection but never sends headers (or sends them then dies) hangs there, before the first byte - same "stuck at thinking", not yet covered. RacereceiveHead(and/or the request send) against the stall/deadline watchdog so a pre-stream hang also becomes a retryableHungRequest.Io.Selectarms fall back to bare blocking reads when the pool has no spare slot (8606/8615, and the new peek equivalent). Under subagent fan-out this can still wedge. Poison the connection + returnHungRequestinstead of blocking.Refs
postStreamread loop + the now-guarded peek:src/main.zig~8597-8672streamStallTask/WatchdogFired/stream_stall_ms; retryable mapping inpostWatchedrequest()(error.Interruptednot retried;error.HungRequestretried)