diff --git a/scripts/ui.js b/scripts/ui.js index e22b9ba729d..da92d77bf67 100644 --- a/scripts/ui.js +++ b/scripts/ui.js @@ -88,11 +88,48 @@ function runSpawnCall(spawnCall, label) { return; } + let forwardedSignal = null; + let forceKillTimer = null; + // Keep UI dev children in the foreground process group for native TTY + // resize/job-control behavior. Only forward direct wrapper termination. + const forwardedSignals = ["SIGTERM"]; + const signalHandlers = new Map( + forwardedSignals.map((signal) => [ + signal, + () => { + forwardedSignal ??= signal; + child.kill(signal); + forceKillTimer ??= setTimeout(() => child.kill("SIGKILL"), 5_000); + }, + ]), + ); + const cleanupSignalHandlers = () => { + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler); + } + if (forceKillTimer) { + clearTimeout(forceKillTimer); + } + }; + for (const [signal, handler] of signalHandlers) { + process.on(signal, handler); + } + child.on("error", (err) => { + cleanupSignalHandlers(); console.error(`Failed to launch ${label}:`, err); process.exit(1); }); - child.on("exit", (code) => { + child.on("exit", (code, signal) => { + cleanupSignalHandlers(); + if (forwardedSignal) { + process.kill(process.pid, forwardedSignal); + return; + } + if (signal) { + process.kill(process.pid, signal); + return; + } if (code !== 0) { process.exit(code ?? 1); } @@ -118,7 +155,8 @@ function runSpawnCallSync(spawnCall, label) { return; } if (result.signal) { - process.exit(1); + process.kill(process.pid, result.signal); + return; } if ((result.status ?? 1) !== 0) { process.exit(result.status ?? 1); diff --git a/test/scripts/ui.test.ts b/test/scripts/ui.test.ts index a28f7bec174..7440d746dc3 100644 --- a/test/scripts/ui.test.ts +++ b/test/scripts/ui.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -10,6 +10,38 @@ import { shouldUseCmdExeForCommand, } from "../../scripts/ui.js"; +async function waitFor(predicate: () => boolean, label: string, timeoutMs = 3_000): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`timed out waiting for ${label}`); + } + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } +} + +async function waitForExit( + child: ChildProcess, + timeoutMs = 3_000, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("timed out waiting for child exit")); + }, timeoutMs); + child.once("exit", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + describe("scripts/ui windows spawn behavior", () => { it("wraps Windows command launchers with cmd.exe without enabling shell mode", () => { expect( @@ -160,4 +192,51 @@ describe("scripts/ui windows spawn behavior", () => { expect(output).not.toContain("Missing UI runner"); expect(output).toContain("vite"); }); + + it.runIf(process.platform !== "win32")( + "terminates the pnpm child on wrapper SIGTERM", + async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ui-wrapper-signals-")); + const runnerPath = path.join(tempDir, "pnpm.mjs"); + const readyFile = path.join(tempDir, "ready"); + const signaledFile = path.join(tempDir, "signaled"); + + fs.writeFileSync( + runnerPath, + [ + "import fs from 'node:fs';", + "process.on('SIGTERM', () => {", + " fs.writeFileSync(process.env.SIGNALED_FILE, 'SIGTERM');", + " setTimeout(() => process.exit(0), 25);", + "});", + "fs.writeFileSync(process.env.READY_FILE, process.argv.slice(2).join(' '));", + "setInterval(() => {}, 1000);", + ].join("\n"), + ); + + const wrapper = spawn(process.execPath, ["scripts/ui.js", "install"], { + cwd: path.resolve("."), + env: { + ...process.env, + npm_execpath: runnerPath, + READY_FILE: readyFile, + SIGNALED_FILE: signaledFile, + }, + stdio: "ignore", + }); + + try { + await waitFor(() => fs.existsSync(readyFile), "UI runner readiness"); + expect(fs.readFileSync(readyFile, "utf8")).toBe("install"); + wrapper.kill("SIGTERM"); + + const exit = await waitForExit(wrapper); + expect(exit).toEqual({ code: null, signal: "SIGTERM" }); + expect(fs.readFileSync(signaledFile, "utf8")).toBe("SIGTERM"); + } finally { + wrapper.kill("SIGKILL"); + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }, + ); });