Skip to content

Free the .jsc sidecar blob handed to ResolvedSource.bytecode_cache - #38138

Open
robobun wants to merge 4 commits into
mainfrom
farm/838ffabb/free-bytecode-sidecar
Open

robobun wants to merge 4 commits into
mainfrom
farm/838ffabb/free-bytecode-sidecar

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Every load of a module built with bun build --target=bun --bytecode leaks its .jsc sidecar: RSS grows by exactly the sidecar size per require() / import() (3.5 MB sidecar, 100 loads with delete require.cache in between: +340 MB; the same module built without --bytecode stays flat). Long-lived processes that reload such modules (require.cache busting, repeated workers) never get that memory back.
  • Zig::SourceProvider::create (src/jsc/bindings/ZigSourceProvider.cpp) chose the CachedBytecode destructor with resolvedSource.needsDeref ? free : noop, but the same function sets needsDeref = false about 30 lines earlier for every non-builtin source (that is the source_code deref). So the destructor was always the no-op and the Box<[u8]> that transpile_source_code_inner (src/runtime/jsc_hooks.rs) and TranspilerJob (src/jsc/RuntimeTranspilerStore.rs) hand over via heap::into_raw was never freed.
  • needsDeref cannot be the signal even when read at the right time: it is also true on the Node compile cache paths (node_compile_cache::fetch), whose blobs belong to the cache's entry map for the life of the process. The compile cache currently relies on the destructor being a no-op (this was called out and dismissed for that reason in the review of node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660), so the two cases need a dedicated bit.
  • Smaller instances of the same leak: a sidecar that never reaches a SourceProvider (the import() of a file that require() already put in the require map re-reads the file and then reuses the existing module; an overridden module._compile only receives the source text) was dropped on the floor too, and OwnedResolvedSource::drop (src/jsc/ResolvedSource.rs) did not free it either.

Fix

  • Add bytecode_cache_needs_free to ResolvedSource (Rust struct and the C mirror in headers-handwritten.h; it lands in existing padding next to needsDeref / already_bundled, so the layout is otherwise unchanged). The two sidecar producers set it; everything else leaves the default false.
  • SourceProvider::create installs the free destructor only when the bit is set and clears the bit as it hands the blob to the CachedBytecode, so neither the copy stored in the provider nor the caller's struct can free it again. Borrowed blobs (compile cache, standalone executables) get no destructor, which is what they got before.
  • New Zig::freeOwnedBytecodeCache() releases a still-owned blob from ResolvedSourceCodeHolder (every fetch path in ModuleLoader.cpp) and from the overridden _compile branch of evaluateWithPotentiallyOverriddenCompile; OwnedResolvedSource::drop does the same on the Rust side.
  • Why this is right: the blob is a Box<[u8]> from Rust's global allocator, so Bun::defaultAllocatorFree (the destructor already used here) and reconstructing the Box<[u8]> from bytecode_cache / bytecode_cache_size are the matching frees; the bit travels with the struct and is cleared by whoever takes the blob, so each blob is freed exactly once; and ownership is now stated by the producer instead of being inferred from an unrelated string refcount flag, which is what let the compile cache and sidecar cases collide.
  • Verified with test/bundler/bun-build-api.test.ts ("bytecode output does not leak the .jsc sidecar when the module is released"): five variants covering both producers and all three release points, including module._compile overridden with a non-function, which returns early from that branch. Each variant loads a module with a 0.61 MB sidecar 60 times and requires RSS growth to stay under two thirds of one sidecar per load (24.5 MB): it grows 2 to 12 MB with this change and 40 to 80 MB without it (debug build; 41 to 44 MB on the 1.4.0 release for the plain variants). The OwnedResolvedSource::drop path is only reachable when a transpiler job is torn down before reaching C++, so it has no test.
  • Also ran test/regression/issue/26298.test.ts (standalone executables with embedded bytecode) and the compile cache tests in test/js/node/module/node-module-module.test.js on the debug (ASAN) build to confirm the borrowed-blob paths still get no destructor.
  • Textual overlap with standalone_graph: treat embedded bytecode as read-only #34801, which changes the type of the same bytecode_cache field; the two changes are independent.

