Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/hooks/useClaudeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/useSessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions src/server/services/claude-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,8 +603,21 @@ export function getPendingInput(
};
}

/**
* Race a promise against a timeout. Resolves with `undefined` if the timeout fires first.
*/
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
return Promise.race([
promise,
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms)),
]);
}
Comment on lines +609 to +614
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of withTimeout has two issues:

  1. Ambiguity: It resolves to undefined both when the timeout occurs and when the input promise resolves to undefined. Since state.currentQuery.interrupt() returns Promise<void>, it always resolves to undefined on success, making the timeout check at line 631 always true.
  2. Resource Leak: The setTimeout timer is not cleared if the promise resolves before the timeout, which can lead to an accumulation of active timers in a long-running process.

I suggest using a wrapper object to distinguish between success and timeout, and ensuring the timer is cleared.

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<{ ok: true; value: T } | { ok: false }> {
  let timeoutId: ReturnType<typeof setTimeout>;
  const timeoutPromise = new Promise<{ ok: false }>((resolve) => {
    timeoutId = setTimeout(() => resolve({ ok: false }), ms);
  });
  return Promise.race([
    promise.then((value) => ({ ok: true as const, value })),
    timeoutPromise,
  ]).finally(() => clearTimeout(timeoutId));
}


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<boolean> {
const state = sessions.get(sessionId);
Expand All @@ -614,10 +627,18 @@ export async function interruptClaude(sessionId: string): Promise<boolean> {
}

try {
await state.currentQuery.interrupt();
const result = await withTimeout(state.currentQuery.interrupt(), INTERRUPT_TIMEOUT_MS);
if (result === undefined) {
Comment on lines +630 to +631
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update this check to align with the improved withTimeout implementation that uses a wrapper object to distinguish between a successful interruption and a timeout.

Suggested change
const result = await withTimeout(state.currentQuery.interrupt(), INTERRUPT_TIMEOUT_MS);
if (result === undefined) {
const result = await withTimeout(state.currentQuery.interrupt(), INTERRUPT_TIMEOUT_MS);
if (!result.ok) {

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;
}
}
Expand Down Expand Up @@ -709,6 +730,37 @@ export async function markLastMessageAsInterrupted(sessionId: string): Promise<v
});
}

/**
* Force-clean a session's in-memory state and kill the subprocess.
* Used as a fallback when interrupt() hangs or fails.
*
* Calls close() on the query to trigger the SDK's subprocess kill
* (SIGTERM immediately, SIGKILL after 5s), then cleans up our state.
*/
function forceCleanSession(sessionId: string): void {
const state = sessions.get(sessionId);
if (!state) return;

// close() is synchronous (fire-and-forget) and kills the subprocess
if (state.currentQuery) {
try {
state.currentQuery.close();
} catch {
// Ignore close errors
}
}

if (state.pendingInput) {
state.pendingInput.reject(new Error('Session force-cleaned'));
state.pendingInput = null;
}

state.isRunning = false;
state.currentQuery = null;
sessions.delete(sessionId);
sseEvents.emitClaudeRunning(sessionId, false);
}

/**
* Stop a session's Claude query and clean up state.
* Called when a session is stopped.
Expand Down
Loading