File size: 6,383 Bytes
3144483 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | import { spawn } from "node:child_process";
import { once } from "node:events";
import process from "node:process";
import { describe, expect, it, vi } from "vitest";
import {
inspectNodeWorkerProcessIdentity,
requireNodeWorkerProcessIdentity,
type NodeWorkerProcessIdentity,
} from "../node-host/node-worker-process-identity.js";
import { createDeferredCore } from "../shared/deferred.js";
import { runUtf8CommandWithTimeout } from "./exec.js";
import { killProcessTree } from "./kill-tree.js";
describe("runUtf8CommandWithTimeout Windows integration", () => {
it.runIf(process.platform === "win32")(
"closes a nested Node descendant after an inner spawnSync timeout and outer failure",
async () => {
const descendantSource = 'setInterval(() => {}, 1000); process.send("ready");';
const innerSource = [
'const { spawn } = require("node:child_process");',
`const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendantSource)}], { stdio: ["ignore", "ignore", "ignore", "ipc"], windowsHide: true });`,
'child.once("message", () => process.stdout.write(String(child.pid) + "\\n"));',
].join("\n");
// Match the finalization fence's real child deadline. Each non-detached Node
// child has libuv job ownership; this does not cover arbitrary grandchildren
// or hosts that deny job assignment.
const outerSource = [
'const { spawnSync } = require("node:child_process");',
`const result = spawnSync(process.execPath, ["-"], { input: ${JSON.stringify(innerSource)}, stdio: ["pipe", "inherit", "pipe"], timeout: 60_000, killSignal: "SIGKILL", encoding: "utf8", windowsHide: true });`,
"if (result.error) process.stderr.write(result.error.message);",
"process.exitCode = result.error || result.status !== 0 ? 1 : 0;",
].join("\n");
const ready = createDeferredCore<NodeWorkerProcessIdentity>();
const controller = new AbortController();
let child: NodeWorkerProcessIdentity | undefined;
let output = "";
const pending = runUtf8CommandWithTimeout([process.execPath, "-e", outerSource], {
timeoutMs: 75_000,
killProcessTree: true,
signal: controller.signal,
onOutputChunk: (chunk, stream) => {
if (stream !== "stdout" || child) {
return;
}
output += chunk.toString("utf8");
if (output.includes("\n")) {
child = requireNodeWorkerProcessIdentity(Number(output.trim()));
ready.resolve(child);
}
},
});
try {
const descendant = await Promise.race([
ready.promise,
pending.then(() => {
throw new Error("inner command ended without descendant readiness");
}),
]);
expect(inspectNodeWorkerProcessIdentity(descendant)).toBe("live");
await expect(pending).resolves.toMatchObject({
code: 1,
termination: "exit",
stderr: expect.stringContaining("ETIMEDOUT"),
});
await vi.waitFor(
() => {
expect(inspectNodeWorkerProcessIdentity(descendant)).toBe("dead");
},
{ timeout: 5_000, interval: 50 },
);
} finally {
controller.abort();
await pending.catch(() => undefined);
// Failed assertions still clean only the exact observed fixture process.
if (child && inspectNodeWorkerProcessIdentity(child) === "live") {
process.kill(child.pid, "SIGKILL");
await vi.waitFor(
() => {
expect(inspectNodeWorkerProcessIdentity(child!)).toBe("dead");
},
{ timeout: 5_000, interval: 50 },
);
}
}
},
90_000,
);
it.runIf(process.platform === "win32")(
"keeps truncated UTF-8 head output on a code point boundary",
async () => {
const result = await runUtf8CommandWithTimeout(
[process.execPath, "-e", "process.stdout.write('a😀z'); process.stderr.write('b😀y')"],
{
maxOutputBytes: 3,
outputCapture: "head",
timeoutMs: 3_000,
},
);
expect(result.stdout).toBe("a");
expect(result.stderr).toBe("b");
expect(result.stdoutTruncatedBytes).toBe(5);
expect(result.stderrTruncatedBytes).toBe(5);
},
);
it.runIf(process.platform === "win32")(
"force-kills a real Windows process tree when graceful taskkill refuses it",
async () => {
const program = [
'const { spawn } = require("node:child_process");',
'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore", windowsHide: true });',
'child.once("spawn", () => process.stdout.write(String(child.pid) + "\\n"));',
'child.once("error", () => process.exit(1));',
"setInterval(() => {}, 1000);",
].join("\n");
const parent = spawn(process.execPath, ["-e", program], {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
const parentPid = parent.pid;
const parentStdout = parent.stdout;
if (parentPid === undefined || parentStdout === null) {
parent.kill();
throw new Error("Could not start the Windows process tree");
}
try {
const [output] = await once(parentStdout, "data");
const childPid = Number.parseInt(String(output).trim(), 10);
expect(Number.isSafeInteger(childPid)).toBe(true);
expect(() => process.kill(parentPid, 0)).not.toThrow();
expect(() => process.kill(childPid, 0)).not.toThrow();
// An unforced taskkill refuses Node console processes. Cleanup must not
// depend on this unref'd timer surviving an application shutdown.
killProcessTree(parentPid, { graceMs: 30_000 });
await vi.waitFor(
() => {
expect(() => process.kill(parentPid, 0)).toThrow();
expect(() => process.kill(childPid, 0)).toThrow();
},
{ timeout: 5_000, interval: 50 },
);
} finally {
// The retained child handle is safe after exit; taskkill of its reusable
// PID is not. This fixture's non-detached Node child is in its libuv job.
parent.kill("SIGKILL");
parentStdout.destroy();
}
},
15_000,
);
});
|