Skip to content

fix: memoize legacyNodeResolve resolver to avoid native memory leak#481

Open
B4nan wants to merge 4 commits into
un-ts:masterfrom
B4nan:fix/legacy-node-resolver-memory-leak
Open

fix: memoize legacyNodeResolve resolver to avoid native memory leak#481
B4nan wants to merge 4 commits into
un-ts:masterfrom
B4nan:fix/legacy-node-resolver-memory-leak

Conversation

@B4nan
Copy link
Copy Markdown

@B4nan B4nan commented Apr 9, 2026

Problem

legacyNodeResolve in src/utils/resolve.ts constructs a fresh unrs-resolver ResolverFactory via createNodeResolver on every import resolution. unrs-resolver is a Rust binding that allocates native memory which is not visible to V8's heap limits, so per-call construction causes RSS to grow without bound over the course of a lint run.

We hit this on a large lerna monorepo (apify/apify-core, ~60 workspace packages, thousands of files). A single ESLint worker peaked at >19 GB RSS before being OOM-killed by the runner. Bumping --max-old-space-size did not help — the leak is in native memory, not the JS heap.

The dominant time-spender shown by TIMING=all is import-x/no-cycle, but the memory hot-spot is the resolver. Disabling no-cycle entirely reduced peak RSS by only ~2.5 GB (from ~12.5 GB to ~10 GB single-worker) — confirming the leak is in the per-call resolver allocation, not the cycle traversal.

Fix

Memoize unrs-resolver instances by their option signature.

In practice the resolver options object is constant across calls within a single lint run, so the cache is effectively a singleton — but JSON-stringifying the options is cheap enough to handle the multi-resolver-config case correctly, and doesn't risk a stale cache if options ever vary.

Verification

Local repro on apify/apify-core's src/packages (~60 workspace packages):

variant concurrency heap result peak RSS
baseline (master) auto 8 GB OOM (worker) ~22 GB
baseline auto 16 GB OOM (worker) ~20 GB
baseline 4 16 GB OOM (worker) ~18 GB
baseline 1 12 GB passes (4m13s on CI) ~10 GB
with this patch auto 8 GB passes (~4m local, ~3m CI) ~20 GB
with this patch 4 8 GB passes (1m47s local) ~24 GB

So this restores the ability to use --concurrency=auto with the default 8 GB heap on a typical CI runner.

Notes

  • legacyNodeResolve is the path taken when no import-x/resolver-next is configured and the resolver name is in LEGACY_NODE_RESOLVERS. That covers the default node resolver, which is what most consumers use, so this fix benefits the common case.
  • The cache uses a Map<string, Resolver> keyed by JSON.stringify(opts). A WeakMap keyed by the options object would be slightly more efficient but doesn't work because callers often pass freshly-constructed options objects rather than reusing the same reference.
  • I didn't add a test because the leak only manifests over thousands of resolutions and would need a fixture monorepo to reproduce. Happy to add one if you'd like — let me know what shape you'd prefer.

Summary by CodeRabbit

  • Refactor

    • Resolver instances are now cached per option set, reducing repeated work and improving repeated-resolution performance and memory usage.
    • A public method to clear resolver caches was added so cached filesystem state can be reset when needed.
  • Tests

    • Tests updated to clear resolver caches to ensure filesystem changes are observed during test runs.

`legacyNodeResolve` constructs a fresh `unrs-resolver` `ResolverFactory`
on every import resolution via `createNodeResolver`. `unrs-resolver` is
a Rust binding that allocates native memory invisible to V8's heap
limits, so per-call construction causes RSS to grow without bound over
a lint run.

We hit this on a large lerna monorepo: a single ESLint worker peaked at
~19 GB RSS before the OOM killer fired. Bumping `--max-old-space-size`
did not help because the leak is in native memory, not the JS heap.

