Skip to content

Harden SSE stall watchdog: guard peekByte + receiveHead, make stalls retryable (reconnect) #56

Description

@justrach

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:

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions