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:
@@ -0,0 +1,100 @@
|
|||||||
|
# Debugging Journal: session.status() Infinite Hang
|
||||||
|
|
||||||
|
**Date:** 2026-05-16
|
||||||
|
**Branch:** fix/status-timeout-hang
|
||||||
|
**Worktree:** mimo v2.5 pro
|
||||||
|
**Severity:** Critical — deadlocks entire plugin event chain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Environment
|
||||||
|
|
||||||
|
- **Runtime:** Bun 1.3.x (TypeScript, strict mode)
|
||||||
|
- **Host:** OpenCode plugin system (`oh-my-opencode`)
|
||||||
|
- **Symptom:** Plugin hangs forever during prompting — no hooks fire, no events processed, system becomes unresponsive
|
||||||
|
- **Reporter:** User reports "갑자기 프롬프팅하다가 뻗어버리는" (suddenly hangs while prompting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Hypotheses
|
||||||
|
|
||||||
|
### H1: `isSessionActive()` has no timeout — CONFIRMED
|
||||||
|
|
||||||
|
`isSessionActive()` in `src/shared/session-idle-settle.ts` calls `client.session.status()` with NO timeout wrapper. The SDK client (`packages/sdk/js/src/client.ts`) explicitly disables fetch timeout:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const customFetch: any = (req: any) => {
|
||||||
|
req.timeout = false // DISABLES TIMEOUT
|
||||||
|
return fetch(req)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the opencode server is slow or unresponsive, this call hangs forever.
|
||||||
|
|
||||||
|
### H2: Sequential `await` in `dispatchToHooks` propagates hang — CONFIRMED
|
||||||
|
|
||||||
|
`src/plugin/event.ts` calls hooks sequentially:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const dispatchToHooks = async (input: EventInput): Promise<void> => {
|
||||||
|
await runEventHookSafely("todoContinuationEnforcer", ...)
|
||||||
|
await runEventHookSafely("runtimeFallback", ...)
|
||||||
|
await runEventHookSafely("atlasHook", ...)
|
||||||
|
// ... 20+ hooks, ALL sequential await
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`runEventHookSafely` catches errors but has NO timeout. If any hook hangs, the entire chain is blocked.
|
||||||
|
|
||||||
|
### H3: Reservation deadlock — CONFIRMED (secondary effect)
|
||||||
|
|
||||||
|
In `dispatchAfterSessionIdle()`:
|
||||||
|
1. Reservation is set BEFORE `isSessionActive()` call
|
||||||
|
2. If `isSessionActive()` hangs, the `finally` block never runs
|
||||||
|
3. Reservation is never released
|
||||||
|
4. All subsequent calls for the same sessionID return "reserved"
|
||||||
|
|
||||||
|
### H4: Multiple hooks trigger same hang path — CONFIRMED
|
||||||
|
|
||||||
|
At least 6 hooks call `promptAsyncAfterSessionIdle` or `shouldPromptAfterSessionIdle`:
|
||||||
|
- `todoContinuationEnforcer` (main session continuation)
|
||||||
|
- `atlasHook` (boulder session continuation)
|
||||||
|
- `ralphLoop` (ralph loop continuation)
|
||||||
|
- `runtimeFallback` (error recovery)
|
||||||
|
- `sessionRecovery` (session resume)
|
||||||
|
- `teamIdleWakeHint` (team mode wake)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
`isSessionActive()` in `session-idle-settle.ts` has no timeout on `client.session.status()`. The SDK client disables fetch timeout. When opencode server is slow/unresponsive:
|
||||||
|
|
||||||
|
1. `isSessionActive()` hangs forever
|
||||||
|
2. `dispatchAfterSessionIdle()` hangs (reservation never released)
|
||||||
|
3. `dispatchToHooks()` hangs (sequential await blocks all subsequent hooks)
|
||||||
|
4. Plugin becomes completely unresponsive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix Plan
|
||||||
|
|
||||||
|
Add timeout wrapper to `isSessionActive()`. If status call exceeds threshold (5s), treat session as inactive (return false). This is safe because:
|
||||||
|
- If the server is unresponsive, we can't trust the status anyway
|
||||||
|
- Returning false lets the caller proceed (dispatch or skip)
|
||||||
|
- The reservation mechanism prevents duplicate dispatches even with false negatives
|
||||||
|
|
||||||
|
**Files to change:**
|
||||||
|
- `src/shared/session-idle-settle.ts` — add timeout to `isSessionActive()`
|
||||||
|
- `src/hooks/shared/session-idle-settle.test.ts` — add timeout behavior test
|
||||||
|
- `src/hooks/shared/prompt-async-gate.test.ts` — add status-timeout recovery test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] Existing tests pass (no behavioral change for normal flow)
|
||||||
|
- [ ] New test: `isSessionActive` returns false when status call hangs
|
||||||
|
- [ ] New test: `dispatchAfterSessionIdle` recovers from status timeout
|
||||||
|
- [ ] `bun test` green
|
||||||
|
- [ ] `bun run typecheck` green
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import type { BoulderState } from "../../features/boulder-state"
|
import type { BoulderState } from "../../features/boulder-state"
|
||||||
import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-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_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 type { AtlasHookOptions, PendingTaskRef } from "./types"
|
||||||
import { createAtlasHook } from "./index"
|
import { createAtlasHook } from "./index"
|
||||||
import { createToolExecuteAfterHandler } from "./tool-execute-after"
|
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
|
// then - stale idle is consumed, not converted into another scheduled continuation
|
||||||
const scheduledDelays = Array.from(activeTimers.values())
|
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 && delay !== DEFAULT_SESSION_STATUS_TIMEOUT_MS)).toHaveLength(0)
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.setTimeout = originalSetTimeout
|
globalThis.setTimeout = originalSetTimeout
|
||||||
globalThis.clearTimeout = originalClearTimeout
|
globalThis.clearTimeout = originalClearTimeout
|
||||||
|
|||||||
@@ -1423,4 +1423,32 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
response: { accepted: true, sessionID: "ses_bound_prompt" },
|
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
|
// then
|
||||||
expect(shouldPrompt).toBe(true)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
export const DEFAULT_SESSION_IDLE_SETTLE_MS = 150
|
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> {
|
export function settleAfterSessionIdle(ms = DEFAULT_SESSION_IDLE_SETTLE_MS): Promise<void> {
|
||||||
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
|
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 = {
|
type SessionStatusClient = {
|
||||||
session?: {
|
session?: {
|
||||||
status?: () => Promise<unknown>
|
status?: () => Promise<unknown>
|
||||||
@@ -32,13 +47,20 @@ export function isActiveSessionStatusType(statusType: string): boolean {
|
|||||||
return ACTIVE_SESSION_STATUSES.has(statusType)
|
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") {
|
if (typeof client.session?.status !== "function") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const statusResult = await client.session.status()
|
const statusResult = await withStatusTimeout(
|
||||||
|
client.session.status(),
|
||||||
|
statusTimeoutMs,
|
||||||
|
)
|
||||||
const status = getSessionStatusPayload(statusResult)[sessionID]
|
const status = getSessionStatusPayload(statusResult)[sessionID]
|
||||||
if (!isRecord(status)) {
|
if (!isRecord(status)) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user