The fix memoizes resolver instances by their JSON-stringified options.
In practice the options object is constant across calls within a single
lint run, so the cache is effectively a singleton. Verified locally:
with this patch + `--concurrency=auto` + 8 GB heap, peak RSS drops from
OOM-territory to ~20 GB and the lint completes cleanly.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 9, 2026

⚠️ No Changeset found

Latest commit: e9e2460

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 9, 2026

📝 Walkthrough

Walkthrough

Added memoization for node resolvers in src/utils/resolve.ts with getCachedNodeResolver(opts) and an exported clearCachedNodeResolvers(); legacyNodeResolve now reuses cached resolvers. Introduced NodeResolver type with clearCache() and updated createNodeResolver return type. Tests call the new clear function after renames.

Changes

Cohort / File(s) Summary
Resolver cache & API
src/utils/resolve.ts
Add cachedNodeResolvers map, getCachedNodeResolver(opts), and exported clearCachedNodeResolvers(); update legacyNodeResolve to use cached resolvers for primary and fallback creation.
Node resolver type
src/node-resolver.ts
Introduce exported NodeResolver type (extends NewResolver with clearCache()); change createNodeResolver return type to NodeResolver and implement clearCache delegation.
Tests
test/utils/resolve.spec.ts
Call clearCachedNodeResolvers() in beforeAll() within the rename-related test to ensure resolver caches are cleared after filesystem rename.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • un-ts/eslint-plugin-import-x#272 — Touches node resolver construction/legacy resolve paths and overlaps with resolver caching and creation changes.

Suggested labels

bug

Suggested reviewers

  • JounQin
  • SukkaW

Poem

🐰
I hid a patch beneath a root,
Where resolvers nap and keep things mute.
Hop once, hop twice, the cache is clear—
Fresh paths found, no stale veneer. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: memoizing the legacyNodeResolve resolver to prevent native memory leaks from repeated unrs-resolver instantiation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codesandbox-ci
Copy link
Copy Markdown

codesandbox-ci Bot commented Apr 9, 2026

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 9, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 11 complexity · 0 duplication

Metric Results
Complexity 11
Duplication 0

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

Copy link
Copy Markdown
Collaborator

@SukkaW SukkaW left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change LGTM after all.

But if you have faced the performance issue with the legacy node resolver, maybe you really should migrate to the next version of the resolver and give that a shot? With the newer version of resolver, we have already implemented instance reuse.

You can find more information about the next version of resolver here: https://github.com/un-ts/eslint-plugin-import-x/releases/tag/v4.6.0 and #192

Comment thread src/utils/resolve.ts Outdated
@B4nan
Copy link
Copy Markdown
Author

B4nan commented Apr 11, 2026

Thanks for the review! Switched the cache key to stable-hash-x in cbe9eae.

And great pointer on import-x/resolver-next — I'd missed that the legacy path and the new path diverge on instance reuse. I've now migrated our consumer to opt into the new resolver via createNodeResolver() + import-x/resolver-next, which sidesteps the leak entirely and let us drop our local patch. That's the proper fix for us.

I still think this PR is worth landing for the legacy path though: anyone still on import-x/resolver (or the default settings['import-x/resolve'] fallthrough) will hit the same native memory growth on a monorepo of this size, and they won't discover it until it OOMs their CI. The memoization makes the legacy path behave the same way the new path already does, so there's no functional downside to keeping it.

Happy to close if you'd rather keep the legacy path stagnant as a nudge for people to migrate — just let me know.

B4nan added a commit to apify/apify-eslint-config that referenced this pull request Apr 11, 2026
…stance

`eslint-plugin-import-x`'s legacy `node` resolver path (the default
fall-through when `import-x/resolver-next` is not configured) constructs
a fresh `unrs-resolver.ResolverFactory` on every import resolution.
`unrs-resolver` is a Rust binding that allocates native memory invisible
to V8 heap limits, so per-call construction causes worker RSS to grow
without bound over a lint run.

On a large lerna monorepo (apify/apify-core, ~60 packages, thousands
of files), this reliably OOM-killed CI workers within 2 minutes.

