* fix(application): retain completed evidence through cancellation Validate completed provider reports before classifying cancellation and preserve Evidence and captured diagnostics. Share MCP recording and delivery across custom observation handlers. Add source-owned completion and public SDK recording controls. Refs #3923, #3927, #4261. * fix(wasm): retain final observations through cancellation Keep completed Evidence after finalization and through CLI/MCP delivery. Preserve output and cleanup failure precedence. Refs #4254. * fix(browser): retain final observations and forward cancellation Classify final CDP detach and completed scenario cleanup at the provider boundary. Translate completed partials into canonical Evidence for MCP recording, and forward CLI cancellation to passive Electron tools. Refs #4255, #4256, #4257. * fix(ghidra): isolate cancellation of shared startup waiters Keep startup group-owned while each caller has an independent abortable wait. Await final-owner cleanup and preserve cleanup facts before retrying. Reuse the shared abortable promise helper. Refs #3983. * fix(mcp): keep shutdown registrations until cleanup succeeds Retain repeated event requests while unresolved owners need cleanup, and unregister only after success. Verify coalescing and failed-owner retry. Refs #4253. * fix(adb): own transfer snapshots and stage screen publication Bind transfer digests to the exact private input bytes and preserve caller APK names. Retain failed descriptors and private-root owners with typed cleanup evidence; publish captures exclusively after the cancellation cutoff. Refs #4251, #4252. * fix(verification): report ADB cleanup failures Always close after attempted verification and keep unexpected primary failures and cleanup details visible. Exercise the production script with controlled imports, including null and undefined throws. Refs #4260. * fix(runner): stop owned Windows commands on cancellation Use the retained ChildProcess handle for IPC cancellation and parent disconnection. Keep the SQLite lock until observed child close, and include native Windows regressions in the curated CI lane. Native Windows results remain pending. Refs #4258. * fix(website): canonicalize link containment boundaries Compare resolved targets with resolved repository and site roots. Accept directory aliases while preserving escape checks and keep fixtures within their temporary owner. Refs #4264. * docs(testing): cover completion races and cleanup retry Clarify validation-before-cancellation, independent startup waiter controls, producer and adapter coverage, and repeated shutdown events. * fix(filesystem): retain file owners and completed reads after close failure * fix(session): report retained snapshot staging in cleanup status Fixes #4277. Include both snapshot and profile file-resource scopes in the owning status fact, preserving publication receipts and cleanup retry. * docs(mcp): clarify recovery of raw partial observations Document the observed SDK cancellation boundary and the open design decision in #4276 without promoting raw errors to Evidence. * fix(kuna): own the complete installation inspection lifetime * test(kuna): cover session cleanup of shared cache resources * fix(hopper): isolate publisher operation resource admission * fix(hopper): preserve launcher cleanup failure classification * fix(adb): retain ordered cleanup and screen publication outcomes * fix(runner): retain Windows job ownership through cleanup retries * docs: clarify inspection admission and ordered cleanup regressions * fix(ghidra): preserve startup cleanup and request deadlines * fix(runner): supervise stdin errors and bootstrap native controls * docs: require queued lifecycle controls to survive admission * fix(cli): leave cleanup resolution with resource owners * test(adb): verify owned install snapshots through CLI * fix(errors): exclude resolved primary cleanup resources * fix(adb): resolve publication cleanup after its handle closes * fix(android): resolve JADX hash cleanup after owned close * refactor(android): share hash failure cause options * fix(kuna): gate commands on retained descriptor cleanup * docs: cover cleanup admission and owner-confirmed recovery * refactor(artifacts): bind cleanup errors to confirmed release * fix(adb): resolve recovered cleanup at retained owners * fix(kuna): resolve installation cleanup after owned close * fix(kuna): gate cache reads on retained hash cleanup * fix(setup): preserve publisher cleanup admission failures * fix(android): resolve the reported retirement cleanup error * test(runner): scope post-spawn fault control to POSIX * test(adb): cover persistent snapshot owner retirement * fix(kuna): preserve file outcomes with explicit cleanup ownership * fix(artifacts): resolve errors through retained cleanup owners * docs(lifecycle): carry cleanup ownership in typed outcomes * fix(adb): bind staged screen errors to their cleanup lease * fix(kuna): bind profile cleanup through a close capability * fix(artifacts): retain descriptor resources in cleanup errors * fix(pcap): bind existing errors to snapshot retirement * fix(windows): load source owner through its file URL * test(build): copy the command runner helper into fixtures * fix(mcp): own shared observation providers in the transport * fix(jadx): resolve every retained cleanup retry error * fix(kuna): bind retry errors to every retained cleanup owner * fix(adb): bind admission and shutdown errors to exact cleanup leases * fix(windows): resolve command paths before native ownership * test(publisher): assert exact descriptor recovery without release metadata * docs(lifecycle): require every retry error to follow its owners * fix(kuna): keep hash cleanup state stable across concurrent retries * test(windows): exercise surviving descendants with scoped shutdown deadlines * fix(uninstall): preserve primary read errors through cleanup failure * test(publisher): assert cleanup Result and exact owner recovery * test(windows): observe supervisor resources through IPC disconnect * fix(browser): retain typed cancellation after interrupted actions * test(publisher): assert ownership phases without retry-count coupling * fix(lifecycle): preserve abort reasons through shared provider execution * fix(jadx): preserve execution and late hash cancellation causes * fix(adb): compose late cancellation with exact cleanup failures * test(mcp): use the shared budget for session workflows
751 lines
26 KiB
TypeScript
751 lines
26 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import {
|
|
access,
|
|
copyFile,
|
|
mkdir,
|
|
readFile,
|
|
utimes,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { createRequire } from "node:module";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
|
|
import { expect, it, onTestFinished, vi } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
|
|
const supervisorPath = fileURLToPath(
|
|
new URL("../../scripts/run-exclusive-command.mjs", import.meta.url),
|
|
);
|
|
|
|
const runnerPath = fileURLToPath(
|
|
new URL("../../scripts/run-exclusive.mjs", import.meta.url),
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"holds ownership until an uncooperative command has finished cancellation",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-runner-cancel-");
|
|
const runner = launch(
|
|
directory,
|
|
'process.on("SIGTERM", () => {}); process.stdout.write("ready"); setInterval(() => {}, 1000);',
|
|
);
|
|
try {
|
|
await runner.ready;
|
|
runner.child.kill("SIGTERM");
|
|
expect(await runner.closed).toMatchObject({ code: 143, signal: null });
|
|
const replacement = launch(directory, 'process.stdout.write("ready");');
|
|
expect(await replacement.closed).toMatchObject({ code: 0, signal: null });
|
|
} finally {
|
|
if (runner.child.exitCode === null) runner.child.kill("SIGKILL");
|
|
await runner.closed;
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"retains the lock when the command exits but a descendant ignores cancellation",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-runner-descendant-");
|
|
const pidPath = join(directory, "descendant.pid");
|
|
const activityPath = join(directory, "activity.log");
|
|
const descendant = `
|
|
const fs = require("node:fs");
|
|
process.on("SIGTERM", () => {});
|
|
fs.writeFileSync(${JSON.stringify(activityPath)}, "active\\n");
|
|
fs.writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));
|
|
setInterval(() => fs.appendFileSync(${JSON.stringify(activityPath)}, "active\\n"), 20);
|
|
`;
|
|
const runner = launch(
|
|
directory,
|
|
`
|
|
const fs = require("node:fs");
|
|
require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}], { stdio: "ignore" });
|
|
const ready = setInterval(() => {
|
|
if (fs.existsSync(${JSON.stringify(pidPath)})) {
|
|
clearInterval(ready);
|
|
process.stdout.write("ready");
|
|
}
|
|
}, 10);
|
|
setInterval(() => {}, 1000);
|
|
`,
|
|
);
|
|
let descendantPid: number | undefined;
|
|
try {
|
|
await runner.ready;
|
|
descendantPid = Number(await readFile(pidPath, "utf8"));
|
|
runner.child.kill("SIGTERM");
|
|
const contender = launch(
|
|
directory,
|
|
'process.stdout.write("unexpected");',
|
|
);
|
|
expect(await contender.closed).toMatchObject({
|
|
code: 1,
|
|
stderr: expect.stringContaining("already running"),
|
|
});
|
|
expect(await runner.closed).toMatchObject({ code: 143 });
|
|
const stopped = await readFile(activityPath, "utf8");
|
|
await delay(100);
|
|
expect(await readFile(activityPath, "utf8")).toBe(stopped);
|
|
expect(
|
|
await launch(directory, 'process.stdout.write("done");').closed,
|
|
).toMatchObject({ code: 0 });
|
|
} finally {
|
|
if (runner.child.exitCode === null) runner.child.kill("SIGTERM");
|
|
await runner.closed;
|
|
// Also clean up when this regression is run against the broken runner.
|
|
if (descendantPid !== undefined) stopIfPresent(descendantPid);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform === "win32")(
|
|
"terminates the owned command after supervisor IPC cancellation",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-runner-win-cancel-");
|
|
const gate = join(directory, "release-child");
|
|
const started = join(directory, "child-started");
|
|
const runner = launchSupervisor(directory, gatedCommand(gate, started));
|
|
onTestFinished(async () => {
|
|
await writeFile(gate, "release");
|
|
await runner.closed();
|
|
});
|
|
try {
|
|
await runner.ready;
|
|
await expect(readFile(started, "utf8")).resolves.toBe("started");
|
|
runner.child.send("SIGTERM");
|
|
await expect(runner.closed()).resolves.toMatchObject({ code: 143 });
|
|
expect(await pathExists(gate)).toBe(false);
|
|
await expect(
|
|
launch(directory, 'process.stdout.write("released");').closed,
|
|
).resolves.toMatchObject({ code: 0, stdout: "released" });
|
|
} finally {
|
|
await writeFile(gate, "release");
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform === "win32")(
|
|
"keeps the lock after leader exit and cancels a surviving grandchild by message or disconnect",
|
|
async () => {
|
|
await exerciseWindowsGrandchild("message");
|
|
await exerciseWindowsGrandchild("disconnect");
|
|
},
|
|
);
|
|
|
|
async function exerciseWindowsGrandchild(
|
|
cancellation: "message" | "disconnect",
|
|
): Promise<void> {
|
|
const directory = await createTestTempDirectory("rea-runner-win-tree-");
|
|
const activityPath = join(directory, "grandchild-activity.log");
|
|
const readyPath = join(directory, "grandchild-ready");
|
|
const leaderExitedPath = join(directory, "leader-exited");
|
|
const childExitedPath = join(directory, "child-exited");
|
|
const childErrorPath = join(directory, "child-error");
|
|
const grandchildExitedPath = join(directory, "grandchild-exited");
|
|
const grandchildErrorPath = join(directory, "grandchild-error");
|
|
const gatePath = join(directory, "grandchild-stop");
|
|
const grandchild = `
|
|
const fs = require("node:fs");
|
|
const activity = ${JSON.stringify(activityPath)};
|
|
const gate = ${JSON.stringify(gatePath)};
|
|
process.on("exit", (code) => fs.writeFileSync(${JSON.stringify(grandchildExitedPath)}, String(code)));
|
|
process.on("uncaughtExceptionMonitor", (error) => fs.writeFileSync(${JSON.stringify(grandchildErrorPath)}, String(error)));
|
|
fs.writeFileSync(${JSON.stringify(readyPath)}, "ready");
|
|
const write = () => fs.appendFileSync(activity, "active\\n");
|
|
write();
|
|
const timer = setInterval(() => {
|
|
if (fs.existsSync(gate)) {
|
|
clearInterval(timer);
|
|
process.exit(0);
|
|
}
|
|
write();
|
|
}, 20);
|
|
`;
|
|
const child = `
|
|
const fs = require("node:fs");
|
|
const grandchildProcess = require("node:child_process").spawn(
|
|
process.execPath,
|
|
["-e", ${JSON.stringify(grandchild)}],
|
|
{ detached: true, stdio: "ignore" },
|
|
);
|
|
grandchildProcess.once("error", (error) => fs.writeFileSync(${JSON.stringify(childErrorPath)}, String(error)));
|
|
grandchildProcess.unref();
|
|
process.on("exit", (code) => fs.writeFileSync(${JSON.stringify(childExitedPath)}, String(code)));
|
|
`;
|
|
const leader = `
|
|
const fs = require("node:fs");
|
|
process.on("exit", () => fs.writeFileSync(${JSON.stringify(leaderExitedPath)}, "exited"));
|
|
const child = require("node:child_process").spawn(
|
|
process.execPath,
|
|
["-e", ${JSON.stringify(child)}],
|
|
{ detached: true, stdio: "ignore" },
|
|
);
|
|
child.unref();
|
|
const waitForGrandchild = setInterval(() => {
|
|
if (!fs.existsSync(${JSON.stringify(readyPath)})) return;
|
|
clearInterval(waitForGrandchild);
|
|
process.stdout.write("ready");
|
|
}, 10);
|
|
`;
|
|
const runner = launchSupervisor(directory, leader);
|
|
onTestFinished(async () => {
|
|
await writeFile(gatePath, "stop");
|
|
await runner.closed();
|
|
});
|
|
|
|
try {
|
|
await runner.ready;
|
|
await expect(readFile(readyPath, "utf8")).resolves.toBe("ready");
|
|
await vi.waitFor(async () =>
|
|
expect(await readFile(leaderExitedPath, "utf8")).toBe("exited"),
|
|
);
|
|
const activityBefore = await readFile(activityPath, "utf8");
|
|
await expect
|
|
.poll(async () => (await readFile(activityPath, "utf8")).length, {
|
|
timeout: 5_000,
|
|
})
|
|
.toBeGreaterThan(activityBefore.length);
|
|
const activityDuring = await readFile(activityPath, "utf8");
|
|
const contender = await launch(
|
|
directory,
|
|
'process.stdout.write("unexpected");',
|
|
).closed;
|
|
const evidence = JSON.stringify({
|
|
cancellation,
|
|
supervisorAlive: runner.child.exitCode === null,
|
|
leaderExited: await pathExists(leaderExitedPath),
|
|
descendantReady: await pathExists(readyPath),
|
|
descendantWroteDuringProbe: activityDuring.length > activityBefore.length,
|
|
descendantExited: await pathExists(grandchildExitedPath),
|
|
descendantError: await pathExists(grandchildErrorPath),
|
|
childExited: await pathExists(childExitedPath),
|
|
childError: await pathExists(childErrorPath),
|
|
supervisorCode: runner.child.exitCode,
|
|
supervisorStdout: runner.stdout,
|
|
supervisorStderr: runner.stderr,
|
|
contender,
|
|
});
|
|
expect(
|
|
contender,
|
|
`Windows process-tree ownership evidence: ${evidence}`,
|
|
).toMatchObject({
|
|
code: 1,
|
|
stderr: expect.stringContaining("already running"),
|
|
});
|
|
|
|
if (cancellation === "disconnect") runner.child.disconnect();
|
|
else runner.child.send("SIGTERM");
|
|
await expect(runner.closed()).resolves.toMatchObject({ code: 143 });
|
|
const stopped = await readFile(activityPath, "utf8");
|
|
await delay(100);
|
|
expect(await readFile(activityPath, "utf8")).toBe(stopped);
|
|
await expect(
|
|
launch(directory, 'process.stdout.write("released");').closed,
|
|
).resolves.toMatchObject({ code: 0, stdout: "released" });
|
|
} finally {
|
|
await writeFile(gatePath, "stop");
|
|
}
|
|
}
|
|
|
|
it.runIf(process.platform === "win32")(
|
|
"stops the owned command after the supervisor IPC disconnects",
|
|
async () => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-runner-win-disconnect-",
|
|
);
|
|
const gate = join(directory, "release-child");
|
|
const started = join(directory, "child-started");
|
|
const runner = launchSupervisor(directory, gatedCommand(gate, started));
|
|
onTestFinished(async () => {
|
|
await writeFile(gate, "release");
|
|
await runner.closed();
|
|
});
|
|
try {
|
|
await runner.ready;
|
|
await expect(readFile(started, "utf8")).resolves.toBe("started");
|
|
runner.child.disconnect();
|
|
await expect(runner.closed()).resolves.toMatchObject({ code: 143 });
|
|
expect(await pathExists(gate)).toBe(false);
|
|
await expect(
|
|
launch(directory, 'process.stdout.write("released");').closed,
|
|
).resolves.toMatchObject({ code: 0, stdout: "released" });
|
|
} finally {
|
|
await writeFile(gate, "release");
|
|
}
|
|
},
|
|
);
|
|
|
|
it("does not treat an old database timestamp as permission to replace a live owner", async () => {
|
|
const directory = await createTestTempDirectory("rea-runner-live-");
|
|
const runner = launch(
|
|
directory,
|
|
'process.stdout.write("ready"); setTimeout(() => {}, 1500);',
|
|
);
|
|
try {
|
|
await runner.ready;
|
|
const old = new Date("2000-01-01T00:00:00Z");
|
|
await utimes(
|
|
join(directory, ".cache/rea-command-locks/test.sqlite"),
|
|
old,
|
|
old,
|
|
);
|
|
const contender = launch(directory, 'process.stdout.write("unexpected");');
|
|
expect(await contender.closed).toMatchObject({
|
|
code: 1,
|
|
stderr: expect.stringContaining("already running"),
|
|
stdout: "",
|
|
});
|
|
expect(await runner.closed).toMatchObject({ code: 0 });
|
|
} finally {
|
|
if (runner.child.exitCode === null) runner.child.kill("SIGKILL");
|
|
await runner.closed;
|
|
}
|
|
});
|
|
|
|
it("preserves executable arguments through the public runner", async () => {
|
|
const directory = await createTestTempDirectory("rea-runner-argv-");
|
|
const payload = 'spaces, "quotes", & ^ % ! (parens)';
|
|
const code = "process.stdout.write(JSON.stringify(process.argv.slice(1)))";
|
|
expect(
|
|
await execute(directory, process.execPath, ["-e", code, payload]),
|
|
).toMatchObject({
|
|
code: 0,
|
|
stdout: JSON.stringify([payload]),
|
|
stderr: "",
|
|
});
|
|
});
|
|
|
|
it.runIf(process.platform === "win32")(
|
|
"launches an executable from a path containing spaces",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-runner-spaced-exe-");
|
|
const executable = join(directory, "node runtime", "node.exe");
|
|
await mkdir(join(directory, "node runtime"), { recursive: true });
|
|
await copyFile(process.execPath, executable);
|
|
const payload = 'argument with spaces and "quotes" & ^ % !';
|
|
const code = "process.stdout.write(JSON.stringify(process.argv.slice(1)))";
|
|
expect(
|
|
await execute(directory, executable, ["-e", code, payload]),
|
|
).toMatchObject({
|
|
code: 0,
|
|
stdout: JSON.stringify([payload]),
|
|
stderr: "",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform === "win32")(
|
|
"resolves bare node and Windows command shims without losing arguments",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-runner-shims-");
|
|
const payload = 'argument with spaces and "quotes" & ^ % !';
|
|
const code = "process.stdout.write(JSON.stringify(process.argv.slice(1)))";
|
|
expect(
|
|
await execute(directory, "node", ["-e", code, payload]),
|
|
).toMatchObject({
|
|
code: 0,
|
|
stdout: JSON.stringify([payload]),
|
|
stderr: "",
|
|
});
|
|
|
|
const npm = await execute(directory, "npm", ["--version"]);
|
|
expect(npm).toMatchObject({ code: 0, stderr: "" });
|
|
expect(npm.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/u);
|
|
|
|
const npmProject = join(directory, "npm project");
|
|
await mkdir(npmProject, { recursive: true });
|
|
await writeFile(
|
|
join(npmProject, "package.json"),
|
|
JSON.stringify({
|
|
scripts: {
|
|
"print-argv":
|
|
'node -e "process.stdout.write(JSON.stringify(process.argv.slice(1)))" --',
|
|
},
|
|
}),
|
|
);
|
|
const shimPayload = 'argument with spaces and "quotes" & ^ % !';
|
|
const npmArgv = await execute(npmProject, "npm", [
|
|
"run",
|
|
"print-argv",
|
|
"--",
|
|
shimPayload,
|
|
]);
|
|
expect(npmArgv).toMatchObject({ code: 0, stderr: "" });
|
|
expect(npmArgv.stdout).toContain(JSON.stringify([shimPayload]));
|
|
|
|
const turbo = await execute(
|
|
directory,
|
|
join(process.cwd(), "node_modules", ".bin", "turbo"),
|
|
["--version"],
|
|
);
|
|
expect(turbo).toMatchObject({ code: 0, stderr: "" });
|
|
expect(turbo.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/u);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"recovers after abrupt owner death and keeps simultaneous contenders exclusive",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-runner-recover-");
|
|
// Killing the public runner disconnects its supervisor, which retains
|
|
// ownership while cancelling the group before automatic OS lock release.
|
|
const interrupted = launch(
|
|
directory,
|
|
'process.on("SIGTERM", () => {}); process.stdout.write("ready"); setInterval(() => {}, 1000);',
|
|
);
|
|
await interrupted.ready;
|
|
interrupted.child.kill("SIGKILL");
|
|
expect(await interrupted.closed).toMatchObject({
|
|
code: null,
|
|
signal: "SIGKILL",
|
|
});
|
|
const activityPath = join(directory, "active");
|
|
const logPath = join(directory, "activity.log");
|
|
await writeFile(logPath, "");
|
|
const command = `
|
|
const fs = require("node:fs");
|
|
const active = ${JSON.stringify(activityPath)};
|
|
const log = ${JSON.stringify(logPath)};
|
|
try { fs.closeSync(fs.openSync(active, "wx")); }
|
|
catch { process.stderr.write("commands overlapped"); process.exit(77); }
|
|
fs.appendFileSync(log, "start\\n");
|
|
setTimeout(() => {
|
|
fs.appendFileSync(log, "end\\n");
|
|
fs.unlinkSync(active);
|
|
}, 250);
|
|
`;
|
|
const results = await Promise.all(
|
|
Array.from({ length: 8 }, () => launch(directory, command).closed),
|
|
);
|
|
expect(results.some(({ code }) => code === 0)).toBe(true);
|
|
for (const result of results) {
|
|
expect([0, 1]).toContain(result.code);
|
|
if (result.code === 1) expect(result.stderr).toContain("already running");
|
|
expect(result.stderr).not.toContain("commands overlapped");
|
|
}
|
|
const events = (await readFile(logPath, "utf8")).trim().split("\n");
|
|
expect(events.length).toBeGreaterThan(0);
|
|
for (let index = 0; index < events.length; index += 2)
|
|
expect(events.slice(index, index + 2)).toEqual(["start", "end"]);
|
|
expect(
|
|
await launch(directory, 'process.stdout.write("done");').closed,
|
|
).toMatchObject({ code: 0, stdout: "done" });
|
|
},
|
|
);
|
|
|
|
function stopIfPresent(pid: number): void {
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch (cause) {
|
|
if (
|
|
!(cause instanceof Error) ||
|
|
!("code" in cause) ||
|
|
cause.code !== "ESRCH"
|
|
)
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
function launch(directory: string, command: string) {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[runnerPath, "test", process.execPath, "-e", command],
|
|
{ cwd: directory, stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let notifyReady: (() => void) | undefined;
|
|
const ready = withTimeout(
|
|
new Promise<void>((resolveReady) => {
|
|
notifyReady = resolveReady;
|
|
}),
|
|
"runner child did not start",
|
|
);
|
|
// Some scenarios intentionally never print a ready marker.
|
|
void ready.catch(() => {});
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
if (stdout.includes("ready")) notifyReady?.();
|
|
});
|
|
child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
|
|
const closed = withTimeout(
|
|
new Promise<{
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}>((resolveClosed, reject) => {
|
|
child.once("error", reject);
|
|
child.once("close", (code, signal) => {
|
|
notifyReady?.();
|
|
resolveClosed({ code, signal, stdout, stderr });
|
|
});
|
|
}),
|
|
"runner did not finish",
|
|
);
|
|
return { child, ready, closed };
|
|
}
|
|
|
|
function launchSupervisor(directory: string, command: string) {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
fileURLToPath(
|
|
new URL("../../scripts/run-exclusive-command.mjs", import.meta.url),
|
|
),
|
|
"test",
|
|
process.execPath,
|
|
"-e",
|
|
command,
|
|
],
|
|
{ cwd: directory, stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
|
);
|
|
if (child.stdout === null || child.stderr === null)
|
|
throw new Error("runner supervisor streams were not piped");
|
|
const stdoutStream = child.stdout;
|
|
const stderrStream = child.stderr;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let notifyReady: (() => void) | undefined;
|
|
const ready = withTimeout(
|
|
new Promise<void>((resolveReady) => {
|
|
notifyReady = resolveReady;
|
|
}),
|
|
"runner supervisor child did not start",
|
|
);
|
|
void ready.catch(() => {});
|
|
stdoutStream.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
if (stdout.includes("ready")) notifyReady?.();
|
|
});
|
|
stderrStream.on("data", (chunk) => (stderr += chunk.toString()));
|
|
const exited = new Promise<{
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
}>((resolveExit, reject) => {
|
|
child.once("error", reject);
|
|
child.once("exit", (code, signal) => {
|
|
notifyReady?.();
|
|
resolveExit({ code, signal });
|
|
});
|
|
});
|
|
const stdoutClosed = new Promise<void>((resolveClose) => {
|
|
stdoutStream.once("close", resolveClose);
|
|
});
|
|
const stderrClosed = new Promise<void>((resolveClose) => {
|
|
stderrStream.once("close", resolveClose);
|
|
});
|
|
const ipcDisconnected = new Promise<void>((resolveDisconnect) => {
|
|
child.once("disconnect", resolveDisconnect);
|
|
});
|
|
const finished = Promise.all([
|
|
exited,
|
|
stdoutClosed,
|
|
stderrClosed,
|
|
ipcDisconnected,
|
|
]).then(([{ code, signal }]) => ({ code, signal, stdout, stderr }));
|
|
return {
|
|
child,
|
|
ready,
|
|
closed: () => withTimeout(finished, "runner supervisor did not finish"),
|
|
get stdout() {
|
|
return stdout;
|
|
},
|
|
get stderr() {
|
|
return stderr;
|
|
},
|
|
};
|
|
}
|
|
|
|
function gatedCommand(gate: string, started: string): string {
|
|
return `
|
|
const fs = require("node:fs");
|
|
const gate = ${JSON.stringify(gate)};
|
|
fs.writeFileSync(${JSON.stringify(started)}, "started");
|
|
process.stdout.write("ready");
|
|
const poll = setInterval(() => {
|
|
if (fs.existsSync(gate)) {
|
|
clearInterval(poll);
|
|
process.exit(0);
|
|
}
|
|
}, 10);
|
|
`;
|
|
}
|
|
|
|
function pathExists(path: string): Promise<boolean> {
|
|
return access(path).then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
}
|
|
|
|
function execute(
|
|
directory: string,
|
|
command: string,
|
|
arguments_: string[] = [],
|
|
) {
|
|
return new Promise<{
|
|
code: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}>((resolve, reject) => {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[runnerPath, "test", command, ...arguments_],
|
|
{ cwd: directory, stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => (stdout += chunk.toString()));
|
|
child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
|
|
child.once("error", reject);
|
|
child.once("close", (code) => resolve({ code, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
function withTimeout<T>(
|
|
promise: Promise<T>,
|
|
message: string,
|
|
timeoutMs = 5_000,
|
|
): Promise<T> {
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
return Promise.race([
|
|
promise,
|
|
new Promise<never>((_, reject) => {
|
|
timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
}),
|
|
]).finally(() => clearTimeout(timeout));
|
|
}
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"retains lock ownership through a post-spawn child error until close",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-runner-child-error-");
|
|
const readyPath = join(directory, "command-ready");
|
|
const gatePath = join(directory, "allow-command-exit");
|
|
const closedPath = join(directory, "command-closed");
|
|
const source = await readFile(supervisorPath, "utf8");
|
|
const crossSpawn = pathToFileURL(
|
|
createRequire(import.meta.url).resolve("cross-spawn"),
|
|
).href;
|
|
const spawnCall =
|
|
/child = spawn\(command\[0\], command\.slice\(1\), \{\n\s+cwd: process\.cwd\(\),\n\s+env: process\.env,\n\s+stdio: "inherit",\n\s+\}\);/u;
|
|
const postSpawnInjection = `child.once("spawn", () => {
|
|
const waitForReady = setInterval(() => {
|
|
if (!existsSync(${JSON.stringify(readyPath)})) return;
|
|
clearInterval(waitForReady);
|
|
process.stdout.write("controlled-post-spawn-error\\n");
|
|
child.emit("error", new Error("controlled post-spawn child error"));
|
|
child.emit("error", new Error("controlled secondary child error"));
|
|
}, 5);
|
|
});`;
|
|
expect(source).toMatch(spawnCall);
|
|
const injectedSource = source
|
|
.replace(
|
|
'import spawn from "cross-spawn";',
|
|
`import spawn from ${JSON.stringify(crossSpawn)};\nimport { existsSync } from "node:fs";`,
|
|
)
|
|
.replace(spawnCall, (call) => `${call}\n ${postSpawnInjection}`);
|
|
expect(injectedSource).toContain(postSpawnInjection);
|
|
expect(injectedSource).toContain("controlled post-spawn child error");
|
|
const copiedSupervisor = join(directory, "run-exclusive-command.mjs");
|
|
const copiedHelperDirectory = join(directory, "lib");
|
|
await mkdir(copiedHelperDirectory, { recursive: true });
|
|
await copyFile(
|
|
join(process.cwd(), "scripts/lib/windows-owned-command.mjs"),
|
|
join(copiedHelperDirectory, "windows-owned-command.mjs"),
|
|
);
|
|
await writeFile(copiedSupervisor, injectedSource);
|
|
|
|
const gatedCommand = `
|
|
const fs = require("node:fs");
|
|
fs.writeFileSync(${JSON.stringify(readyPath)}, "ready");
|
|
process.stdout.write("command-ready\\n");
|
|
const waitForGate = setInterval(() => {
|
|
if (!fs.existsSync(${JSON.stringify(gatePath)})) return;
|
|
clearInterval(waitForGate);
|
|
fs.writeFileSync(${JSON.stringify(closedPath)}, "closed");
|
|
process.stdout.write("command-closed\\n");
|
|
}, 5);
|
|
`;
|
|
const supervisor = spawn(
|
|
process.execPath,
|
|
[copiedSupervisor, "test", process.execPath, "-e", gatedCommand],
|
|
{ cwd: directory, stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
|
);
|
|
if (supervisor.stdout === null || supervisor.stderr === null) {
|
|
supervisor.kill("SIGKILL");
|
|
throw new Error("supervisor output pipes were not created");
|
|
}
|
|
const stdoutStream = supervisor.stdout;
|
|
const stderrStream = supervisor.stderr;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
const outputWaiters = new Map<string, (() => void)[]>();
|
|
stdoutStream.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
for (const [marker, waiters] of outputWaiters) {
|
|
if (!stdout.includes(marker)) continue;
|
|
outputWaiters.delete(marker);
|
|
for (const notify of waiters) notify();
|
|
}
|
|
});
|
|
stderrStream.on("data", (chunk) => (stderr += chunk.toString()));
|
|
const waitForOutput = (marker: string) => {
|
|
if (stdout.includes(marker)) return Promise.resolve();
|
|
return withTimeout(
|
|
new Promise<void>((resolveOutput) => {
|
|
outputWaiters.set(marker, [
|
|
...(outputWaiters.get(marker) ?? []),
|
|
resolveOutput,
|
|
]);
|
|
}),
|
|
`supervisor did not write ${marker}`,
|
|
);
|
|
};
|
|
const exited = withTimeout(
|
|
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
|
(resolveExit, reject) => {
|
|
supervisor.once("error", reject);
|
|
supervisor.once("exit", (code, signal) =>
|
|
resolveExit({ code, signal }),
|
|
);
|
|
},
|
|
),
|
|
"supervisor did not exit",
|
|
);
|
|
try {
|
|
await waitForOutput("controlled-post-spawn-error");
|
|
const contender = await execute(directory, process.execPath, [
|
|
"-e",
|
|
'process.stdout.write("lock acquired");',
|
|
]);
|
|
expect(contender).toMatchObject({
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: expect.stringContaining("already running"),
|
|
});
|
|
expect(stdout).not.toContain("command-closed");
|
|
|
|
await writeFile(gatePath, "open");
|
|
await waitForOutput("command-closed");
|
|
expect(await exited).toEqual({ code: 1, signal: null });
|
|
expect(stderr).toContain("controlled post-spawn child error");
|
|
expect(stderr).not.toContain("controlled secondary child error");
|
|
expect(
|
|
await execute(directory, process.execPath, [
|
|
"-e",
|
|
'process.stdout.write("lock acquired");',
|
|
]),
|
|
).toMatchObject({ code: 0, stdout: "lock acquired" });
|
|
} finally {
|
|
await writeFile(gatePath, "open");
|
|
await waitForOutput("command-closed").catch(() => undefined);
|
|
await exited.catch(() => undefined);
|
|
}
|
|
},
|
|
);
|