mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
refactor: extract spawn abort handling into shared utility
This commit is contained in:
+12
-117
@@ -1,4 +1,3 @@
|
|||||||
import { spawn } from "node:child_process";
|
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { logger } from "@/ui/logger";
|
import { logger } from "@/ui/logger";
|
||||||
import { restoreTerminalState } from "@/ui/terminalState";
|
import { restoreTerminalState } from "@/ui/terminalState";
|
||||||
@@ -6,14 +5,7 @@ import { claudeCheckSession } from "./utils/claudeCheckSession";
|
|||||||
import { getProjectPath } from "./utils/path";
|
import { getProjectPath } from "./utils/path";
|
||||||
import { systemPrompt } from "./utils/systemPrompt";
|
import { systemPrompt } from "./utils/systemPrompt";
|
||||||
import { withBunRuntimeEnv } from "@/utils/bunRuntime";
|
import { withBunRuntimeEnv } from "@/utils/bunRuntime";
|
||||||
|
import { spawnWithAbort } from "@/utils/spawnWithAbort";
|
||||||
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 async function claudeLocal(opts: {
|
export async function claudeLocal(opts: {
|
||||||
abort: AbortSignal,
|
abort: AbortSignal,
|
||||||
@@ -87,118 +79,21 @@ export async function claudeLocal(opts: {
|
|||||||
...opts.claudeEnvVars
|
...opts.claudeEnvVars
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[ClaudeLocal] Spawning claude');
|
logger.debug(`[ClaudeLocal] Spawning claude with args: ${JSON.stringify(args)}`);
|
||||||
logger.debug(`[ClaudeLocal] Args: ${JSON.stringify(args)}`);
|
|
||||||
|
|
||||||
const child = spawn('claude', args, {
|
spawnWithAbort({
|
||||||
stdio: ['inherit', 'inherit', 'inherit'],
|
command: 'claude',
|
||||||
signal: opts.abort,
|
args,
|
||||||
killSignal: 'SIGINT',
|
|
||||||
cwd: opts.path,
|
cwd: opts.path,
|
||||||
env: withBunRuntimeEnv(env, { allowBunBeBun: false }),
|
env: withBunRuntimeEnv(env, { allowBunBeBun: false }),
|
||||||
|
signal: opts.abort,
|
||||||
|
logLabel: 'ClaudeLocal',
|
||||||
|
spawnName: 'claude',
|
||||||
|
installHint: 'Claude CLI',
|
||||||
|
includeCause: true,
|
||||||
|
logExit: true,
|
||||||
shell: process.platform === 'win32'
|
shell: process.platform === 'win32'
|
||||||
});
|
}).then(r).catch(reject);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
|
|||||||
+12
-67
@@ -1,7 +1,6 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { logger } from '@/ui/logger';
|
import { logger } from '@/ui/logger';
|
||||||
import { restoreTerminalState } from '@/ui/terminalState';
|
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.
|
* Filter out 'resume' subcommand which is managed internally by hapi.
|
||||||
@@ -60,71 +59,17 @@ export async function codexLocal(opts: {
|
|||||||
|
|
||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await spawnWithAbort({
|
||||||
const child = spawn('codex', args, {
|
command: 'codex',
|
||||||
stdio: ['inherit', 'inherit', 'inherit'],
|
args,
|
||||||
signal: opts.abort,
|
cwd: opts.path,
|
||||||
cwd: opts.path,
|
env: process.env,
|
||||||
env: process.env
|
signal: opts.abort,
|
||||||
});
|
logLabel: 'CodexLocal',
|
||||||
|
spawnName: 'codex',
|
||||||
let abortKillTimeout: NodeJS.Timeout | null = null;
|
installHint: 'Codex CLI',
|
||||||
const abortHandler = () => {
|
includeCause: true,
|
||||||
if (abortKillTimeout) {
|
logExit: true
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export function restoreTerminalState(): void {
|
|||||||
if (process.stdout.isTTY) {
|
if (process.stdout.isTTY) {
|
||||||
// Disable kitty keyboard protocol / CSI u key release reporting if enabled.
|
// Disable kitty keyboard protocol / CSI u key release reporting if enabled.
|
||||||
process.stdout.write('\x1b[>4;0m');
|
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');
|
process.stdout.write('\x1b[?2004l');
|
||||||
}
|
}
|
||||||
if (process.stdin.isTTY) {
|
if (process.stdin.isTTY) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user