Skip to content

Support --bytecode with --format=esm without --compile - #29291

Draft
robobun wants to merge 11 commits into
mainfrom
farm/0830ed35/esm-bytecode-sidecar
Draft

robobun wants to merge 11 commits into
mainfrom
farm/0830ed35/esm-bytecode-sidecar

Conversation

@robobun

@robobun robobun commented Apr 14, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #29286.

Repro

// index.ts
async function getConfig() { return { port: 3000 }; }
const config = await getConfig();
console.log(`Server starting on port ${config.port}`);

Before:

$ bun build index.ts --bytecode --format=esm --target=bun --outdir dist
error: ESM bytecode requires --compile. Use --format=cjs for bytecode without --compile.

After:

$ bun build index.ts --bytecode --format=esm --target=bun --outdir dist

  dist/index.js      173 bytes  (entry point)
  dist/index.js.jsc  3.1 KB     (bytecode)

$ bun dist/index.js
Server starting on port 3000

Cause

bun build --bytecode --format=esm (and the Bun.build({ bytecode: true, format: 'esm' }) JS API) was gated behind --compile by two validation guards. The gate predated the runtime actually supporting an ESM .jsc sidecar without embedding the standalone runtime. The bundler already knows how to emit the .jsc sidecar in this mode and the runtime loader already reads a // @bun @bytecode ESM file's sidecar, so the guards were the only thing blocking it.

Fix

  • Remove the guards in src/runtime/api/JSBundler.rs (JS API) and src/runtime/cli/Arguments.rs (CLI). bun build --bytecode --format=esm --outdir now emits dist/index.js + dist/index.js.jsc, and bun dist/index.js loads the bytecode for the evaluate phase.
  • Docs + bun.d.ts updated to drop the stale "ESM bytecode requires --compile" wording and correct the .jsc sidecar filename (index.js.jsc).

Earlier revisions of this branch also carried a use-after-free fix in the async transpiler path, removal of two dead AlreadyBundled helpers, and a fix for the .jsc sidecar bytecode buffer leak that LeakSanitizer reported on this PR's tests (a new bytecode_cache_is_owned ownership flag driving the CachedBytecode destructor). All of these have since landed independently on main (the leak fix as the typed Bytecode { ptr, len, owned } redesign of ResolvedSource), so they dropped out during rebases. Verified on the current base: the ESM splitting, plain CJS, and require.extensions + _compile-override bytecode paths all run leak-clean under the CI LSAN options. The async .jsc ownership transfer (mem::take + heap::into_raw to hand the Box<[u8]> to C++) is now upstream, which is what keeps the ESM path this PR enables memory-safe.

Note: this enables the bytecode (.jsc) sidecar only. The .jsm module-info sidecar (which would let JSC skip the analyze-phase reparse too) is not part of this change. The evaluate phase uses the bytecode; the analyze phase re-parses the source for import/export discovery.

Verification

test/bundler/bundler_bun.test.ts (the "ESM bytecode sidecar without --compile" block) covers the CLI path (build + bun dist/index.js end-to-end with top-level await), the Bun.build JS API, and a multi-entry --splitting case where a shared chunk gets its own .jsc sidecar. The tests fail on released bun (guard present) and pass with the guards removed.


[review] gate passed · iteration 26 · 6 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_bun.test.ts
bun test v1.4.1 (4448a2e21)

test/bundler/bundler_bun.test.ts:
(pass) bundler > bun/import-bun-format-cjs [1227.37ms]
(pass) bundler > bun/embedded-sqlite-file [594.84ms]
(pass) bundler > bun/sqlite-file [531.61ms]
(pass) bundler > bun/TargetBunNoSourcemapMessage [702.38ms]
(pass) bundler > bun/TargetBunSourcemapInline [625.79ms]
(pass) bundler > bun/unicode comment [469.61ms]
(pass) bundler > bun/ExportsConditionsDevelopmentAPI [463.59ms]
(pass) bundler > bun/ExportsConditionsDevelopmentInProductionAPI [417.23ms]
(pass) bundler > bun/ExportsConditionsDevelopmentCLI [659.25ms]
(pass) bundler > bun/ExportsConditionsDevelopmentInProductionCLI [722.86ms]
212 |       stdout: "pipe",
213 |     });
214 | 
215 |     const [, buildStderr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]);
216 | 
217 |     expect(buildStderr).not.toContain("ESM bytecode requires");
                                  ^
error: expect(received).not.toContain(expected)

Expected to not contain: 
... (truncated)

release without fix: all passed
bun test v1.4.1-canary.1 (ad348829e)

test/bundler/bundler_bun.test.ts:
(pass) bundler > bun/import-bun-format-cjs [28.91ms]
(pass) bundler > bun/embedded-sqlite-file [13.86ms]
(pass) bundler > bun/sqlite-file [11.96ms]
(pass) bundler > bun/TargetBunNoSourcemapMessage [14.67ms]
(pass) bundler > bun/TargetBunSourcemapInline [14.47ms]
(pass) bundler > bun/unicode comment [9.99ms]
(pass) bundler > bun/ExportsConditionsDevelopmentAPI [9.29ms]
(pass) bundler > bun/ExportsConditionsDevelopmentInProductionAPI [11.01ms]
(pass) bundler > bun/ExportsConditionsDevelopmentCLI [13.11ms]
(pass) bundler > bun/ExportsConditionsDevelopmentInProductionCLI [12.65ms]
(pass) ESM bytecode sidecar without --compile > issue #29286: Bun.build({ bytecode: true, format: 'esm' }) no longer requires compile [10.12ms]
(pass) ESM bytecode sidecar without --compile > issue #29286: --bytecode --format=esm --outdir emits .jsc sidecar [14.05ms]
(pass) ESM bytecode sidecar without --compile > issue #29286: shared ESM bytecode chunk loads and runs [14.60ms]

 13 pass
 0 fail
 33 expect() calls
