diff --git a/src/entry.ts b/src/entry.ts index 05a453831..4cf1e4eb4 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; import process from "node:process"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; +import { spawnWithSignalForwarding } from "./process/spawn-with-signal-forwarding.js"; if (process.argv.includes("--no-color")) { process.env.NO_COLOR = "1"; @@ -16,41 +16,56 @@ function hasExperimentalWarningSuppressed(nodeOptions: string): boolean { return nodeOptions.includes(EXPERIMENTAL_WARNING_FLAG) || nodeOptions.includes("--no-warnings"); } -function ensureExperimentalWarningSuppressed(): void { - if (process.env.CLAWDBOT_NODE_OPTIONS_READY === "1") return; +function ensureExperimentalWarningSuppressed(): boolean { + if (process.env.CLAWDBOT_NODE_OPTIONS_READY === "1") return false; const nodeOptions = process.env.NODE_OPTIONS ?? ""; - if (hasExperimentalWarningSuppressed(nodeOptions)) return; + if (hasExperimentalWarningSuppressed(nodeOptions)) return false; + process.env.CLAWDBOT_NODE_OPTIONS_READY = "1"; process.env.NODE_OPTIONS = `${nodeOptions} ${EXPERIMENTAL_WARNING_FLAG}`.trim(); - const result = spawnSync(process.execPath, [...process.execArgv, ...process.argv.slice(1)], { - stdio: "inherit", - env: process.env, + + const { child } = spawnWithSignalForwarding( + process.execPath, + [...process.execArgv, ...process.argv.slice(1)], + { + stdio: "inherit", + env: process.env, + }, + ); + + child.on("exit", (code, signal) => { + if (signal) { + process.exitCode = 1; + return; + } + process.exit(code ?? 1); }); - if (result.signal) process.exit(1); - process.exit(result.status ?? 1); + + // Parent must not continue running the CLI. + return true; } -ensureExperimentalWarningSuppressed(); +if (!ensureExperimentalWarningSuppressed()) { + const parsed = parseCliProfileArgs(process.argv); + if (!parsed.ok) { + // Keep it simple; Commander will handle rich help/errors after we strip flags. + console.error(`[clawdbot] ${parsed.error}`); + process.exit(2); + } -const parsed = parseCliProfileArgs(process.argv); -if (!parsed.ok) { - // Keep it simple; Commander will handle rich help/errors after we strip flags. - console.error(`[clawdbot] ${parsed.error}`); - process.exit(2); + if (parsed.profile) { + applyCliProfileEnv({ profile: parsed.profile }); + // Keep Commander and ad-hoc argv checks consistent. + process.argv = parsed.argv; + } + + import("./cli/run-main.js") + .then(({ runCli }) => runCli(process.argv)) + .catch((error) => { + console.error( + "[clawdbot] Failed to start CLI:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exitCode = 1; + }); } - -if (parsed.profile) { - applyCliProfileEnv({ profile: parsed.profile }); - // Keep Commander and ad-hoc argv checks consistent. - process.argv = parsed.argv; -} - -import("./cli/run-main.js") - .then(({ runCli }) => runCli(process.argv)) - .catch((error) => { - console.error( - "[clawdbot] Failed to start CLI:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exitCode = 1; - }); diff --git a/src/process/spawn-with-signal-forwarding.test.ts b/src/process/spawn-with-signal-forwarding.test.ts new file mode 100644 index 000000000..29e187da3 --- /dev/null +++ b/src/process/spawn-with-signal-forwarding.test.ts @@ -0,0 +1,104 @@ +import net from "node:net"; +import path from "node:path"; +import process from "node:process"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { spawnWithSignalForwarding } from "./spawn-with-signal-forwarding.js"; + +function waitForLine(stream: NodeJS.ReadableStream, timeoutMs = 10_000): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("timeout waiting for line")); + }, timeoutMs); + + const onData = (chunk: Buffer | string): void => { + buffer += chunk.toString(); + const idx = buffer.indexOf("\n"); + if (idx >= 0) { + const line = buffer.slice(0, idx).trim(); + cleanup(); + resolve(line); + } + }; + + const onError = (err: unknown): void => { + cleanup(); + reject(err); + }; + + const cleanup = (): void => { + clearTimeout(timeout); + stream.off("data", onData); + stream.off("error", onError); + }; + + stream.on("data", onData); + stream.on("error", onError); + }); +} + +function canConnect(port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.end(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + }); +} + +describe("spawnWithSignalForwarding", () => { + const children: Array<{ kill: (signal?: NodeJS.Signals) => boolean }> = []; + + afterEach(() => { + for (const child of children) { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + } + children.length = 0; + }); + + it( + "forwards SIGTERM to spawned child", + async () => { + const tsxPath = path.resolve(process.cwd(), "node_modules/.bin/tsx"); + const childPath = path.resolve(process.cwd(), "test/fixtures/signal-forwarding/child.ts"); + + const { child } = spawnWithSignalForwarding(tsxPath, [childPath], { + stdio: ["ignore", "pipe", "inherit"], + env: process.env, + }); + children.push(child); + + if (!child.stdout) throw new Error("expected stdout"); + const portLine = await waitForLine(child.stdout); + const port = Number(portLine); + expect(Number.isFinite(port)).toBe(true); + + expect(await canConnect(port)).toBe(true); + + // Simulate systemd sending SIGTERM to the parent process. + process.emit("SIGTERM"); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("timeout waiting for child exit")), 10_000); + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + + await new Promise((r) => setTimeout(r, 250)); + expect(await canConnect(port)).toBe(false); + }, + 20_000, + ); +}); diff --git a/src/process/spawn-with-signal-forwarding.ts b/src/process/spawn-with-signal-forwarding.ts new file mode 100644 index 000000000..af57a9ce0 --- /dev/null +++ b/src/process/spawn-with-signal-forwarding.ts @@ -0,0 +1,40 @@ +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { spawn } from "node:child_process"; +import process from "node:process"; + +export type SpawnWithSignalForwardingOptions = { + signals?: NodeJS.Signals[]; +}; + +export function spawnWithSignalForwarding( + command: string, + args: string[], + options: SpawnOptions, + { signals = ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"] }: SpawnWithSignalForwardingOptions = {}, +): { child: ChildProcess; detach: () => void } { + const child = spawn(command, args, options); + + const listeners = new Map void>(); + for (const signal of signals) { + const listener = (): void => { + try { + child.kill(signal); + } catch { + // ignore + } + }; + listeners.set(signal, listener); + process.on(signal, listener); + } + + const detach = (): void => { + for (const [signal, listener] of listeners) { + process.off(signal, listener); + } + listeners.clear(); + }; + + child.once("exit", detach); + + return { child, detach }; +} diff --git a/test/fixtures/signal-forwarding/child.ts b/test/fixtures/signal-forwarding/child.ts new file mode 100644 index 000000000..a9b55c3fc --- /dev/null +++ b/test/fixtures/signal-forwarding/child.ts @@ -0,0 +1,19 @@ +import http from "node:http"; + +const server = http.createServer((_, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("ok"); +}); + +server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") throw new Error("unexpected address"); + process.stdout.write(`${addr.port}\n`); +}); + +const shutdown = (): void => { + server.close(() => process.exit(0)); +}; + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown);