fix(package): kill candidate resolver trees on windows

This commit is contained in:
Vincent Koc
2026-06-21 06:37:51 +02:00
parent b0f21f8af7
commit 34806b39cd
2 changed files with 81 additions and 31 deletions

View File

@@ -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;
}

View File

@@ -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<Record<string, string> | 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<string, string> | 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<string, string> | 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;