mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 22:38:23 +08:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
52
src/renderer/src/utils/IdleTimeoutController.ts
Normal file
52
src/renderer/src/utils/IdleTimeoutController.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user