Background

  • bun build --target=bun --bytecode emits out.js starting with // @bun @bytecode @bun-cjs plus out.js.jsc, JSC's serialized bytecode for it. When the runtime loads out.js, the parser stops at the pragma and the transpiler reads the sidecar into a Box<[u8]>; the bytes are returned to C++ in ResolvedSource.bytecode_cache.
  • ResolvedSource is the plain #[repr(C)] struct the Rust module loader fills in for C++ (source text, specifier, bytecode, flags). It is Copy, so it carries no destructor; OwnedResolvedSource is the Rust-side RAII wrapper used while one is in flight, and ResolvedSourceCodeHolder is the C++ scope guard that releases what C++ did not consume.
  • Zig::SourceProvider is Bun's JSC::SourceProvider for a module. When bytecode is present it wraps the bytes in a JSC::CachedBytecode, which JSC decodes instead of parsing; CachedBytecode takes a destructor callback that runs when it is released, and a null callback means the bytes belong to someone else. Bun has three sources of such bytes: the .jsc sidecar (a fresh heap allocation per load), the Node compile cache (process-lifetime entries), and the bytecode section of a bun build --compile executable (part of the binary image). Only the first one is the provider's to free.
Reproduction used to measure the leak
// gen.js: write a module big enough to produce a multi-MB sidecar, then
//   bun build --target=bun --bytecode entry.js --outdir out
const N = 3000, lines = [];
for (let i = 0; i < N; i++) lines.push(`function f${i}(a, b) { var x = a + ${i}; return x > b ? x : [x, b].join(","); }`);
lines.push(`module.exports = [${Array.from({ length: N }, (_, i) => `f${i}`).join(",")}];`);
require("fs").writeFileSync("entry.js", lines.join("\n"));

// probe.js
const file = require("path").resolve("./out/entry.js");
function load() { require(file); delete require.cache[file]; module.children.length = 0; }
for (let i = 0; i < 5; i++) load();
Bun.gc(true);
const before = process.memoryUsage.rss();
for (let i = 0; i < 100; i++) load();
Bun.gc(true);
console.log(((process.memoryUsage.rss() - before) / 1048576).toFixed(1), "MB");

bun 1.4.0, 3.5 MB sidecar: 340.7 MB (3.4 MB per load); the same module built without --bytecode: 15 to 22 MB of allocator noise at every checkpoint, no growth. import() instead of require() leaks the same way (222 MB over 60 loads), and BUN_DEBUG_AsyncModule=1 confirms those loads go through the RuntimeTranspilerStore producer.

Zig::SourceProvider::create picked the CachedBytecode destructor from
resolvedSource.needsDeref, but the same function clears needsDeref a few
lines earlier for every non-builtin source, so the destructor was always
the no-op one and the Box<[u8]> read from a `.jsc` sidecar leaked on every
load of a `// @Bun @bytecode` module. needsDeref cannot be used for this
anyway: it is also true for the Node compile cache paths, whose blobs are
owned by the cache's entry map and must not be freed.

Add an explicit bytecode_cache_needs_free bit to ResolvedSource (Rust and
C mirrors), set it in the two sidecar producers, and make every consumer
honor it: SourceProvider::create hands an owned blob to the CachedBytecode
destructor and clears the bit; ResolvedSourceCodeHolder and the overridden
module._compile path free a blob that never reached a SourceProvider;
OwnedResolvedSource::drop frees it on the Rust side. Compile cache and
standalone executable blobs leave the bit clear and keep no destructor.
@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:41 PM PT - Aug 13th, 2026

❌ @robobun, your commit 9e80222 has 1 failures in Build #94745 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38138

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

bun-38138 --bun

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on bun 1.4.0 and a debug build of main: a bun build --target=bun --bytecode module leaks its .jsc sidecar on every load (3.5 MB sidecar, 100 require() + delete require.cache cycles: +340 MB RSS; the same module built without --bytecode stays flat). import() leaks the same way.
  • Fix: explicit bytecode_cache_needs_free ownership bit on ResolvedSource, honored by SourceProvider::create, ResolvedSourceCodeHolder, the overridden _compile branch (freed before its early returns, per review) and OwnedResolvedSource::drop. Compile cache and standalone blobs are unaffected.
  • Tests: five variants in test/bundler/bun-build-api.test.ts (require, import, import of a module already in require.cache, overridden _compile, non-callable _compile). Without the fix they grow 40 to 80 MB over 60 loads of a 0.61 MB sidecar; with it 2 to 12 MB, against a 24.5 MB bound. Standalone bytecode (test/regression/issue/26298.test.ts) and the compile cache tests still pass on the ASAN build.
  • Review threads addressed; waiting on CI.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 52 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: 8b66c85a-4cc2-40f1-97aa-ca13f910e74f

