Skip to content

test: make the import() AST leak fixture actually evict the module - #38165

Open
robobun wants to merge 1 commit into
mainfrom
farm/3717c3e4/esm-leak-fixture-evicts-module
Open

test: make the import() AST leak fixture actually evict the module#38165
robobun wants to merge 1 commit into
mainfrom
farm/3717c3e4/esm-leak-fixture-evicts-module

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • require.cache > files transpiled and loaded don't leak the AST > via import() (test/cli/run/require-cache.test.ts:298) spawns test/cli/run/esm-bug-leak-fixture.mjs, which is supposed to load the 200 KB esm-leak-fixture-large-ast.mjs 55 times and check that RSS stays flat.
  • The fixture evicts with delete require.cache[dest] where dest = await import.meta.resolve(...) (esm-bug-leak-fixture.mjs:3). Since feat(runtime): align import.meta.resolve with node.js's implementation #5827 that is a file:// URL string; require.cache is keyed by absolute path, so the delete never matches anything. The module is transpiled and evaluated once and the other 54 import(dest) calls are cache hits, so the RSS check measures nothing. (When the fixture was written in Fix memory leak in require #6790, import.meta.resolve() returned a promise of an absolute path, hence the await. feat(runtime): align import.meta.resolve with node.js's implementation #5827 switched esm-fixture-leak-small.mjs to require.resolve() but left this fixture on the old key.)
  • Measured with a copy of the large module that counts its own evaluations: 1 evaluation with the file:// key, 55 with the require.resolve() key. On a debug+ASAN build the test passes in 1.8 s while its require() sibling, which does the same 55 loads of a same-sized module for real, times out at 20 s.

Fix

  • Key the eviction with require.resolve(), as require-cache-bug-leak-fixture.js, esm-fixture-leak-small.mjs and cjs-fixture-leak-small.js already do.
  • After the warm-up loop, print --fail-- and exit 1 if dest is not a key in require.cache. A key the cache does not use now fails the test instead of passing it; the previous file:// key trips this check on the first run.
  • This is the right key because require.cache exposes the runtime module cache under the resolved absolute path, which is exactly what require.resolve() returns: after import(url), path in require.cache is true and url in require.cache is false, and delete require.cache[path] followed by import() evaluates the module again (checked on both the release and the debug build).
  • The one check is enough to prove the loop reloads: in and delete on require.cache are the has and deleteProperty traps in src/js/builtins/CommonJS.ts:388, and both look up the ES module registry by the same key, so a key that in finds is the key delete evicts.
  • The bounds (120 MB, 400 MB under ASAN) are unchanged. With the 55 loads now real, the fixture reports 0 to 20 MB across 7 release runs on linux x64. On the ASAN debug build it reports 179 MB against the require() sibling's 169 MB for the same workload, so on the release-asan lane it should behave like the sibling, which has been running there against the same 400 MB bound.
  • Cost on release: about 1.2 s standalone and 1.8 to 2.1 s when run next to its sibling (the sibling takes 1.0 s and 1.1 to 2.0 s), against the test's 20 s timeout.
  • Verified:
    • USE_SYSTEM_BUN=1 bun test test/cli/run/require-cache.test.ts: 11 pass; via import() now takes about as long as via require().
    • bun bd test test/cli/run/require-cache.test.ts -t "leak the AST": before, via import() passes in 1.8 s and via require() times out; after, both time out the same way, since debug builds run the 200 KB workload at a fraction of release speed. test(require-cache): skip the leak fixtures under debug builds #38148 skips these blocks on debug builds; CI has no debug lane, so no CI lane is affected by that.
    • Test-only change, so there is no src fail-before/pass-after to show; the evaluation counts above are the before/after evidence.
  • Touches only esm-bug-leak-fixture.mjs. test(require-cache): skip the leak fixtures under debug builds #38148 (require-cache.test.ts), test(require-cache): measure allocator-live bytes instead of RSS in the source code leak fixtures #37586 and test(require-cache): gate the long-export-names import() leak fixture on mimalloc page growth #34159 (inline fixtures in the test file), test: warm up the import() file-path leak fixture and shorten its loop #37562 (esm-fixture-leak-small.mjs) and test: surface ASAN status to leak fixtures via bunEnv #35081 (the ASAN detection lines of the standalone fixtures) change other lines.

Background

  • require.cache in Bun is a view of the runtime's module cache and also lists ES modules (Node only lists CommonJS there). Keys are resolved absolute paths. Deleting a key evicts the module, so the next import() or require() of that path reads, transpiles and evaluates the file again; that is what lets these fixtures run the transpiler many times in one process and watch RSS.
  • import.meta.resolve() follows Node: it is synchronous and returns a file:// URL string. Bun's original version returned a promise of an absolute path, which is what this fixture was written against.
  • ASAN quarantine: ASAN keeps freed allocations in a quarantine (256 MB by default) instead of handing them back to the allocator, so RSS on an ASAN build grows with what was allocated and freed rather than with what is retained. That is why the fixtures have a separate, larger bound for ASAN binaries.