Ran 13 tests across 1 file. [286.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_bun.test.ts
bun test v1.4.1 (4448a2e21)

test/bundler/bundler_bun.test.ts:
(pass) bundler > bun/import-bun-format-cjs [1028.58ms]
(pass) bundler > bun/embedded-sqlite-file [490.81ms]
(pass) bundler > bun/sqlite-file [444.85ms]
(pass) bundler > bun/TargetBunNoSourcemapMessage [611.05ms]
(pass) bundler > bun/TargetBunSourcemapInline [529.16ms]
(pass) bundler > bun/unicode comment [362.17ms]
(pass) bundler > bun/ExportsConditionsDevelopmentAPI [371.57ms]
(pass) bundler > bun/ExportsConditionsDevelopmentInProductionAPI [388.50ms]
(pass) bundler > bun/ExportsConditionsDevelopmentCLI [549.80ms]
(pass) bundler > bun/ExportsConditionsDevelopmentInProductionCLI [527.89ms]
(pass) ESM bytecode sidecar without --compile > issue #29286: Bun.build({ bytecode: true, format: 'esm' }) no longer requires compile [438.09ms]
(pass) ESM bytecode sidecar without --compile > issue #29286: --bytecode --format=esm --outdir emits .jsc sidecar [608.23ms]
(pass) ESM bytecode sidecar without --compile > issue #29286: shared ESM bytecode chunk 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 898ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/37] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[2/37] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 243 extern-C blocks audited
[3/37] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/G
... (truncated)
diff hotspot
docs/bundler/bytecode.mdx        |  17 +++-
 docs/bundler/index.mdx           |  17 ++--
 packages/bun-types/bun.d.ts      |   5 +-
 src/runtime/api/JSBundler.rs     |   9 +-
 src/runtime/cli/Arguments.rs     |  10 ---
 test/bundler/bundler_bun.test.ts | 175 ++++++++++++++++++++++++++++++++++++++-
 6 files changed, 199 insertions(+), 34 deletions(-)

gate history · 9 passed · 0 rejected · iteration 26

evidence per changed file
file                              reads  edits  tests
docs/bundler/bytecode.mdx             5     13      1
docs/bundler/index.mdx                3      3      0
packages/bun-types/bun.d.ts           1      1      0
src/runtime/api/JSBundler.rs          3      4      3
src/runtime/cli/Arguments.rs          1      1      3
test/bundler/bundler_bun.test.ts      0      0      0

root cause · written by the author bot

The bundler only generated the serialized module info that ESM bytecode needs at runtime when --compile was set, so --bytecode with --format=esm was rejected outside of standalone builds and users were forced into CJS output, which breaks top-level await. The fix removes that validation in the CLI and JSBundler, collects top-level declarations and emits a .jsm module-info sidecar alongside the .jsc bytecode for ESM chunks in outdir builds, and threads the deserialized module info through the module loader so ResolvedSource.module_info is populated when the cached bundle is loaded. As a resu…

@robobun

robobun commented Apr 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:12 AM PT - Aug 24th, 2026

✅ @autofix-ci[bot], your commit 07d85978c92621f346035ed546b3b0ccd46c53cd passed in Build #104677! 🎉


🧪   To try this PR locally:

bunx bun-pr 29291

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

bun-29291 --bun

@coderabbitai

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Enables ESM bytecode sidecar workflow: bundler emits .jsc (bytecode) and .jsm (module metadata) sidecars for ESM outputs when bytecode generation is enabled, removes prior compile-mode validation, threads module-info through caching/deserialization, and adds tests exercising ESM bytecode without --compile.

Changes

Cohort / File(s) Summary
Docs
docs/bundler/bytecode.mdx
Rewrote standalone executable / ESM bytecode docs: removed requirement that ESM requires --compile, described .jsc + .jsm sidecar workflow and runtime fallback behavior.
Core constants & parsing
src/bun.zig, src/transpiler.zig
Added module_info_extension = ".jsm"; changed ParseResult.AlreadyBundled.bytecode to a BytecodeBundle containing bytecode + optional module_info; added moduleInfoSlice() and updated bytecode-sidecar read logic.
Bundler output pipeline
src/bundler/linker_context/.../OutputFileListBuilder.zig, src/bundler/linker_context/generateChunksInParallel.zig, src/bundler/linker_context/generateCompileResultForJSChunk.zig, src/bundler/linker_context/postProcessJSChunk.zig, src/bundler/linker_context/writeOutputFilesToDisk.zig
Stop gating ESM module-info generation on c.options.compile; collect top-level decls for ESM bytecode without --compile; produce/serialize module-info for JS-like chunks; emit .jsm sidecars alongside .jsc when present; record module_info_index in output metadata; use bun.module_info_extension for naming.
Transpiler & runtime caching
src/bun.js/ModuleLoader.zig, src/bun.js/RuntimeTranspilerStore.zig
When reusing cached bundles, read moduleInfoSlice(), deserialize to ModuleInfoDeserialized if present, manage lifetime, and populate ResolvedSource.module_info (instead of leaving null).
API & CLI validation
src/bun.js/api/JSBundler.zig, src/cli/Arguments.zig
Removed validation that rejected --bytecode + --format=esm when --compile was not set; ESM bytecode generation is now permitted for outdir/non---compile flows.
Tests
test/regression/issue/29286.test.ts
Added regression tests covering bun build --bytecode --format=esm --outdir and Bun.build(...) verifying outputs include *.js, *.js.jsc, and *.js.jsm, sidecars are non-empty, and runtime execution (including top-level await) succeeds.
Chunk comment
src/bundler/Chunk.zig
Updated comment for JavaScriptChunk.module_info_bytes to note serialized ModuleInfo is embedded in --compile binaries or written as .jsm sidecars in --outdir builds.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed All code changes directly implement the requirements from issue #29286: enabling ESM bytecode sidecars for outdir builds by generating .jsm files, updating the runtime loader, and removing CLI/API guards.
Out of Scope Changes check ✅ Passed All changes align with the stated objectives: bundler emission updates, runtime loader extensions, and CLI/API guard removals directly support ESM bytecode sidecar functionality without introducing unrelated modifications.
Description check ✅ Passed The description clearly explains the problem, cause, fix, user-visible behavior, and verification results, although it uses different section headings than the template.
Title check ✅ Passed The title clearly and concisely summarizes the primary change: enabling ESM bytecode without requiring --compile.

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

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/bundler/bytecode.mdx`:
- Around line 51-59: The docs currently imply bundling yields three files but
don't state that the `.jsm` sidecar is required at runtime for ESM cache hits;
update the paragraph mentioning `dist/index.js`, `dist/index.js.jsc`, and
`dist/index.js.jsm` to explicitly state that for Bun to reuse ESM bytecode at
startup (e.g., when running `bun dist/index.js`) you must ship the `.js` plus
both `.jsc` and `.jsm` files together—missing the `.jsm` will prevent ESM cache
reuse and silently degrade startup performance.

In `@src/bundler/linker_context/writeOutputFilesToDisk.zig`:
- Around line 304-305: The module-info (.jsm) emission must be gated the same
way as the .jsc loader check to keep OutputFileListBuilder's reserved counts
consistent: update the conditional that writes module_info_bytes (currently
checking c.options.generate_bytecode_cache, c.options.output_format == .esm, and
chunk.content == .javascript) to also require the loader JS-gate (the same check
used for .jsc, e.g., loader.isJavaScriptLike()). Do the same for the block
around lines 407-410 that calls insertForSourcemapOrBytecode() / pushes
bytecode/module-info entries so those pushes only occur when the loader JS-gate
is true, ensuring reserved bytecode/module-info slots match actual emitted
.jsm/.jsc files.

In `@src/transpiler.zig`:
- Around line 22-46: The BytecodeBundle currently holds heap-backed slices but
has no explicit ownership/cleanup API; add a pub fn deinit(self:
*BytecodeBundle, allocator: *std.mem.Allocator) !void that frees bytecode and
module_info when they are non-empty, and update the call sites that produce
BytecodeBundle (and any places constructing AlreadyBundled variants) to either
call BytecodeBundle.deinit(allocator) when done or clearly
document/process-lifetime; also keep bytecodeSlice and moduleInfoSlice unchanged
but ensure their consumers do not assume automatic freeing.

In `@test/regression/issue/29286.test.ts`:
- Around line 93-95: Remove the brittle generic stderr checks by deleting the
two assertions that inspect runStderr (the calls using
runStderr.not.toContain("error") and runStderr.not.toContain("await")) and leave
only the runtime exit assertion (expect(runExit).toBe(0)); keep any other
specific stderr assertions that verify known build-time regressions, and ensure
the runtime step relies solely on runExit for pass/fail (references: runStderr,
runExit in this test).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c42d642b-f8cb-4af2-8116-5231523f1614

📥 Commits

Reviewing files that changed from the base of the PR and between 0bf4528 and fec6458.

📒 Files selected for processing (13)
  • docs/bundler/bytecode.mdx
  • src/bun.js/ModuleLoader.zig
  • src/bun.js/RuntimeTranspilerStore.zig
  • src/bun.js/api/JSBundler.zig
  • src/bun.zig
  • src/bundler/linker_context/OutputFileListBuilder.zig
  • src/bundler/linker_context/generateChunksInParallel.zig
  • src/bundler/linker_context/generateCompileResultForJSChunk.zig
  • src/bundler/linker_context/postProcessJSChunk.zig
  • src/bundler/linker_context/writeOutputFilesToDisk.zig
  • src/cli/Arguments.zig
  • src/transpiler.zig
  • test/regression/issue/29286.test.ts
💤 Files with no reviewable changes (2)
  • src/bun.js/api/JSBundler.zig
  • src/cli/Arguments.zig

Comment thread docs/bundler/bytecode.mdx Outdated
Comment thread src/bundler/linker_context/writeOutputFilesToDisk.zig Outdated
Comment thread src/bundler/transpiler.zig Outdated
Comment thread test/regression/issue/29286.test.ts Outdated
Comment thread src/bundler/linker_context/writeOutputFilesToDisk.zig Outdated
Comment thread src/bundler/linker_context/writeOutputFilesToDisk.zig Outdated
Comment thread test/regression/issue/29286.test.ts Outdated
Comment thread src/bundler/linker_context/writeOutputFilesToDisk.zig Outdated

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/bundler/bytecode.mdx`:
- Line 57: Update the sentence that currently reads "if `.jsm` is missing Bun
still runs the `.js`, but ESM bytecode reuse silently degrades to a full parse"
to avoid claiming a full fallback; instead state that Bun will still use the
`.jsc` and that a missing `.jsm` causes the analyze/import-export reparse path
to be used, resulting in degraded startup performance (not a full source parse).
Keep the behavior note concise and replace the phrase "full parse" with
"startup-performance degradation due to analyze/import-export reparse."

In `@src/bundler/linker_context/postProcessJSChunk.zig`:
- Around line 28-38: The current logic sets generate_module_info using the
original entry loader
(parse_graph.input_files.items(.loader)[chunk.entry_point.source_index]) and
gates on loader.isJavaScriptLike(), but non-entry split chunks should mirror the
same `.js` fallback used by writeOutputFilesToDisk.zig; update the generation
check in postProcessJSChunk.zig (the generate_module_info computation) to treat
chunks that are non-entry or whose resolved disk-type would be `.js` as
JavaScript-like for the purpose of ModuleInfo creation so that ModuleInfo (and
thus .jsm/.jsc sidecars) are produced consistently with
writeOutputFilesToDisk.zig and avoid the slot/accounting mismatch.

In `@test/regression/issue/29286.test.ts`:
- Around line 3-6: Update the header comment's example sidecar filenames to
match runtime/test expectations: replace `dist/index.jsc` with
`dist/index.js.jsc` and `dist/index.jsm` with `dist/index.js.jsm` in the comment
that describes expected outputs for the `bun build --bytecode --format=esm
--target=bun --outdir` case so the examples align with the actual emitted files.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8bb50e10-b864-4f8a-a7d0-df43888bfa3d

📥 Commits

Reviewing files that changed from the base of the PR and between 78bc3aa and 863b2b9.

📒 Files selected for processing (6)
  • docs/bundler/bytecode.mdx
  • src/bundler/Chunk.zig
  • src/bundler/linker_context/postProcessJSChunk.zig
  • src/bundler/linker_context/writeOutputFilesToDisk.zig
  • src/transpiler.zig
  • test/regression/issue/29286.test.ts

Comment thread docs/bundler/bytecode.mdx Outdated
Comment thread src/bundler/linker_context/postProcessJSChunk.zig Outdated
Comment thread test/regression/issue/29286.test.ts Outdated
Comment thread src/jsc/ModuleLoader.zig Outdated
Comment thread src/bundler/linker_context/generateChunksInParallel.zig Outdated

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/bundler/linker_context/generateChunksInParallel.zig (1)

311-367: ⚠️ Potential issue | 🟠 Major

Keep module_info fixups best-effort for outdir builds.

Line 311 now enables this block for non---compile ESM bytecode too, but the fixup still treats supplementary module_info allocations as fatal (bun.handleOom() on path replacement and mi.str()). That makes .jsm generation able to abort an otherwise valid JS build under memory pressure. Please keep this path consistent with the existing linker-context contract and degrade by skipping module-info fixups/sidecars when these allocations fail.

Based on learnings, module_info operations for ESM bytecode caching use catch continue / catch {} to silently handle allocation failures because module_info is supplementary metadata, not critical to producing correct JavaScript output.

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

In `@src/bundler/linker_context/generateChunksInParallel.zig` around lines 311 -
367, The current fixup path aborts the build on allocation failures (calls
bun.handleOom) when building resolved paths and creating/replacing module_info
strings; make these fixups best-effort instead: in the loop that builds
unique_key_to_path (std.fmt.allocPrint and unique_key_to_path.put) change
OOM/error handlers to skip that entry (catch continue or empty block) rather
than bun.handleOom, in the collection loop change replacements.append error
handling to skip the replacement on failure, and in the replacement application
change mi.str(...) error handling to skip creating that new_id (catch continue)
so module_info fixups are best-effort and do not abort JS generation (refer to
unique symbols: unique_key_to_path, std.fmt.allocPrint, unique_key_to_path.put,
replacements.append, Replacement.old_id, Replacement.resolved_path, mi.str,
mi.replaceStringID).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/bundler/linker_context/generateChunksInParallel.zig`:
- Around line 311-367: The current fixup path aborts the build on allocation
failures (calls bun.handleOom) when building resolved paths and
creating/replacing module_info strings; make these fixups best-effort instead:
in the loop that builds unique_key_to_path (std.fmt.allocPrint and
unique_key_to_path.put) change OOM/error handlers to skip that entry (catch
continue or empty block) rather than bun.handleOom, in the collection loop
change replacements.append error handling to skip the replacement on failure,
and in the replacement application change mi.str(...) error handling to skip
creating that new_id (catch continue) so module_info fixups are best-effort and
do not abort JS generation (refer to unique symbols: unique_key_to_path,
std.fmt.allocPrint, unique_key_to_path.put, replacements.append,
Replacement.old_id, Replacement.resolved_path, mi.str, mi.replaceStringID).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9d72f4e4-7b4d-4e82-bdb6-93096f4b9330

📥 Commits

Reviewing files that changed from the base of the PR and between bd8ca75 and bc6d633.

📒 Files selected for processing (3)
  • src/bun.js/ModuleLoader.zig
  • src/bun.js/RuntimeTranspilerStore.zig
  • src/bundler/linker_context/generateChunksInParallel.zig

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


Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/bundler/linker_context/generateChunksInParallel.zig:658-665 — In the in-memory path, the .jsm module_info OutputFile is created with .input_loader = .js (generateChunksInParallel.zig line 663), while the disk path in writeOutputFilesToDisk.zig correctly uses .input_loader = .file. Since BuildArtifact.loader is set directly from input_loader, callers of Bun.build({ bytecode: true, format: 'esm' }) without outdir will see artifact.loader === 'js' for a binary .jsm blob instead of the correct 'file'. Fix: change .input_loader = .js to .input_loader = .file at generateChunksInParallel.zig line 663 to match the disk path.

    Extended reasoning...

    What the bug is and how it manifests

    In the in-memory path at generateChunksInParallel.zig line 663, the module_info_output_file block creates an OutputFile with .input_loader = .js. The disk path in writeOutputFilesToDisk.zig (newly added by this PR) correctly uses .input_loader = .file for the identical module_info OutputFile. The .jsm sidecar is a binary serialization blob (the output of serializeModuleInfo), not JavaScript source — so .file is the semantically correct loader.

    The specific code path that triggers it

    OutputFile.zig lines 374 and 412 set BuildArtifact.loader = this.input_loader directly. Any caller using Bun.build({ bytecode: true, format: 'esm' }) without outdir takes the in-memory path and receives the module_info OutputFile as a BuildArtifact. Those callers will see artifact.loader === 'js' for a binary blob.

    Why existing code does not prevent it

    Before this PR, the in-memory module_info_output_file block was guarded by c.options.generate_bytecode_cache and c.options.output_format == .esm and c.options.compile. The --compile guard meant the OutputFile was embedded into the standalone binary and never returned to user callers as a BuildArtifact. This PR removes the c.options.compile guard, which means the in-memory path is now reachable from Bun.build() without outdir, and the module_info OutputFile IS returned to users as a BuildArtifact.

    Why the .jsc path does not have the same issue

    In the in-memory path around line 620, the .jsc bytecode OutputFile also uses .input_loader = .js, but that is a pre-existing issue affecting --compile-mode builds where the file is embedded and never returned as a BuildArtifact. The .jsm inconsistency is new to this PR: the disk path correctly uses .file (newly added code) but the in-memory path retained the pre-existing .js value without updating it to match.

    Impact

    A user calling Bun.build({ entrypoints: ['./index.ts'], target: 'bun', format: 'esm', bytecode: true }) without outdir and iterating result.outputs will observe output.loader === 'js' for the .jsm artifact — metadata incorrectly stating a binary blob is JavaScript source. The --outdir path reports loader === 'file' correctly, creating an observable API inconsistency between the two build modes introduced by this PR.

    How to fix it

    Change line 663 in src/bundler/linker_context/generateChunksInParallel.zig from .input_loader = .js to .input_loader = .file. This matches the disk path and correctly conveys that the .jsm sidecar is an opaque binary file, not JavaScript.

    Step-by-step proof

    1. Bun.build({ entrypoints: ['./index.ts'], target: 'bun', format: 'esm', bytecode: true }) is called without outdir.
    2. root_path.len == 0, so the in-memory path in generateChunksInParallel.zig is taken.
    3. The module_info_output_file block at line 649 runs: c.options.generate_bytecode_cache and c.options.output_format == .esm are both true (the c.options.compile guard was removed by this PR).
    4. chunk.content.javascript.module_info_bytes is non-null, so OutputFile.init(.{ .input_loader = .js, ... }) is returned.
    5. output_files.insertForSourcemapOrBytecode(module_info_output_file.?) inserts this OutputFile into the results list.
    6. result.outputs exposes it to the caller as a BuildArtifact.
    7. OutputFile.zig:374: artifact.loader = this.input_loader yields artifact.loader === 'js' for a binary .jsm blob.
    8. The --outdir path via writeOutputFilesToDisk.zig sets .input_loader = .file and yields artifact.loader === 'file' for the same file, demonstrating the inconsistency.
  • 🔴 src/bundler/linker_context/generateChunksInParallel.zig:658-665 — When serializeModuleInfo() returns null on OOM (three explicit catch paths in js_printer.zig:6348,6353,6354) or ModuleInfo.create() returns null (catch null in postProcessJSChunk.zig:50), module_info_bytes stays null for that chunk while bytecode generation can still succeed and fill its slot — leaving the module_info supplementary slot that calculateOutputFileListCapacity() pre-reserved unfilled. This causes OutputFileListBuilder.take()'s assertf(total_insertions == output_files.items.len) to panic in debug builds, and silently produces a zero-value OutputFile in the reserved-but-unfilled slot in release builds. Fix: in the in-memory path (generateChunksInParallel.zig lines 658-665), add if (bytecode_output_file == null) break :brk null; at the top of the module_info block, mirroring the guard already present in the disk-write path (writeOutputFilesToDisk.zig); alternatively, null out module_info_bytes whenever serialization fails so the slot reservation and the actual fill stay in sync.

    Extended reasoning...

    What the bug is and how it manifests

    OutputFileListBuilder.calculateOutputFileListCapacity() (OutputFileListBuilder.zig line 94) reserves module_info_count = bytecode_count supplementary slots — one slot per JS-like ESM chunk. This count is computed purely from loader.isJavaScriptLike(), with no knowledge of whether serialization will succeed. The per-chunk serialization loop in generateChunksInParallel.zig (~line 360) runs first, assigning chunk.content.javascript.module_info_bytes = bun.js_printer.serializeModuleInfo(mi). serializeModuleInfo() is defined in js_printer.zig and has three explicit null-return-on-OOM paths: mi.finalize() catch return null (line 6348), deserialized.serialize(...) catch return null (line 6353), and buf.toOwnedSlice(bun.default_allocator) catch null (line 6354). When any of these fires, module_info_bytes remains null for that chunk. A second trigger: ModuleInfo.create(bun.default_allocator, ...) catch null in postProcessJSChunk.zig:50 — if allocation itself fails, chunk.content.javascript.module_info stays null, the cross-chunk fixup loop skips it (orelse continue), and module_info_bytes is never set. In both cases the reserved slot is never filled.

    The specific code path that triggers it

    For the in-memory path (lines 649-665 of generateChunksInParallel.zig): the module_info_output_file block checks only c.options.generate_bytecode_cache and c.options.output_format == .esm and then chunk.content.javascript.module_info_bytes being non-null. If serialization OOM'd, module_info_bytes is null → module_info_output_file = null → no call to insertForSourcemapOrBytecode for that slot. Meanwhile, bytecode_output_file may still succeed (bytecode generation is independent of module_info serialization) and does call insertForSourcemapOrBytecode. The slot accounting: capacity = N chunks + 2N supplementary (N bytecode + N module_info); total insertions = N chunks + N (bytecode only, for the failing chunk). When k chunks fail serialization: total_insertions = N + (2N - k), output_files.items.len = N + 2N = 3N, and the assertf total_insertions == output_files.items.len fires: 3N-k ≠ 3N.

    Why existing code doesn't prevent it

    The disk-write path in writeOutputFilesToDisk.zig has an explicit guard as the very first statement of the module_info block: if (bytecode_output_file == null) break :brk null; with a comment explaining the rationale (keep slot counts consistent, avoid the take() assertion). The in-memory path at lines 649-665 of generateChunksInParallel.zig has no equivalent guard. This asymmetry means the same logical problem is fixed in one path but not the other. The OOM-graceful degradation pattern (catch null) is documented in codebase learnings as intentional for module_info operations — but its interaction with the slot-reservation accounting was not accounted for when the PR lifted the c.options.compile guard.

    What the impact would be

    In debug/assert builds: the bun.assertf in OutputFileListBuilder.take() (line 62) panics with "total_insertions (N) != output_files.items.len (M)". In release builds: take() strips the assert and sets items.len = total_insertions, so the list is shorter than allocated; in practice the sequential-counter logic means already-written entries remain correct — but a zero-value OutputFile occupies a reserved slot in the output list, and the chunk's module_info_index field may be unset or point at the wrong index. The trigger requires genuine OOM, which is rare in normal operation but expected in constrained CI environments or very large build graphs.

    How to fix it

    Two equivalent approaches: (1) In the in-memory path, add if (bytecode_output_file == null) break :brk null; as the first statement of the module_info_output_file block, mirroring the disk path. (2) After serialization fails (module_info_bytes stays null), also null out any pre-existing bytecode result for that chunk so both slots remain unfilled and the counts stay consistent.

    Step-by-step proof

    1. Build has N=1 JS-like ESM chunk under --bytecode --format=esm. calculateOutputFileListCapacity reserves 1 chunk + 2 supplementary = 3 total output file slots.
    2. Cross-chunk fixup loop runs: ModuleInfo.create() succeeds, but buf.toOwnedSlice() hits OOM → serializeModuleInfo() returns null → module_info_bytes = null.
    3. Per-chunk loop: bytecode block calls jsc.CachedBytecode.generate() — succeeds → bytecode_output_file non-null → insertForSourcemapOrBytecode(bytecode_output_file) called → total_insertions = 2 (1 chunk + 1 bytecode).
    4. module_info block: module_info_bytes == null → module_info_output_file = null → no insertForSourcemapOrBytecode call → total_insertions stays at 2.
    5. output_files.insertForChunk(chunk_output_file) called → total_insertions = 2 (already counted above) — wait, recount: after the chunk file itself is inserted: total_insertions = 1 (bytecode) + 1 (chunk main file) = 2.
    6. take() checks: total_insertions (2) == output_files.items.len (3) → assertf fires in debug builds.

Comment thread src/bundler/linker_context/writeOutputFilesToDisk.zig Outdated
Comment thread src/bundler/linker_context/generateCompileResultForJSChunk.zig Outdated
Comment thread src/bundler/linker_context/generateChunksInParallel.zig Outdated
Comment thread test/regression/issue/29286.test.ts Outdated
Comment thread src/bundler/linker_context/writeOutputFilesToDisk.zig Outdated
Comment thread src/bundler/linker_context/generateChunksInParallel.zig Outdated
Comment thread src/jsc/ModuleLoader.zig Outdated
Comment thread src/bundler/linker_context/writeOutputFilesToDisk.zig Outdated
Comment thread src/bundler/linker_context/postProcessJSChunk.zig Outdated
@robobun
robobun force-pushed the farm/0830ed35/esm-bytecode-sidecar branch from 4ad62eb to 5103f97 Compare May 5, 2026 01:08
Comment thread test/regression/issue/29286.test.ts Outdated
@robobun
robobun force-pushed the farm/0830ed35/esm-bytecode-sidecar branch from b048a7a to 8d51419 Compare May 5, 2026 03:48
Comment thread src/runtime/api/JSBundler.zig Outdated
Comment thread src/bundler/transpiler.zig Outdated
@robobun

robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator Author

(robobun) Pausing this PR for maintainer triage. Tests pass on Linux/macOS locally and in CI; windows-2019-x64-test-bun consistently fails in a way I can't diagnose from a Linux dev container without BuildKite CLI/auth access to the test output. The substantive fix (ESM bytecode .jsc+.jsm sidecars, loader wiring, leak-free ownership of .jsm bytes at disk-write and runtime-load time) should be sound for the reporter's macOS platform — and the Windows failure may be pre-existing (only the non-baseline x64 test lane flags, while the baseline and aarch64-build lanes pass). Happy to pick back up with specifics if someone can attach the Windows test log.

@robobun
robobun force-pushed the farm/0830ed35/esm-bytecode-sidecar branch from 5a0473d to 85e8723 Compare May 14, 2026 19:32
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

CI checkpoint for maintainers: on 85e8723, both windows-2019-x64-test-bun and windows-2019-x64-baseline-test-bun pass, debian-13-x64-asan-test-bun passes, all build lanes pass. buildkite/bun aggregate shows failing while lanes are still in flight (windows-11-aarch64-test-bun, debian-13-aarch64-test-bun, and a handful of x64 test lanes are pending). Not pushing ci: retrigger — per project guidance I would only reroll once, and the individual-lane data so far is green. Will pick back up if a specific lane fails on diff-touched code.

Comment thread src/runtime/api/JSBundler.rs
@robobun

robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI flake note: the debian-13-* and ubuntu-25.04-* test-bun lanes currently fail on most PRs in the queue (see #30735, #30741, #30743 — all recently merged despite the same lane failures). Windows, macOS, Alpine, and debian-13-x64-asan lanes pass on this PR. Not pushing ci: retrigger per project guidance — the diff-green evidence is unambiguous:

  • windows-2019-x64-test-bun — pass (was previously failing before the NUL-fix)
  • windows-2019-x64-baseline-test-bun — pass
  • windows-11-aarch64-test-bun — pass
  • darwin-14-x64-test-bun — pass
  • darwin-26-aarch64-test-bun — pass
  • alpine-3.23 (aarch64 + x64 + baseline) — pass
  • debian-13-x64-asan-test-bun — pass
  • all build lanes — pass

Leaving for a maintainer to merge alongside the other PRs hitting the same debian/ubuntu flake.

@robobun
robobun force-pushed the farm/0830ed35/esm-bytecode-sidecar branch from 85e8723 to 204464b Compare June 21, 2026 00:06
@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto latest main (204464b). One conflict in src/bundler/transpiler.rs: main independently landed the .jsc-sidecar NUL-terminator fix (with an added total >= path_buf2.len() bounds check), so my version of that change is now redundant. Resolved by taking main's version wholesale.

The PR is now a clean 3-file diff:

  • src/runtime/api/JSBundler.rs — remove the ESM bytecode requires compile guard (JS API path)
  • src/runtime/cli/Arguments.rs — remove the same guard (CLI path)
  • test/regression/issue/29286.test.ts — regression coverage

Build + both regression tests pass locally; gate confirmed (tests fail on released bun, pass with the guard removed).

@robobun
robobun force-pushed the farm/0830ed35/esm-bytecode-sidecar branch from 204464b to e8fece6 Compare June 21, 2026 00:21
Comment thread test/regression/issue/29286.test.ts Outdated
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/cli/Arguments.rs
@robobun
robobun requested a review from alii as a code owner June 21, 2026 01:11

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

Re-reviewed after f2e57e0 — no bugs found. That commit implements the structural completion I suggested on the earlier revision (ResolvedSourceCodeHolder now frees an owned bytecode_cache when no consumer adopts it, and SourceProvider::create clears bytecode_cache_is_owned after adopting), so the class of edge-case leaks noted in that thread is closed. Given the scope — a new field in the #[repr(C)] ResolvedSource mirror, ownership/free semantics across the Rust↔C++ boundary, an added LSAN suppression, and removal of a user-facing bytecode gate — a maintainer sign-off is still worthwhile.

What was reviewed

  • ResolvedSource field ordering matches on both sides of the FFI mirror; Default initializes the new flag to false.
  • createCommonJSModule / evaluate / evaluateWithPotentiallyOverriddenCompile all take ResolvedSource&, so SourceProvider::create's flag-clear reaches the same struct the holder wraps — no double-free on the adopted path; nested holders in handleVirtualModuleResult are safe because the free path also nulls the pointer.
  • The two owned-producer sites (jsc_hooks.rs, RuntimeTranspilerStore.rs) set bytecode_cache_is_owned = !ptr.is_null(); borrowed producers (--compile embedded section, node compile cache) inherit false from Default.
  • Tests exercise CLI, JS API, and --splitting chunk sidecars end-to-end under ASAN.
Extended reasoning...

Overview

The PR removes the "ESM bytecode requires --compile" guard from both the CLI (Arguments.rs) and JS API (JSBundler.rs), updates docs/bun.d.ts, and — following an LSAN report on the first ASAN CI run of the new tests — fixes a pre-existing .jsc sidecar bytecode buffer leak by adding ResolvedSource.bytecode_cache_is_owned (Rust + C++ mirror), keying the CachedBytecode destructor on it, freeing owned-but-unadopted buffers in ~ResolvedSourceCodeHolder (new in f2e57e0), freeing owned buffers in OwnedResolvedSource::drop, and adding a built-in LSAN suppression for the async transpiler worker. 13 files changed including a 172-line regression test.

Security risks

None identified. No new untrusted-input parsing, auth, crypto, or path handling. The change loosens a build-time validation gate but does not touch runtime security-relevant code paths; the sidecar loader itself is pre-existing.

Level of scrutiny

High. This is not a mechanical change: it inserts a field mid-struct in a #[repr(C)] layout mirrored across Rust and C++ (a mismatch would silently corrupt every ResolvedSource crossing the boundary), changes who frees what and when across FFI (Box::into_raw on the Rust side, defaultAllocatorFree on the C++ side, with an adopt-then-clear-flag protocol), adds an LSAN suppression that broadens what the ASAN lane will silently accept from TranspilerJob::run, and removes a user-facing gate that changes bun build/Bun.build behavior. REVIEW.md's memory-safety and API-design sections both point at maintainer review for exactly this shape.

Other factors

  • The most recent commit (f2e57e0) directly implements the suggestion from my prior 🟣 inline note, closing the remaining CJS edge-case leak paths. I verified all C++ consumers take ResolvedSource& by reference (JSCommonJSModule.h:86-89, 162-179), so the adopt-then-clear protocol is sound and nested ResolvedSourceCodeHolders don't double-free.
  • The PR has been through 21 iterations with extensive back-and-forth; all comment-cop threads and prior inline findings are resolved. A community user (shreeve) validated the build against a production-shaped worker-pool workload with timing and memory measurements.
  • The __lsan_default_suppressions entry now cites CI build #97165 as the concrete report and matches the breadth of the existing sync-path twin, per the author's justification; that's a judgment call a maintainer should ratify.
  • The bug-hunting system found no issues on this revision.

Comment thread src/runtime/bin_entry/mod.rs Outdated
Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
Comment thread src/jsc/bindings/ModuleLoader.h Outdated
Comment thread src/jsc/bindings/ModuleLoader.h Outdated

@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. Because it changes memory ownership across the Rust/C++ FFI boundary (new bytecode_cache_is_owned on the #[repr(C)] ResolvedSource mirror, ResolvedSourceCodeHolder destructor now freeing bytecode, CachedBytecode destructor selection) and adds an LSAN suppression, a human look is still warranted.

Checked: struct layout stays in lock-step between ResolvedSource.rs and headers-handwritten.h (field inserted at same position, before module_info); all four bytecode_cache producers set the flag consistently (two sidecar producers → owned, --compile/node-compile-cache → borrowed via Default); SourceProvider::create clears the flag after adopting so ~ResolvedSourceCodeHolder won't double-free; builtinLoader now guarded by a holder so the overridden-_compile path frees its buffer.
Ruled out: double-free when res->success == false (the destructor's free is gated on success, and the memset zero-init keeps the flag false on the error union path).

Extended reasoning...

Overview

This PR removes two validation guards (CLI in Arguments.rs, JS API in JSBundler.rs) that blocked --bytecode --format=esm without --compile, updates three doc/type files, and adds a regression test. That much is straightforward.

The bulk of the diff, though, is a memory-safety fix that grew out of an LSAN report on this branch: a new bytecode_cache_is_owned: bool field on the FFI-shared ResolvedSource struct (mirrored in headers-handwritten.h), wired through both sidecar producers (jsc_hooks.rs, RuntimeTranspilerStore.rs), consumed by ZigSourceProvider.cpp to select the CachedBytecode destructor, and freed by ~ResolvedSourceCodeHolder (now moved to ModuleLoader.h so builtinLoader in JSCommonJSExtensions.cpp can use it) and by Rust's OwnedResolvedSource::drop. Plus a new built-in LSAN suppression for the async transpiler thread.

Security risks

None identified. No user-controlled input reaches new parsing/allocation paths; the change gates on an ownership flag set by trusted producers. The LSAN suppression widens sanitizer blind spots for TranspilerJob::run but is documented, cites CI build #97165, and mirrors the existing Bun__transpileFile suppression for the sync twin.

Level of scrutiny

High. REVIEW.md calls native memory safety "the most-blocked category". This PR adds a field to a #[repr(C)] struct that must stay byte-for-byte identical across Rust and C++, changes RAII destructor semantics for a holder used at every module-load site, and introduces an adopt-then-clear-flag protocol between SourceProvider::create and ResolvedSourceCodeHolder. A layout mismatch or a missed clear would be UAF or leak. That's exactly the class of change a maintainer should sign off on, even though the bug-hunting system found nothing and every one of my prior 8+ inline threads over 25 iterations has been addressed and resolved.

Other factors

The PR has strong test coverage (three concurrent tests: CLI, JS API, multi-entry splitting) with the fails-on-main / passes-on-PR evidence in the description. All comment-cop and prior review threads are resolved. The remaining reason to defer is purely the nature of the code touched, not any outstanding concern.

robobun and others added 8 commits August 24, 2026 04:31
Two independent changes, both needed to make the feature actually work
end-to-end:

1. Remove the `ESM bytecode requires compile` guards in
   `src/runtime/api/JSBundler.rs` and `src/runtime/cli/Arguments.rs`.
   The guards rejected `--bytecode --format=esm` (and the JS-API
   equivalent) unless `--compile` was also set, which forced users
   with top-level `await` to either embed the full Bun runtime per
   binary or refactor every TLA into async IIFEs. The underlying
   bundler already knows how to emit the .jsc sidecar in this mode; it
   was just locked behind the guard.

2. Fix a latent ZStr NUL-terminator bug in
   `src/bundler/transpiler.rs` that panicked on any non-trivial
   bundle path. The `.jsc` sidecar loader uses
   `bun_paths::PathBuffer::uninit()` and then takes a `ZStr` from
   the buffer — `ZStr::from_buf` asserts `buf[total] == 0`, but
   the comment claiming the buffer was zero-initialized was wrong.
   Short paths (like those in a single-directory test) happened to
   have 0 at the check offset by chance; longer paths (absolute
   tempdirs, Windows paths) hit garbage bytes and panicked with
   "missing NUL at buf[len]". Write the terminator explicitly.

Closes #29286
RuntimeTranspilerStore's TranspilerJob::run() stored a borrowed pointer
(bytecode_slice().as_ptr()) from parse_result.already_bundled's Box<[u8]>
into resolved_source.bytecode_cache, then returned. parse_result drops on
return — freeing the box — before the dispatch_to_main_thread scopeguard
hands the job (and the now-dangling pointer) to the JS thread, which
passes it to C++ CachedBytecode::create: a use-after-free.

Mirror the synchronous jsc_hooks.rs path: mem::take the AlreadyBundled
variant out and heap::into_raw the Box so C++ becomes the owner. This
path was already reachable for CJS .jsc sidecars; enabling ESM bytecode
without --compile routes AlreadyBundled::Bytecode through it too.

Also trim the regression test's header/inline comments to drop stale
references to the NUL-terminator fix that landed on main separately.
Removing the guards left three references that now contradict behavior:
- packages/bun-types/bun.d.ts: BuildConfig.bytecode JSDoc (IDE tooltip)
- docs/bundler/index.mdx: bundler reference page (JS-API + CLI tabs) and
  the inlined JSDoc copy
- src/runtime/api/JSBundler.rs: "since esm doesn't really work yet" on the
  format-defaulting branch (the CJS default stays for back-compat; the
  rationale was false)

All describe the bytecode .jsc sidecar working for ESM without --compile.
Two entrypoints sharing an import produce a separate non-entry chunk with
its own .jsc sidecar; the test builds with --splitting and runs an entry,
covering chunk-sidecar loading that the single-file cases don't reach.

Note: this does not specifically exercise the async RuntimeTranspilerStore
path the earlier UAF fix guards. Verified with the store's debug scope and a
fix-reverted ASAN run that bundled `.jsc` chunks load through the synchronous
fetch path (which already transfers ownership correctly), not TranspilerJob::
run, for both static and dynamic imports from `bun <out>.js`. The ownership
fix stands as a defensive mirror of the sync path.
ESM bytecode no longer requires --compile. Without --compile it emits a
.jsc sidecar next to each .js chunk that Bun loads automatically, and
top-level await works because the output is ESM. Also corrects the
sidecar filename shown in the CommonJS and debugging examples: the
extension is appended to the full chunk name, so index.js produces
index.js.jsc, not index.jsc.
The three regression tests each spawn isolated Bun subprocesses in their
own tempDir with no shared state, so run them with test.concurrent per
test/CLAUDE.md. Also correct the inline comment on the shared-chunk test:
bundled .jsc chunks load through the synchronous fetch path, not the
async transpiler store.
LeakSanitizer flagged the sidecar bytecode buffer in CI: the destructor
passed to JSC::CachedBytecode was selected from resolvedSource.needsDeref,
but that flag tracks source_code ownership and is always cleared before
the selection (in SourceProvider::create's deref block for the sync path,
and in AsyncModule::fulfill for the async store path), so the no-op
destructor meant for --compile's embedded bytecode was chosen for every
owned sidecar buffer and the Box<[u8]> was never freed.

Add a dedicated ResolvedSource.bytecode_cache_is_owned flag: true for the
two .jsc sidecar producers (sync fetch and the async transpiler store),
false for borrowed memory (--compile embedded section, node compile
cache, both unchanged). The C++ destructor selection now uses it, and
OwnedResolvedSource::drop frees an owned buffer that was never handed to
C++.

Also add the async transpiler store to the built-in LSAN suppressions:
buffers it allocates become owned by CachedBytecode inside a
SourceProvider, which lives in the libpas heap LSAN cannot scan, so an
owner still alive at exit reports its (correctly owned) buffer. The
sync-path twin suppression is leak:Bun__transpileFile in
test/leaksan.supp.

Collapse the bytecode format-default comment in JSBundler.rs to one line.
@robobun
robobun force-pushed the farm/0830ed35/esm-bytecode-sidecar branch from d791792 to b5fe6cb Compare August 24, 2026 04:36

@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 — after the rebase this is down to two guard deletions plus docs/types/tests; the memory-safety work from earlier revisions has landed on main and is no longer in the diff. The two inline notes are non-blocking nits (docs completeness, test strengthening).

Extended reasoning...

Overview

The PR now touches 6 files: two source files where a validation guard is deleted (src/runtime/api/JSBundler.rs removes the 7-line ESM bytecode requires compile: true throw and rewords one comment; src/runtime/cli/Arguments.rs removes the 10-line CLI equivalent), two docs pages (bytecode.mdx, index.mdx), the bun.d.ts JSDoc, and a new regression test. All of the earlier-reviewed C++/Rust memory-safety changes (ResolvedSource.rs, ModuleLoader.{cpp,h}, ZigSourceProvider.cpp, headers-handwritten.h, bin_entry/mod.rs LSAN suppression, RuntimeTranspilerStore.rs, JSCommonJSExtensions.cpp) landed independently on main and dropped out of this diff during rebase, per the PR description and the <changed-files count="6"> metadata.

Security risks

None. This removes an error-out guard on a build-time option combination; no untrusted-input parsing, no auth/crypto/permission surface. The guard was purely a UX gate on a code path the bundler and runtime loader already implement.

Level of scrutiny

Low-to-medium. The remaining source change is a pure deletion of two validation branches — no new logic, no new allocation, no new FFI surface. The bundler already emits the .jsc sidecar for ESM chunks and the runtime loader already reads it via the // @bun @bytecode pragma; the guards were the only thing blocking users. The evidence block shows the tests fail on unfixed bun (ESM bytecode requires in stderr) and pass on both debug-ASAN and release with the fix.

Other factors

Every prior review thread on this PR is resolved. My last review (2026-08-22) was on the 15-file revision; the substantive change since then is the rebase that shrank the diff, so nothing new to re-verify beyond confirming the memory-safety hunks are gone. The two findings this run are both explicitly nits: (1) bytecode.mdx drops the analyze-phase-reparse caveat when comparing sidecar vs --compile — an editorial completeness note, nothing stated is false; (2) the run steps in the regression test don't set BUN_JSC_verboseDiskCache=1 to assert cache-hit, so a future ESM-sidecar-loader regression would fail open — but the build-side assertions (no error, .js.jsc exists and non-empty, @bytecode header present, not @bun-cjs) already lock in the guard-removal fix this PR actually makes. Neither blocks merge.

Comment thread docs/bundler/bytecode.mdx
Comment thread test/regression/issue/29286.test.ts Outdated
…ifference

Bytecode loading fails open, so the run steps now set
BUN_JSC_verboseDiskCache=1 and assert the Disk Cache hit, matching the
sibling bytecode tests. The docs note that --compile also embeds module
metadata to skip parsing entirely, while the sidecar accelerates only
the evaluate phase.
Comment thread test/regression/issue/29286.test.ts Outdated
Comment thread test/regression/issue/29286.test.ts Outdated
robobun and others added 2 commits August 24, 2026 05:38
test/regression/issue is for true regressions; this guard never allowed
ESM bytecode without --compile, so the tests move next to the existing
target:bun bytecode coverage. The splitting test now requires two cache
hits (entry plus shared chunk) so a regression in the hashed-chunk
sidecar lookup cannot hide behind the entry's own hit.

@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 — after the rebases this is now just the two guard deletions plus docs/types/tests, and every prior review thread is resolved.

What was reviewed:

  • Guard removal in JSBundler.rs and Arguments.rs — the sibling format must be 'cjs' or 'esm' and target must be 'bun' checks are preserved; only the --compile-required gate is dropped.
  • Tests now live in bundler_bun.test.ts, assert [Disk Cache] Cache hit (≥2 for the splitting case) so a fail-open loader regression can't pass silently, and cover CLI + Bun.build + --splitting.
  • Docs restore the analyze-phase caveat (sidecar accelerates evaluate; module graph still parsed from source) and bun.d.ts matches.
Extended reasoning...

Overview

The PR now touches six files: two source files where a validation guard is deleted (src/runtime/api/JSBundler.rs, src/runtime/cli/Arguments.rs), two docs pages (docs/bundler/bytecode.mdx, docs/bundler/index.mdx), the bun.d.ts type declarations, and test/bundler/bundler_bun.test.ts. Earlier revisions carried a UAF fix in the async transpiler path, a ResolvedSourceCodeHolder header move, an LSAN suppression, and a sidecar bytecode-buffer leak fix; per the PR description and the diff, all of those landed on main independently and dropped out during rebases. What remains is a pure guard removal (7+10 lines deleted, one comment reworded) enabling a code path that the bundler and runtime already support.

Security risks

None. The change removes an over-restrictive input-validation gate on bun build options; it does not touch auth, crypto, permissions, network, or untrusted-input parsing. The remaining bytecode validations (target: 'bun' required; format must be cjs or esm) are preserved.

Level of scrutiny

Low-to-medium. The source change is deletion-only; the enabled path (ESM .jsc sidecar emission + runtime sidecar loading) already exists on main and is exercised end-to-end by the new tests. The docs and .d.ts changes are prose updates matching the new behavior. This PR has been through ~26 iterations and eight review passes; every thread is resolved.

Other factors

  • All prior feedback from this reviewer has been actioned in the current diff: the analyze-phase caveat is back in bytecode.mdx; both run steps set BUN_JSC_verboseDiskCache=1 and assert cache hits; the splitting test asserts ≥2 hits so the shared-chunk lookup can't hide behind the entry's hit; tests were moved out of test/regression/issue/ into test/bundler/bundler_bun.test.ts.
  • Tests follow harness conventions: tempDir, bunEnv/bunExe, await using on spawns, concurrent pipe draining via Promise.all, test.concurrent, Windows path-separator normalization in the Bun.build case.
  • The evidence block in the PR description shows the tests fail on released bun (guard present) and pass under both debug+ASAN and release with the fix.
  • No CODEOWNERS-sensitive paths; no outstanding human-reviewer comments; comment-cop threads all resolved (no multi-line comments remain in src/ in this diff).

@alii

alii commented Aug 24, 2026

Copy link
Copy Markdown
Member

I think this is waiting for enabling module_info in the runtime transpiler cache first

@alii
alii marked this pull request as draft August 24, 2026 07:36
@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Understood, and that matches the scope note in the description: this PR enables evaluate-phase bytecode only. The analyze phase still re-parses the source, because nothing supplies module_info without --compile. The docs in this PR state that limit explicitly.

One data point for the decision, from the validation earlier in this thread: the evaluate-only win measured about 1.44x faster median worker boot on a ~1.1 MB artifact, at about +5 MB phys_footprint per process.

I can hold this PR rebased until module_info lands in the runtime transpiler cache. I can also work on that piece first, either as a precursor PR or folded into this one. Tell me which you prefer.

@shreeve

shreeve commented Aug 25, 2026

Copy link
Copy Markdown

The Bun 1.4 release sort of implies this is included, but the status is still "draft". Was it included or not?

@robobun

robobun commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Not included. Verified against 1.4.1:

$ bun --version
1.4.1
$ bun build ./i.ts --bytecode --format=esm --target=bun --outdir=dist
error: ESM bytecode requires --compile. Use --format=cjs for bytecode without --compile.

The validation guards this PR removes are still present on current main (src/runtime/api/JSBundler.rs and src/runtime/cli/Arguments.rs). What 1.4 did pick up from the work around this PR is internal: the bytecode buffer ownership redesign and related leak fixes landed on main independently. The user-facing gate is unchanged.

The PR was moved to draft after the comment above: it waits on module_info support in the runtime transpiler cache, so that sidecar ESM bytecode skips the analyze-phase reparse as well before the gate is lifted. Until then the released behavior stands: CommonJS bytecode works without --compile, ESM bytecode requires it.

This branch was successfully deployed

1 active (outdated) deployment
staging - docs — b305a678 Deployed Aug 15, 2026 by mintlify[bot]
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.

Support --bytecode with --format=esm without --compile (generate .jsc sidecar files for ESM bundles)

3 participants