fix(shared): add timeout to isSessionActive to prevent infinite hang

The SDK client disables fetch timeout (req.timeout = false). When the
opencode server is slow or unresponsive, client.session.status() hangs
forever, blocking the entire dispatchToHooks chain (sequential await)
and deadlocking the plugin.

Add a 5s timeout wrapper around the status call in isSessionActive().
On timeout, the catch block returns false (treat as inactive), letting
the caller proceed instead of hanging indefinitely.

The timeout parameter is exposed for testing to avoid 5s test delays.

Refs: .debugging/status-timeout-hang.md
This commit is contained in:
YeonGyu-Kim
2026-05-17 03:40:17 +09:00
parent 169e61f775
commit 36468b118d
4 changed files with 168 additions and 2 deletions
+100
View File
@@ -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
@@ -505,4 +505,32 @@ describe("promptAsyncAfterSessionIdle", () => {
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