From 604aa301899f9a6dc901ea383349cd82932eac2a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 21 Jun 2026 08:12:18 +0200 Subject: [PATCH] fix(qa): taskkill lifecycle probe trees on windows --- .../plugins/plugin-lifecycle-probe-runtime.ts | 23 +++++++++- .../plugin-lifecycle-probe.e2e.test.ts | 44 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts index 6cb934436449..d4496b23798c 100644 --- a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts +++ b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts @@ -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 = () => { diff --git a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts index 0aff24aafe79..2222e148d9f2 100644 --- a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts +++ b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts @@ -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;