refactor: extract spawn abort handling into shared utility

This commit is contained in:
weishu
2025-12-26 18:18:12 +08:00
parent b3499bb5ed
commit 5d79a3f1ef
4 changed files with 159 additions and 184 deletions
+12 -117
View File
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { logger } from "@/ui/logger";
import { restoreTerminalState } from "@/ui/terminalState";
@@ -6,14 +5,7 @@ import { claudeCheckSession } from "./utils/claudeCheckSession";
import { getProjectPath } from "./utils/path";
import { systemPrompt } from "./utils/systemPrompt";
import { withBunRuntimeEnv } from "@/utils/bunRuntime";
const isAbortError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') {
return false;
}
const maybeError = error as { name?: string; code?: string };
return maybeError.name === 'AbortError' || maybeError.code === 'ABORT_ERR';
};
import { spawnWithAbort } from "@/utils/spawnWithAbort";
export async function claudeLocal(opts: {
abort: AbortSignal,
@@ -87,118 +79,21 @@ export async function claudeLocal(opts: {
...opts.claudeEnvVars
}
logger.debug('[ClaudeLocal] Spawning claude');
logger.debug(`[ClaudeLocal] Args: ${JSON.stringify(args)}`);
logger.debug(`[ClaudeLocal] Spawning claude with args: ${JSON.stringify(args)}`);
const child = spawn('claude', args, {
stdio: ['inherit', 'inherit', 'inherit'],
signal: opts.abort,
killSignal: 'SIGINT',
spawnWithAbort({
command: 'claude',
args,
cwd: opts.path,
env: withBunRuntimeEnv(env, { allowBunBeBun: false }),
signal: opts.abort,
logLabel: 'ClaudeLocal',
spawnName: 'claude',
installHint: 'Claude CLI',
includeCause: true,
logExit: true,
shell: process.platform === 'win32'
});
let settled = false;
const abortTimeoutMs = {
term: 1000,
kill: 3000
};
let abortTermTimeout: NodeJS.Timeout | null = null;
let abortKillTimeout: NodeJS.Timeout | null = null;
let forcedTermination = false;
let abortStartedAt: number | null = null;
const isAlive = () => child.exitCode === null && !child.killed;
const formatAbortElapsed = () => {
if (abortStartedAt === null) {
return 'n/a';
}
return `${Date.now() - abortStartedAt}ms`;
};
const abortHandler = () => {
if (abortTermTimeout || abortKillTimeout) {
logger.debug('[ClaudeLocal] Abort already in progress');
return;
}
abortStartedAt = Date.now();
logger.debug('[ClaudeLocal] Abort signaled, waiting for SIGINT to exit');
abortTermTimeout = setTimeout(() => {
if (isAlive()) {
forcedTermination = true;
logger.debug(`[ClaudeLocal] Abort timeout reached (${formatAbortElapsed()}), sending SIGTERM`);
try {
child.kill('SIGTERM');
} catch (error) {
logger.debug('[ClaudeLocal] Failed to send SIGTERM', error);
}
}
abortKillTimeout = setTimeout(() => {
if (isAlive()) {
forcedTermination = true;
logger.debug(`[ClaudeLocal] Abort timeout reached (${formatAbortElapsed()}), sending SIGKILL`);
try {
child.kill('SIGKILL');
} catch (error) {
logger.debug('[ClaudeLocal] Failed to send SIGKILL', error);
}
}
}, abortTimeoutMs.kill);
}, abortTimeoutMs.term);
};
if (opts.abort.aborted) {
abortHandler();
} else {
opts.abort.addEventListener('abort', abortHandler);
}
const cleanupAbortHandler = () => {
if (abortTermTimeout) {
clearTimeout(abortTermTimeout);
abortTermTimeout = null;
}
if (abortKillTimeout) {
clearTimeout(abortKillTimeout);
abortKillTimeout = null;
}
opts.abort.removeEventListener('abort', abortHandler);
};
const finalize = (error?: Error) => {
if (settled) {
return;
}
settled = true;
cleanupAbortHandler();
if (error) {
reject(error);
} else {
r();
}
};
child.on('error', (error) => {
if (opts.abort.aborted || isAbortError(error)) {
logger.debug('[ClaudeLocal] Spawn aborted while switching');
if (!child.pid) {
finalize();
}
return;
}
const message = error instanceof Error ? error.message : String(error);
finalize(new Error(`Failed to spawn claude: ${message}. Is Claude installed and on PATH?`));
});
child.on('exit', (code, signal) => {
logger.debug(`[ClaudeLocal] Child exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}, aborted=${opts.abort.aborted}, forced=${forcedTermination}, elapsed=${formatAbortElapsed()})`);
if ((signal === 'SIGTERM' || signal === 'SIGINT' || signal === 'SIGKILL') && opts.abort.aborted) {
// Normal termination due to abort signal
finalize();
} else if (signal) {
finalize(new Error(`Process terminated with signal: ${signal}`));
} else {
finalize();
}
});
}).then(r).catch(reject);
});
} finally {
process.stdin.resume();
+12 -67
View File
@@ -1,7 +1,6 @@
import { spawn } from 'node:child_process';
import { logger } from '@/ui/logger';
import { restoreTerminalState } from '@/ui/terminalState';
import { killProcessByChildProcess } from '@/utils/process';
import { spawnWithAbort } from '@/utils/spawnWithAbort';
/**
* Filter out 'resume' subcommand which is managed internally by hapi.
@@ -60,71 +59,17 @@ export async function codexLocal(opts: {
process.stdin.pause();
try {
await new Promise<void>((resolve, reject) => {
const child = spawn('codex', args, {
stdio: ['inherit', 'inherit', 'inherit'],
signal: opts.abort,
cwd: opts.path,
env: process.env
});
let abortKillTimeout: NodeJS.Timeout | null = null;
const abortHandler = () => {
if (abortKillTimeout) {
return;
}
abortKillTimeout = setTimeout(() => {
if (child.exitCode === null && !child.killed) {
logger.debug('[CodexLocal] Abort timeout reached, sending SIGKILL');
try {
void killProcessByChildProcess(child, true);
} catch (error) {
logger.debug('[CodexLocal] Failed to send SIGKILL:', error);
}
}
}, 5000);
};
if (opts.abort.aborted) {
abortHandler();
} else {
opts.abort.addEventListener('abort', abortHandler);
}
const cleanupAbortHandler = () => {
if (abortKillTimeout) {
clearTimeout(abortKillTimeout);
abortKillTimeout = null;
}
opts.abort.removeEventListener('abort', abortHandler);
};
child.on('error', (error) => {
cleanupAbortHandler();
if (opts.abort.aborted) {
resolve();
return;
}
const message = error instanceof Error ? error.message : String(error);
reject(new Error(`Failed to spawn codex: ${message}. Is Codex CLI installed and on PATH?`, { cause: error }));
});
child.on('exit', (code, signal) => {
cleanupAbortHandler();
if (signal === 'SIGTERM' && opts.abort.aborted) {
resolve();
return;
}
if (signal) {
reject(new Error(`Process terminated with signal: ${signal}`));
return;
}
if (typeof code === 'number' && code !== 0) {
reject(new Error(`Process exited with code: ${code}`));
return;
}
resolve();
});
await spawnWithAbort({
command: 'codex',
args,
cwd: opts.path,
env: process.env,
signal: opts.abort,
logLabel: 'CodexLocal',
spawnName: 'codex',
installHint: 'Codex CLI',
includeCause: true,
logExit: true
});
} finally {
process.stdin.resume();
+2
View File
@@ -2,6 +2,8 @@ export function restoreTerminalState(): void {
if (process.stdout.isTTY) {
// Disable kitty keyboard protocol / CSI u key release reporting if enabled.
process.stdout.write('\x1b[>4;0m');
// Disable focus reporting to avoid stray ^[[I on mode switches.
process.stdout.write('\x1b[?1004l');
process.stdout.write('\x1b[?2004l');
}
if (process.stdin.isTTY) {
+133
View File
@@ -0,0 +1,133 @@
import { spawn, type SpawnOptions, type StdioOptions } from 'node:child_process';
import { logger } from '@/ui/logger';
import { killProcessByChildProcess } from '@/utils/process';
const DEFAULT_ABORT_EXIT_CODES = [130, 137, 143];
const DEFAULT_ABORT_SIGNALS: NodeJS.Signals[] = ['SIGTERM'];
const isAbortError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') {
return false;
}
const maybeError = error as { name?: string; code?: string };
return maybeError.name === 'AbortError' || maybeError.code === 'ABORT_ERR';
};
export type SpawnWithAbortOptions = {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
logLabel: string;
spawnName: string;
installHint: string;
abortKillTimeoutMs?: number;
abortExitCodes?: number[];
abortSignals?: NodeJS.Signals[];
includeCause?: boolean;
logExit?: boolean;
shell?: SpawnOptions['shell'];
stdio?: StdioOptions;
};
export async function spawnWithAbort(options: SpawnWithAbortOptions): Promise<void> {
const abortKillTimeoutMs = options.abortKillTimeoutMs ?? 5000;
const abortExitCodes = options.abortExitCodes ?? DEFAULT_ABORT_EXIT_CODES;
const abortSignals = options.abortSignals ?? DEFAULT_ABORT_SIGNALS;
const stdio = options.stdio ?? ['inherit', 'inherit', 'inherit'];
const logPrefix = options.logLabel ? `[${options.logLabel}] ` : '';
const logDebug = (message: string, ...args: unknown[]) => {
logger.debug(`${logPrefix}${message}`, ...args);
};
await new Promise<void>((resolve, reject) => {
const child = spawn(options.command, options.args, {
stdio,
signal: options.signal,
cwd: options.cwd,
env: options.env,
shell: options.shell
});
let abortKillTimeout: NodeJS.Timeout | null = null;
const abortHandler = () => {
if (abortKillTimeout) {
return;
}
abortKillTimeout = setTimeout(() => {
if (child.exitCode === null && !child.killed) {
logDebug('Abort timeout reached, sending SIGKILL');
try {
void killProcessByChildProcess(child, true);
} catch (error) {
logDebug('Failed to send SIGKILL', error);
}
}
}, abortKillTimeoutMs);
};
if (options.signal.aborted) {
abortHandler();
} else {
options.signal.addEventListener('abort', abortHandler);
}
const cleanupAbortHandler = () => {
if (abortKillTimeout) {
clearTimeout(abortKillTimeout);
abortKillTimeout = null;
}
options.signal.removeEventListener('abort', abortHandler);
};
child.on('error', (error) => {
cleanupAbortHandler();
if (options.signal.aborted && isAbortError(error)) {
logDebug('Spawn aborted while switching');
if (!child.pid) {
resolve();
}
return;
}
if (options.signal.aborted) {
resolve();
return;
}
const message = error instanceof Error ? error.message : String(error);
const errorMessage = `Failed to spawn ${options.spawnName}: ${message}. ` +
`Is ${options.installHint} installed and on PATH?`;
if (options.includeCause) {
reject(new Error(errorMessage, { cause: error }));
} else {
reject(new Error(errorMessage));
}
});
child.on('exit', (code, signal) => {
cleanupAbortHandler();
if (options.logExit) {
logDebug(`Child exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}, aborted=${options.signal.aborted})`);
}
if (options.signal.aborted && signal && abortSignals.includes(signal)) {
resolve();
return;
}
if (options.signal.aborted && typeof code === 'number' && abortExitCodes.includes(code)) {
resolve();
return;
}
if (signal) {
reject(new Error(`Process terminated with signal: ${signal}`));
return;
}
if (typeof code === 'number' && code !== 0) {
reject(new Error(`Process exited with code: ${code}`));
return;
}
resolve();
});
});
}