📥 Commits

Reviewing files that changed from the base of the PR and between bb76859 and 9e80222.

📒 Files selected for processing (8)
  • src/jsc/ResolvedSource.rs
  • src/jsc/bindings/JSCommonJSModule.cpp
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/ZigSourceProvider.cpp
  • src/jsc/bindings/ZigSourceProvider.h
  • src/jsc/bindings/headers-handwritten.h
  • src/runtime/jsc_hooks.rs
  • test/bundler/bun-build-api.test.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5a8f1e58-79a1-4ec5-80fb-d54baf8dee86

📥 Commits

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

📒 Files selected for processing (9)
  • src/jsc/ResolvedSource.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/bindings/JSCommonJSModule.cpp
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/ZigSourceProvider.cpp
  • src/jsc/bindings/ZigSourceProvider.h
  • src/jsc/bindings/headers-handwritten.h
  • src/runtime/jsc_hooks.rs
  • test/bundler/bun-build-api.test.ts

Walkthrough

The change adds bytecode cache ownership metadata to resolved sources, releases owned Rust allocations across module-loading paths, and adds regression coverage for repeated sidecar loading and garbage collection.

Changes

Bytecode cache lifetime

Layer / File(s) Summary
Ownership contract and propagation
src/jsc/ResolvedSource.rs, src/jsc/RuntimeTranspilerStore.rs, src/runtime/jsc_hooks.rs, src/jsc/bindings/headers-handwritten.h
ResolvedSource records whether its bytecode cache is owned. Transpilation sets the flag for allocated caches. OwnedResolvedSource frees owned caches on drop.
Native cleanup paths
src/jsc/bindings/ZigSourceProvider.cpp, src/jsc/bindings/ZigSourceProvider.h, src/jsc/bindings/ModuleLoader.cpp, src/jsc/bindings/JSCommonJSModule.cpp
Native helpers release owned sidecar buffers. Cached bytecode receives a destructor only for owned buffers. Failed resolution and overridden _compile paths release owned caches.
Bytecode sidecar lifetime regression tests
test/bundler/bun-build-api.test.ts
Tests repeated require(), import(), cache reuse, and overridden _compile loading while checking RSS growth after garbage collection.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: freeing .jsc sidecar data owned by ResolvedSource.bytecode_cache.
Description check ✅ Passed The description clearly explains the problem, implementation, ownership model, affected paths, and verification results, although it uses different headings than the template.

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

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

Beyond the inline finding, I also checked: the Node compile cache producer (jsc_hooks.rs:3322) and the standalone-executable producer (jsc_hooks.rs:4036) leave bytecode_cache_needs_free at its false default, so borrowed blobs still get no destructor; and SourceProvider::create clears the flag on the by-reference resolvedSource before the constructor copies it into m_resolvedSource, so neither the provider's stored copy nor the caller's ResolvedSourceCodeHolder can free the blob a second time.

Extended reasoning...

Verified the two borrowed-blob producers do not set the new flag and that the flag-clear in SourceProvider::create happens before the struct is copied into the provider, ruling out double-free on both the provider-owned copy and the holder. The inline nit already covers the one uncovered early-return; nothing further to add.

Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
A non-callable module._compile returned before reaching the free. Cover
it with a test variant, and size each variant's threshold by how many
sidecars a load reads.
Comment thread src/jsc/ResolvedSource.rs Outdated
Comment thread src/jsc/ResolvedSource.rs
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
Comment thread src/jsc/bindings/ZigSourceProvider.cpp Outdated
Comment thread src/jsc/bindings/ZigSourceProvider.cpp Outdated
Comment thread src/jsc/bindings/ZigSourceProvider.h Outdated
Comment thread src/jsc/bindings/headers-handwritten.h Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/jsc/ResolvedSource.rs
Comment thread src/jsc/bindings/ZigSourceProvider.cpp
Comment thread src/jsc/bindings/ZigSourceProvider.h
Comment thread src/jsc/bindings/headers-handwritten.h
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Overlaps with #39405, which maps the sidecar (and the module text) instead of reading it into a heap buffer. That PR needs the same three release points as this one (CachedBytecode destructor, ResolvedSourceCodeHolder, the overridden module._compile branch, plus OwnedResolvedSource::drop), so the needs-free bit here becomes the owning file pointer there. If #39405 lands first this PR becomes unnecessary; if this one lands first, #39405 rebases on top of it.

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