Skip to content

Commit 7f313b3

Browse files
committed
fix(glob): bound gitignore matching memory to prevent scan OOM
socket fix and socket scan aborted with "FATAL ERROR: CALL_AND_RETRY_LAST ... heap out of memory" (SIGABRT) on large monorepos. globWithGitIgnore discovers every nested .gitignore and unions their patterns; the non-negated code path handed that whole set to fast-glob's native ignore option. fast-glob re-compiles and re-tests its entire ignore array inside each directory scan, so a set of tens of thousands of patterns exhausts V8 code space, which raising --max-old-space-size does not relieve. Route the high-cardinality gitignore set through a single reused ignore instance (which compiles each rule once and memoizes it) and hand fast-glob only the small bounded set it needs to prune directories during the walk. The negated-pattern path already worked this way; this unifies both paths and removes the asymmetry that left the common case crashing. Add a regression test that builds a 100k-pattern nested-.gitignore tree and asserts the walk completes with the correct manifests, and correct a comment in getPackageFilesForScan that overstated what the streaming filter prevents.
1 parent 32446f9 commit 7f313b3

3 files changed

Lines changed: 107 additions & 33 deletions

File tree

src/utils/glob-oom.test.mts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import path from 'node:path'
4+
5+
import { describe, expect, it } from 'vitest'
6+
7+
import { normalizePath } from '@socketsecurity/registry/lib/path'
8+
9+
import { globWithGitIgnore } from './glob.mts'
10+
11+
// Defined at module scope to satisfy linting rules.
12+
function filterJsonFiles(filepath: string): boolean {
13+
return filepath.endsWith('.json')
14+
}
15+
16+
// This suite lives in its own file, with no mock-fs node_modules preload, so the
17+
// large ignore set it builds is the only significant allocation in the worker.
18+
describe('globWithGitIgnore() large monorepo memory', () => {
19+
// Regression: `socket fix` / `socket scan` aborted with
20+
// `FATAL ERROR: CALL_AND_RETRY_LAST … heap out of memory` (SIGABRT) on large
21+
// monorepos. globWithGitIgnore discovers every nested .gitignore and unions
22+
// their patterns; handing that whole set to fast-glob's `ignore` option made
23+
// fast-glob re-compile and re-test the entire array inside every directory
24+
// scan, so tens of thousands of patterns exhausted V8 code space. Routing the
25+
// gitignore set through a single reused `ignore` instance bounds the cost. The
26+
// flat union built below is large enough to crash the old path; the walk must
27+
// instead complete and return the right manifests. Uses the real filesystem
28+
// because mock-fs would hold every pattern in memory and add its own overhead.
29+
it('does not exhaust memory on a huge nested-.gitignore pattern set', async () => {
30+
const realTmp = mkdtempSync(path.join(tmpdir(), 'socket-glob-oom-'))
31+
try {
32+
// 100 packages * 1000 lines = 100k distinct patterns. The pre-fix code
33+
// (whole set handed to fast-glob, re-compiled per directory scan) exhausts
34+
// a constrained test-worker heap at this count, while the reused `ignore`
35+
// instance stays well within it.
36+
const pkgCount = 100
37+
const linesPerPkg = 1_000
38+
// Each line anchors to a distinct local generated dir, so the flat union
39+
// across packages is pkgCount * linesPerPkg distinct patterns.
40+
const lines: string[] = []
41+
for (let l = 0; l < linesPerPkg; l += 1) {
42+
lines.push(`generated_${l}/`)
43+
}
44+
const gitignoreBody = `${lines.join('\n')}\n`
45+
// The root manifest and one manifest per package must be found.
46+
writeFileSync(path.join(realTmp, 'package.json'), '{}')
47+
const expected = [normalizePath(path.join(realTmp, 'package.json'))]
48+
for (let d = 0; d < pkgCount; d += 1) {
49+
const pkgDir = path.join(realTmp, 'packages', `pkg-${d}`)
50+
const ignoredDir = path.join(pkgDir, 'generated_0')
51+
mkdirSync(ignoredDir, { recursive: true })
52+
writeFileSync(path.join(pkgDir, '.gitignore'), gitignoreBody)
53+
writeFileSync(path.join(pkgDir, 'package.json'), '{}')
54+
// A manifest inside the package's own ignored generated dir must be
55+
// excluded, proving the gitignore set is still honored.
56+
writeFileSync(path.join(ignoredDir, 'package.json'), '{}')
57+
expected.push(normalizePath(path.join(pkgDir, 'package.json')))
58+
}
59+
60+
// Mirror the production call shape: a manifest filter forces the streaming
61+
// branch that getPackageFilesForScan always takes.
62+
const results = await globWithGitIgnore(['**/*'], {
63+
cwd: realTmp,
64+
filter: filterJsonFiles,
65+
})
66+
67+
expect(results.map(normalizePath).sort()).toEqual(expected.sort())
68+
} finally {
69+
rmSync(realTmp, { force: true, recursive: true })
70+
}
71+
}, 60_000)
72+
})

src/utils/glob.mts

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -290,22 +290,32 @@ export async function globWithGitIgnore(
290290
}
291291
}
292292

293-
let hasNegatedPattern = false
294-
for (const p of ignores) {
295-
if (p.charCodeAt(0) === 33 /*'!'*/) {
296-
hasNegatedPattern = true
297-
break
298-
}
299-
}
293+
// Match every gitignore-derived pattern through a single reused `ignore`
294+
// instance instead of handing the whole set to fast-glob's native `ignore`
295+
// option. fast-glob re-compiles and re-tests its entire ignore array inside
296+
// each directory scan (`node::fs::AfterScanDir`), so a large monorepo whose
297+
// nested `.gitignore` files union to tens of thousands of patterns aborts with
298+
// `CALL_AND_RETRY_LAST … heap out of memory`. Raising `--max-old-space-size`
299+
// does not reliably help: much of the cost is regex executable code in V8 code
300+
// space rather than the data heap. The `ignore` package compiles each rule
301+
// once and memoizes it, so the cost scales with the pattern count rather than
302+
// being multiplied by the number of directories walked. fast-glob
303+
// keeps only the small, bounded set it needs to PRUNE directories during the
304+
// walk (`defaultIgnore`, which already excludes node_modules and .git, plus
305+
// the anchored CLI minimatch ignores); the high-cardinality gitignore set is
306+
// applied per streamed entry by `ig` below. The `ignore` package also honors
307+
// negated re-includes, which fast-glob, globby, and tinyglobby cannot express.
308+
// The negated-pattern path already worked this way; routing both cases through
309+
// it removes the asymmetry that left the common, non-negated case crashing on
310+
// large repos.
311+
const ig = ignore().add([...ignores])
300312

301313
const globOptions = {
302314
__proto__: null,
303315
absolute: true,
304316
cwd,
305317
dot: true,
306-
ignore: hasNegatedPattern
307-
? [...defaultIgnore, ...cliMinimatchIgnores]
308-
: [...ignores, ...cliMinimatchIgnores].map(stripTrailingSlash),
318+
ignore: [...defaultIgnore, ...cliMinimatchIgnores],
309319
...additionalOptions,
310320
// Skip directories the running user cannot read rather than aborting the
311321
// whole walk on the first `EACCES` (see the .gitignore discovery walk
@@ -316,33 +326,22 @@ export async function globWithGitIgnore(
316326
suppressErrors: true,
317327
} as GlobOptions
318328

319-
// When no filter is provided and no negated patterns exist, use the fast path.
320-
if (!hasNegatedPattern && !filter) {
321-
return await fastGlob.glob(patterns as string[], globOptions)
322-
}
323-
// Add support for negated "ignore" patterns which many globbing libraries,
324-
// including 'fast-glob', 'globby', and 'tinyglobby', lack support for.
325-
// Use streaming to avoid unbounded memory accumulation.
326-
// This is critical for large monorepos with 100k+ files.
329+
// Stream results so memory stays bounded on large monorepos with 100k+ files:
330+
// `ig` applies the gitignore matching per entry and the optional caller filter
331+
// (e.g. manifest files only) drops non-matches before they accumulate, instead
332+
// of collecting every path and filtering afterward.
327333
const results: string[] = []
328-
const ig = hasNegatedPattern ? ignore().add([...ignores]) : null
329334
const stream = fastGlob.globStream(
330335
patterns as string[],
331336
globOptions,
332337
) as AsyncIterable<string>
333338
for await (const p of stream) {
334-
// Check gitignore patterns with negation support.
335-
if (ig) {
336-
// Note: the input files must be INSIDE the cwd. If you get strange looking
337-
// relative path errors here, most likely your path is outside the given cwd.
338-
const relPath = globOptions.absolute ? path.relative(cwd, p) : p
339-
if (ig.ignores(relPath)) {
340-
continue
341-
}
339+
// Note: the input files must be INSIDE the cwd. If you get strange looking
340+
// relative path errors here, most likely your path is outside the given cwd.
341+
const relPath = globOptions.absolute ? path.relative(cwd, p) : p
342+
if (ig.ignores(relPath)) {
343+
continue
342344
}
343-
// Apply the optional filter to reduce memory usage.
344-
// When scanning large monorepos, this filters early (e.g., to manifest files only)
345-
// instead of accumulating all 100k+ files and filtering later.
346345
if (filter && !filter(p)) {
347346
continue
348347
}

src/utils/path-resolve.mts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,12 @@ export async function getPackageFilesForScan(
144144
...options,
145145
} as PackageFilesForScanOptions
146146

147-
// Apply the supported files filter during streaming to avoid accumulating
148-
// all files in memory. This is critical for large monorepos with 100k+ files
149-
// where accumulating all paths before filtering causes OOM errors.
147+
// Apply the supported files filter during streaming so globWithGitIgnore drops
148+
// non-manifest paths as they are walked instead of collecting every path first.
149+
// This bounds RESULT-path memory on large monorepos with 100k+ files. Note it
150+
// does NOT bound the gitignore ignore-pattern memory: that OOM (regex compile
151+
// exhausting V8 code space) is handled inside globWithGitIgnore by matching the
152+
// gitignore set through a single reused `ignore` instance.
150153
const filter = createSupportedFilesFilter(supportedFiles)
151154

152155
const normalizedInputPaths = inputPaths.map(p =>

0 commit comments

Comments
 (0)