The remote run proved the instrumentation did not work. Timing landed — "chunk 1/1 was killed after 2006ms" — but the in-flight-file naming produced nothing and fell through to the pre-existing generic message. The feature I wrote to diagnose a kill was itself destroyed by the kill. Root cause, confirmed rather than assumed. The reporter yielded strings, which node pipes into the --test-reporter-destination WriteStream. That stream BUFFERS. execFileSync's timeout sends SIGKILL, which is uncatchable and gives nothing a chance to flush, so the events sat in a buffer that died with the child. The parent's own timer reported correctly because it lives in the parent — which is exactly why half the feature looked fine. The reporter now writes each event with fs.appendFileSync, unbuffered and durable at the moment it happens, to a path passed through GSD_RUN_TESTS_EVENTS_FILE. Env vars do not count toward the Windows 32,767-char argv ceiling, so moving the path out of argv also REDUCES FIXED_OVERHEAD; the accounting moved with it rather than being left stale. The destination is now a fixed devNull sink that stays empty by design. Silence was the reason this was invisible for a whole run. Failing to read the events file now says so explicitly, and distinguishes a file that could not be read at all from one that exists but is empty — the generic fallback firing quietly is what let a broken feature look like a working one. A write is unbuffered but not atomic, so a kill can still interleave a partial line; the reader tolerates exactly one unparsable trailing line and reports the complete ones before it. The failing T1 was left red and untouched rather than weakened to pass. Three new unit tests cover the reader directly, with no subprocess, so the parsing half is verifiable without a full runner pass: missing file, existing-but-empty file, and a truncated final line. Also adds ndjson-reporter.cjs to GSD_SCRIPTS_LIB_FILES in bin/install.js — scripts/lib/ ships, and omitting it meant the file would install everywhere and orphan on uninstall. That single omission caused 4 of the 7 remote failures. Verification runs on the remote runner. Refs #4012
82 lines
4.2 KiB
JavaScript
82 lines
4.2 KiB
JavaScript
'use strict';
|
|
|
|
// Machine-readable companion reporter for scripts/run-tests.cjs (#3889).
|
|
//
|
|
// node:test's built-in reporters (spec/tap) are human-formatted and
|
|
// scripts/run-tests.cjs spawns the child with `stdio: 'inherit'` — by design,
|
|
// per #3597/#1051, to avoid the maxBuffer and live-output risks of piping —
|
|
// so the parent process has no way to see WHICH file was executing when a
|
|
// per-chunk timeout kills the child. This reporter runs ALONGSIDE the normal
|
|
// human reporter (a second `--test-reporter` on the same invocation, per
|
|
// Node's documented multi-reporter pairing) and appends one JSON object per
|
|
// line to a path supplied via the GSD_RUN_TESTS_EVENTS_FILE env var. On a
|
|
// timeout, run-tests.cjs reads that file back to name the file(s) still
|
|
// in flight (a `test:start` with no matching `test:pass`/`test:fail`).
|
|
//
|
|
// Durability, not `--test-reporter-destination` (#3889 root cause): a
|
|
// reporter that YIELDS strings has them piped by Node into a
|
|
// `fs.WriteStream` targeting the destination path, and that stream buffers.
|
|
// The parent's `execFileSync` timeout SIGKILLs the child on a hang, and
|
|
// SIGKILL is uncatchable and gives the process zero chance to flush — so a
|
|
// yield-based reporter can lose every event still sitting in the stream's
|
|
// buffer, which is exactly the case this feature exists to diagnose (proven
|
|
// live: a chunk killed at 2006ms produced a `killed after 2006ms` line from
|
|
// the TIMER, which lives in the parent, but zero usable events from the
|
|
// reporter, which lives in the child and never flushed). Writing each event
|
|
// with `fs.appendFileSync` — synchronous and unbuffered — makes it durable
|
|
// the instant it happens, before the process can be killed out from under
|
|
// it. The reporter therefore yields NOTHING; it is a pure side-effecting
|
|
// sink. Node still requires a `--test-reporter-destination` to pair with
|
|
// this `--test-reporter` (see run-tests.cjs's reporterArgsFor), but that
|
|
// destination is a throwaway sink that stays empty by design — the durable
|
|
// path is GSD_RUN_TESTS_EVENTS_FILE, not the destination Node manages.
|
|
//
|
|
// Contract targeted: Node's "Custom reporters" contract
|
|
// (https://nodejs.org/api/test.html#custom-reporters) — a reporter module's
|
|
// default export is a function receiving the test runner's event stream (an
|
|
// AsyncIterable of `{ type, data }` objects) and returning an iterable (sync
|
|
// or async) of the reporter's output. This one intentionally emits no output
|
|
// (see the durability note above) — a plain `async function` that returns an
|
|
// empty array satisfies the contract without an `async function*` generator
|
|
// that would otherwise never `yield` (require-yield). This repo's
|
|
// `engines.node` requires >=24.0.0 (package.json), where this contract has
|
|
// been stable since Node 20.
|
|
//
|
|
// Kept intentionally tiny: only the three event types run-tests.cjs needs to
|
|
// pair start/completion are handled; everything else (diagnostics, plans,
|
|
// coverage) is ignored so a truncated events file (the process is SIGKILLed
|
|
// mid-`appendFileSync` on timeout — an individual write is unbuffered but
|
|
// not atomic, so the OS can still interleave a partial write with the kill)
|
|
// never leaves more than one dangling unparsable trailing line.
|
|
module.exports = async function ndjsonEventReporter(source) {
|
|
const eventsPath = process.env.GSD_RUN_TESTS_EVENTS_FILE;
|
|
for await (const event of source) {
|
|
if (!eventsPath) continue; // no destination configured — nothing to record
|
|
if (
|
|
event.type === 'test:start' ||
|
|
event.type === 'test:pass' ||
|
|
event.type === 'test:fail'
|
|
) {
|
|
const { file, name, nesting, testNumber } = event.data || {};
|
|
const line = `${JSON.stringify({
|
|
type: event.type,
|
|
file,
|
|
name,
|
|
nesting,
|
|
testNumber,
|
|
ts: Date.now(),
|
|
})}\n`;
|
|
try {
|
|
require('fs').appendFileSync(eventsPath, line);
|
|
} catch {
|
|
// Best-effort: a write failure here (e.g. the events dir vanished)
|
|
// must never crash the test run this reporter is only observing.
|
|
}
|
|
}
|
|
}
|
|
// Intentionally empty: this reporter is a pure side-effecting sink (see the
|
|
// durability note above), never a source of reporter OUTPUT. Node still
|
|
// requires the exported function to return an iterable.
|
|
return [];
|
|
};
|