From 34806b39cd7baf10fbcf567828cc6426d7e15e4c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 21 Jun 2026 06:37:51 +0200 Subject: [PATCH] fix(package): kill candidate resolver trees on windows --- .../resolve-openclaw-package-candidate.mjs | 44 ++++++++---- ...resolve-openclaw-package-candidate.test.ts | 68 +++++++++++++------ 2 files changed, 81 insertions(+), 31 deletions(-) diff --git a/scripts/resolve-openclaw-package-candidate.mjs b/scripts/resolve-openclaw-package-candidate.mjs index 8214cd166263..94511e5818a6 100644 --- a/scripts/resolve-openclaw-package-candidate.mjs +++ b/scripts/resolve-openclaw-package-candidate.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Normalizes package-acceptance inputs into the tarball shape consumed by Docker E2E. -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { lookup as dnsLookupCb } from "node:dns"; import { lookup as dnsLookup } from "node:dns/promises"; @@ -197,17 +197,7 @@ function run(command, args, options = {}) { let timedOut = false; let killTimer; let forceKillAt; - const killChild = (signal) => { - if (useProcessGroup && child.pid) { - try { - process.kill(-child.pid, signal); - return; - } catch { - // The process group can disappear between timeout and cleanup. - } - } - child.kill(signal); - }; + const killChild = (signal) => signalChildProcessTree(child, signal, { useProcessGroup }); const terminateChild = () => { killChild("SIGTERM"); const killAfterMs = options.killAfterMs ?? COMMAND_TIMEOUT_KILL_AFTER_MS; @@ -312,6 +302,36 @@ async function finishTimedOutProcessTree( } } +export function signalChildProcessTree( + child, + signal, + { + platform = process.platform, + runTaskkill = spawnSync, + useProcessGroup = platform !== "win32", + } = {}, +) { + if (useProcessGroup && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // The process group can disappear between timeout and cleanup. + } + } + 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); +} + function childHasExited(child) { return child.exitCode !== null || child.signalCode !== null; } diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts index b9d4c9d59d28..ce1bf54362ae 100644 --- a/test/scripts/resolve-openclaw-package-candidate.test.ts +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -5,7 +5,7 @@ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promise import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ARTIFACT_TARBALL_SCAN_MAX_ENTRIES, assertExpectedSha256ForTest, @@ -20,6 +20,7 @@ import { readPackageBuildSourceSha, resolveNpmPackageCandidatePackRunner, runCommandForTest, + signalChildProcessTree, validateOpenClawPackageSpec, } from "../../scripts/resolve-openclaw-package-candidate.mjs"; @@ -214,6 +215,31 @@ describe("resolve-openclaw-package-candidate", () => { }); }); + it("signals Windows package runner process trees with taskkill", () => { + const child = { + kill: vi.fn(), + pid: 12345, + }; + const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 })); + + signalChildProcessTree(child, "SIGTERM", { + platform: "win32", + runTaskkill, + }); + expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/PID", "12345", "/T"], { + stdio: "ignore", + }); + + signalChildProcessTree(child, "SIGKILL", { + platform: "win32", + runTaskkill, + }); + expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/PID", "12345", "/T", "/F"], { + stdio: "ignore", + }); + expect(child.kill).not.toHaveBeenCalled(); + }); + it("keeps npm pack filenames inside the package candidate output directory", async () => { const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-npm-pack-")); tempDirs.push(dir); @@ -688,26 +714,30 @@ describe("resolve-openclaw-package-candidate", () => { const requestHeaders: Array | undefined> = []; try { - await downloadUrl("https://packages.internal:8443/artifactory/openclaw/openclaw.tgz", target, { - fetchImpl: async (_url: URL, init?: RequestInit) => { - requestHeaders.push(init?.headers as Record | undefined); - if (requestHeaders.length === 1) { - return new Response(null, { - headers: { - location: "https://mirror.internal:8443/artifactory/openclaw/openclaw.tgz", - }, - status: 302, + await downloadUrl( + "https://packages.internal:8443/artifactory/openclaw/openclaw.tgz", + target, + { + fetchImpl: async (_url: URL, init?: RequestInit) => { + requestHeaders.push(init?.headers as Record | undefined); + if (requestHeaders.length === 1) { + return new Response(null, { + headers: { + location: "https://mirror.internal:8443/artifactory/openclaw/openclaw.tgz", + }, + status: 302, + }); + } + return new Response(new Uint8Array([4, 5, 6]), { + headers: { "content-length": "3" }, + status: 200, }); - } - return new Response(new Uint8Array([4, 5, 6]), { - headers: { "content-length": "3" }, - status: 200, - }); + }, + lookupHost: lookupAddresses([{ address: "10.0.0.8", family: 4 }]), + maxBytes: 3, + trustedSource, }, - lookupHost: lookupAddresses([{ address: "10.0.0.8", family: 4 }]), - maxBytes: 3, - trustedSource, - }); + ); } finally { if (previousToken === undefined) { delete process.env.OPENCLAW_TRUSTED_PACKAGE_TOKEN;