From 8eea49f9dae896d29184b5ea5f2801c01ef6ef8d Mon Sep 17 00:00:00 2001 From: Haoqing Wang <78337154+hqhq1025@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:56:45 +0800 Subject: [PATCH] fix(cli): fix process exit handling deadlock and error masking in Claude SDK (#343) --- cli/src/claude/claudeRemote.ts | 11 +++++- cli/src/claude/claudeRemoteLauncher.ts | 3 +- cli/src/claude/sdk/query.ts | 55 ++++++++++++++++++-------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 3d534e3b..28d7645d 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -79,7 +79,16 @@ export async function claudeRemote(opts: { process.env.DISABLE_AUTOUPDATER = '1'; // Get initial message - const initial = await opts.nextMessage(); + let initial; + try { + initial = await opts.nextMessage(); + } catch (e) { + if (e instanceof AbortError) { + logger.debug(`[claudeRemote] Aborted during initial message`); + return; + } + throw e; + } if (!initial) { // No initial message - exit return; } diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index ba65fdac..477336cd 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -363,7 +363,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { } catch (e) { logger.debug('[remote]: launch error', e); if (!this.exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + const detail = e instanceof Error ? e.message : String(e); + session.client.sendSessionEvent({ type: 'message', message: `Process exited unexpectedly: ${detail}` }); continue; } } finally { diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 41af155c..d5d20e93 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -88,6 +88,7 @@ export class Query implements AsyncIterableIterator { */ private async readMessages(): Promise { const rl = createInterface({ input: this.childStdout }) + let hadError = false try { for await (const line of rl) { @@ -118,9 +119,14 @@ export class Query implements AsyncIterableIterator { } await this.processExitPromise } catch (error) { + hadError = true this.inputStream.error(error as Error) } finally { - this.inputStream.done() + // Only call done() on clean exit - calling done() after error() + // would mask the error since Stream.next() checks isDone before hasError + if (!hadError) { + this.inputStream.done() + } this.cleanupControllers() rl.close() } @@ -381,35 +387,50 @@ export function query(config: { process.on('exit', cleanup) // Handle process exit - const processExitPromise = new Promise((resolve) => { - child.on('close', (code) => { - if (config.options?.abort?.aborted) { - query.setError(new AbortError('Claude Code process aborted by user')) - } - if (code !== 0) { - query.setError(new Error(`Claude Code process exited with code ${code}`)) - } else { - resolve() - } - }) + let resolveExit: () => void + let rejectExit: (error: Error) => void + const processExitPromise = new Promise((resolve, reject) => { + resolveExit = resolve + rejectExit = reject }) - // Create query instance + // Create query instance BEFORE registering close handler + // to avoid temporal dependency on `query` variable const query = new Query(childStdin, child.stdout, processExitPromise, canCallTool) + // Register close handler - query is safely defined now + child.on('close', (code) => { + if (config.options?.abort?.aborted) { + const err = new AbortError('Claude Code process aborted by user') + query.setError(err) + rejectExit(err) + } else if (code !== 0) { + const err = new Error(`Claude Code process exited with code ${code}`) + query.setError(err) + rejectExit(err) + } else { + resolveExit() + } + }) + // Handle process errors child.on('error', (error) => { cleanupMcpConfig?.() if (config.options?.abort?.aborted) { - query.setError(new AbortError('Claude Code process aborted by user')) + const err = new AbortError('Claude Code process aborted by user') + query.setError(err) + rejectExit(err) } else { - query.setError(new Error(`Failed to spawn Claude Code process: ${error.message}`)) + const err = new Error(`Failed to spawn Claude Code process: ${error.message}`) + query.setError(err) + rejectExit(err) } }) - // Cleanup on exit - processExitPromise.finally(() => { + // Cleanup on exit (catch rejection to avoid unhandled promise warning) + processExitPromise.catch(() => {}).finally(() => { cleanup() + process.removeListener('exit', cleanup) config.options?.abort?.removeEventListener('abort', cleanup) if (process.env.CLAUDE_SDK_MCP_SERVERS) { delete process.env.CLAUDE_SDK_MCP_SERVERS