Evaluation-count probe and measurements

large.mjs is a copy of esm-leak-fixture-large-ast.mjs with globalThis.__evals = (globalThis.__evals ?? 0) + 1; appended; the probe runs the fixture's two loops against it with either key (BUN_RUNTIME_TRANSPILER_CACHE_PATH=0, as the test harness sets):

release, key = import.meta.resolve():  evals: 1,  measured loop: 0 ms
release, key = require.resolve():      evals: 55, measured loop: 383 ms
debug+ASAN, import.meta.resolve():     evals: 1,  measured loop: 164 ms
debug+ASAN, require.resolve():         evals: 55, measured loop: 80.8 s

Fixture output with this change, release linux x64, 7 runs: leaked = 0, 4, 15, 20, 3, 16, 10 MB (bound 120). Debug+ASAN build: esm fixture 179 MB, require-cache-bug-leak-fixture.js 169 MB (the fixtures apply the 120 MB bound there because they detect ASAN by the bun-asan binary name; #35081 is about that).

Same fixture with the file:// key put back, to exercise the new check:

require.cache has no entry for file:///.../test/cli/run/esm-leak-fixture-large-ast.mjs so deleting it never evicts the module

--fail--

bun bd test test/cli/run/require-cache.test.ts -t "leak the AST" on main: via import() passes in 1811 ms, via require() times out after 20000 ms. With this change: both time out after 20000 ms.

esm-bug-leak-fixture.mjs keyed its `delete require.cache[...]` by the value of
import.meta.resolve(), which has returned a file:// URL since #5827. The module
cache is keyed by absolute path, so the delete never matched and the 200 KB
module was transpiled once; the remaining 54 import() calls were cache hits and
the RSS check measured nothing.

Resolve the key with require.resolve(), like the sibling fixtures, and fail the
fixture up front if the key is not present in require.cache after importing, so
the same mistake cannot make the test pass vacuously again.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 28f9e010-6f76-4cf4-b977-d811a5cf75a5

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and c03cc39.

📒 Files selected for processing (1)
  • test/cli/run/esm-bug-leak-fixture.mjs

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed, waiting on CI.

Reproduced by appending an evaluation counter to a copy of esm-leak-fixture-large-ast.mjs and running the fixture's loops against it: the import.meta.resolve() key gives 1 evaluation for 55 import() calls, the require.resolve() key gives 55. The fixture now uses the latter and fails itself if the key is not present in require.cache after importing (the old key trips that check). Release run of the full file: 11 pass.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — test-only fix that brings this fixture in line with its siblings.

What was reviewed:

  • Confirmed require.resolve() + delete require.cache[dest] + await import(dest) matches the pattern in esm-fixture-leak-small.mjs and require-cache-bug-leak-fixture.js.
  • Verified the consuming test (require-cache.test.ts:307) asserts toEndWith("--pass--"), so the new --fail-- guard on a bad cache key correctly fails the test rather than passing vacuously.
  • Checked that import(absolutePath) (vs the previous file:// URL) is already exercised by the sibling ESM fixture, so no behavior divergence there.
Extended reasoning...

Overview

This PR touches a single test fixture, test/cli/run/esm-bug-leak-fixture.mjs, which is spawned by the "files transpiled and loaded don't leak the AST > via import()" test in require-cache.test.ts. Two changes: (1) replace await import.meta.resolve(...) with require.resolve(...) so the delete require.cache[dest] actually evicts the module (the previous file:// URL never matched a cache key, so 54 of the 55 loads were cache hits and the RSS check was measuring nothing), and (2) add a post-warmup guard that fails with --fail-- if dest is not present in require.cache, so a wrong key can never silently pass again.

Security risks

None. Test-fixture-only change; no runtime, native, or user-facing code touched.

Level of scrutiny

Low. This is a small mechanical correction to a leak-test fixture that had drifted after import.meta.resolve() semantics changed. The new key is identical to what the three sibling fixtures (esm-fixture-leak-small.mjs, require-cache-bug-leak-fixture.js, cjs-fixture-leak-small.js) already use, and await import(absolutePath) is already the pattern in esm-fixture-leak-small.mjs, so there's no novel behavior being introduced. The added in require.cache guard is a strict tightening — it makes a previously vacuous test able to fail, which is exactly what REVIEW.md asks for ("prove the test fails for the RIGHT reason").

Other factors

The PR description provides direct before/after evidence (1 vs 55 evaluations, timing parity with the require() sibling) and shows the new guard tripping on the old key. RSS bounds are unchanged and the author reports 0–20 MB on release against the 120 MB bound, well within margin. The consuming test asserts stdout.trim().toEndWith("--pass--") and exitCode === 0, both of which the new failure path violates, so the guard is wired correctly. No prior reviews or outstanding comments on the timeline.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 AM PT - Aug 13th, 2026

@robobun, your commit c03cc39 has 1 failures in Build #94624 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38165

That installs a local version of the PR into your bun-38165 executable, so you can run:

bun-38165 --bun

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant