Skip to content

feat(streaming): replace fixed timeout with resettable idle timeout#13497

Merged
DeJeune merged 3 commits intomainfrom
feat/idle-timeout-for-streaming
Mar 16, 2026
Merged

feat(streaming): replace fixed timeout with resettable idle timeout#13497
DeJeune merged 3 commits intomainfrom
feat/idle-timeout-for-streaming

Conversation

@EurFelux
Copy link
Copy Markdown
Collaborator

@EurFelux EurFelux commented Mar 16, 2026

What this PR does

Before this PR:

The streaming timeout used a fixed AbortSignal.timeout() that starts counting from the initial request. For long-running SSE streams (e.g., complex reasoning or tool-calling chains), the request could be aborted even while data was actively flowing.

After this PR:

Introduces an IdleTimeoutController that resets its countdown every time a stream chunk is received. The timeout now only fires when the stream is truly idle (no data received for the configured duration), not based on total elapsed time.

Fixes #13489

Why we need it and why it was done in this way

The following tradeoffs were made:

  • The idle timeout controller is a simple utility class with reset() and cleanup() methods, exposed via an IdleTimeoutHandle interface and threaded through ModernAiProviderConfig to AiSdkToChunkAdapter.
  • The reset() callback is invoked on every reader.read() return in readFullStream, which is the natural place where SSE chunks are consumed.
  • cleanup() is called in the finally block to prevent timer leaks when the stream ends normally or errors out.

The following alternatives were considered:

  • Implementing idle timeout at the AI SDK level — not feasible as the AI SDK does not support idle timeout natively.
  • Using a wrapper around reader.read() with Promise.race — more invasive and less clean than resetting a timer on each chunk.

Breaking changes

None. The default timeout duration remains unchanged (30 minutes). The only behavioral change is that the timeout now resets on each chunk instead of being fixed from request start.

Special notes for your reviewer

  • The IdleTimeoutController is covered by 6 unit tests (fake timers).
  • The new optional idleTimeout constructor parameter on AiSdkToChunkAdapter is backward-compatible — existing callers (e.g., messageThunk.ts) are unaffected.

Checklist

Release note

NONE

The previous AbortSignal.timeout() created a fixed total timeout from
the start of the request. For SSE streaming, this is not ideal because
long-running streams could be terminated even when data is actively
flowing. Replace it with an IdleTimeoutController that resets the
countdown every time a stream chunk is received, only aborting when
the stream is truly idle.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Signed-off-by: icarus <[email protected]>
Copy link
Copy Markdown
Collaborator Author

@EurFelux EurFelux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great PR! The IdleTimeoutController is clean, well-tested, and solves a real problem — long-running streams being killed even while actively receiving data.

What I liked:

  • Arrow function methods (reset =, cleanup =) ensure correct this binding when passed as callbacks — nice attention to detail
  • Test coverage is thorough: normal expiry, reset, multiple resets, post-abort no-op, and cleanup
  • Backward-compatible — existing callers (e.g., messageThunk.ts) are unaffected

Minor suggestions (non-blocking):

  • The AiSdkToChunkAdapter constructor now has 9 positional params — consider an options object pattern as a follow-up
  • resetIdleTimeout/cleanupIdleTimeout in AiSdkMiddlewareConfig is a layering concern — they're stream lifecycle, not middleware config

LGTM 🎉

Comment on lines +39 to +48
@@ -41,14 +43,18 @@ export class AiSdkToChunkAdapter {
enableWebSearch?: boolean,
onSessionUpdate?: (sessionId: string) => void,
getSessionWasCleared?: () => boolean,
providerId?: string
providerId?: string,
resetIdleTimeout?: () => void,
cleanupIdleTimeout?: () => void
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Consider using an options object to reduce positional parameter sprawl

The constructor now has 9 positional parameters, many of which are optional. This is becoming hard to read and maintain — callers need to pass undefined placeholders to reach later parameters (as seen in index_new.ts lines 336-337).

Consider refactoring to an options object pattern:

interface AiSdkToChunkAdapterOptions {
  mcpTools?: MCPTool[]
  accumulate?: boolean
  enableWebSearch?: boolean
  onSessionUpdate?: (sessionId: string) => void
  getSessionWasCleared?: () => boolean
  providerId?: string
  resetIdleTimeout?: () => void
  cleanupIdleTimeout?: () => void
}

constructor(
  onChunk: (chunk: Chunk) => void,
  options?: AiSdkToChunkAdapterOptions
)

This is not a blocker for this PR since it's a pre-existing issue you're extending, but worth considering as a follow-up to improve the DX.

Comment on lines +29 to +30
resetIdleTimeout?: () => void
cleanupIdleTimeout?: () => void
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: These callbacks feel out of place in AiSdkMiddlewareConfig

resetIdleTimeout and cleanupIdleTimeout are not middleware concerns — they're stream lifecycle callbacks. Threading them through the middleware config just to pass them to AiSdkToChunkAdapter creates coupling between unrelated layers.

An alternative would be to pass them directly when constructing the adapter (e.g., via a separate streamLifecycle option), keeping the middleware config focused on actual middleware behavior.

Low priority — the current approach works, just noting the layering concern.

…tHandle

Move resetIdleTimeout/cleanupIdleTimeout from separate parameters into
a single IdleTimeoutHandle interface. Place the handle on
ModernAiProviderConfig instead of AiSdkMiddlewareConfig to keep
middleware config focused on middleware concerns.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Signed-off-by: icarus <[email protected]>
Copy link
Copy Markdown
Collaborator Author

@EurFelux EurFelux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the refactor commit (e8642d8b). Both suggestions from the previous round have been addressed nicely:

  1. IdleTimeoutHandle interface — cleanly aggregates reset/cleanup into a single handle object, reducing the constructor param count and improving readability
  2. Layering fixAiSdkMiddlewareConfig is now free of idle timeout concerns; idleTimeout lives only in ModernAiProviderConfig where it belongs

The code is clean and well-structured. No further comments — LGTM 🎉

The IdleTimeoutController already implements IdleTimeoutHandle, so
pass it directly instead of destructuring into a new object.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Signed-off-by: icarus <[email protected]>
Copy link
Copy Markdown
Contributor

@cherry-ai-bot cherry-ai-bot bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the latest revision. The idle-timeout design is clean and the wiring is consistent end-to-end: the controller is created at request-build time, its signal is used for the request, the stream adapter resets it on chunk arrival, and cleanup happens in the stream finally path. This fixes the original failure mode where long-lived but still-active streams could be aborted just because total elapsed time exceeded the fixed timeout. The added tests also give good confidence for the reset / expiry / cleanup behavior. I don't see any remaining blocking issues in the current version.

@DeJeune DeJeune merged commit 888ce83 into main Mar 16, 2026
11 checks passed
@DeJeune DeJeune deleted the feat/idle-timeout-for-streaming branch March 16, 2026 07:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: 1.7.25 version request was aborted, signal timed out

2 participants