Files
reverse-engineering-anything/tests/boundary/process/processFixtureLifecycle.test.ts
T
gensokyo-devandgensokyo-dev 07e4b700ac test: let the deadline policy own test waits
Vitest's expect.poll and vi.waitFor stop after one second by default, a
host-speed guess that the project's deadline policy does not see. Under
hosted-CI load, kunaProjectConcurrency failed that way twice on unrelated
PRs. Another 256 waits relied on the default, and 97 passed literals
picked to fit some host.

Waits now take as long as their test may run, less the reporting reserve
that children already keep. Each Vitest project sets expect.poll's
default, and the new waitFor in tests/support/testWait.ts replaces
vi.waitFor. Fixture readiness waits (awaitAcknowledgement and the provider
process waits) default to the same deadline, and their host-speed literal
arguments are removed. One remainingTestDeadline owns the arithmetic for
both children and waits.

Ten waits whose short timeout was the real check now assert it directly
(Kuna version probe, Cutter discovery, doctor and Inspector cancellation,
apktool fixture teardown, JADX disconnect): settlement before the
production deadline that would otherwise also satisfy the wait, or a
deadline pinned beyond every test deadline.

The rea/project-wait-deadline lint rule rejects vi.waitFor and explicit
expect.poll timeouts. The testing guide and the CI tests audit skill now
document the policy.

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-11-14 19:28:38 +08:00

308 lines
9.5 KiB
TypeScript

