Skip to content

[agent] Bound the SSE read after finish_reason so a stream without [DONE] cannot stall the call #176

Closed

Description

@yusiwen

Found while delivering #174 / PR #175, which made this path read to data: [DONE]. Filed rather than
folded in, because #175 fixed the usage gap and this is a separate, unfixed one.

Context

PR #175 made the OpenAI-compatible SSE reader run to data: [DONE] instead of returning at
finish_reason, because the usage chunk that answers stream_options.include_usage arrives after
that marker. The trade that came with it is unbounded.

Measured at d5a625a, against an endpoint that streams a complete answer, sends finish_reason,
then holds the connection open (no [DONE], no close):

Endpoint ends with elapsed Content err
data: [DONE] 2 ms "Hi" nil
nothing (connection held open) 1.502 s "Hi" nil

The 1.502 s is the caller's context deadline in that run. The TUI and the one-shot CLI pass no
deadline of their own, so the real bound is agent/provider_openai.go:133 —
http.Client{Timeout: 120 * time.Second}, the whole request. For that entire window a run looks like
it is still streaming an answer that was already complete.

The delay is the smaller half. chatStream returns the partial result with nil error
(agent/provider_openai.go:314-316 only logs scanner.Err()), so the agent loop takes a truncated
stream for a finished answer: nothing is retried, nothing is reported, and the transcript gets
whatever text arrived before the cut.

The Ollama provider already has the mechanism this needs — ollamaIdleTimeout
(agent/provider_ollama.go:39), reset after every line (agent/provider_ollama.go:277), with a
cancel cause that names the bound that fired.

What to build

  • An idle bound on the OpenAI-compatible stream read: fail when no line arrives for a named interval,
    reset the interval on every line, and name the bound in the error.
  • Make the bound a package variable, so a test can shrink it (the Ollama pair are already test-shrunk).
  • Keep the usage chunk reachable. A shortcut that stops at finish_reason once usage has been seen
    is not a fix: an endpoint that never reports usage would return at the wrong point, and the flush
    that follows the marker would be cut.

Acceptance

  • A test server that streams deltas, finish_reason, then holds the connection open returns within
    the shrunk bound — not at the caller's deadline — and the error names the bound.
  • The same server, when it then sends a usage chunk and [DONE], still returns the full content
    and the reported usage: the bound must not cut a stream that is merely slow.
  • A slow-but-alive stream (a line every half-interval, several lines) is not cut.
  • go test ./... -count=1 and go test ./... -count=1 -race green.

Reproduction

Scratch module at /tmp/dsh-repro-175 (replace → this repository): cd /tmp/dsh-repro-175 && go run .
prints both rows of the table above.

Activity

  1. yusiwen commented on Oct 10, 2026

    @yusiwen
    OwnerAuthor

    Landed as PR #178, rebase-merged to master as
    49964e2.

    What the fix is: agent/provider_openai.go now carries the same pair of bounds the Ollama
    provider has had since issue #1 — openAIRequestTimeout (120 s) for one non-streaming request, and
    openAIIdleTimeout (2 min) for the silence between lines of a stream, reset by every line, with the
    cancel cause naming the bound. A failed read returns openai stream: … instead of the partial
    answer with a nil error, and openAIRequestError keeps errors.Is(err, context.Canceled) for a
    caller cancellation so an interrupted run still reads as interrupted.

    Measurements

    • Repro (/tmp/dsh-repro-175), endpoint that completes its answer and then holds the connection open:

      Ends with before after
      data: [DONE] 2 ms, "Hi", err=nil 3 ms, "Hi", err=nil
      nothing 1.502 s, "Hi", err=nil 1.5 s (caller deadline), resp=nil, err=openai stream: context deadline exceeded
    • The unit test shrinks the bound to 400 ms and the silence case fails there — 0.40 s — instead of
      waiting for anything longer.

    • go test ./... -count=1 and -race: 13 packages ok, 0 failed, before and after the merge.

    • CI: nine checks green, 11 annotations = the baseline, per job. No new annotations.

    • Main-module test functions: 767 → 769 (+2).

    The mutation check changed the code. Removing the caller-cancellation classification still
    passed the streaming subtest, because an HTTP/1.1 read error already wraps context.Canceled. Rather
    than keep a guard that could not fail, the helper got its own unit test fed an HTTP/2-shaped error
    (http2: stream closed) — and the mutation now fails it. Four mutations, four failures; the
    "watchdog never fires" one fails as a 25 s test timeout, which is the read blocking without the
    bound.

    Left open deliberately: the bound is uniform, so a stream that goes quiet after a complete answer
    still waits the full idle bound before failing; and a stream that closes without [DONE] is still
    accepted as complete, because OpenAI-compatible servers vary and failing their working answers costs
    more than the truncation it would catch.

  2. added a commit that references this issue on Oct 10, 2026
    49964e2
  3. yusiwen commented on Oct 11, 2026

    @yusiwen
    OwnerAuthor

    Closing the trail on this one: the coverage half of the work also landed.

    • Code half (the idle bound, watchdog reset, named cause): 49964e2 (#178), as recorded above.
    • Test half (TestOpenAIBatchTimesOutOnSilentEndpoint, pinning openAIRequestTimeout on the non-streaming path): a6b86c8 (#177, authored by @AdamMagued).

    The "left open deliberately" residuals in the comment above are now tracked with measured repros:

    • #185 — http.Client.Timeout covers the streaming body read, so a working generation longer than 120 s is still cut.
    • #186 — the idle bound cannot fire while a client-wide timeout of the same length is armed, so the error does not name the bound under the shipped defaults.
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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions