Skip to content

Bytecode cache for the JS builtins in bun build --compile --bytecode - #33316

Draft
robobun wants to merge 2 commits into
mainfrom
farm/c842455a/builtin-bytecode-cache
Draft

Bytecode cache for the JS builtins in bun build --compile --bytecode#33316
robobun wants to merge 2 commits into
mainfrom
farm/c842455a/builtin-bytecode-cache

Conversation

@robobun

@robobun robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Blocked on oven-sh/WebKit#270. WEBKIT_VERSION currently points at that PR's preview build (rebased onto f0f60fd2, the sha main pins, so the preview is main's WebKit plus that one commit). It needs re-pointing at the merged main autobuild sha before this lands. Draft until then.

A --compile --bytecode binary already ships bytecode for the app's own modules, but every builtin it touches is still parsed from source on first use. node:net alone drags in ~50 internal/* modules. That parse is most of what's left at startup.

Why this isn't just another bytecode generation step

Two things make the builtins different.

They have no cache entry type. Builtins are compiled with createBuiltinExecutable() into an UnlinkedFunctionExecutable — a builtin function, which is the only parse mode whose lexer accepts the @-prefixed intrinsics. JSC's bytecode cache has top-level entries for programs, modules and eval, because those are the top-level units of a script; nothing is shaped like a function. oven-sh/WebKit#270 adds encodeFunctionExecutable() / decodeFunctionExecutable() and a CachedFunctionExecutableTag for exactly this.

(Compiling them as programs instead doesn't work: BytecodeGenerator's ProgramNode constructor hardcodes m_isBuiltinFunction(false), so every nested function comes back as a normal executable, and the first one that needs a re-parse hits SyntaxError: Invalid character '@' at runtime.)

Their dependency graph is invisible to the bundler. The bundle's import records name only the builtins the app imports directly. Those then require() each other through InternalModuleRegistry by numeric id, resolved at runtime, so the bundler never sees the edges. bundle-modules.ts already derives that graph from the bundled output to lay the source blob out in dependency order (#36101); it now also emits it as an adjacency table, and the cache set is closed over it — caching "net" means caching node:net plus every internal/* module it transitively reaches.

How it works

  • generate (BuiltinModuleBytecode.cpp): createBuiltinExecutable()recursivelyGenerateUnlinkedCodeBlockForFunctionExecutable()encodeFunctionExecutable(), on the existing off-thread bytecode VM. That VM only gets Bun's @-private names registered (JSVMClientData::registerBuiltinNames()), which is all the builtin-mode lexer needs; it has no Bun VirtualMachine for the rest of the client data to attach to.
  • collect (bundle_v2.rs): walk the reachable import records for ImportRecordTag::Builtin/Bun, re-canonicalize through the alias table (the records are de-prefixed: node:fsfs), map to module ids, close over the adjacency, generate.
  • embed (StandaloneModuleGraph.rs): a sorted (module_id, bytecode) index in the standalone graph, payloads 128-byte aligned like the chunk bytecode already is. The graph sets a flag at startup when it holds any, so a Bun that embeds nothing never takes the lookup.
  • use (InternalModuleRegistry.cpp): generateModule() tries the embedded entry before compiling from source.

The runtime and the generator both get a builtin's source from builtinModuleSource() (the .incbin blob from #35071 in release, the on-disk bundle in debug) and build the SourceCode with builtinModuleSourceCode(), so a build's cache entries are always keyed on exactly what that build parses; a drift there would silently turn every lookup into a miss.

Safety

A cache entry carries a JSC cache version and a SourceCodeKey over the builtin's source. A mismatch is rejected and the builtin is parsed, and CachedFunctionExecutable::decode() restores m_isBuiltinFunction, so even a partially-decoded tree re-parses in builtin mode. Stale, corrupt, or missing entries all degrade to today's behavior rather than failing.

Cross-compiles skip generation entirely — src/js is specialized per platform at Bun's own build time (process.platform is a define), so the key would never match and the bytes would be dead weight.

Attaching a debugger or a profiler changes defaultCodeGenerationMode(), which is part of the key, so those runs fall back to parsing.

Verification

$ bun build --compile --bytecode count.ts --outfile c-bc && ./c-bc
decoded:51
$ bun build --compile count.ts --outfile c-nobc && ./c-nobc
decoded:0

