Merge pull request #4093 from code-yeongyu/k2p6-turbo
fix(prompt-async-gate): timeout isSessionActive to prevent infinite hang on stale SDK status
This commit is contained in:
@@ -1674,11 +1674,17 @@ session_id: ses_untrusted_999
|
|||||||
writeBoulderState(TEST_DIR, state)
|
writeBoulderState(TEST_DIR, state)
|
||||||
|
|
||||||
const originalSetTimeout = globalThis.setTimeout
|
const originalSetTimeout = globalThis.setTimeout
|
||||||
const scheduledDelays: number[] = []
|
const originalClearTimeout = globalThis.clearTimeout
|
||||||
|
const activeTimers = new Map<ReturnType<typeof setTimeout>, number>()
|
||||||
globalThis.setTimeout = ((_handler: Parameters<typeof setTimeout>[0], timeout?: number, ..._args: unknown[]) => {
|
globalThis.setTimeout = ((_handler: Parameters<typeof setTimeout>[0], timeout?: number, ..._args: unknown[]) => {
|
||||||
scheduledDelays.push(timeout ?? 0)
|
const id = originalSetTimeout(() => undefined, 0)
|
||||||
return originalSetTimeout(() => undefined, 0)
|
activeTimers.set(id, timeout ?? 0)
|
||||||
|
return id
|
||||||
}) as typeof setTimeout
|
}) as typeof setTimeout
|
||||||
|
globalThis.clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
|
||||||
|
activeTimers.delete(id)
|
||||||
|
originalClearTimeout(id)
|
||||||
|
}) as typeof clearTimeout
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mockInput = createMockPluginInput()
|
const mockInput = createMockPluginInput()
|
||||||
@@ -1702,10 +1708,12 @@ session_id: ses_untrusted_999
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then - stale idle is consumed, not converted into another scheduled continuation
|
// then - stale idle is consumed, not converted into another scheduled continuation
|
||||||
|
const scheduledDelays = Array.from(activeTimers.values())
|
||||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||||
expect(scheduledDelays.filter((delay) => delay >= 5_000 && delay !== DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS)).toHaveLength(0)
|
expect(scheduledDelays.filter((delay) => delay >= 5_000 && delay !== DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS)).toHaveLength(0)
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.setTimeout = originalSetTimeout
|
globalThis.setTimeout = originalSetTimeout
|
||||||
|
globalThis.clearTimeout = originalClearTimeout
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2519,7 +2527,9 @@ session_id: ses_untrusted_999
|
|||||||
capturedTimers.delete(id)
|
capturedTimers.delete(id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
originalClearTimeout(id)
|
if (id !== undefined) {
|
||||||
|
originalClearTimeout(id)
|
||||||
|
}
|
||||||
}) as typeof clearTimeout
|
}) as typeof clearTimeout
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -476,6 +476,34 @@ describe("promptAsyncAfterSessionIdle", () => {
|
|||||||
expect(promptCalls).toBe(1)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given session.status never resolves #when promptAsync is requested #then isSessionActive times out and dispatch is attempted", async () => {
|
||||||
|
// given
|
||||||
|
let promptCalls = 0
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => new Promise(() => {}),
|
||||||
|
promptAsync: async () => {
|
||||||
|
promptCalls += 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await promptAsyncAfterSessionIdle({
|
||||||
|
client,
|
||||||
|
sessionID: "ses_status_hang",
|
||||||
|
input: { path: { id: "ses_status_hang" }, body: { parts: [] } },
|
||||||
|
source: "test:status-hang",
|
||||||
|
settleMs: 0,
|
||||||
|
postDispatchHoldMs: 0,
|
||||||
|
dispatchTimeoutMs: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.status).toBe("dispatched")
|
||||||
|
expect(promptCalls).toBe(1)
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
test("#given SDK prompt depends on its session receiver #when the gate dispatches #then method binding is preserved", async () => {
|
test("#given SDK prompt depends on its session receiver #when the gate dispatches #then method binding is preserved", async () => {
|
||||||
// given
|
// given
|
||||||
const session = {
|
const session = {
|
||||||
|
|||||||
@@ -169,7 +169,19 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
|||||||
await settleAfterSessionIdle(settleMs)
|
await settleAfterSessionIdle(settleMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
let sessionActive = false
|
||||||
|
if (canReadStatus) {
|
||||||
|
try {
|
||||||
|
sessionActive = await withDispatchTimeout(
|
||||||
|
isSessionActive(client, sessionID),
|
||||||
|
Math.min(dispatchTimeoutMs, 5000),
|
||||||
|
`[prompt-async-gate] ${sessionName} isSessionActive`,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
sessionActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sessionActive) {
|
||||||
log(`[prompt-async-gate] ${sessionName} skipped because session is active`, { sessionID, source })
|
log(`[prompt-async-gate] ${sessionName} skipped because session is active`, { sessionID, source })
|
||||||
return { status: "active" }
|
return { status: "active" }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user