test: bound fixture child processes by the remaining test deadline

A processTest child run without its own timeoutMs now gets the test's
remaining deadline less a reporting reserve (at least 5 seconds, or a
tenth of the deadline). A hung child still fails with its captured output,
without a fixed per-call value that assumes a host's speed.

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and gensokyo-dev committed 2026-11-14 13:21:13 +08:00
1 parent 5b158b2d1d
commit 077bebe49b
3 files changed
+79 -8

No files matched your search

+4 -1
View File
@@ -307,7 +307,10 @@ assertion. `vitest.config.ts` applies the deadlines in
and 120 seconds for built projects, with hooks sharing their project's
deadline. Coverage doubles every deadline, and so does hosted CI, where
CPU-bound cases share every core with other workers and their subprocesses.
Do not pass a per-test timeout to fit a slow host. Tag a bounded workload that needs longer with
Do not pass a per-test timeout to fit a slow host. A child started through
the `processTest` registry, including `cli.run`, gets the test's remaining
deadline less a reporting reserve, so a hung child fails with its captured
output. Pass `timeoutMs` only when the child's deadline is itself under test. Tag a bounded workload that needs longer with
`{ tags: ["long-running"] }`, or a whole file with
`// @module-tag long-running`. Assert elapsed time explicitly when speed is the
behavior under test. Derive an explicit deadline only when a test must outlive
@@ -6,7 +6,11 @@ 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";
import {
createTestProcessRegistry,
processTest,
remainingProcessDeadline,
} from "../../support/process/processFixture.js";
const within = async <T>(
operation: Promise<T>,
@@ -277,3 +281,34 @@ it("terminates a stalled real child at its deadline while retaining captured out
await processes.shutdown();
}
});
it("derives a child deadline from the test deadline, keeping a reporting reserve", () => {
expect(remainingProcessDeadline(120_000, 0)).toBe(108_000);
expect(remainingProcessDeadline(30_000, 20_000)).toBe(5_000);
// The reserve never drops below five seconds, nor the deadline below one.
expect(remainingProcessDeadline(8_000, 0)).toBe(3_000);
expect(remainingProcessDeadline(8_000, 7_500)).toBe(1_000);
expect(remainingProcessDeadline(0, 0)).toBeUndefined();
});
processTest(
"stops a hung child before its test deadline and reports its output",
// The test deadline is the behavior under test: the child gets what remains.
{ timeout: 8_000 },
async ({ processes }) => {
const started = performance.now();
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);
},
);
+39 -6
View File
@@ -8,6 +8,26 @@ import {
import { test as base } from "vitest";
const SHUTDOWN_GRACE_MS = 1_000;
/** Minimum time kept back from a test deadline to stop a child and report it. */
const MIN_REPORT_RESERVE_MS = 5_000;
/**
* Default child deadline: the test's remaining deadline, less a reserve for
* stopping the child and reporting its captured output. A hung child then fails
* with its output instead of an opaque test timeout, without a fixed value that
* assumes a host's speed. Undefined when the test itself has no deadline.
*/
export const remainingProcessDeadline = (
testDeadlineMs: number,
elapsedMs: number,
): number | undefined => {
if (!(testDeadlineMs > 0)) return undefined;
const reserve = Math.max(MIN_REPORT_RESERVE_MS, testDeadlineMs / 10);
return Math.max(
SHUTDOWN_GRACE_MS,
Math.floor(testDeadlineMs - elapsedMs - reserve),
);
};
/** Captured output and termination state from one child process. */
export interface ProcessResult {
@@ -70,13 +90,17 @@ const stopChild = async (
return waitForExit(child, SHUTDOWN_GRACE_MS);
};
/** Create a child-process registry for a fixture composition root. */
/**
* Create a child-process registry for a fixture composition root. A run without
* its own `timeoutMs` uses `defaultTimeoutMs`, when supplied.
*/
export const createTestProcessRegistry = (
spawnChild: (
command: string,
arguments_: readonly string[],
options: SpawnOptionsWithoutStdio,
) => ChildProcessWithoutNullStreams = nodeSpawn,
defaultTimeoutMs?: () => number | undefined,
): TestProcesses => {
const children = new Set<ChildProcessWithoutNullStreams>();
@@ -98,7 +122,11 @@ export const createTestProcessRegistry = (
return {
spawn,
run: async (command, arguments_, options = {}) => {
const { input, timeoutMs, ...spawnOptions } = options;
const {
input,
timeoutMs = defaultTimeoutMs?.(),
...spawnOptions
} = options;
const child = spawn(command, arguments_, spawnOptions);
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
@@ -196,11 +224,16 @@ export const createTestProcessRegistry = (
};
};
/** Vitest API extended with a bounded child-process registry. */
/**
* Vitest API extended with a child-process registry whose runs are bounded by
* the remaining test deadline unless they choose their own.
*/
export const processTest = base.extend<{ processes: TestProcesses }>({
// oxlint-disable-next-line no-empty-pattern -- Vitest parses fixture dependencies from object destructuring.
processes: async ({}, use) => {
const processes = createTestProcessRegistry();
processes: async ({ task }, use) => {
const started = performance.now();
const processes = createTestProcessRegistry(nodeSpawn, () =>
remainingProcessDeadline(task.timeout, performance.now() - started),
);
try {
await use(processes);
} finally {