Files
hyperframes/packages/cli/src/utils/cancellableProcess.test.ts
T
Miguel Ángel 84f7965492 fix(cli): a disk that fills mid-unzip fails the browser install at once instead of hanging (#6013)
* fix(cli): a disk that fills mid-unzip fails the browser install at once instead of hanging

* test(cli): the unzip tests hold on Windows, and a cancel stops unzip
2026-10-21 09:45:18 +00:00

127 lines
4.9 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { runCancellableProcess, runRenderSetupWorker } from "./cancellableProcess.js";
import { testProcessIsAlive, waitForTestCondition } from "./processTestUtils.js";
const IS_POSIX = process.platform !== "win32";
function processGroupOf(pid: number): string {
return execFileSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" }).trim();
}
describe.skipIf(!IS_POSIX)("cancellable process tree teardown", () => {
it("reaps a SIGTERM-resistant grandchild after the setup root exits", async () => {
const testDir = mkdtempSync(join(tmpdir(), "hyperframes-cancellable-process-"));
const readyPath = join(testDir, "ready");
const completionPath = join(testDir, "completed");
const completionDelayMs = 750;
const grandchildSource = [
'import { writeFileSync } from "node:fs";',
"const [readyPath, completionPath, completionDelay] = process.argv.slice(1);",
'process.on("SIGTERM", () => undefined);',
"writeFileSync(readyPath, String(process.pid));",
'setTimeout(() => writeFileSync(completionPath, "completed"), Number(completionDelay));',
"setInterval(() => undefined, 1000);",
].join("\n");
const rootSource = [
'import { spawn } from "node:child_process";',
"const [grandchildSource, readyPath, completionPath, completionDelay] = process.argv.slice(1);",
'spawn(process.execPath, ["--input-type=module", "--eval", grandchildSource, readyPath, completionPath, completionDelay], { stdio: "ignore" });',
'process.on("SIGTERM", () => process.exit(0));',
"setInterval(() => undefined, 1000);",
].join("\n");
const controller = new AbortController();
const abortReason = new Error("cancel setup");
let rootPid: number | undefined;
let grandchildPid: number | undefined;
try {
const setup = runCancellableProcess(
process.execPath,
[
"--input-type=module",
"--eval",
rootSource,
grandchildSource,
readyPath,
completionPath,
String(completionDelayMs),
],
{
signal: controller.signal,
onSpawn: (pid) => {
rootPid = pid;
},
},
);
// existsSync goes true as soon as the write opens (and truncates) the
// file, before its content lands — read must retry, not just exist-check.
await waitForTestCondition(() => {
if (!existsSync(readyPath)) return false;
const pid = Number(readFileSync(readyPath, "utf8"));
if (!Number.isInteger(pid) || pid <= 0) return false;
grandchildPid = pid;
return true;
}, 2_000);
controller.abort(abortReason);
await expect(setup).rejects.toBe(abortReason);
expect(rootPid).toBeDefined();
expect(grandchildPid).toBeGreaterThan(0);
expect(testProcessIsAlive(rootPid!)).toBe(false);
expect(testProcessIsAlive(grandchildPid!)).toBe(false);
await new Promise((resolve) => setTimeout(resolve, completionDelayMs + 100));
expect(existsSync(completionPath)).toBe(false);
} finally {
if (rootPid && testProcessIsAlive(rootPid)) process.kill(rootPid, "SIGKILL");
if (grandchildPid && testProcessIsAlive(grandchildPid))
process.kill(grandchildPid, "SIGKILL");
rmSync(testDir, { recursive: true, force: true });
}
}, 5_000);
it("keeps a child it does not detach in the caller's process group, and a cancel still stops it", async () => {
const controller = new AbortController();
const abortReason = new Error("cancel unzip");
let childPid: number | undefined;
try {
const run = runCancellableProcess(
process.execPath,
["--eval", "setTimeout(() => undefined, 10_000)"],
{
signal: controller.signal,
detached: false,
onSpawn: (pid) => {
childPid = pid;
},
},
);
expect(processGroupOf(childPid!)).toBe(processGroupOf(process.pid));
controller.abort(abortReason);
await expect(run).rejects.toBe(abortReason);
expect(testProcessIsAlive(childPid!)).toBe(false);
} finally {
if (childPid && testProcessIsAlive(childPid)) process.kill(childPid, "SIGKILL");
}
}, 5_000);
});
describe("render setup worker failure", () => {
it("rejects with only the worker's reason, not its crash output", async () => {
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const failure = runRenderSetupWorker("bogus" as "lint", {}, {});
await expect(failure).rejects.toThrow(/^Unknown render setup mode: bogus$/);
} finally {
stderr.mockRestore();
}
});
});