* 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>
95 lines
4.4 KiB
JavaScript
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'
|
|
);
|
|
});
|
|
});
|