Skip to content

module loader: report module source text to the GC as extra memory - #38135

Open
robobun wants to merge 1 commit into
mainfrom
farm/d3c19702/report-module-source-memory-to-gc
Open

robobun wants to merge 1 commit into
mainfrom
farm/d3c19702/report-module-source-memory-to-gc

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • require(file); delete require.cache[file] in a loop grows RSS by about the module's source size on every load, without bound: 1023 KB per load for a module holding a 1 MB string literal, still linear after 600 loads. await import(file); delete require.cache[file] does the same (1024 KB per load). The JS heap stays flat the whole time.
  • Nothing is leaked: every load copies the transpiled source into a WTF::StringImpl owned by the module's Zig::SourceProvider (src/jsc/bindings/ZigSourceProvider.cpp, SourceProvider::create), and Bun.gc(true) frees all of the copies (the live allocations of a 50 load loop drop from 54 x 1 MB to the baseline). The copies pile up because no collection ever runs: JSC was never told those bytes exist, and evaluating such a module allocates about 200 bytes on the JS heap (BUN_JSC_logGC: normal bytes: 168), so its 8 MB allocation budget is never reached. Bun's own GC timer does not run inside a synchronous loop either, and in the import() case it asks for an unscoped collection once a second, which JSC runs as an eden collection since nothing has scheduled a full one, and eden collections do not release the strings (see Background).
  • The measurements that surfaced this concluded the retention tracked string literal bytes. It tracks source size; the control module it used (1 MB of var statements) allocates enough JS heap per load to trigger collections on its own, which is also why the existing fixtures in require-cache.test.ts (10000 exports or 20000 calls per module) never saw this.

Fix

  • SourceProvider::create reports the source's StringImpl::cost() through Heap::deprecatedReportExtraMemory for non builtin modules.
  • This is the accounting a JSString applies to its own StringImpl (JSString::finishCreation reports cost()); a module's source is the same kind of object, native bytes whose lifetime ends when the GC drops the cells referencing them, so it has to count toward the allocation budget the same way. deprecatedReportExtraMemory is JSC's API for memory that is not owned by a single cell (it is what JSReportExtraMemoryCost and WebCore's image and document wrappers use); there is no cell here whose lifetime matches the provider's.
  • The deprecated variant is also what makes the fix work, not just a convenience: the reported bytes drive didAllocate, so a burst of loads requests an eden collection about every 8 MB of source, and they stay in extraMemorySize() until a full collection, so after that eden collection the old generation ratio check (minEdenToOldGenerationRatio) schedules the full collection that clears sourceProviderCacheMap and frees the strings. A plain reportExtraMemoryAllocated with no cell would only ever produce eden collections, which free nothing here. With BUN_JSC_logGC=1, a 40 load loop now shows 3 eden and 2 full collections, each requested at oversized bytes: 7340900 or so, i.e. by the reported sources.
  • cost() reports a given StringImpl once and is 0 for static strings, so a provider built over a string that already went through a JSString (plugins) or a shared string is not counted twice. Builtins are skipped because they live as long as the VM, so reporting them would be pressure with nothing to reclaim; process.memoryUsage().external after requiring 7 builtins is unchanged (834 bytes before and after).
  • Results (release build): the 1 MB module loop goes from 1023 KB per load to 3 KB per load over 300 loads (7 KB over 600); a module that is a 150k element array literal goes from 1044 to 45 KB per load; the import() + delete require.cache loop from 1024 to 59 KB per load; live allocations after a 50 load loop with no manual GC: 5 x 1 MB instead of 54. Loading a 1000 module graph (12 MB of source) and 200 modules of 100 KB string data (20 MB, the shape that gains the most collections: 3 instead of 1) take the same wall time as before within noise (medians 245 vs 239 ms and 39 vs 41 ms); test/js/bun/resolve/load-same-js-file-a-lot.test.ts (10000 imports) is unchanged within noise.
  • Tests: test/cli/run/require-cache.test.ts, describe module source text is reported to the GC. Two fixtures check that process.memoryUsage().external (which is extraMemorySize()) grows by at least the literal's size after loading a transpiled and a // @bun prebuilt module, via require() and via import(); before this change they report 0 bytes (1571 for the ESM record). The third runs the synchronous loop over a prebuilt 1 MB module and reads heapStats().objectTypeCounts.Module without forcing a GC: before this change exactly 32 of 32 Module objects are still alive, after it about 6 (the last budget's worth; the bound is 16). The fixtures are JSC level rather than RSS based so they hold under ASAN, where freed strings sit in the quarantine, and they run in fresh processes so the loads are the only possible trigger. The prebuilt form is used for the loop because it skips transpiling 1 MB per iteration in debug builds; it reaches the same SourceProvider::create.
  • Verified: the three new tests fail on the build without the change and pass with it, in both debug (bun bd test) and release; the whole file passes in release (the 7 other leak fixtures in it time out under debug builds with or without this change, as noted in their own timeouts). gc-controller-cadence, crypto-extra-memory and v8-module tests pass. As a GC safety check for the new request point, BUN_JSC_gcMaxHeapSize=8192 (a collection requested at nearly every create()) over test/cli/run module loading tests and test/js/bun/resolve ran 379 tests with no crashes.