import { EventEmitter, once } from "node:events";
import { createServer, type Socket } from "node:net";
import { setTimeout as delay } from "node:timers/promises";
import { PassThrough } from "node:stream";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { expect, it, vi } from "vitest";
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
import { createTestProcessRegistry } from "../../support/process/processFixture.js";
const within = async <T>(
operation: Promise<T>,
timeoutMs: number,
timeoutValue: string,
): Promise<T | string> => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<string>((resolve) => {
timer = setTimeout(() => resolve(timeoutValue), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
};
it("bounds a timed-out run when the leader exits but output remains open", async () => {
vi.useFakeTimers();
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
pid: 123,
exitCode: null as number | null,
signalCode: null as NodeJS.Signals | null,
kill: vi.fn(() => false),
});
const processes = createTestProcessRegistry(
() => child as unknown as ChildProcessWithoutNullStreams,
);
const pending = processes.run("fixture", [], { timeoutMs: 100 });
const outcome = pending.then(
() => "resolved",
(error: unknown) => error,
);
try {
child.stdout.write("partial output");
child.exitCode = 0;
child.emit("exit", 0, null);
await vi.advanceTimersByTimeAsync(2_101);
const result = await Promise.race([
outcome,
Promise.resolve("still pending"),
]);
expect(result).toBeInstanceOf(Error);
expect(child.kill).not.toHaveBeenCalled();
expect(result).toMatchObject({
message: "Test process exceeded its 100ms deadline",
cause: { exitCode: 0, signal: null, stdout: "partial output" },
});
} finally {
child.emit("close", 0, null);
await outcome;
await processes.shutdown();
vi.useRealTimers();
}
});
it("retains an unconfirmed child for shutdown retry after the run deadline", async () => {
vi.useFakeTimers();
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
pid: 456,
exitCode: null as number | null,
signalCode: null as NodeJS.Signals | null,
kill: vi.fn(() => true),
});
const processes = createTestProcessRegistry(
() => child as unknown as ChildProcessWithoutNullStreams,
);
const pending = processes.run("fixture", [], { timeoutMs: 100 });
const outcome = pending.then(
() => "resolved",
(error: unknown) => error,
);
try {
await vi.advanceTimersByTimeAsync(2_101);
expect(await outcome).toMatchObject({
message: "Test process exceeded its 100ms deadline",
cause: { exitCode: null, signal: null },
});
const firstShutdown = processes.shutdown().then(
() => "resolved",
(error: unknown) => error,
);
await vi.advanceTimersByTimeAsync(2_001);
expect(await firstShutdown).toMatchObject({
message: "Test child process shutdown failed",
errors: [
expect.objectContaining({
message: "test child process 456 did not exit after SIGKILL",
}),
expect.any(Error),
],
});
child.kill.mockImplementation(() => {
child.exitCode = 0;
child.emit("exit", 0, null);
return true;
});
await expect(processes.shutdown()).resolves.toBeUndefined();
expect(child.kill).toHaveBeenCalledTimes(5);
} finally {
child.exitCode = 0;
child.emit("exit", 0, null);
child.emit("close", 0, null);
await outcome;
await processes.shutdown();
vi.useRealTimers();
}
});
it("returns normal completion without a timeout and preserves child errors", async () => {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
pid: 789,
exitCode: null as number | null,
signalCode: null as NodeJS.Signals | null,
kill: vi.fn(() => true),
});
const processes = createTestProcessRegistry(
() => child as unknown as ChildProcessWithoutNullStreams,
);
const run = processes.run("fixture", []);
child.stdout.write("completed");
child.exitCode = 0;
child.emit("exit", 0, null);
child.emit("close", 0, null);
await expect(run).resolves.toEqual({
exitCode: 0,
signal: null,
stdout: "completed",
stderr: "",
});
await processes.shutdown();
const failure = new Error("spawn failed");
child.exitCode = null;
const failedRun = processes.run("fixture", []);
child.emit("error", failure);
child.emit("close", null, null);
await expect(failedRun).rejects.toBe(failure);
await processes.shutdown();
});
const releaseDescendant = async (peer: Socket | undefined): Promise<void> => {
if (peer === undefined) {
// The child has a finite self-expiry even if it never connects.
await delay(9_500);
return;
}
if (peer.destroyed) return;
const acknowledgement = once(peer, "data");
const closed = once(peer, "close");
peer.write("stop");
const result = await within(
Promise.all([acknowledgement, closed]),
10_000,
"still open",
);
if (result === "still open") {
peer.destroy();
throw new Error("descendant connection did not close");
}
expect(String(result[0][0])).toBe("stopping");
};
it("times out when a real descendant holds the exited leader's output open", async () => {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Expected TCP listener");
let peer: Socket | undefined;
let descendantPid: number | undefined;
const connected = new Promise<void>((resolve) => {
server.once("connection", (socket) => {
peer = socket;
socket.once("data", (data: Buffer) => {
descendantPid = Number(data.toString());
resolve();
});
});
});
const descendant = `
const net = require("node:net");
const socket = net.connect(Number(process.argv[1]), "127.0.0.1", () => {
process.stdout.write("descendant output\\n");
socket.write(String(process.pid));
});
socket.on("data", () => socket.write("stopping", () => process.exit(0)));
setTimeout(() => process.exit(0), 9000);
`;
const leader = `
const { spawn } = require("node:child_process");
const child = spawn(process.execPath, ["-e", process.argv[1], process.argv[2]], {
detached: true, stdio: ["ignore", "inherit", "inherit"],
});
child.unref();
`;
const processes = createTestProcessRegistry();
const pending = processes.run(
process.execPath,
["-e", leader, descendant, String(address.port)],
{ timeoutMs: 3000 },
);
const outcome = pending.then(
() => "resolved",
(error: unknown) => error,
);
try {
await awaitAcknowledgement(connected, outcome, "descendant connection");
const result = await within(outcome, 7_000, "still pending");
expect(result).toMatchObject({
message: "Test process exceeded its 3000ms deadline",
cause: {
exitCode: 0,
signal: null,
stdout: expect.stringContaining("descendant output"),
},
});
expect(descendantPid).toBeGreaterThan(0);
} finally {
try {
await releaseDescendant(peer);
} finally {
await outcome;
await processes.shutdown();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
}
});
it("terminates a stalled real child at its deadline while retaining captured output", async () => {
const processes = createTestProcessRegistry();
try {
const result = await processes
.run(
process.execPath,
[
"-e",
'process.stdout.write("ready\\n"); setInterval(() => {}, 1000);',
],
{ timeoutMs: 1000 },
)
.catch((cause: unknown) => cause);
expect(result).toMatchObject({
message: "Test process exceeded its 1000ms deadline",
cause: {
exitCode: null,
signal: expect.stringMatching(/^SIG(?:TERM|KILL)$/u),
stdout: "ready\n",
stderr: "",
},
});
await expect(processes.shutdown()).resolves.toBeUndefined();
} finally {
await processes.shutdown();
}
});
it(
"stops a hung child before its running test's deadline and reports its output",
// The test deadline is the behavior under test: the child gets what remains.
{ timeout: 8_000 },
async () => {
// processTest uses this registry, so one registry covers both entry points.
const processes = createTestProcessRegistry();
const started = performance.now();
try {
const failure: unknown = await processes
.run(process.execPath, [
"-e",
"process.stdout.write('started'); setInterval(() => {}, 1_000);",
])
.catch((error: unknown) => error);
expect(failure).toMatchObject({
message: expect.stringMatching(
/^Test process exceeded its \d+ms deadline$/u,
),
cause: { stdout: "started" },
});
expect(performance.now() - started).toBeLessThan(6_000);
} finally {
await processes.shutdown();
}
},
);