fix: handle signal-killed exit code and guard SIGTERM kill

- code ?? 0 → code ?? 1: signal-terminated processes return null exit code,
  which was incorrectly coerced to 0 (success) instead of 1 (failure)
- wrap proc.kill(SIGTERM) in try/catch to match SIGKILL guard and prevent
  EPERM/ESRCH from crashing on already-dead processes
This commit is contained in:
Cole Leavitt 2026-02-21 16:03:06 -07:00 committed by YeonGyu-Kim
parent b666ab24df
commit e1568a4705

View File

@ -1,114 +1,116 @@
import { spawn } from "node:child_process" import { spawn } from "node:child_process";
import { getHomeDirectory } from "./home-directory" import { getHomeDirectory } from "./home-directory";
import { findBashPath, findZshPath } from "./shell-path" import { findBashPath, findZshPath } from "./shell-path";
export interface CommandResult { export interface CommandResult {
exitCode: number exitCode: number;
stdout?: string stdout?: string;
stderr?: string stderr?: string;
} }
const DEFAULT_HOOK_TIMEOUT_MS = 30_000 const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
const SIGKILL_GRACE_MS = 5_000 const SIGKILL_GRACE_MS = 5_000;
export interface ExecuteHookOptions { export interface ExecuteHookOptions {
forceZsh?: boolean forceZsh?: boolean;
zshPath?: string zshPath?: string;
/** Timeout in milliseconds. Process is killed after this. Default: 30000 */ /** Timeout in milliseconds. Process is killed after this. Default: 30000 */
timeoutMs?: number timeoutMs?: number;
} }
export async function executeHookCommand( export async function executeHookCommand(
command: string, command: string,
stdin: string, stdin: string,
cwd: string, cwd: string,
options?: ExecuteHookOptions, options?: ExecuteHookOptions,
): Promise<CommandResult> { ): Promise<CommandResult> {
const home = getHomeDirectory() const home = getHomeDirectory();
const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
const expandedCommand = command const expandedCommand = command
.replace(/^~(?=\/|$)/g, home) .replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`) .replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd) .replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd) .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd);
let finalCommand = expandedCommand let finalCommand = expandedCommand;
if (options?.forceZsh) { if (options?.forceZsh) {
const zshPath = findZshPath(options.zshPath) const zshPath = findZshPath(options.zshPath);
const escapedCommand = expandedCommand.replace(/'/g, "'\\''") const escapedCommand = expandedCommand.replace(/'/g, "'\\''");
if (zshPath) { if (zshPath) {
finalCommand = `${zshPath} -lc '${escapedCommand}'` finalCommand = `${zshPath} -lc '${escapedCommand}'`;
} else { } else {
const bashPath = findBashPath() const bashPath = findBashPath();
if (bashPath) { if (bashPath) {
finalCommand = `${bashPath} -lc '${escapedCommand}'` finalCommand = `${bashPath} -lc '${escapedCommand}'`;
} }
} }
} }
return new Promise((resolve) => { return new Promise(resolve => {
let settled = false let settled = false;
let killTimer: ReturnType<typeof setTimeout> | null = null let killTimer: ReturnType<typeof setTimeout> | null = null;
const proc = spawn(finalCommand, { const proc = spawn(finalCommand, {
cwd, cwd,
shell: true, shell: true,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd }, env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
}) });
let stdout = "" let stdout = "";
let stderr = "" let stderr = "";
proc.stdout?.on("data", (data: Buffer) => { proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString() stdout += data.toString();
}) });
proc.stderr?.on("data", (data: Buffer) => { proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString() stderr += data.toString();
}) });
proc.stdin?.write(stdin) proc.stdin?.write(stdin);
proc.stdin?.end() proc.stdin?.end();
const settle = (result: CommandResult) => { const settle = (result: CommandResult) => {
if (settled) return if (settled) return;
settled = true settled = true;
if (killTimer) clearTimeout(killTimer) if (killTimer) clearTimeout(killTimer);
if (timeoutTimer) clearTimeout(timeoutTimer) if (timeoutTimer) clearTimeout(timeoutTimer);
resolve(result) resolve(result);
} };
proc.on("close", (code) => { proc.on("close", code => {
settle({ settle({
exitCode: code ?? 0, exitCode: code ?? 1,
stdout: stdout.trim(), stdout: stdout.trim(),
stderr: stderr.trim(), stderr: stderr.trim(),
}) });
}) });
proc.on("error", (err) => { proc.on("error", err => {
settle({ exitCode: 1, stderr: err.message }) settle({ exitCode: 1, stderr: err.message });
}) });
const timeoutTimer = setTimeout(() => { const timeoutTimer = setTimeout(() => {
if (settled) return if (settled) return;
// Try graceful shutdown first // Try graceful shutdown first
proc.kill("SIGTERM") try {
killTimer = setTimeout(() => { proc.kill("SIGTERM");
if (settled) return } catch {}
try { killTimer = setTimeout(() => {
proc.kill("SIGKILL") if (settled) return;
} catch {} try {
}, SIGKILL_GRACE_MS) proc.kill("SIGKILL");
// Append timeout notice to stderr } catch {}
stderr += `\nHook command timed out after ${timeoutMs}ms` }, SIGKILL_GRACE_MS);
}, timeoutMs) // Append timeout notice to stderr
stderr += `\nHook command timed out after ${timeoutMs}ms`;
}, timeoutMs);
// Don't let the timeout timer keep the process alive // Don't let the timeout timer keep the process alive
if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) { if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) {
timeoutTimer.unref() timeoutTimer.unref();
} }
}) });
} }