Skip to content

Commit a964957

Browse files
committed
refactor: make spawnTest async and drop dead exit-reason branch
1 parent f3536ed commit a964957

4 files changed

Lines changed: 42 additions & 22 deletions

File tree

implementors/node/child_process.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ export interface SpawnTestResult {
1313
export function spawnTest(
1414
filePath: string,
1515
options?: SpawnTestOptions,
16-
): SpawnTestResult;
16+
): Promise<SpawnTestResult>;

implementors/node/child_process.js

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawnSync } from 'node:child_process';
1+
import { spawn } from 'node:child_process';
22
import path from 'node:path';
33
import { pathToFileURL } from 'node:url';
44

@@ -28,7 +28,7 @@ const ABORT_EXIT_CODES = [132, 133, 134, 139, 0xc0000409, 0xc000001d];
2828
* `'inherit'` streams it straight to the terminal as the child runs (so the
2929
* output of a slow or hanging test is visible immediately) and leaves the
3030
* returned `stdout` empty. stderr is always captured for diagnostics.
31-
* @returns {{ status: number | null, aborted: boolean, stdout: string, stderr: string }}
31+
* @returns {Promise<{ status: number | null, aborted: boolean, stdout: string, stderr: string }>}
3232
*/
3333
export const spawnTest = (filePath, options = {}) => {
3434
// --expose-gc is mandatory: gc.js (loaded via harness.js) throws at import
@@ -42,18 +42,35 @@ export const spawnTest = (filePath, options = {}) => {
4242
filePath,
4343
];
4444

45-
const result = spawnSync(process.execPath, args, {
45+
// spawn (not spawnSync) so a hung child doesn't block the event loop and the
46+
// test runner can still enforce its per-test timeout.
47+
const child = spawn(process.execPath, args, {
4648
cwd: options.cwd ?? process.cwd(),
47-
maxBuffer: 100 * 1024 * 1024,
4849
stdio: ['ignore', options.stdout ?? 'pipe', 'pipe'],
4950
});
50-
if (result.error) throw result.error;
51-
return {
52-
status: result.status,
53-
aborted: result.signal !== null || ABORT_EXIT_CODES.includes(result.status),
54-
stderr: result.stderr?.toString() ?? '',
55-
stdout: result.stdout?.toString() ?? '',
56-
};
51+
52+
return new Promise((resolve, reject) => {
53+
let stdout = '';
54+
let stderr = '';
55+
// child.stdout is null when stdout is 'inherit' (streamed to the terminal).
56+
child.stdout?.setEncoding('utf8').on('data', (chunk) => {
57+
stdout += chunk;
58+
});
59+
child.stderr.setEncoding('utf8').on('data', (chunk) => {
60+
stderr += chunk;
61+
});
62+
child.on('error', reject);
63+
// 'close' (not 'exit') fires once the stdio streams have drained, so stdout
64+
// and stderr are complete.
65+
child.on('close', (status, signal) => {
66+
resolve({
67+
status,
68+
aborted: signal !== null || ABORT_EXIT_CODES.includes(status),
69+
stderr,
70+
stdout,
71+
});
72+
});
73+
});
5774
};
5875

5976
// This module is loaded in both contexts: imported by the parent test runner

implementors/node/tests.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,23 @@ export function listDirectoryEntries(dir: string) {
3131
return { directories, files };
3232
}
3333

34-
export function runFileInSubprocess(cwd: string, filePath: string): void {
34+
export async function runFileInSubprocess(
35+
cwd: string,
36+
filePath: string,
37+
): Promise<void> {
3538
// Stream stdout live rather than buffering it, so output from a slow or
3639
// hanging test shows up immediately. stderr is still captured to attach to
3740
// the failure message below.
38-
const { status, aborted, stderr } = spawnTest(filePath, {
41+
const { status, aborted, stderr } = await spawnTest(filePath, {
3942
cwd,
4043
stdout: 'inherit',
4144
});
4245

4346
if (status === 0) return;
4447

45-
const reason = aborted ?
46-
'aborted' :
47-
status !== null ? `exit code ${status}` : 'unknown';
48+
// A null status means the child was killed by a signal, which already sets
49+
// `aborted`, so the non-aborted branch always has a numeric exit code.
50+
const reason = aborted ? 'aborted' : `exit code ${status}`;
4851
const trimmedStderr = stderr.trim();
4952
const stderrSuffix = trimmedStderr ?
5053
`\n--- stderr ---\n${trimmedStderr}\n--- end stderr ---` :

tests/harness/spawn-test.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ if (typeof spawnTest !== 'function') {
1919
// Successful child: exits 0, stderr empty, and harness globals are available
2020
// inside the child (the child checks `typeof assert === 'function'` itself).
2121
{
22-
const result = spawnTest('spawn-test-ok-child.mjs');
22+
const result = await spawnTest('spawn-test-ok-child.mjs');
2323
assert.strictEqual(result.status, 0, `ok child exited with status ${result.status}; stderr:\n${result.stderr}`);
2424
assert.strictEqual(result.aborted, false);
2525
assert.strictEqual(result.stderr, '');
2626
}
2727

2828
// Failing child: non-zero status and stderr contains the thrown marker.
2929
{
30-
const result = spawnTest('spawn-test-fail-child.mjs');
30+
const result = await spawnTest('spawn-test-fail-child.mjs');
3131
assert.notStrictEqual(result.status, 0, 'fail child should exit non-zero');
3232
// A thrown error is a clean non-zero exit, not an abnormal termination.
3333
assert.strictEqual(result.aborted, false);
@@ -38,7 +38,7 @@ if (typeof spawnTest !== 'function') {
3838

3939
// Result shape: all four fields are present.
4040
{
41-
const result = spawnTest('spawn-test-ok-child.mjs');
41+
const result = await spawnTest('spawn-test-ok-child.mjs');
4242
for (const key of ['status', 'aborted', 'stdout', 'stderr']) {
4343
if (!(key in result)) {
4444
throw new Error(`Expected spawnTest result to have "${key}" field`);
@@ -56,7 +56,7 @@ if (typeof spawnTest !== 'function') {
5656
// actually reached the terminal would need the child to write to stdout, which
5757
// isn't expressible in portable ECMAScript, so we only check the contract here.)
5858
{
59-
const result = spawnTest('spawn-test-ok-child.mjs', { stdout: 'inherit' });
59+
const result = await spawnTest('spawn-test-ok-child.mjs', { stdout: 'inherit' });
6060
assert.strictEqual(result.status, 0, `ok child exited with status ${result.status}; stderr:\n${result.stderr}`);
6161
assert.strictEqual(result.stdout, '', 'inherited stdout should not be captured');
6262
}
@@ -65,7 +65,7 @@ if (typeof spawnTest !== 'function') {
6565
// makes the bare child filename unresolvable. The child's stderr must name
6666
// the specific file Node tried to load, proving cwd actually shifted.
6767
{
68-
const result = spawnTest('spawn-test-ok-child.mjs', { cwd: '..' });
68+
const result = await spawnTest('spawn-test-ok-child.mjs', { cwd: '..' });
6969
assert.notStrictEqual(result.status, 0, 'expected cwd ".." to make the child filename unresolvable');
7070
if (!result.stderr.includes('spawn-test-ok-child.mjs')) {
7171
throw new Error(`Expected stderr to reference the unresolved child filename, got:\n${result.stderr}`);

0 commit comments

Comments
 (0)