From d4638cd690be7bf82b33020006768d2163586197 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 21:24:35 -0700 Subject: [PATCH 1/2] Fix session stuck in "Stopping" state when interrupt hangs The SDK's interrupt() and close() calls can hang indefinitely if the agent process is already dead or unresponsive. This causes the stop mutation to never complete, leaving the UI stuck on "Stopping...". Changes: - Add 5-second timeout to interruptClaude() so it doesn't hang forever - Add forceCleanSession() to clean up in-memory state when SDK calls fail or time out - Add onError handlers to stop/interrupt mutations on the client so the UI recovers by refetching actual state instead of staying stuck Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useClaudeState.ts | 8 ++++- src/hooks/useSessionState.ts | 5 ++++ src/server/services/claude-runner.ts | 44 ++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/hooks/useClaudeState.ts b/src/hooks/useClaudeState.ts index c2c53b75..86f0a5f8 100644 --- a/src/hooks/useClaudeState.ts +++ b/src/hooks/useClaudeState.ts @@ -61,7 +61,13 @@ export function useClaudeState(sessionId: string) { ); const sendMutation = trpc.claude.send.useMutation(); - const interruptMutation = trpc.claude.interrupt.useMutation(); + const interruptMutation = trpc.claude.interrupt.useMutation({ + onError: () => { + // If interrupt fails (e.g. timeout), refetch running state + // so the UI doesn't stay stuck on "Interrupting..." + void refetch(); + }, + }); const answerMutation = trpc.claude.answerQuestion.useMutation(); const send = useCallback( diff --git a/src/hooks/useSessionState.ts b/src/hooks/useSessionState.ts index e315ca0c..3ba035d4 100644 --- a/src/hooks/useSessionState.ts +++ b/src/hooks/useSessionState.ts @@ -41,6 +41,11 @@ export function useSessionState(sessionId: string) { onSuccess: (data) => { utils.sessions.get.setData({ sessionId }, { session: data.session }); }, + onError: () => { + // If the stop mutation fails (e.g. timeout or server error), + // refetch session state so we don't stay stuck on "Stopping..." + void refetch(); + }, }); // The API endpoint is "delete" but it now archives instead of permanently deleting diff --git a/src/server/services/claude-runner.ts b/src/server/services/claude-runner.ts index b76e8382..f2b86262 100644 --- a/src/server/services/claude-runner.ts +++ b/src/server/services/claude-runner.ts @@ -603,8 +603,21 @@ export function getPendingInput( }; } +/** + * Race a promise against a timeout. Resolves with `undefined` if the timeout fires first. + */ +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(undefined), ms)), + ]); +} + +const INTERRUPT_TIMEOUT_MS = 5_000; + /** * Interrupt a running Claude query. + * Uses a timeout to prevent hanging if the SDK's interrupt() never resolves. */ export async function interruptClaude(sessionId: string): Promise { const state = sessions.get(sessionId); @@ -614,10 +627,18 @@ export async function interruptClaude(sessionId: string): Promise { } try { - await state.currentQuery.interrupt(); + const result = await withTimeout(state.currentQuery.interrupt(), INTERRUPT_TIMEOUT_MS); + if (result === undefined) { + log.warn('interruptClaude: Timed out, force-cleaning state', { sessionId }); + forceCleanSession(sessionId); + } return true; } catch (err) { - log.warn('interruptClaude: Failed', { sessionId, error: toError(err).message }); + log.warn('interruptClaude: Failed, force-cleaning state', { + sessionId, + error: toError(err).message, + }); + forceCleanSession(sessionId); return false; } } @@ -709,6 +730,25 @@ export async function markLastMessageAsInterrupted(sessionId: string): Promise Date: Fri, 10 Apr 2026 21:31:24 -0700 Subject: [PATCH 2/2] Call close() in forceCleanSession to kill orphaned subprocess When interrupt() times out, forceCleanSession was only cleaning up in-memory state but leaving the CLI subprocess running. Now it calls close() first, which triggers the SDK's subprocess kill chain (SIGTERM immediately, SIGKILL after 5s). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/services/claude-runner.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/server/services/claude-runner.ts b/src/server/services/claude-runner.ts index f2b86262..099002f3 100644 --- a/src/server/services/claude-runner.ts +++ b/src/server/services/claude-runner.ts @@ -731,13 +731,25 @@ export async function markLastMessageAsInterrupted(sessionId: string): Promise