Files
msd-core/tests/c8-merge-async-flag.test.cjs
Tom Boucher 4d70b4dc43 fix(#4068): add --merge-async to c8 coverage-merge invocations (#4069)
* test(#4068): failing-first regression guard for c8 --merge-async flag

Adds a config-invariant test asserting test:coverage:unit and
test:coverage:report pass --merge-async to c8, guarding against the
coverage-merge OOM (release.yml finalize dry-run, exit 134/SIGABRT)
regressing a third time (prior stopgap: #199).

Also commits the sourced research memo backing the diagnosis.

* test(#4068): rename regression test to avoid lint-test-file-count collision

coverage-merge-async-flag.test.cjs's effective prefix (coverage-merge-
async-flag) matched the unrelated gsd-core/bin/lib/coverage.cjs module's
2-file cap under lint-test-file-count.cjs's startsWith bucketing, failing
FAIL_EXCEEDS_LIMIT (confirmed via the RED gsd-test run on 7b6e6ca9e).
Renamed to c8-merge-async-flag.test.cjs -- no colliding prefix.

* fix(#4068): add --merge-async to c8 coverage-merge invocations

The `test:coverage:unit` script OOM-crashed (exit 134, SIGABRT) in the
release.yml finalize dry-run of 1.12.0: all 1785 unit tests pass, then
c8's report/merge phase crashes ~76s later against the 6144 MB heap
ceiling. Root cause (verified against this repo's pinned c8@11.0.0
source, node_modules/c8/lib/report.js): the default sync merge path,
Report._getMergedProcessCov(), loads every raw V8 coverage file for the
whole run into memory as one array before merging. This is a recurrence
of #199 (same OOM class at ~466 tests, "fixed" by raising the heap
ceiling) -- the suite outgrew 6144 MB as it grew to 1785 tests, and
test.yml's own coverage-gate job already independently hit and
stopgap-fixed the identical class once (test.yml:614-620, 4096->8192 MB).

c8 ships the upstream fix for exactly this: --merge-async (c8 v7.14.0,
already inside the pinned c8@^11.0.0 range -- no dependency bump),
which switches to Report._getMergedProcessCovAsync(), reading and
merging one raw file at a time instead of loading them all at once.
The merge arithmetic (mergeProcessCovs()) and --all's zero-coverage-file
inclusion are unchanged between the two paths, so the coverage gate's
accuracy is unaffected.

Verified empirically (not just by source inspection): against a 223 MB
synthetic raw-coverage corpus derived from this repo's actual 235
gsd-core/bin/lib/**/*.cjs files (same order of magnitude as the 358 MB
figure documented in test.yml), c8's real Report class crashes with the
same "Ineffective mark-compacts near heap limit" signature via the sync
path at a 300 MB heap ceiling, while the async path completes at a flat
~40-50 MB peak down to a 100 MB ceiling. Full repro steps and numbers:
.gsd/bug/fix-4068-coverage-merge-oom/10-diagnosis.md.

Only `test:coverage:unit` and `test:coverage:report` are changed.
`test:coverage` (line 150) is a non-CI dev-convenience script, never
invoked as a command by any workflow. `test:coverage:unit:raw` (line
153) already runs --reporter none, which skips the merge/report phase
this flag affects, entirely -- adding it there would be a no-op.

Fixes #4068

* fix(#4068): add changeset fragment for the coverage-merge OOM fix

The changeset fragment was generated locally (npm run changeset) but
never committed -- isolated code review caught the gap (no
.changeset/*.md on the branch, confirmed via git diff --name-status).

* chore(#4068): backfill changeset PR number

pr:0 -> pr:4069

---------

Co-authored-by: sim <sim@local>
2026-08-29 22:03:28 -04:00

95 lines
4.4 KiB
JavaScript

'use strict';
// Regression guard for #4068: c8's default (sync) coverage-merge phase loads every
// raw V8 coverage file for the whole run into memory as one array before merging
// (`Report._getMergedProcessCov`), which OOM-crashed `npm run test:coverage:unit` in
// the `release.yml` `finalize` dry-run of 1.12.0 (exit 134, SIGABRT) once the unit
// suite grew to 1785 tests / 15 chunks -- a recurrence of #199, which hit the same
// class at ~466 tests and was "fixed" by raising the heap ceiling instead of the
// merge shape. `--merge-async` switches to `_getMergedProcessCovAsync`, which reads
// and merges one raw file at a time (documented root-cause verification, including a
// real memory-shape repro against this repo's actual pinned c8@11.0.0, lives in
// .gsd/bug/fix-4068-coverage-merge-oom/10-diagnosis.md).
//
// This test cannot behaviorally reproduce the OOM itself: `gsd-test` never invokes
// `npm run test:coverage:unit` (it runs `node --test` directly), and a heap-ceiling
// crossover point is not a stable, portable assertion across this repo's OS x Node
// matrix on shared benches (see .gsd/bug/fix-4068-coverage-merge-oom/50-test-matrix.md
// for the full seam analysis). What IS stable and worth guarding: the flag must not
// silently disappear from the two scripts that need it, and must not be added to a
// script whose merge/report phase never runs (a no-op that would misleadingly imply
// coverage here too).
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const pkg = require(path.join(__dirname, '..', 'package.json'));
const scripts = pkg.scripts;
describe('coverage-merge-async-flag (#4068)', () => {
test('test:coverage:unit carries --merge-async', () => {
assert.match(
scripts['test:coverage:unit'],
/(?:^|\s)c8\s.*--merge-async/,
'test:coverage:unit must pass --merge-async to c8, or the report/merge phase ' +
'reverts to loading every raw V8 coverage file into memory at once (#4068)'
);
});
test('test:coverage:report carries --merge-async', () => {
assert.match(
scripts['test:coverage:report'],
/(?:^|\s)c8\s.*--merge-async/,
'test:coverage:report (the coverage-gate job merge step in test.yml) must pass ' +
'--merge-async to c8 for the same reason as test:coverage:unit (#4068)'
);
});
test('--merge-async lands in the c8 invocation, not after the node runner', () => {
for (const key of ['test:coverage:unit', 'test:coverage:report']) {
const script = scripts[key];
const flagIndex = script.indexOf('--merge-async');
const nodeIndex = script.indexOf(' node ');
assert.ok(flagIndex !== -1, `${key}: --merge-async not found`);
// A negative nodeIndex (test:coverage:report has no `node` invocation of its
// own -- it re-slices already-merged dumps) means there is nothing to be
// "after"; the flag only needs to precede the runner when one exists.
if (nodeIndex !== -1) {
assert.ok(
flagIndex < nodeIndex,
`${key}: --merge-async (index ${flagIndex}) must precede the node ` +
`invocation (index ${nodeIndex}) so it is consumed by c8's own arg ` +
'parser, not forwarded as argv to the wrapped script'
);
}
}
});
test('test:coverage is intentionally unchanged (whole-suite dev convenience, never run by CI as a command)', () => {
assert.doesNotMatch(
scripts['test:coverage'],
/--merge-async/,
'test:coverage (package.json line ~150) is not invoked by any workflow as a ' +
'command -- adding the flag here is out of this fix\'s scope (see diagnosis ' +
'artifact "Not-the-bug" section)'
);
});
test('test:coverage:unit:raw is intentionally unchanged (--reporter none skips the merge/report phase entirely)', () => {
assert.match(
scripts['test:coverage:unit:raw'],
/--reporter none/,
'test:coverage:unit:raw must keep --reporter none -- this is what defers all ' +
'merging to the separate coverage-gate job (test:coverage:report)'
);
assert.doesNotMatch(
scripts['test:coverage:unit:raw'],
/--merge-async/,
'--merge-async on a --reporter none run is a no-op (Report.run() never ' +
'reaches the merge dispatch) and would misleadingly imply this script does ' +
'its own merging'
);
});
});