diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index dcefaf0ee648..a858450d4d24 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -1,7 +1,12 @@ #!/usr/bin/env -S node --import tsx // Telegram User Crabbox Proof script supports OpenClaw repository automation. -import { type ChildProcess, spawn, type SpawnOptionsWithoutStdio } from "node:child_process"; +import { + type ChildProcess, + spawn, + spawnSync, + type SpawnOptionsWithoutStdio, +} from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -587,13 +592,37 @@ function timedOutError(message: string) { const activeCommandChildren = new Set(); let commandCleanupHandlersInstalled = false; -function signalCommandTree(child: ChildProcess, signal: NodeJS.Signals) { - if (child.pid && process.platform !== "win32") { +type CommandTreeTarget = Pick; + +export function signalCommandTree( + child: CommandTreeTarget, + signal: NodeJS.Signals, + { + platform = process.platform, + runTaskkill = spawnSync, + useProcessGroup = platform !== "win32", + }: { + platform?: NodeJS.Platform; + runTaskkill?: typeof spawnSync; + useProcessGroup?: boolean; + } = {}, +) { + if (child.pid && useProcessGroup) { try { process.kill(-child.pid, signal); return; } catch {} } + if (platform === "win32" && typeof child.pid === "number") { + const args = ["/PID", String(child.pid), "/T"]; + if (signal === "SIGKILL") { + args.push("/F"); + } + const result = runTaskkill("taskkill", args, { stdio: "ignore" }); + if (!result.error && result.status === 0) { + return; + } + } child.kill(signal); } diff --git a/test/scripts/telegram-user-crabbox-proof.test.ts b/test/scripts/telegram-user-crabbox-proof.test.ts index 2b23599d8544..d74c373c680d 100644 --- a/test/scripts/telegram-user-crabbox-proof.test.ts +++ b/test/scripts/telegram-user-crabbox-proof.test.ts @@ -18,6 +18,7 @@ import { renderRemoteSetup, renderSelectDesktopChat, runCommand, + signalCommandTree, stageFullSessionArtifacts, startLocalSut, waitForLog, @@ -217,7 +218,10 @@ describe("telegram user Crabbox proof log polling", () => { fs.mkdirSync(publishDir); fs.writeFileSync(path.join(publishDir, "stale.txt"), "stale"); fs.mkdirSync(path.join(outputDir, "publish-gif-only")); - fs.writeFileSync(path.join(outputDir, "session.json"), '{"sshKey":"/private/tmp/openclaw/key"}'); + fs.writeFileSync( + path.join(outputDir, "session.json"), + '{"sshKey":"/private/tmp/openclaw/key"}', + ); fs.writeFileSync(path.join(outputDir, "lease.json"), '{"token":"secret"}'); fs.writeFileSync(path.join(outputDir, "status.json"), '{"ok":true}'); fs.writeFileSync(path.join(outputDir, "probe.json"), '{"ok":true}'); @@ -349,6 +353,31 @@ setInterval(() => {}, 1000); } }); + it("signals Windows proof command process trees with taskkill", () => { + const child = { + kill: vi.fn(), + pid: 12345, + }; + const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 })); + + signalCommandTree(child, "SIGTERM", { + platform: "win32", + runTaskkill, + }); + expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/PID", "12345", "/T"], { + stdio: "ignore", + }); + + signalCommandTree(child, "SIGKILL", { + platform: "win32", + runTaskkill, + }); + expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/PID", "12345", "/T", "/F"], { + stdio: "ignore", + }); + expect(child.kill).not.toHaveBeenCalled(); + }); + posixIt("lets timed-out command descendants exit during kill grace", async () => { const root = makeTempDir(); const scriptPath = path.join(root, "trap-term-grace.mjs");