Merge pull request #4092 from code-yeongyu/fix/status-timeout-hang

fix(shared): add timeout to isSessionActive to prevent infinite hang
This commit is contained in:
YeonGyu-Kim
2026-05-21 12:58:14 +09:00
committed by GitHub
5 changed files with 170 additions and 3 deletions
+2 -1
View File
@@ -12,6 +12,7 @@ import {
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
import { DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS } from "../../shared/prompt-async-gate"
import { DEFAULT_SESSION_STATUS_TIMEOUT_MS } from "../../shared/session-idle-settle"
import type { AtlasHookOptions, PendingTaskRef } from "./types"
import { createAtlasHook } from "./index"
import { createToolExecuteAfterHandler } from "./tool-execute-after"
@@ -1711,7 +1712,7 @@ session_id: ses_untrusted_999
// then - stale idle is consumed, not converted into another scheduled continuation
const scheduledDelays = Array.from(activeTimers.values())
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 && delay !== DEFAULT_SESSION_STATUS_TIMEOUT_MS)).toHaveLength(0)
} finally {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
@@ -1423,4 +1423,32 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
response: { accepted: true, sessionID: "ses_bound_prompt" },
})
})
test("#given session.status hangs forever #when promptAsync gate checks activity #then it dispatches after status timeout", { timeout: 10_000 }, async () => {
// given
let promptCalls = 0
const neverSettles = new Promise<never>(() => {})
const client = {
session: {
status: () => neverSettles,
promptAsync: async () => {
promptCalls += 1
},
},
}
// when
const result = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_status_timeout",
input: { path: { id: "ses_status_timeout" }, body: { parts: [] } },
source: "test:status-timeout",
settleMs: 0,
postDispatchHoldMs: 0,
})
// then
expect(result.status).toBe("dispatched")
expect(promptCalls).toBe(1)
})
})
@@ -52,4 +52,20 @@ describe("session idle prompt guard", () => {
// then
expect(shouldPrompt).toBe(true)
})
test("#given session.status hangs forever #when checking active session #then it returns false after timeout", async () => {
// given
const neverSettles = new Promise<never>(() => {})
const client = {
session: {
status: () => neverSettles,
},
}
// when
const active = await isSessionActive(client, "ses-hang", 50)
// then
expect(active).toBe(false)
})
})
+24 -2
View File
@@ -1,9 +1,24 @@
export const DEFAULT_SESSION_IDLE_SETTLE_MS = 150
export const DEFAULT_SESSION_STATUS_TIMEOUT_MS = 5_000
export function settleAfterSessionIdle(ms = DEFAULT_SESSION_IDLE_SETTLE_MS): Promise<void> {
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
}
function withStatusTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutID: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<T>((_resolve, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(`session.status() timed out after ${timeoutMs}ms`))
}, timeoutMs)
})
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
}
})
}
type SessionStatusClient = {
session?: {
status?: () => Promise<unknown>
@@ -32,13 +47,20 @@ export function isActiveSessionStatusType(statusType: string): boolean {
return ACTIVE_SESSION_STATUSES.has(statusType)
}
export async function isSessionActive(client: SessionStatusClient, sessionID: string): Promise<boolean> {
export async function isSessionActive(
client: SessionStatusClient,
sessionID: string,
statusTimeoutMs: number = DEFAULT_SESSION_STATUS_TIMEOUT_MS,
): Promise<boolean> {
if (typeof client.session?.status !== "function") {
return false
}
try {
const statusResult = await client.session.status()
const statusResult = await withStatusTimeout(
client.session.status(),
statusTimeoutMs,
)
const status = getSessionStatusPayload(statusResult)[sessionID]
if (!isRecord(status)) {
return false