diff --git a/test/scripts/bundled-plugin-install-uninstall-probe.test.ts b/test/scripts/bundled-plugin-install-uninstall-probe.test.ts index 40839ec40b95..fb606aed12fd 100644 --- a/test/scripts/bundled-plugin-install-uninstall-probe.test.ts +++ b/test/scripts/bundled-plugin-install-uninstall-probe.test.ts @@ -9,6 +9,15 @@ import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs"; import { withEnvAsync } from "../../src/test-utils/env.js"; +import { + killPidIfAlive, + pidIsAlive, + registerRuntimeCommandOutputTimeoutTest, + registerRuntimeCommandTimeoutTests, + waitForDead, + waitForFile, + waitForPidFile, +} from "./bundled-plugin-runtime-command.test-support.js"; const tempDirs: string[] = []; const probePath = path.resolve("scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs"); @@ -146,85 +155,6 @@ async function closeServer(server: HttpServer | NetServer): Promise { }); } -async function waitForFile(filePath: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (fs.existsSync(filePath)) { - return; - } - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - } - throw new Error(`timeout waiting for ${filePath}`); -} - -function parseCompletedPidFile(content: string): number | undefined { - const match = /^([1-9]\d*)\n$/u.exec(content); - if (!match) { - return undefined; - } - const pid = Number(match[1]); - return Number.isSafeInteger(pid) ? pid : undefined; -} - -async function waitForPidFile(filePath: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const pid = parseCompletedPidFile(fs.readFileSync(filePath, "utf8")); - if (pid !== undefined) { - return pid; - } - } catch { - // The child creates the file asynchronously; keep polling until its payload is complete. - } - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - } - throw new Error(`timeout waiting for pid in ${filePath}`); -} - -function pidIsAlive(pid: number): boolean { - if (!Number.isSafeInteger(pid) || pid <= 0) { - return false; - } - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function waitForDead(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!pidIsAlive(pid)) { - return; - } - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - } - throw new Error(`timeout waiting for pid ${pid} to exit`); -} - -function killPidIfAlive(pid: number | undefined): void { - if (pid === undefined || !pidIsAlive(pid)) { - return; - } - try { - process.kill(pid, "SIGKILL"); - } catch (error) { - // The process can exit after the liveness probe; ESRCH already satisfies cleanup. - if ((error as NodeJS.ErrnoException | undefined)?.code !== "ESRCH") { - throw error; - } - } -} - afterEach(() => { vi.restoreAllMocks(); for (const dir of tempDirs.splice(0)) { @@ -783,98 +713,7 @@ describe("bundled plugin install/uninstall probe", () => { // These cases install parent signal handlers and manipulate real process groups. // Keep them serial so one teardown cannot signal another case's child tree. - (process.platform !== "win32" ? it : it.skip)( - "kills timed-out runtime command groups", - async () => { - const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); - const root = createPackageRoot(); - const commandPath = path.join(root, "timeout-command.mjs"); - const descendantPidPath = path.join(root, "timed-out-descendant.pid"); - const descendantScript = [ - "import fs from 'node:fs';", - `fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(process.pid) + "\\n");`, - "process.on('SIGTERM', () => {});", - "setInterval(() => {}, 1000);", - ].join("\n"); - fs.writeFileSync( - commandPath, - [ - "import childProcess from 'node:child_process';", - `childProcess.spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify( - descendantScript, - )}], { stdio: "ignore" });`, - "setInterval(() => {}, 1000);", - "", - ].join("\n"), - "utf8", - ); - - let descendantPid: number | undefined; - try { - const commandResult = runtimeSmoke - .runCommand(process.execPath, [commandPath], { detached: undefined, timeoutMs: 250 }) - .catch((error: unknown) => error); - descendantPid = await waitForPidFile(descendantPidPath, 1000); - const error = await commandResult; - if (!(error instanceof Error)) { - throw new Error("expected runtime command to time out"); - } - expect(error.message).toMatch(/timed out after 250ms/u); - - await waitForDead(descendantPid, 2000); - } finally { - killPidIfAlive(descendantPid); - fs.rmSync(root, { force: true, recursive: true }); - } - }, - ); - - (process.platform !== "win32" ? it : it.skip)( - "falls back to direct kills for non-detached command timeouts", - async () => { - const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); - const root = createPackageRoot(); - const commandPath = path.join(root, "non-detached-timeout-command.mjs"); - const commandPidPath = path.join(root, "non-detached-command.pid"); - fs.writeFileSync( - commandPath, - [ - "import fs from 'node:fs';", - `fs.writeFileSync(${JSON.stringify(commandPidPath)}, String(process.pid) + "\\n");`, - "setInterval(() => {}, 1000);", - "", - ].join("\n"), - "utf8", - ); - - let commandPid: number | undefined; - let settleTimer: ReturnType | undefined; - try { - const commandResult = runtimeSmoke - .runCommand(process.execPath, [commandPath], { detached: false, timeoutMs: 500 }) - .catch((error: unknown) => error); - commandPid = await waitForPidFile(commandPidPath, 1000); - const error = await Promise.race([ - commandResult, - new Promise((resolve) => { - settleTimer = setTimeout(() => { - resolve(new Error("runCommand did not settle after timeout")); - }, 2000); - }), - ]); - if (!(error instanceof Error)) { - throw new Error("expected non-detached runtime command to time out"); - } - expect(error.message).toMatch(/timed out after 500ms/u); - - await waitForDead(commandPid, 1000); - } finally { - clearTimeout(settleTimer); - killPidIfAlive(commandPid); - fs.rmSync(root, { force: true, recursive: true }); - } - }, - ); + registerRuntimeCommandTimeoutTests(createPackageRoot); (process.platform !== "win32" ? it : it.skip)( "cleans detached runtime command groups when the parent is signaled", @@ -1125,23 +964,7 @@ describe("bundled plugin install/uninstall probe", () => { ); }); - it("bounds runtime smoke child commands and preserves captured output", async () => { - const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); - const startedAt = Date.now(); - - await expect( - runtimeSmoke.runCommand( - process.execPath, - [ - "-e", - "process.stdout.write('partial\\n'); process.stderr.write('problem\\n'); setInterval(() => {}, 1000);", - ], - { timeoutMs: 200 }, - ), - ).rejects.toThrow(/timed out after 200ms[\s\S]*partial[\s\S]*problem/u); - - expect(Date.now() - startedAt).toBeLessThan(2_500); - }); + registerRuntimeCommandOutputTimeoutTest(); it("cleans per-call RPC state directories", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); diff --git a/test/scripts/bundled-plugin-runtime-command.test-support.ts b/test/scripts/bundled-plugin-runtime-command.test-support.ts new file mode 100644 index 000000000000..3f8ffabbbfb8 --- /dev/null +++ b/test/scripts/bundled-plugin-runtime-command.test-support.ts @@ -0,0 +1,357 @@ +import childProcess, { type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, it, vi } from "vitest"; + +const realSetTimeout = setTimeout; +const realClearTimeout = clearTimeout; +const runtimeSmokePath = path.resolve( + "scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs", +); + +export async function waitForFile(filePath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) { + return; + } + await new Promise((resolve) => { + realSetTimeout(resolve, 20); + }); + } + throw new Error(`timeout waiting for ${filePath}`); +} + +function parseCompletedPidFile(content: string): number | undefined { + const match = /^([1-9]\d*)\n$/u.exec(content); + if (!match) { + return undefined; + } + const pid = Number(match[1]); + return Number.isSafeInteger(pid) ? pid : undefined; +} + +export async function waitForPidFile(filePath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const pid = parseCompletedPidFile(fs.readFileSync(filePath, "utf8")); + if (pid !== undefined) { + return pid; + } + } catch { + // The child creates the file asynchronously; keep polling until its payload is complete. + } + await new Promise((resolve) => { + realSetTimeout(resolve, 20); + }); + } + throw new Error(`timeout waiting for pid in ${filePath}`); +} + +export function pidIsAlive(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export async function waitForDead(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!pidIsAlive(pid)) { + return; + } + await new Promise((resolve) => { + realSetTimeout(resolve, 20); + }); + } + throw new Error(`timeout waiting for pid ${pid} to exit`); +} + +export function killPidIfAlive(pid: number | undefined): void { + if (pid === undefined || !pidIsAlive(pid)) { + return; + } + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + // The process can exit after the liveness probe; ESRCH already satisfies cleanup. + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ESRCH") { + throw error; + } + } +} + +function readOwnedDescendantPid(filePath: string): number { + const pid = parseCompletedPidFile(fs.readFileSync(filePath, "utf8")); + if (pid === undefined) { + throw new Error("owned descendant PID is incomplete"); + } + return pid; +} + +function killOwnedRuntimeCommand(child: ChildProcess | undefined, detached: boolean): void { + if (!child?.pid) { + return; + } + if (detached) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") { + throw error; + } + } + } else if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } +} + +async function withRealWatchdog(pending: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + pending, + new Promise((_resolve, reject) => { + timer = realSetTimeout( + () => reject(new Error("runtime command did not settle")), + timeoutMs, + ); + }), + ]); + } finally { + realClearTimeout(timer); + } +} + +async function observeRuntimeCommandTimeout(params: { + run: () => Promise; + ready: (child: ChildProcess) => Promise; + timeoutMs: number; + detached: boolean; + root?: string; + descendantPidPath?: string; + exitTimeoutMs?: number; +}): Promise { + const spawnSpy = vi.spyOn(childProcess, "spawn"); + let child: ChildProcess | undefined; + let commandResult: Promise | undefined; + let descendantPid: number | undefined; + let cleanupSignalsSent = false; + let settled = false; + // Freeze only the parent's policy clock. Real subprocess startup, pipe I/O, + // readiness and cleanup watchdogs must not consume or depend on that clock. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + commandResult = params + .run() + .catch((error: unknown) => error) + .finally(() => { + settled = true; + }); + const spawned = spawnSpy.mock.results[0]; + if (spawned?.type !== "return" || !spawned.value.pid) { + throw new Error("runtime command did not expose its spawned child"); + } + child = spawned.value; + const descendants = await params.ready(child); + const assertRunning = () => { + expect(pidIsAlive(child!.pid!)).toBe(true); + for (const pid of descendants) { + expect(pidIsAlive(pid)).toBe(true); + } + expect(settled).toBe(false); + }; + assertRunning(); + await vi.advanceTimersByTimeAsync(params.timeoutMs - 1); + assertRunning(); + await vi.advanceTimersByTimeAsync(1); + const error = await withRealWatchdog(commandResult, 2000); + if (!(error instanceof Error)) { + throw new Error("expected runtime command to time out"); + } + for (const pid of [child.pid!, ...descendants]) { + await waitForDead(pid, params.exitTimeoutMs ?? 2000); + } + return error; + } finally { + try { + try { + // Recover ownership before fallible joins, including failed readiness. + if (params.descendantPidPath && fs.existsSync(params.descendantPidPath)) { + descendantPid = readOwnedDescendantPid(params.descendantPidPath); + } + } finally { + try { + killOwnedRuntimeCommand(child, params.detached); + } finally { + // A broken group-termination path must still reap the known descendant. + // This fallback follows the successful-path death assertions above. + killPidIfAlive(descendantPid); + } + } + cleanupSignalsSent = true; + } finally { + vi.useRealTimers(); + spawnSpy.mockRestore(); + if (commandResult) { + await withRealWatchdog(commandResult, 2000); + } + if (child?.pid) { + await waitForDead(child.pid, params.exitTimeoutMs ?? 2000); + } + if (descendantPid !== undefined) { + await waitForDead(descendantPid, 2000); + } + if (settled && cleanupSignalsSent && params.root) { + fs.rmSync(params.root, { force: true, recursive: true }); + } + } + } +} + +export function registerRuntimeCommandTimeoutTests(createPackageRoot: () => string): void { + (process.platform !== "win32" ? it : it.skip)( + "kills timed-out runtime command groups", + async () => { + const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); + const root = createPackageRoot(); + const commandPath = path.join(root, "timeout-command.mjs"); + const descendantPidPath = path.join(root, "timed-out-descendant.pid"); + const readyPath = path.join(root, "timed-out-descendant.ready"); + const descendantScript = [ + "import fs from 'node:fs';", + "process.on('SIGTERM', () => {});", + "setInterval(() => {}, 1000);", + `fs.writeFileSync(${JSON.stringify(readyPath)}, "ready");`, + ].join("\n"); + fs.writeFileSync( + commandPath, + [ + "import childProcess from 'node:child_process';", + "import fs from 'node:fs';", + `const child = childProcess.spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify( + descendantScript, + )}], { stdio: "ignore" });`, + `fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(child.pid) + "\\n");`, + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const error = await observeRuntimeCommandTimeout({ + run: () => + runtimeSmoke.runCommand(process.execPath, [commandPath], { + detached: undefined, + timeoutMs: 250, + }), + ready: async () => { + const pid = await waitForPidFile(descendantPidPath, 1000); + await waitForFile(readyPath, 1000); + return [pid]; + }, + timeoutMs: 250, + detached: true, + root, + descendantPidPath, + }); + expect(error.message).toMatch(/timed out after 250ms/u); + }, + ); + + (process.platform !== "win32" ? it : it.skip)( + "falls back to direct kills for non-detached command timeouts", + async () => { + const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); + const root = createPackageRoot(); + const commandPath = path.join(root, "non-detached-timeout-command.mjs"); + const commandPidPath = path.join(root, "non-detached-command.pid"); + fs.writeFileSync( + commandPath, + [ + "import fs from 'node:fs';", + "setInterval(() => {}, 1000);", + `fs.writeFileSync(${JSON.stringify(commandPidPath)}, String(process.pid) + "\\n");`, + "", + ].join("\n"), + "utf8", + ); + + const error = await observeRuntimeCommandTimeout({ + run: () => + runtimeSmoke.runCommand(process.execPath, [commandPath], { + detached: false, + timeoutMs: 500, + }), + ready: async (child) => { + expect(await waitForPidFile(commandPidPath, 1000)).toBe(child.pid); + return []; + }, + timeoutMs: 500, + detached: false, + root, + exitTimeoutMs: 1000, + }); + expect(error.message).toMatch(/timed out after 500ms/u); + }, + ); +} + +export function registerRuntimeCommandOutputTimeoutTest(): void { + it("bounds runtime smoke child commands and preserves captured output", async () => { + const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); + const startedAt = Date.now(); + const error = await observeRuntimeCommandTimeout({ + run: () => + runtimeSmoke.runCommand( + process.execPath, + [ + "-e", + "setInterval(() => {}, 1000); process.stdout.write('partial\\n'); process.stderr.write('problem\\n');", + ], + { timeoutMs: 200 }, + ), + ready: async (child) => { + let stdout = "", + stderr = ""; + const onStdout = (chunk: string | Buffer) => { + stdout += chunk.toString(); + }; + const onStderr = (chunk: string | Buffer) => { + stderr += chunk.toString(); + }; + child.stdout!.on("data", onStdout); + child.stderr!.on("data", onStderr); + try { + const deadline = Date.now() + 1000; + while ( + (!stdout.includes("partial") || !stderr.includes("problem")) && + Date.now() < deadline + ) { + await new Promise((resolve) => { + realSetTimeout(resolve, 20); + }); + } + expect(stdout).toContain("partial"); + expect(stderr).toContain("problem"); + return []; + } finally { + child.stdout!.off("data", onStdout); + child.stderr!.off("data", onStderr); + } + }, + timeoutMs: 200, + detached: process.platform !== "win32", + }); + expect(error.message).toMatch(/timed out after 200ms[\s\S]*partial[\s\S]*problem/u); + expect(Date.now() - startedAt).toBeLessThan(2_500); + }); +}