fix(providers): content-idle stream watchdog that keepalive pings cannot reset - #170
Closed
jasonkneen wants to merge 1 commit into
Closed
jasonkneen wants to merge 1 commit into
jasonkneen wants to merge 1 commit into
Conversation
…not reset Invariant: stream liveness timers reset on content events only, never on keepalive pings. Cause: the only liveness guard was undici bodyTimeout (httpIdleTimeoutMs, default 300s), which resets on any body bytes including SSE pings and comment keepalives, so a stalled generation that keeps pinging never timed out. Library users of providers/agent-core had no idle guard at all. Fix: add StreamOptions.streamIdleTimeoutMs and withStreamIdleTimeout, applied at the stream-dispatch entry points (createProvider, Models, compat fallback, coding-agent ModelRuntime). When > 0 it runs the request under a child AbortController linked to the caller signal, resets on every emitted stream event, and on expiry aborts the request and terminates with stopReason "error" and "Stream idle timeout: no content for Nms (phase: ..., last content at <iso>)", which isRetryableAssistantError treats as retryable. Caller aborts still yield "aborted". coding-agent passes the existing httpIdleTimeoutMs setting (0 disables).
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Three unresolved moderate findings remain in stream-idle-timeout.ts.
Review effort: Lite
Findings: None
What changed in this PR
Adds a content-idle watchdog that ignores keepalive pings, aborts stalled streams, preserves partial output, and integrates across provider and coding-agent dispatch paths.
Changes:
- Adds and exports
streamIdleTimeoutMsandwithStreamIdleTimeout. - Applies watchdog protection across models, compatibility APIs, SDK, and runtime providers.
- Adds timeout, abort, cleanup, partial-content, and SDK propagation tests.
| File | Summary | Final comments |
|---|---|---|
packages/providers/test/stream-idle-timeout.test.ts |
Tests watchdog behavior and SDK integration. | None. |
packages/providers/src/utils/stream-idle-timeout.ts |
Implements watchdog timing, abort linkage, cleanup, and timeout errors. | Moderate (1 vote): Track token/delta receipt separately so start events retain the waiting for first token diagnostic. Moderate (1 vote): Caller cancellation during setup can incorrectly produce error instead of aborted. Moderate (1 vote): Convert synchronous start exceptions into terminal error events instead of rethrowing. |
packages/providers/src/types.ts |
Defines the new stream option. | None. |
packages/providers/src/models.ts |
Guards model and provider stream dispatch. | None. |
packages/providers/src/index.ts |
Exports the watchdog utility. | None. |
packages/providers/src/compat.ts |
Guards compatibility API streams. | None. |
packages/coding-agent/test/sdk-stream-options.test.ts |
Tests SDK option propagation and disabling. | None. |
packages/coding-agent/src/core/sdk.ts |
Maps the existing HTTP idle setting to the watchdog. | None. |
packages/coding-agent/src/core/model-runtime.ts |
Guards runtime and extension-provider streams. | None. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Today the only stream liveness guard is undici
bodyTimeout/headersTimeout, set inpackages/coding-agent/src/core/http-dispatcher.ts(~84) from thehttpIdleTimeoutMssetting (default 300s).bodyTimeoutresets on any body bytes, including SSEpingevents and comment keepalives. A stalled generation that keeps pinging therefore never times out, and the turn hangs until the user aborts.StreamOptions.timeoutMs(packages/providers/src/types.ts) is only an SDK request timeout. Library users of@step-harness/providersand agent-core had no idle guard at all.Invariant: idle timers reset on content frames only, never on keepalives. The guard fires and is covered by a test. The error names the phase and the last activity.
Fix
StreamOptions.streamIdleTimeoutMs(0 or undefined disables it).withStreamIdleTimeout(packages/providers/src/utils/stream-idle-timeout.ts, exported from the package). When the value is > 0, it runs the request under a childAbortControllerlinked to the caller's signal. It starts the timer when the request is issued and resets it on every emittedAssistantMessageEvent: start,*_start,*_delta,*_endand toolcall events. Adapters filter pings before they reach this layer; for example,iterateAnthropicEventsskips non-message SSE events. The OpenAI SDK's SSE decoder drops: pingcomment lines.openai-completionspushes events only for choices with content, andopenai-responses-sharedpushes only on typed content events:response.createdandin_progresspush nothing. When the timer expires, the watchdog aborts the request. It then ends the stream right away withstopReason: "error"(not "aborted"), keeps the last partial content, and sets an error such asStream idle timeout: no content for 300000ms (phase: streaming text_delta, last content at 2026-...Z). Before the first token the message readsphase: waiting for first token, followed by eitherrequest started at ...orlast content at .... The text contains "timeout", soisRetryableAssistantErrorretries it like a network stall. A caller abort still produces "aborted". Timers and listeners are cleared on every terminal path. The outermost guard consumes the option, so nested dispatch layers do not add a second guard.createProvider(all built-in, models.json and faux providers),Models.stream/streamSimple, the compat registered-API fallback, and coding-agent'sModelRuntime.stream/streamSimple(which also covers extension providers).sdk.tssetsstreamIdleTimeoutMsfrom the existinghttpIdleTimeoutMssetting (0 disables it; a per-request override is honoured). No new setting was added.Test
packages/providers/test/stream-idle-timeout.test.tsuses fake timers and a scripted wire API behindcreateProvider. The API emitsstartand then nothing, which is how a ping-only upstream looks at the event layer. Covered cases:stopReason "error"and the exact message naming the phase and last content time, and the error counts as retryable.streaming text_deltaand keeps the partial text.streamIdleTimeoutMsof 0 or undefined installs no watchdog.Error: Test timed out in 30000msbecause the stream never ended.packages/coding-agent/test/sdk-stream-options.test.ts: two new tests check that the watchdog is installed fromhttpIdleTimeoutMs(the provider sees a linked signal and the option is consumed) and not installed when the value is 0. Both failed without thesdk.tschange.Risk / behaviour change
httpIdleTimeoutMsof silence. That is the same budget as today'sbodyTimeout, so behaviour is no worse than now, except that pings no longer keep such a request alive. Users who hit this can raisehttpIdleTimeoutMsor set it to 0.AbortSignalin place of the caller's signal, or a signal where the caller passed none.streamIdleTimeoutMs. The providers default is unchanged: no watchdog.https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9