Observation-class finding, noticed while measuring the blast radius for #7256 (PR #7348). Filed unassigned, no pm:queue — for triage to grade. Not folded into #7256's PR: different file (packages/cli/src/commands/test.ts, not packages/core/src/qa/runner.ts), and a different defect.
What happens
os test accepts a glob and documents ** support (resolveGlob's own header: "Supports * (single segment wildcard) and ** (recursive wildcard)"). Run one from a repository root and the command dies before it loads a single suite:
$ node packages/cli/bin/run.js test '**/*.test.json'
🧪 ObjectStack Quality Protocol Runner
-------------------------------------
Target: http://localhost:3000
<--- Last few GCs --->
[32114] 521473 ms: Mark-Compact 8080.5 (8231.1) -> 8065.8 (8232.1) MB, …
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
Exit code 134, after ~8 minutes of GC thrash. Measured on origin/main @ f3f855ac in a pnpm install-ed worktree.
Why
resolveGlob splits the pattern at the first wildcard segment to get a static base dir. For **/*.test.json the first segment IS the wildcard, so baseDir stays . — and then:
const entries = fs.readdirSync(baseDir, { recursive: true, encoding: 'utf-8' }) as string[];
return entries.filter(entry => regex.test(…)).map(…).filter(fullPath => fs.statSync(fullPath).isFile());
readdirSync(… { recursive: true }) materialises every path under the base as one array before any filtering, and nothing excludes node_modules. In this monorepo that is millions of entries; the array exhausts the default heap. The filter that would have thrown almost all of them away never runs.
Two smaller consequences of the same shape, worth fixing together:
- No ignore list. Even where the tree is small enough to enumerate, a suite vendored inside
node_modules (or dist, or .git) is a match os test would happily load and run.
statSync per surviving match. Fine at the current scale, but it is a second full pass over whatever survived.
Why it has gone unnoticed
Nothing in the repository uses a ** pattern with os test — the default is qa/*.test.json, whose base dir is the small qa/ directory, and the documented examples (content/docs/deployment/cli.mdx) are all single-segment. The repository also contains no Quality Protocol suites at all (measured for #7256: no qa/ directory in any example app, and the only *.test.json files in the tree are three tsconfig.test.json), so nobody has had a reason to widen the glob. A downstream project with a deep node_modules and a qa/**/*.test.json layout is where this lands first.
Direction
Walk the tree lazily with a prune list (node_modules, .git, dist, build) instead of materialising readdirSync(recursive), and stop descending once a directory cannot match the remaining pattern. A ** glob should also not be able to reach into node_modules even when the heap survives it.
Dedup
Searched open issues and PRs across the repo for resolveGlob, os test + glob, and the OOM signature before filing — nothing names this.
Observation-class finding, noticed while measuring the blast radius for #7256 (PR #7348). Filed unassigned, no
pm:queue— for triage to grade. Not folded into #7256's PR: different file (packages/cli/src/commands/test.ts, notpackages/core/src/qa/runner.ts), and a different defect.What happens
os testaccepts a glob and documents**support (resolveGlob's own header: "Supports*(single segment wildcard) and**(recursive wildcard)"). Run one from a repository root and the command dies before it loads a single suite:Exit code 134, after ~8 minutes of GC thrash. Measured on
origin/main@f3f855acin apnpm install-ed worktree.Why
resolveGlobsplits the pattern at the first wildcard segment to get a static base dir. For**/*.test.jsonthe first segment IS the wildcard, sobaseDirstays.— and then:readdirSync(… { recursive: true })materialises every path under the base as one array before any filtering, and nothing excludesnode_modules. In this monorepo that is millions of entries; the array exhausts the default heap. The filter that would have thrown almost all of them away never runs.Two smaller consequences of the same shape, worth fixing together:
node_modules(ordist, or.git) is a matchos testwould happily load and run.statSyncper surviving match. Fine at the current scale, but it is a second full pass over whatever survived.Why it has gone unnoticed
Nothing in the repository uses a
**pattern withos test— the default isqa/*.test.json, whose base dir is the smallqa/directory, and the documented examples (content/docs/deployment/cli.mdx) are all single-segment. The repository also contains no Quality Protocol suites at all (measured for #7256: noqa/directory in any example app, and the only*.test.jsonfiles in the tree are threetsconfig.test.json), so nobody has had a reason to widen the glob. A downstream project with a deepnode_modulesand aqa/**/*.test.jsonlayout is where this lands first.Direction
Walk the tree lazily with a prune list (
node_modules,.git,dist,build) instead of materialisingreaddirSync(recursive), and stop descending once a directory cannot match the remaining pattern. A**glob should also not be able to reach intonode_moduleseven when the heap survives it.Dedup
Searched open issues and PRs across the repo for
resolveGlob,os test+ glob, and the OOM signature before filing — nothing names this.