`import-x` v4.6.0+ already has proper instance reuse on the new resolver
path — you just have to opt in by passing a single `createNodeResolver()`
instance via `import-x/resolver-next`. See:
  https://github.com/un-ts/eslint-plugin-import-x/releases/tag/v4.6.0
  un-ts/eslint-plugin-import-x#481 (review)

Set it in the shared config so every consumer gets the fix automatically
and nobody has to rediscover it via an OOM.
B4nan added a commit to apify/apify-eslint-config that referenced this pull request Apr 11, 2026
…stance (#42)

## Summary

\`eslint-plugin-import-x\`'s legacy \`node\` resolver path — the default
fall-through when \`import-x/resolver-next\` is not configured —
constructs a fresh \`unrs-resolver.ResolverFactory\` on every import
resolution. \`unrs-resolver\` is a Rust binding that allocates native
memory invisible to V8 heap limits, so per-call construction causes
worker RSS to grow without bound over a lint run.

On a large lerna monorepo (apify/apify-core, ~60 packages, thousands of
files), this reliably OOM-killed CI workers within 2 minutes, regardless
of how much JS heap we gave them. Bumping \`--max-old-space-size\`
didn't help because the leak is in native memory.

## Fix

\`eslint-plugin-import-x\` v4.6.0+ already has proper instance reuse —
but only on the new resolver path. You opt in by passing a single
reusable \`createNodeResolver()\` instance via
\`import-x/resolver-next\`. Since our shared config is the obvious
chokepoint, set it here so every consumer gets the fix automatically and
nobody has to rediscover the leak via an OOM.

## Context

- un-ts/eslint-plugin-import-x#481 (the leak in the legacy path, and the
review feedback pointing to this as the proper fix)
- https://github.com/un-ts/eslint-plugin-import-x/releases/tag/v4.6.0
(release notes where instance reuse landed)
- un-ts/eslint-plugin-import-x#192 (the original
upstream PR introducing the new resolver path)

## Verification

Tested on apify-core by configuring the same thing locally in its
\`eslint.config.mjs\`. \`npx eslint ./src/packages --concurrency=auto\`
with 8 GB heap now completes cleanly where it previously OOM-killed the
worker every time.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 12, 2026

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-import-x@481

commit: e9e2460

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/utils/resolve.ts (1)

128-156: Add a small regression test around cache reuse.

This fix is centralized in a tiny helper, so it would be easy to regress later. A focused test that verifies two fresh-but-equivalent option objects reuse one resolver, while different option signatures allocate separate resolvers, would lock the behavior in without needing the large-scale OOM repro in CI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/resolve.ts` around lines 128 - 156, Add a focused regression test
for getCachedNodeResolver/cachedNodeResolvers: create two
distinct-but-equivalent option objects and assert getCachedNodeResolver(optsA)
=== getCachedNodeResolver(optsB) (same resolver instance), then assert that a
call with a different option signature returns a different instance; do this by
calling getCachedNodeResolver (or spying/mocking createNodeResolver) to verify
reuse and distinct allocation behavior for different stableHash keys; reference
getCachedNodeResolver, cachedNodeResolvers, createNodeResolver, and stableHash
when locating the code to test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/utils/resolve.ts`:
- Around line 128-156: Add a focused regression test for
getCachedNodeResolver/cachedNodeResolvers: create two distinct-but-equivalent
option objects and assert getCachedNodeResolver(optsA) ===
getCachedNodeResolver(optsB) (same resolver instance), then assert that a call
with a different option signature returns a different instance; do this by
calling getCachedNodeResolver (or spying/mocking createNodeResolver) to verify
reuse and distinct allocation behavior for different stableHash keys; reference
getCachedNodeResolver, cachedNodeResolvers, createNodeResolver, and stableHash
when locating the code to test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b53c136-bfbd-4e28-abaf-9e1a4403f20e

📥 Commits

Reviewing files that changed from the base of the PR and between cbe9eae and d37ee74.

📒 Files selected for processing (1)
  • src/utils/resolve.ts

The memoized `unrs-resolver` `ResolverFactory` instances cache filesystem
lookups internally. When test files are renamed, the stale internal cache
causes resolution failures. This adds a `clearCache()` method on the
`NodeResolver` type (delegating to `ResolverFactory.clearCache()`) and
exports `clearCachedNodeResolvers()` from utils to flush both the
resolver map and each resolver's internal FS cache. The rename cache
correctness tests now call this after the file rename.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/utils/resolve.spec.ts (1)

525-532: Optional: co-locate rename and cache-clear in one hook for stronger ordering.

Current behavior is fine, but merging these two beforeAll hooks reduces future order-coupling risk if more hooks are inserted later.

♻️ Suggested refactor
-        beforeAll(() =>
-          fs.promises.rename(testFilePath(original), testFilePath(changed)),
-        )
-
-        // The memoized unrs-resolver instances cache FS lookups internally.
-        // After a rename we must clear them so the resolver sees the new state.
-        beforeAll(() => clearCachedNodeResolvers())
+        beforeAll(async () => {
+          await fs.promises.rename(testFilePath(original), testFilePath(changed))
+          // The memoized unrs-resolver instances cache FS lookups internally.
+          // After a rename we must clear them so the resolver sees the new state.
+          clearCachedNodeResolvers()
+        })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/utils/resolve.spec.ts` around lines 525 - 532, Co-locate the filesystem
rename and resolver cache clear into a single beforeAll hook to guarantee
ordering: replace the two separate beforeAll(...) calls with one beforeAll that
first calls fs.promises.rename(testFilePath(original), testFilePath(changed))
and then calls clearCachedNodeResolvers(), so the rename completes before the
memoized unrs-resolver instances are cleared; refer to testFilePath(...) and
clearCachedNodeResolvers() to locate the operations to combine.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/utils/resolve.spec.ts`:
- Around line 525-532: Co-locate the filesystem rename and resolver cache clear
into a single beforeAll hook to guarantee ordering: replace the two separate
beforeAll(...) calls with one beforeAll that first calls
fs.promises.rename(testFilePath(original), testFilePath(changed)) and then calls
clearCachedNodeResolvers(), so the rename completes before the memoized
unrs-resolver instances are cleared; refer to testFilePath(...) and
clearCachedNodeResolvers() to locate the operations to combine.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 62b3f996-352d-4499-97d6-74baeee84fae

📥 Commits

Reviewing files that changed from the base of the PR and between d37ee74 and e9e2460.

📒 Files selected for processing (3)
  • src/node-resolver.ts
  • src/utils/resolve.ts
  • test/utils/resolve.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/utils/resolve.ts

Comment on lines +529 to +532
// The memoized unrs-resolver instances cache FS lookups internally.
// After a rename we must clear them so the resolver sees the new state.
beforeAll(() => clearCachedNodeResolvers())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get it. Previously, we didn't need to clear the cache, and the tests finished fine. What's going on here, why do we need to clear the cache now?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR, legacyNodeResolve constructed a brand new ResolverFactory on every call, so each call got a fresh internal FS cache. After memoizing, the same ResolverFactory is reused — and unrs-resolver keeps its own internal Rust-side cache of FS lookups that survives across calls. The wrapper fileExistsCache has a TTL, but the resolver's internal cache doesn't, so once the wrapper misses and asks the resolver, the resolver still returns its stale answer.

The test sequence is:

  1. resolve ./CaseyKasem.js → resolver caches "this file exists"
  2. rename the file on disk
  3. resolve ./CASEYKASEM2.js → before: new resolver, sees the rename. after: same cached resolver, internal cache says it doesn't exist → test fails

In a real lint run files don't get renamed mid-run, so this only matters in tests. clearCachedNodeResolvers() is just the test-side equivalent of "pretend this is a fresh process."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants