fix(qa): taskkill lifecycle probe trees on windows

This commit is contained in:
Vincent Koc
2026-06-21 08:12:18 +02:00
parent 5dd30c3995
commit 604aa30189
2 changed files with 66 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
// Plugin Lifecycle Probe tests cover QA Lab plugin lifecycle evidence.
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
@@ -17,6 +17,7 @@ interface CommandOptions {
env?: NodeJS.ProcessEnv;
outputFile?: string;
spawnImpl?: typeof spawn;
taskkillImpl?: typeof spawnSync;
timeoutKillGraceMs?: number;
timeoutMs?: number;
}
@@ -266,6 +267,26 @@ async function runCommand(command: string, args: readonly string[], options: Com
// The process group may already be gone; fall back to the direct child.
}
}
if (!useProcessGroup && child.pid) {
const runTaskkill = options.taskkillImpl ?? spawnSync;
const args = ["/PID", String(child.pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const result = runTaskkill("taskkill", args, { stdio: "ignore", windowsHide: true });
if (!result.error && result.status === 0) {
return;
}
if (signal !== "SIGKILL") {
const forceResult = runTaskkill("taskkill", [...args, "/F"], {
stdio: "ignore",
windowsHide: true,
});
if (!forceResult.error && forceResult.status === 0) {
return;
}
}
}
child.kill(signal);
};
const isProcessGroupRunning = () => {

View File

@@ -136,6 +136,50 @@ describe("plugin lifecycle matrix probe", () => {
}
});
it("force-kills timed Windows commands with taskkill when graceful taskkill fails", async () => {
vi.useFakeTimers();
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
try {
const child = Object.assign(new FakeCommandChild(), { pid: 12345 });
const taskkillImpl = vi
.fn()
.mockReturnValueOnce({ status: 1 })
.mockImplementationOnce(() => {
queueMicrotask(() => child.emit("exit", null, "SIGTERM"));
return { status: 0 };
});
const runPromise = probeTesting.runCommand("fake-command", ["install"], {
spawnImpl: (() => child) as unknown as typeof import("node:child_process").spawn,
taskkillImpl,
timeoutKillGraceMs: 100,
timeoutMs: 10,
});
const runError = runPromise.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(10);
expect(taskkillImpl).toHaveBeenNthCalledWith(1, "taskkill", ["/PID", "12345", "/T"], {
stdio: "ignore",
windowsHide: true,
});
expect(taskkillImpl).toHaveBeenNthCalledWith(2, "taskkill", ["/PID", "12345", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
expect(child.signals).toEqual([]);
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("fake-command install timed out after 10ms");
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
vi.useRealTimers();
}
});
it("keeps fallback SIGKILL armed for ignored-stdio descendants", async () => {
if (process.platform === "win32") {
return;