51 = node:net + its transitive internal/* closure, from an entry point that only ever names node:net; +5.8 MB on the binary. An app requiring node:net + node:http + node:fs gets 69 builtins from the cache and, on a debug build, starts in ~0.77s instead of ~1.3s (release numbers will be smaller, debug parses much slower, but the shape holds).

test/bundler/bundler_compile.test.tscompile/BytecodeCachesBuiltinModules compiles the same entry point twice into one outfile (these are full copies of the Bun binary, so not two at once) and asserts the decode count is 0 without --bytecode and >5 with it. Passes against both a local vendor/WebKit build of #270 and the prebuilt preview.

Re-integration onto current main (909 commits), for reviewers who saw the earlier version

Main moved through this exact area while the PR waited on the WebKit side, so the August rebase was a re-integration rather than a conflict fixup:

  • build: link internal JS module sources via .incbin instead of a 6 MB byte-array header #35071 replaced the 6 MB builtin-source header with a .incbin blob plus an offset table. The earlier version of this PR had its own generated sourceById() switch and had to move the constants include so only one TU paid for it; all of that is gone. builtinModuleSource() now reads the blob through the same offset table, and InternalModuleRegistry.cpp ends up 21 lines shorter than main because the debug/release source split collapses into that one function.
  • codegen: lay out builtin module sources in dependency order #36101 started computing the builtin require graph for blob layout. The PR's own regex over @createInternalModuleById is gone; requireGraph() is shared by the layout and by the new adjacency table, so there is one definition of the edges.
  • Narrow crate-internal Rust visibility across all targets and delete the code it proves dead #36184 independently extracted the same ResolvedSourceTag::try_from_name helper this PR added (theirs is pub(crate)); the duplicate is dropped and internal_module_id narrowed to match.
  • JSVMClientData::create() grew a VmHandle retain that dereferences the Bun VM, so the earlier "create full client data with a null VM" would now crash on every --bytecode build. Replaced with registerBuiltinNames(), which registers only the names.
  • compile: bool became CompileMode; the new compile_target_is_host sits beside it.
  • The first push after the rebase leaked the BunBuiltinNames used by registerBuiltinNames() (2992 bytes per bytecode-VM thread, which LSan flagged across the node:module compile-cache tests too); appendExternalName() copies the names into the VM's own set, so it is now a stack local.
A local-only wrinkle, for anyone running this file under bun bd

A few tests in bundler_compile.test.ts fail against a local debug build regardless of this branch: compile/HelloWorldWithProcessVersionsBun compares process.versions.bun against the runner's version with -debug stripped, which a debug build never satisfies, and the Banner/Footer UTF-8 tests use the default 5s timeout, which a debug --compile (~8s here) can't meet. CI runs release builds and doesn't see either.


no test proof · iteration 7 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bundler_compile.test.ts

@github-actions github-actions Bot added the claude label Jul 3, 2026
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Aug 14th, 2026

@robobun, your commit 247677b has some failures in Build #96831 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33316

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

bun-33316 --bun

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Some node builtins load slower in bun than node #6648 - Directly addresses slow builtin module loading (node:stream, node:crypto, node:http, etc.) by caching their bytecode to eliminate source parsing at startup
  2. Emit JavaScriptCore bytecode directly from Bun's AST #6798 - Implements a practical form of bytecode caching that avoids re-parsing builtin modules, addressing the double-parse overhead described in this issue

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

Fixes #6648
Fixes #6798

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Embed internal module and builtin function bytecode in --compile --bytecode executables #28461 - Also embeds internal module and builtin function bytecode in --compile --bytecode executables, touching the same core files (InternalModuleRegistry.cpp, StandaloneModuleGraph, bundle_v2, CachedBytecode, bundle-modules.ts)

🤖 Generated with Claude Code

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

The duplicate bot is right, and it found something worth acting on. Pulling the thread:

Prior art

#28461 (@sosukesuzuki) does this, and oven-sh/WebKit#177 is its JSC half. Jarred left "I like this idea" on it in March. It's stalled because it's written against src/StandaloneModuleGraph.zig / src/bundler/bundle_v2.zig / src/bun.js/bindings/, which the Rust rewrite (#30412) removed, so it can't be rebased.

I didn't find it before opening this. Apologies for the noise. Credit for the design belongs there.

Where they differ

What #28461 has and this doesn't: it also caches the builtin functions (src/js/builtins/*, the BunBuiltinFunctions executables). That's a real extra win and worth doing, but it's orthogonal to the module graph work, so I'd rather land it as a follow-up than widen this.

What this has and #28461 doesn't: #28461 calls generateBuiltinBytecodes(allocator) with no module list, i.e. it embeds every builtin unconditionally. This one walks the @createInternalModuleById(N) adjacency out from the builtins the bundle actually imports, so an app that only touches node:net gets 26 entries rather than all ~160. That selectivity was the specific thing I was asked for.

The JSC side

This is the awkward part: oven-sh/WebKit#270 duplicates #177. Same four files, same idea, arrived at independently. Happy to close mine.

Before anyone does, one substantive difference. #177's BuiltinFunctionCacheEntry checks only computeJSCBytecodeCacheVersion() and skips the SourceCodeKey, on the stated grounds that "builtin sources are fixed at build time." That holds for bytecode baked into the binary, but not for --compile, where the generating Bun and the Bun the bytecode lands inside are not necessarily the same build. Both bun-side PRs guard the obvious case by skipping cross-compiles, but --compile-executable-path is not covered by that check: point it at another Bun whose src/js differs and a version-only check accepts the entry and runs the wrong bytecode.

#270 makes the entry a real GenericCacheEntry with a CachedSourceCodeKey, so the source hash is part of validation and a mismatch is rejected and the builtin is parsed instead. It also wires SourceCodeType::FunctionType into tagFromSourceCodeType() (it was already declared but unreachable) so isCachedBytecodeStillValid() works on these entries.

So: either keep #177 and add the key check to it, or keep #270. Don't mind which, but I'd rather the API validate than rely on every call site remembering to.

On the other bot

I'm going to leave Fixes #6648 / Fixes #6798 out of the description.

  • #6648 is about require("node:stream") being slow under plain bun. This only touches --compile --bytecode executables, so a normal bun run is unchanged.
  • #6798 is about emitting bytecode from Bun's AST for user files, which is a different problem.

Both are in the neighbourhood, neither is closed by this.

@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Went back through @Jarred-Sumner's two line comments on #28461, since they're feedback on this exact feature. Both now hold here.

can we set an extern "C" bool that says "please check for this here"

Done in cc831ad. Every Bun process compiles its builtins and only a --compile --bytecode executable carries an embedded cache, so the lookup is now gated on a plain flag the standalone graph sets at startup. A normal bun run does one relaxed load per builtin and never crosses into Rust.

can we always use this instead of using either?

(on getVMForBuiltinBytecodeCache(), which #28461 added alongside the existing getVMForBytecodeCache())

There's only one VM here. vmForBytecodeCache() creates JSVMClientData unconditionally, so the same VM serves chunk bytecode and builtin bytecode. Without it the builtin-mode lexer can't resolve @internalModuleRegistry (lookUpPrivateName returns null) and nothing compiles, which is what drove #28461 to a second VM.

The cost is that the chunk-bytecode path now pays JSVMClientData::create() once per bytecode thread, where before it paid nothing. That's one DOMWrapperWorld, a marking constraint, and builtinFunctions().exportNames(). Worth naming, but it's per-thread and only in --bytecode builds. If it turns out to matter I'd rather make client-data creation lazy than go back to two VMs.

@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

CI on cc831ada71 (build 68256): 285 passed, 1 failed. The one failure isn't this branch.

test/js/bun/terminal/terminal.test.tsBun.spawn with terminal option > creates subprocess with terminal attached times out after 90s on darwin 14 x64. It does that on every branch right now. In the last 40 pipeline builds it failed on that same lane in 68251, 68243, 68237, 68235, 68233, 68231, 68227, 68226 and 68225, across claude/webstreams-cpp, claude/node-sqlite-v26, claude/clipboard-api-native, claude/fs-watch-cfrunloop-shutdown and farm/af70c3fa/htmlrewriter-js-bindings. Nothing here touches PTY spawn. Not re-rolling, since a retry lands on the same broken test.

Two things worth taking from the green 285:

  • Every build lane compiled and linked against the WebKit PR preview, including macOS, Windows, musl, and arm64. So the WEBKIT_VERSION pin in this branch is good across the whole matrix, which is most of the risk in swapping it for the merged main sha later.
  • compile/BytecodeCachesBuiltinModules passed on the test lanes.

Also corrected something in the description: I'd written that six bundler_compile.test.ts stderr assertions fail, implying you'd see them here. You won't. They only fail against a local debug build, because debug_warn! compiles out of the release build the test lanes use. My mistake, the note now says so.

Still draft, still waiting on the oven-sh/WebKit#177 vs #270 call before WEBKIT_VERSION can point at a merged sha.

@robobun
robobun force-pushed the farm/c842455a/builtin-bytecode-cache branch from cc831ad to 72d921c Compare July 11, 2026 22:30
@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 72d921cb4f (build 71964): 283 passed, 3 failed, none of them this diff.

  • darwin 26 aarch64 exited during the tart VM hook with sudo: pfctl: command not found + SSH Permission denied on the checkout sync. The auto-retry passed.
  • test/js/web/fetch/fetch-leak.test.ts on darwin 14 aarch64: the fetch({compress}) leaked N MB threshold. Same failure is on recent builds 71962, 71938, 71937, 71915 across unrelated branches.
  • test/bake/dev/request-cookies.test.ts on debian 13 x64-asan, shard 3: AddressSanitizer: SEGV on unknown address 0x7490 in the dev server. This one I chased down properly because it was unique to this build across the last 100.

The bake harness kills the dev process as soon as it sees ASAN's first line, so the log has no stack. I couldn't pull the CI artifact (egress proxy blocks the S3 redirect), so I built locally with the CI flags (--profile=release --asan=on --assertions=off) to get a binary whose .text layout should match. Against that:

llvm-addr2line -e bun-asan 0x92e70f4 -f -C -i
  WTF::VectorTypeOperations<JSC::StructureID>::moveOverlapping  (wtf/Vector.h:150)
  WTF::Vector<JSC::StructureID>::removeAllMatching<...>         (wtf/Vector.h:1798)
  JSC::PropertyInlineCache::visitWeak(...)::$_0::operator()     (bytecode/PropertyInlineCache.cpp:433)
  ...
  JSC::PropertyInlineCache::visitWeak(JSC::ConcurrentJSLockerBase const&, JSC::CodeBlock*)
                                                                 (bytecode/PropertyInlineCache.cpp:430)

nm | containing symbol
  00000000092e43c0 t JSC::PropertyInlineCache::visitWeak(...)   (pc at +0x2d34; next symbol @ 092e71a0)

That's JSC's GC sweeping a CodeBlock's property inline caches, entirely inside the prebuilt WebKit. It sits ~+0x2d34 into a 0x2de0-byte visitWeak with no other symbol between, and the addr2line inlined chain agrees, so I'm confident in the placement despite CI linking in three split jobs and me in one. Nothing in this diff or in WebKit#270 touches PropertyInlineCache.cpp. The shard's second test passed, and the same file passes 20/20 under a local release-asan build.

I've pushed one ci: retrigger (4d29d08bb8). If request-cookies goes red again the symbolization above is a starting point for whoever picks it up, and it's worth looking at whether bake dev under React SSR has a concurrent-JIT / visitWeak window that main's normal test load doesn't usually open.

Still draft, still waiting on the WebKit#177 vs #270 call.

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Re-roll (build 71969 at 4d29d08bb8), which is my one and done.

request-cookies.test.ts did not recur. So the previous run's PropertyInlineCache::visitWeak SEGV was a one-off, as the symbolization said.

What 71969 is flagging so far (build still running at the time of writing):

Test Lane Occurrences on other branches (last 80 builds)
test/js/node/net/net-mongodb-pattern-leak.test.ts debian 13 aarch64 17 — RSS threshold (expected <8MB, got 9.3MB)
JSBundlerPlugin.cpp:325 segfault via test/cli/install/bun-add.test.ts darwin 14 aarch64 11
test/cli/install/bun-add.test.ts "Git URL SCP-style" darwin 14 aarch64 3git@github.com: Permission denied (publickey)
test/bake/dev-and-prod.test.ts windows 2019 x64-baseline retried and passed

None touch the bundler's --bytecode path, InternalModuleRegistry, or the standalone module graph. compile/BytecodeCachesBuiltinModules is green on both runs.

I'm not going to keep pushing ci: retrigger commits; each of these fails across a dozen-plus unrelated branches and the next roll just draws a different hand. The diff is ready for review on the merits, and is still a draft blocked on the WebKit#177 vs #270 decision — once that's settled I'll re-point WEBKIT_VERSION at the merged main sha and take it out of draft.

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Build 71969 finished: 283 passed, 3 failed. One annotation appeared after my previous comment.

test/js/third_party/next-auth/next-auth.test.ts on darwin 14 aarch64 — the should be able to call server action multiple times using auth middleware #18977 case hung for 90s, then the harness killed it and the Next.js child processes reported five cascade crashes (null-deref segfault + invoked crashByPanic()). The test doesn't use --compile or --bytecode. I haven't found it on other recent builds, but the crash traces look like the same shape as the JSBundlerPlugin.cpp:325 segfault (11 other branches) that took down the neighbouring shard.

None of the three failures touch a code path this diff reaches: on a non-standalone binary the only runtime effect here is one relaxed atomic load per builtin that returns false and falls through to the unchanged createBuiltinExecutable() call. compile/BytecodeCachesBuiltinModules is green on both runs.

Re-roll already spent on this branch; not pushing another. Same ask as before: still a draft until the WebKit#177 vs #270 call.

@robobun
robobun force-pushed the farm/c842455a/builtin-bytecode-cache branch from 4d29d08 to 69a3d8d Compare July 14, 2026 13:17
@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto cc0c1e8355 (50 commits). This one was a two-repo rebase: main moved WEBKIT_VERSION to 4895f45d (#34009), so oven-sh/WebKit#270 had to be rebased onto that first (clean; now 321befdd) and this branch's pin updated to its new preview tag autobuild-preview-pr-270-321befdd. The previous ci: retrigger commit is dropped.

Verified against both a local vendor/WebKit@321befdd build and the new prebuilt; compile/BytecodeCachesBuiltinModules passes on each.

Nothing else changes: still a draft pending the WebKit#177 vs #270 call; WEBKIT_VERSION gets re-pointed at a merged main sha before landing.

@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

CI on the rebased 69a3d8dc98 (build 72935): 283 passed, 2 failed, 1 timed out.

Lane Test Occurrences on other recent branches
alpine 3.23 x64 + x64-baseline (shard 9) test/js/node/test/parallel/test-net-connect-memleak.js 21 — FinalizationRegistry GC timing
linux x64-baseline verify-baseline infra step timed out 10

Neither touches anything in this diff. compile/BytecodeCachesBuiltinModules is green on every lane that ran it. This is the cleanest run of the four so far.

Re-roll already spent on this branch; the next push will be the one that re-points WEBKIT_VERSION at a merged main sha once the WebKit#177 / #270 decision is made.

Jarred-Sumner pushed a commit that referenced this pull request Jul 20, 2026
## Repro

```
$ bun bd test test/bundler/bundler_compile.test.ts -t HelloWorldBytecode
...
- [Disk Cache] Cache miss for sourceCode"
+ [Disk Cache] Cache miss for sourceCode
+ debug warn: hintSourcePagesDontNeed: MADV_DONTNEED 4096 bytes"
```

Every standalone executable produced by a debug build prints that line
on startup, so every exact-`stderr` assertion in
`bundler_compile.test.ts` (`HelloWorldBytecode`, the three
`ReactSSR+bytecode` variants) fails under `bun bd`. CI doesn't see it
because the test lanes run a release build. Several open PRs (#33337,
#33316, #29066) have had to call it out as pre-existing debug noise in
their verification sections, and `bun-build-compile.test.ts` grew a
workaround comment that discards stderr.

## Cause

`hint_source_pages_dont_need()` logs the `madvise(MADV_DONTNEED)`
outcome with `debug_warn!`, which writes to stderr unconditionally in
debug builds and is not silenced by `BUN_DEBUG_QUIET_LOGS`. The success
path is purely informational, and the failure path is documented as a
best-effort hint, so neither is a warning.

## Fix

Declare a hidden `StandaloneModuleGraph` scope and route both log sites
through `scoped_log!`. The information is still available via
`BUN_DEBUG_StandaloneModuleGraph=1`; by default a compiled binary's
stderr is clean again. Release builds are unaffected either way since
both macros compile out.

Test changes:

- `compile/HelloWorld` now asserts `stderr: ""` so the regression is
covered directly rather than only as a side effect of the bytecode cache
tests.
- `standalone-madvise-tla.test.ts` was asserting the hint on stderr via
the old `debug_warn!` path; it now opts in with
`BUN_DEBUG_StandaloneModuleGraph=1` and reads the scoped log from stdout
(scoped loggers write to the debug stream, which is stdout by default).
A second run without the env var asserts the scope stays hidden even
with `BUN_DEBUG_QUIET_LOGS` unset.
- The workaround comment and discarded stderr in the `#29963`
PT_GNU_STACK test in `bun-build-compile.test.ts` are replaced with
`expect(stderr).toBe("")`.

## Verification

```
$ bun bd test test/bundler/bundler_compile.test.ts -t "compile/HelloWorld$|HelloWorldBytecode|ImportDeferBytecode|ReactSSR\+bytecode"
 7 pass, 0 fail
$ bun bd test test/js/bun/compile/standalone-madvise-tla.test.ts
 1 pass, 0 fail
$ bun bd test test/bundler/bun-build-compile.test.ts -t "preserves PT_GNU_STACK"
 1 pass, 0 fail

# opt-in still works
$ BUN_DEBUG_StandaloneModuleGraph=1 ./compiled-out
hi
[standalonemodulegraph] hintSourcePagesDontNeed: MADV_DONTNEED 4096 bytes
```

`cargo check -p bun_standalone_graph` and `--target
x86_64-pc-windows-msvc` both clean; `cargo clippy -p
bun_standalone_graph --no-deps` clean.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/bundler/bun-build-compile.test.ts
test/bundler/bundler_compile.test.ts
test/js/bun/compile/standalone-madvise-tla.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
A compiled binary already ships bytecode for the app's own modules, but every
builtin it touches (node:net, node:fs, the internal/* modules they require) is
still parsed from source on first use. That is the bulk of the parse work left
at startup.

Builtins are compiled with createBuiltinExecutable() into an
UnlinkedFunctionExecutable, which the bytecode cache had no entry type for;
oven-sh/WebKit#270 adds encodeFunctionExecutable()/decodeFunctionExecutable()
for exactly this. WEBKIT_VERSION points at that PR's preview build for now and
must be re-pointed at the merged main sha before landing.

Unlike the other bytecode generation steps, this one has to walk a graph the
bundler cannot see. The bundle's import records name only the builtins the app
imports directly; those then require() each other through InternalModuleRegistry
by numeric id, resolved at runtime. bundle-modules.ts already derives that graph
from the bundled output to lay the source blob out in dependency order; it now
also emits it as an adjacency table, and caching "net" means caching node:net
plus every internal/* module it transitively reaches.

The entries are embedded in the standalone module graph and looked up by module
id on first require, behind a flag the graph sets at startup so a Bun that
embeds nothing never takes the lookup. JSC validates each entry against a cache
version and a SourceCodeKey over the builtin's source; the runtime and the
generator obtain that source and build that SourceCode through the same two
functions, so a build's entries are always keyed on what the same build parses.
A rejected or missing entry parses in builtin mode exactly as before.
Cross-compiles skip generation: src/js is specialized per platform at Bun's own
build time, so the key would never match.

The bytecode VM only registers Bun's private names (JSVMClientData::
registerBuiltinNames) rather than creating full client data: the builtin-mode
lexer needs the names, and nothing else in client data applies to a VM with no
Bun VirtualMachine behind it.

Measured on a debug build running require("node:net") + node:http + node:fs:
~1.3s to ~0.77s startup; 69 builtins served from the cache.
@robobun
robobun force-pushed the farm/c842455a/builtin-bytecode-cache branch from 69a3d8d to 6fd90ae Compare August 14, 2026 22:58
Comment thread src/bundler/BundleThread.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/options.rs
Comment thread src/jsc/bindings/BuiltinModuleBytecode.h Outdated
Comment thread src/jsc/bindings/BuiltinModuleBytecode.h Outdated
Comment thread src/jsc/bindings/BunClientData.cpp Outdated
Comment thread src/jsc/bindings/BunClientData.h Outdated
Comment thread src/jsc/bindings/InternalModuleRegistry.cpp Outdated
Comment thread src/jsc/bindings/InternalModuleRegistry.cpp Outdated
Comment thread src/jsc/bindings/InternalModuleRegistry.cpp Outdated
Comment thread src/jsc/bindings/InternalModuleRegistry.cpp Outdated
Comment thread src/jsc/bindings/ZigSourceProvider.cpp
Comment thread src/jsc/bindings/ZigSourceProvider.h
Comment thread src/jsc/lib.rs
BuiltinNames::appendExternalName() copies each name into the VM's own
private-name set (a set of Strings), so the BunBuiltinNames object is only
needed for the duration of its constructor. Leaking it showed up under LSan as
a 2992-byte direct leak from every thread that creates the bytecode VM, which
since the node:module compile cache landed is most --bytecode users, not just
this feature.

Also trims comments that restated what the code or a neighbouring definition
already says, and removes two that gave a wrong reason for an ordering that
does not matter (the bytecode VM is thread-local).
Comment thread src/bundler/bundle_v2.rs
Comment thread src/codegen/bundle-modules.ts
Comment thread src/codegen/bundle-modules.ts
Comment thread src/jsc/CachedBytecode.rs
Comment thread src/jsc/CachedBytecode.rs
Comment thread src/jsc/bindings/BuiltinModuleBytecode.cpp
Comment thread src/jsc/bindings/BuiltinModuleBytecode.h
Comment thread src/jsc/bindings/BunClientData.cpp
Comment thread src/jsc/bindings/BunClientData.h
Comment thread src/standalone_graph/StandaloneModuleGraph.rs
Comment thread src/standalone_graph/StandaloneModuleGraph.rs
Comment thread src/standalone_graph/StandaloneModuleGraph.rs
Comment thread src/standalone_graph/StandaloneModuleGraph.rs
Comment thread src/standalone_graph/StandaloneModuleGraph.rs
Comment thread src/standalone_graph/StandaloneModuleGraph.rs
Comment thread src/standalone_graph/StandaloneModuleGraph.rs
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Two updates, both now in 247677ba88.

CI on the re-integrated push found a real bug. Build 96781's ASAN lane reported a 2992-byte direct leak from JSVMClientData::registerBuiltinNames() on every test that generates bytecode, including all of the node:module compile-cache tests, since those share the bytecode VM. The re-integration had deliberately leaked the BunBuiltinNames used to register the @-names, on the theory that the VM's private-name set pointed into it. That theory was wrong: BuiltinNames::appendExternalName() copies each name into the VM's own set (PrivateNameSet is a set of Strings), so the object is only needed for the duration of its constructor. It's a stack local now. Verified by running the failing tests under an ASAN build with the CI runner's detect_leaks=1 + test/leaksan.supp configuration: the registerBuiltinNames frame no longer appears. The automated comment review below flagged the same site about an hour before CI did, for what it's worth; checking its claim is what turned up the bug.

The line-comment review. The comment checker left one thread on every comment in the diff, one-liners included, all with the same text, and it does so again on each push (56 threads so far across two pushes). I went through the first batch individually rather than dismissing it. Acted on: the leak above; two comments in bundle_v2.rs that gave a reason for an ordering that doesn't actually matter (the bytecode VM is thread-local), now deleted; and the "runtime and generator must agree on the source" point, which was stated in five places and is now stated once, at the top of BuiltinModuleBytecode.h, with the call sites trimmed to a line or removed. Net 45 lines of comments gone. What's left are doc comments in the same style as their neighbours (the StandaloneModuleGraph.rs provenance notes mirror the existing File::bytecode ones, the extern "Rust" docs mirror the existing declaration above them, and collect_builtin_module_bytecode's doc is the transitive-closure design, which is the one thing in this PR a reader can't get from the code). I'm not going to keep trimming against the checker, since it fires on two-line comments too; I'll resolve its threads so the PR stays readable, and fold the two remaining marginal ones (Bun__setHasBuiltinModuleBytecode / Bun__getBuiltinModuleBytecode docs in StandaloneModuleGraph.rs could each lose a sentence) into whatever the next real push is.

The PR description is updated for the re-integrated design, and has a collapsed section listing what moved on main (#35071's .incbin blob, #36101's require graph, #36184's try_from_name, the JSVMClientData::create VmHandle change, CompileMode) and how each was resolved, since the diff looks fairly different from the July version.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status of build 96831 for 247677ba88: every lane that has run is green, 177 jobs, including all 20 debian 13 x64-asan shards, so the leak fix from the previous push is confirmed in CI.

The build is still not green only because its two darwin 14 aarch64 shards have never been picked up. They were scheduled at 23:21, expired unstarted at 01:05 and again at 03:05, and are queued for a third time. That lane is served by 6 agents (release-tier=previous, arm64) which are all healthy and cycling through jobs in 6-9 minutes each, but at the moment there are ~184 such jobs waiting across the pipeline, so jobs are expiring before they reach the front. It isn't specific to this PR and a re-push would only re-enter the same queue, so I'm leaving it to drain.

The one annotation is a yellow retry of test/js/bun/util/inspect-error-leak.test.js (timed out once in a parallel batch on the ASAN lane, passed on retry), unrelated.

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