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

### 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

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780096809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

```release-note
NONE
```

---------

Signed-off-by: icarus <eurfelux@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Phantom
2026-03-16 15:02:08 +08:00
committed by GitHub
parent 31cd13ae8b
commit 888ce83fc7
6 changed files with 162 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ import type { Chunk, ProviderMetadata } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
import { formatErrorMessage, isAbortError } from '@renderer/utils/error'
import type { IdleTimeoutHandle } from '@renderer/utils/IdleTimeoutController'
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
import { AISDKError, type TextStreamPart, type ToolSet } from 'ai'
@@ -33,6 +34,7 @@ export class AiSdkToChunkAdapter {
private hasTextContent = false
private getSessionWasCleared?: () => boolean
private providerId?: string
private idleTimeout?: IdleTimeoutHandle
constructor(
private onChunk: (chunk: Chunk) => void,
@@ -41,7 +43,8 @@ export class AiSdkToChunkAdapter {
enableWebSearch?: boolean,
onSessionUpdate?: (sessionId: string) => void,
getSessionWasCleared?: () => boolean,
providerId?: string
providerId?: string,
idleTimeout?: IdleTimeoutHandle
) {
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
this.accumulate = accumulate
@@ -49,6 +52,7 @@ export class AiSdkToChunkAdapter {
this.onSessionUpdate = onSessionUpdate
this.getSessionWasCleared = getSessionWasCleared
this.providerId = providerId
this.idleTimeout = idleTimeout
}
private markFirstTokenIfNeeded() {
@@ -110,6 +114,9 @@ export class AiSdkToChunkAdapter {
while (true) {
const { done, value } = await reader.read()
// Reset idle timeout on every chunk received from the stream
this.idleTimeout?.reset()
if (done) {
// Flush any remaining content from link converter buffer if web search is enabled
if (this.enableWebSearch) {
@@ -131,6 +138,8 @@ export class AiSdkToChunkAdapter {
} finally {
reader.releaseLock()
this.resetTimingState()
// Clean up the idle timeout timer when the stream ends
this.idleTimeout?.cleanup()
}
}

View File

@@ -17,6 +17,7 @@ import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import { type Assistant, type GenerateImageParams, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils'
import type { IdleTimeoutHandle } from '@renderer/utils/IdleTimeoutController'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { gateway, type LanguageModel, type Provider as AiSdkProvider } from 'ai'
@@ -42,6 +43,7 @@ export type ModernAiProviderConfig = AiSdkMiddlewareConfig & {
// topicId for tracing
topicId?: string
callType: string
idleTimeout?: IdleTimeoutHandle
}
export default class ModernAiProvider {
@@ -335,7 +337,8 @@ export default class ModernAiProvider {
config.enableWebSearch,
undefined,
undefined,
this.config!.providerId
this.config!.providerId,
config.idleTimeout
)
const streamResult = await executor.streamText({

View File

@@ -35,6 +35,7 @@ import type { Model } from '@renderer/types'
import { type Assistant, getEffectiveMcpMode, type MCPTool, type Provider, SystemProviderIds } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { IdleTimeoutController, type IdleTimeoutHandle } from '@renderer/utils/IdleTimeoutController'
import { replacePromptVariables } from '@renderer/utils/prompt'
import { isAIGatewayProvider, isAwsBedrockProvider, isSupportUrlContextProvider } from '@renderer/utils/provider'
import { DEFAULT_TIMEOUT } from '@shared/config/constant'
@@ -111,12 +112,16 @@ export async function buildStreamTextParams(
enableUrlContext: boolean
}
webSearchPluginConfig?: WebSearchPluginConfig
idleTimeout: IdleTimeoutHandle
}> {
const { mcpTools, requestOptions = {} } = options
// No caller currently provides a custom timeout; defaultTimeout (10 min) is the fallback.
const { signal: externalSignal, timeout = DEFAULT_TIMEOUT, headers: inputHeaders = {} } = requestOptions
const timeoutSignal = AbortSignal.timeout(timeout)
const signals = [timeoutSignal]
// Use an idle timeout that resets every time a stream chunk is received,
// instead of a fixed total timeout that starts from the initial request.
const idleTimeout = new IdleTimeoutController(timeout)
const signals = [idleTimeout.signal]
if (externalSignal) {
signals.push(externalSignal)
}
@@ -292,7 +297,8 @@ export async function buildStreamTextParams(
params,
modelId: model.id,
capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext },
webSearchPluginConfig
webSearchPluginConfig,
idleTimeout
}
}

View File

@@ -243,7 +243,8 @@ export async function fetchChatCompletion({
params: aiSdkParams,
modelId,
capabilities,
webSearchPluginConfig
webSearchPluginConfig,
idleTimeout
} = await buildStreamTextParams(messages, assistant, provider, {
mcpTools: mcpTools,
allowedTools,
@@ -279,7 +280,8 @@ export async function fetchChatCompletion({
assistant,
topicId,
callType: 'chat',
uiMessages
uiMessages,
idleTimeout
})
}

View File

@@ -0,0 +1,52 @@
/** Lightweight handle exposing only the reset/cleanup callbacks. */
export interface IdleTimeoutHandle {
reset: () => void
cleanup: () => void
}
/**
* A resettable idle timeout that aborts via an AbortController.
* Each call to `reset()` restarts the countdown.
* When the timeout fires without being reset, the internal AbortController is aborted.
*/
export class IdleTimeoutController {
private controller: AbortController
private timerId: ReturnType<typeof setTimeout> | null = null
private readonly timeoutMs: number
constructor(timeoutMs: number) {
this.timeoutMs = timeoutMs
this.controller = new AbortController()
this.startTimer()
}
/** The AbortSignal that will be aborted on idle timeout. */
get signal(): AbortSignal {
return this.controller.signal
}
/** Reset the idle timer. Call this every time new data arrives. */
reset = (): void => {
if (this.controller.signal.aborted) return
this.clearTimer()
this.startTimer()
}
/** Clean up the timer (e.g. when the stream finishes normally). */
cleanup = (): void => {
this.clearTimer()
}
private startTimer(): void {
this.timerId = setTimeout(() => {
this.controller.abort(new DOMException('Idle timeout exceeded', 'TimeoutError'))
}, this.timeoutMs)
}
private clearTimer(): void {
if (this.timerId !== null) {
clearTimeout(this.timerId)
this.timerId = null
}
}
}

View File

@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IdleTimeoutController } from '../IdleTimeoutController'
describe('IdleTimeoutController', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should abort after the idle timeout expires', () => {
const controller = new IdleTimeoutController(5000)
expect(controller.signal.aborted).toBe(false)
vi.advanceTimersByTime(5000)
expect(controller.signal.aborted).toBe(true)
expect(controller.signal.reason).toBeInstanceOf(DOMException)
expect(controller.signal.reason.name).toBe('TimeoutError')
})
it('should not abort before the timeout', () => {
const controller = new IdleTimeoutController(5000)
vi.advanceTimersByTime(4999)
expect(controller.signal.aborted).toBe(false)
})
it('should reset the timer on reset()', () => {
const controller = new IdleTimeoutController(5000)
// Advance 4 seconds, then reset
vi.advanceTimersByTime(4000)
expect(controller.signal.aborted).toBe(false)
controller.reset()
// Advance another 4 seconds — should NOT abort (timer was reset)
vi.advanceTimersByTime(4000)
expect(controller.signal.aborted).toBe(false)
// Advance 1 more second (5 total since reset) — should abort
vi.advanceTimersByTime(1000)
expect(controller.signal.aborted).toBe(true)
})
it('should support multiple resets', () => {
const controller = new IdleTimeoutController(1000)
for (let i = 0; i < 10; i++) {
vi.advanceTimersByTime(900)
controller.reset()
}
// 10 * 900ms = 9 seconds total, but never timed out
expect(controller.signal.aborted).toBe(false)
// Now let it expire
vi.advanceTimersByTime(1000)
expect(controller.signal.aborted).toBe(true)
})
it('should not restart timer after already aborted', () => {
const controller = new IdleTimeoutController(1000)
vi.advanceTimersByTime(1000)
expect(controller.signal.aborted).toBe(true)
// reset() after abort should be a no-op
controller.reset()
expect(controller.signal.aborted).toBe(true)
})
it('cleanup() should prevent the timeout from firing', () => {
const controller = new IdleTimeoutController(1000)
controller.cleanup()
vi.advanceTimersByTime(5000)
expect(controller.signal.aborted).toBe(false)
})
})