Merge pull request #2144 from code-yeongyu/fix/issue-2087-look-at-hang
fix(look-at): add timeout to sync model retry to prevent process hang
This commit is contained in:
commit
10c25d1d47
@ -399,6 +399,43 @@ describe("promptSyncWithModelSuggestionRetry", () => {
|
|||||||
expect(promptAsyncMock).toHaveBeenCalledTimes(0)
|
expect(promptAsyncMock).toHaveBeenCalledTimes(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should abort and throw timeout error when sync prompt hangs", async () => {
|
||||||
|
// given a client where sync prompt never resolves unless aborted
|
||||||
|
let receivedSignal: AbortSignal | undefined
|
||||||
|
const promptMock = mock((input: { signal?: AbortSignal }) => {
|
||||||
|
receivedSignal = input.signal
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
const signal = input.signal
|
||||||
|
if (!signal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
reject(signal.reason)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
prompt: promptMock,
|
||||||
|
promptAsync: mock(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when calling with short timeout
|
||||||
|
// then should abort the request and throw timeout error
|
||||||
|
await expect(
|
||||||
|
promptSyncWithModelSuggestionRetry(client as any, {
|
||||||
|
path: { id: "session-1" },
|
||||||
|
body: {
|
||||||
|
parts: [{ type: "text", text: "hello" }],
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||||
|
},
|
||||||
|
}, { timeoutMs: 1 })
|
||||||
|
).rejects.toThrow("prompt timed out after 1ms")
|
||||||
|
|
||||||
|
expect(receivedSignal?.aborted).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it("should retry with suggested model on ProviderModelNotFoundError", async () => {
|
it("should retry with suggested model on ProviderModelNotFoundError", async () => {
|
||||||
// given a client that fails first with model-not-found, then succeeds
|
// given a client that fails first with model-not-found, then succeeds
|
||||||
const promptMock = mock()
|
const promptMock = mock()
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { log } from "./logger"
|
import { log } from "./logger"
|
||||||
|
import {
|
||||||
|
createPromptTimeoutContext,
|
||||||
|
PROMPT_TIMEOUT_MS,
|
||||||
|
type PromptRetryOptions,
|
||||||
|
} from "./prompt-timeout-context"
|
||||||
|
|
||||||
type Client = ReturnType<typeof createOpencodeClient>
|
type Client = ReturnType<typeof createOpencodeClient>
|
||||||
|
|
||||||
@ -77,30 +82,36 @@ interface PromptBody {
|
|||||||
interface PromptArgs {
|
interface PromptArgs {
|
||||||
path: { id: string }
|
path: { id: string }
|
||||||
body: PromptBody
|
body: PromptBody
|
||||||
|
signal?: AbortSignal
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function promptWithModelSuggestionRetry(
|
export async function promptWithModelSuggestionRetry(
|
||||||
client: Client,
|
client: Client,
|
||||||
args: PromptArgs,
|
args: PromptArgs,
|
||||||
|
options: PromptRetryOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS
|
||||||
|
const timeoutContext = createPromptTimeoutContext(args, timeoutMs)
|
||||||
// NOTE: Model suggestion retry removed — promptAsync returns 204 immediately,
|
// NOTE: Model suggestion retry removed — promptAsync returns 204 immediately,
|
||||||
// model errors happen asynchronously server-side and cannot be caught here
|
// model errors happen asynchronously server-side and cannot be caught here
|
||||||
const promptPromise = client.session.promptAsync(
|
const promptPromise = client.session.promptAsync({
|
||||||
args as Parameters<typeof client.session.promptAsync>[0],
|
...args,
|
||||||
)
|
signal: timeoutContext.signal,
|
||||||
|
} as Parameters<typeof client.session.promptAsync>[0])
|
||||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
timeoutID = setTimeout(() => {
|
|
||||||
reject(new Error("promptAsync timed out after 120000ms"))
|
|
||||||
}, 120000)
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([promptPromise, timeoutPromise])
|
await promptPromise
|
||||||
|
if (timeoutContext.wasTimedOut()) {
|
||||||
|
throw new Error(`promptAsync timed out after ${timeoutMs}ms`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (timeoutContext.wasTimedOut()) {
|
||||||
|
throw new Error(`promptAsync timed out after ${timeoutMs}ms`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutID !== null) clearTimeout(timeoutID)
|
timeoutContext.cleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,9 +127,28 @@ export async function promptWithModelSuggestionRetry(
|
|||||||
export async function promptSyncWithModelSuggestionRetry(
|
export async function promptSyncWithModelSuggestionRetry(
|
||||||
client: Client,
|
client: Client,
|
||||||
args: PromptArgs,
|
args: PromptArgs,
|
||||||
|
options: PromptRetryOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.session.prompt(args as Parameters<typeof client.session.prompt>[0])
|
const timeoutContext = createPromptTimeoutContext(args, timeoutMs)
|
||||||
|
try {
|
||||||
|
await client.session.prompt({
|
||||||
|
...args,
|
||||||
|
signal: timeoutContext.signal,
|
||||||
|
} as Parameters<typeof client.session.prompt>[0])
|
||||||
|
if (timeoutContext.wasTimedOut()) {
|
||||||
|
throw new Error(`prompt timed out after ${timeoutMs}ms`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (timeoutContext.wasTimedOut()) {
|
||||||
|
throw new Error(`prompt timed out after ${timeoutMs}ms`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
timeoutContext.cleanup()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const suggestion = parseModelSuggestion(error)
|
const suggestion = parseModelSuggestion(error)
|
||||||
if (!suggestion || !args.body.model) {
|
if (!suggestion || !args.body.model) {
|
||||||
@ -130,7 +160,7 @@ export async function promptSyncWithModelSuggestionRetry(
|
|||||||
suggested: suggestion.suggestion,
|
suggested: suggestion.suggestion,
|
||||||
})
|
})
|
||||||
|
|
||||||
await client.session.prompt({
|
const retryArgs: PromptArgs = {
|
||||||
...args,
|
...args,
|
||||||
body: {
|
body: {
|
||||||
...args.body,
|
...args.body,
|
||||||
@ -139,6 +169,24 @@ export async function promptSyncWithModelSuggestionRetry(
|
|||||||
modelID: suggestion.suggestion,
|
modelID: suggestion.suggestion,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as Parameters<typeof client.session.prompt>[0])
|
}
|
||||||
|
|
||||||
|
const timeoutContext = createPromptTimeoutContext(retryArgs, timeoutMs)
|
||||||
|
try {
|
||||||
|
await client.session.prompt({
|
||||||
|
...retryArgs,
|
||||||
|
signal: timeoutContext.signal,
|
||||||
|
} as Parameters<typeof client.session.prompt>[0])
|
||||||
|
if (timeoutContext.wasTimedOut()) {
|
||||||
|
throw new Error(`prompt timed out after ${timeoutMs}ms`)
|
||||||
|
}
|
||||||
|
} catch (retryError) {
|
||||||
|
if (timeoutContext.wasTimedOut()) {
|
||||||
|
throw new Error(`prompt timed out after ${timeoutMs}ms`)
|
||||||
|
}
|
||||||
|
throw retryError
|
||||||
|
} finally {
|
||||||
|
timeoutContext.cleanup()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/shared/prompt-timeout-context.ts
Normal file
49
src/shared/prompt-timeout-context.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
export interface PromptTimeoutArgs {
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptRetryOptions {
|
||||||
|
timeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROMPT_TIMEOUT_MS = 120000
|
||||||
|
|
||||||
|
export function createPromptTimeoutContext(args: PromptTimeoutArgs, timeoutMs: number): {
|
||||||
|
signal: AbortSignal
|
||||||
|
wasTimedOut: () => boolean
|
||||||
|
cleanup: () => void
|
||||||
|
} {
|
||||||
|
const timeoutController = new AbortController()
|
||||||
|
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let timedOut = false
|
||||||
|
|
||||||
|
const abortOnUpstreamSignal = (): void => {
|
||||||
|
timeoutController.abort(args.signal?.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.signal) {
|
||||||
|
if (args.signal.aborted) {
|
||||||
|
timeoutController.abort(args.signal.reason)
|
||||||
|
} else {
|
||||||
|
args.signal.addEventListener("abort", abortOnUpstreamSignal, { once: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutID = setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
|
timeoutController.abort(new Error(`prompt timed out after ${timeoutMs}ms`))
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
signal: timeoutController.signal,
|
||||||
|
wasTimedOut: () => timedOut,
|
||||||
|
cleanup: () => {
|
||||||
|
if (timeoutID !== null) {
|
||||||
|
clearTimeout(timeoutID)
|
||||||
|
}
|
||||||
|
if (args.signal) {
|
||||||
|
args.signal.removeEventListener("abort", abortOnUpstreamSignal)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user