Background

  • Zig::SourceProvider is Bun's JSC::SourceProvider: it owns the module's source text as a WTF::StringImpl and is reference counted from the SourceCode objects held by JSC's executables and code blocks, so it is destroyed by GC when those cells die. For CommonJS it is created in JSCommonJSModule.cpp, for ESM in ModuleLoader.cpp; both go through SourceProvider::create.
  • VM::sourceProviderCacheMap is a JSC parser cache keyed by RefPtr<SourceProvider>. Every Parser construction adds its provider to it, and it is cleared in Heap::deleteSourceProviderCaches, which only does so after a full collection. This is why module sources are released by full collections specifically, and why an eden collection (what Bun's timer or a plain allocation trigger produces) does not help.
  • JSC's extra memory accounting: reportExtraMemoryAllocated(cell, bytes) / reportExtraMemoryVisited tie native bytes to a cell; deprecatedReportExtraMemory(bytes) adds them to a counter that is reset at the next full collection. Both feed Heap::didAllocate, and a collection is requested once the bytes allocated in the current cycle exceed the budget (Bun sets the initial budget, largeHeapSize, to 8 MB). Reports of 64 KB or more count as oversized and are ignored while the last one is more than a third of the cycle's allocation, which with equal sized modules means a collection is requested by the third load at the latest. process.memoryUsage().external and bun:jsc's heapStats().extraMemorySize both expose extraMemorySize().
  • StringImpl::cost() returns the string's byte size the first time it is called on an impl and 0 afterwards (and always 0 for static strings); it exists for exactly this kind of one time report.
  • // @bun / // @bun @bun-cjs is the pragma bun build --target=bun puts at the top of its output; the runtime loader passes such files to JSC without transpiling them.

Every module load copies its transpiled source into a WTF::StringImpl owned
by the Zig::SourceProvider. That string is only freed once the GC drops the
executables and code blocks referencing the provider and, because every
parse also registers the provider in VM::sourceProviderCacheMap, once a full
collection has run. JSC was never told about those bytes, so a loop of
require() + delete require.cache over a module whose JS footprint is tiny
compared to its source (one big literal, an array of numbers, ...) never
requested a collection and kept one copy of the source per load: about
1 MB of RSS per load of a 1 MB module, with no bound.

Report the source's cost() through Heap::deprecatedReportExtraMemory when
the provider is created, the same accounting a JSString applies to its
StringImpl. The reported bytes count toward JSC's allocation budget, so a
burst of loads requests an eden collection every ~8 MB of source, and since
the reported bytes stay in extraMemorySize() until a full collection, the
eden collection's old-generation ratio schedules the full collection that
clears the provider cache and frees the strings. Builtin modules are skipped
because they live as long as the VM.

With this the same loop settles at a few KB of RSS per load (3 KB/load over
300 loads of a 1 MB module, was 1023 KB/load), import() + delete
require.cache behaves the same way (59 KB/load, was 1024 KB/load), and
loading a 1000 module graph or 20 MB of data modules takes the same time as
before.
@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: 7 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: aee75f20-6623-4b6f-8e6b-a933e56b78e5

📥 Commits

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

📒 Files selected for processing (2)
  • src/jsc/bindings/ZigSourceProvider.cpp
  • test/cli/run/require-cache.test.ts

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on main with a 1 MB string literal module in a require() + delete require.cache loop: RSS grows ~1 MB per load with no bound (1023 KB/load over 300 loads, 1033 KB/load over 600), JS heap flat, and Bun.gc(true) frees all of it, so it is unreported GC-owned memory rather than a leak. With this branch the same loop settles at 3 to 7 KB per load, the import() variant at 59 KB per load.

The three new tests in test/cli/run/require-cache.test.ts fail on main (0 bytes reported, 32 of 32 Module objects still alive after the loop) and pass here in both debug and release; the rest of that file passes in release. Nothing to act on from the automated review so far (rate limited, no findings).

@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.

I reviewed this PR and didn't find any bugs. The native change is small and follows the existing deprecatedReportExtraMemory pattern (napi, sqlite, JSC__VM__reportExtraMemory), and the tests are JSC-level rather than RSS-based so they should hold under ASAN. Because it changes GC scheduling on every non-builtin module load and hinges on a specific choice of JSC accounting API (deprecatedReportExtraMemory vs reportExtraMemoryAllocated) to force full collections, a human sign-off on the approach would still be worthwhile.

What was reviewed

  • provider->m_source is a Ref<StringImpl> initialized in the ctor before the report call, so the deref is safe; cost() is one-shot and 0 for static strings, so plugin/shared strings aren't double-counted.
  • deprecatedReportExtraMemory only bumps a counter and calls didAllocate — no synchronous collection at this call site, so no rooting concern for locals in create().
  • Test fixtures drain stdout/stderr/exited concurrently, use tempDir/bunEnv/Buffer.alloc per harness conventions, and assert on extraMemorySize / objectTypeCounts.Module rather than RSS thresholds.
Extended reasoning...

Overview

The PR adds one guarded call in Zig::SourceProvider::create (src/jsc/bindings/ZigSourceProvider.cpp) that reports each non-builtin module's source StringImpl::cost() to Heap::deprecatedReportExtraMemory, plus a new describe block with three tests in test/cli/run/require-cache.test.ts. The goal is to make module source bytes count toward JSC's allocation budget so that a synchronous require/delete require.cache loop over a large-source, low-JS-heap module actually triggers collections instead of piling up one source copy per iteration.

Security risks

None. This is GC accounting only; no user input parsing, no new surface, no auth/crypto/permissions code.

Level of scrutiny

Higher than the diff size suggests. SourceProvider::create runs on every module load, so this changes GC cadence process-wide. The PR description is unusually thorough — it explains why deprecatedReportExtraMemory (which persists in extraMemorySize() until a full collection and thus drives the old-gen ratio check that schedules the full collection needed to clear VM::sourceProviderCacheMap) is the right lever versus cell-tied reportExtraMemoryAllocated, cites perf runs on a 1000-module graph and load-same-js-file-a-lot.test.ts as unchanged within noise, and reports a BUN_JSC_gcMaxHeapSize=8192 stress run over 379 module-loading tests with no crashes. That's exactly the analysis a reviewer would ask for, but the choice of accounting API and the global effect on collection frequency are the kind of design call the review guide flags for maintainer confirmation.

Other factors

  • The pattern matches four existing call sites in src/jsc/bindings/ (napi napi_adjust_external_memory, sqlite, JSC__VM__reportExtraMemory), so it is not novel in this codebase.
  • m_source is a Ref<WTF::StringImpl> set in the ctor, so provider->m_source->cost() is safe immediately after getProvider(); access is from a static member function so visibility is fine.
  • deprecatedReportExtraMemory increments a counter and feeds didAllocate; it does not collect synchronously, so there is no GC-at-unsafe-point concern for the locals in create().
  • Tests look correct and non-flaky by design: fresh subprocesses, JSC-level counters (process.memoryUsage().external, heapStats().objectTypeCounts.Module) instead of RSS, Buffer.alloc(n, fill) for the 1 MB literal, stdout/stderr/exited drained concurrently, // @bun prebuilt module used for the 32-iteration loop to keep debug builds fast. The afterLoop <= LOADS/2 bound (16) against a stated observed value of ~6 leaves headroom.
  • No prior human reviews or outstanding comments on the timeline.

Given the process-wide GC-cadence impact and the deliberate API choice, deferring rather than auto-approving.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

One correction to the automated review above, for whoever signs off on the approach: deprecatedReportExtraMemory does not only bump a counter. Its slow path goes through reportExtraMemoryAllocatedSlowCase, which calls didAllocate and then collectIfNecessaryOrDefer, so this call can request a collection and is a safepoint for one already in progress (that is the point of the change). The rooting conclusion still holds: the only JS values live across the call in create()'s callers are C++ locals, which JSC scans conservatively, and those callers allocate cells immediately before and after this point anyway (createCommonJSModule, JSSourceCode::create), so a collection was already possible there. The BUN_JSC_gcMaxHeapSize=8192 run described in the PR body exercised exactly this, requesting a collection from nearly every create() across the module loading tests.

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. bun --hot always leaks memory #11083 - bun --hot re-loads the same module's transpiled source on every reload, so the never-reported SourceProvider bytes keep RSS climbing; this PR addresses the "a full collection is never triggered" half (complementary to resolver: a busted directory listing keeps its slot and is refilled in place #36675, which targets DirEntry/ref_strings retention).
  2. bun test --isolate: module records retained across per-file global swaps → linear RSS growth (OOMs large suites); still present in 1.3.14 #31771 - bun test --isolate grows RSS linearly with the imported module-graph size per file, and the triage names "never forced a collection" as part of the cause — exactly what unreported module source bytes prevent (complementary to bun test --isolate: reclaim the previous file's module graph on global swap #31772).

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #11083
Fixes #31771

🤖 Generated with Claude Code

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Neither of the two suggested issues is fixed by this PR, so I am not adding the Fixes lines.

This PR only changes whether loading modules counts toward JSC's collection budget; it does not make anything collectable that was not already.

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:35 AM PT - Aug 13th, 2026

❌ @robobun, your commit 255af16 has 1 failures in Build #94494 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38135

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

bun-38135 --bun

This branch has not been deployed

No deployments
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