Skip to content

finding: os test with a ** glob OOMs — resolveGlob readdir-recurses the whole tree including node_modules #7363

Description

@os-zhuang

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions