Skip to content

fix(compile): avoid heap-copying $bunfs module sources - #29319

Closed
sosukesuzuki wants to merge 8 commits into
mainfrom
claude/standalone-graph-avoid-source-clone
Closed

sosukesuzuki wants to merge 8 commits into
mainfrom
claude/standalone-graph-avoid-source-clone

Conversation

@sosukesuzuki

Copy link
Copy Markdown
Member

Summary

$bunfs module sources embedded in a bun build --compile binary live in a mmap'd __BUN section, but on module load every chunk was being fully copied into WebKit Malloc. This PR avoids that copy and saves ~17 MB of heap in the Claude Code CLI binary (294 embedded modules).

Root cause

StandaloneModuleGraph.fromBytes() was not copying the encoding field when deserializing File entries. The default for File.encoding is .binary, which puts File.toWTFString() into the cloneUTF8 branch:

// StandaloneModuleGraph.zig:206-215
pub fn toWTFString(this: *File) bun.String {
    if (this.wtf_string.isEmpty()) {
        switch (this.encoding) {
            .binary, .utf8 => {
                this.wtf_string = bun.String.cloneUTF8(this.contents);  // heap copy
            },
            .latin1 => {
                this.wtf_string = bun.String.createStaticExternal(this.contents, true);  // zero copy
            },
        }
    }
    ...
}

toBytes() writes .latin1 for .js/.jsx/.ts/.tsx, but the field was silently dropped on read, so every module was treated as .binary at runtime and always went through cloneUTF8. As a result, every embedded JS chunk existed in memory twice: once in the __BUN section (mmap, clean) and once in WebKit Malloc (libpas, dirty).

Measurement

Target: Claude Code CLI (bun build --compile output, 294 embedded modules).

heapsnapshot analysis

A /heapdump snapshot shows ModuleRecord with a total self_size of 16.78 MB — the largest type on the JSC heap. self_size here reflects Bun's fork of JSModuleRecord::estimatedSize (vendor/WebKit/Source/JavaScriptCore/runtime/JSModuleRecord.cpp, under USE(BUN_JSC_ADDITIONS)), which folds in sourceCode().provider()->source().length() — i.e. the JS source bytes held on the heap.

Top modules:

self_size module
8,684,337 B /$bunfs/root/chunk-1cynz86c.js
1,447,271 B /$bunfs/root/chunk-wy676xem.js
846,650 B /$bunfs/root/chunk-q1jpqy33.js
664,206 B /$bunfs/root/chunk-dxd7x54w.js
552,850 B /$bunfs/root/chunk-y2z8n5hx.js

lldb confirmation

On a running Claude Code CLI process, memory find -s "createSignal" across the relevant regions:

region address range hits
__BUN (mmap, dirty=0) 0x1051e0000 – 0x108a98000 10+
WebKit Malloc (libpas) 0x116b00000 – 0x122b00000 10+
mimalloc (tag 100) 0x44142000000 – 0x44152000000 0

The same byte sequence (createSignal,\n isEnvTruthy\n} fr) appears at 0x1054e4a6e in __BUN and at 0x118861775 in WebKit Malloc. mimalloc has no hit, so this isn't a double copy — it's a one-way mmap → heap copy that this PR eliminates.

Fix

1. Propagate encoding in fromBytes()

 File{
     .name = sliceToZ(raw_bytes, module.name),
     .loader = module.loader,
+    .encoding = module.encoding,
     .contents = sliceToZ(raw_bytes, module.contents),

With this single line, server-side JS chunks (emitted by the bundler with target=.bun, which sets ascii_only=true, so the bytes are Latin-1 safe) flow through the existing createStaticExternal branch, using the mmap'd __BUN section directly as the WTF string's backing store. The __BUN mapping lives for the lifetime of the process, so a static external pointer into it is safe.

2. Correct the encoding for client-side chunks

toBytes() previously marked every JS loader as .latin1 unconditionally. That's unsafe for client chunks:

 .encoding = switch (output_file.loader) {
-    .js, .jsx, .ts, .tsx => .latin1,
+    .js, .jsx, .ts, .tsx => if ((output_file.side orelse .server) == .server) .latin1 else .binary,
     else => .binary,
 },

Why: Bun.build({ compile: { outputs } }) allows client chunks (target=.browser) to be embedded alongside server chunks. The browser target uses ascii_only=false, so the printer may emit raw UTF-8 bytes. Treating those as Latin-1 via a static external would corrupt any non-ASCII content. Only server chunks are marked .latin1; client chunks stay .binary (i.e. the cloneUTF8 path), which is what server code sees when it does import('$bunfs/...') for a client chunk.

Test

Adds test/bundler/compile-client-chunk-encoding.test.ts, which builds a --compile binary containing a client-side chunk with non-ASCII content and verifies that importing it from the server at runtime returns the correct UTF-8 bytes (no Latin-1 misinterpretation).

Safety notes

  • ZigSourceProvider::create() uses resolvedSource.needsDeref to decide whether to call deref() on the source code. createStaticExternal returns a static String, so deref is a no-op — this path is already exercised today for non-$bunfs modules.
  • JSModuleRecord::visitChildrenImpl calls reportExtraMemoryVisited via sourceCode().memoryCost(). Using an ExternalStringImpl as the backing store doesn't change memoryCost(), so GC accounting is unaffected — the bytes simply no longer have a duplicate heap allocation behind them.
  • File.toWTFString() itself is unchanged. This PR is a bug fix that lets an existing fast path fire correctly; it does not introduce a new code path.

@robobun

robobun commented Apr 15, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 9:51 PM PT - Apr 22nd, 2026

❌ @sosukesuzuki, your commit 99d24d3 has 4 failures in Build #47399 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29319

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

bun-29319 --bun

@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Adds encoding support to module graph serialization/deserialization in the standalone module graph. JavaScript-like outputs now mark server-side chunks as Latin-1 and client-side chunks as binary. Includes a test validating encoding behavior with non-ASCII text in compiled chunks.

Changes

Cohort / File(s) Summary
Module Graph Encoding
src/StandaloneModuleGraph.zig
Adds encoding field to File reconstruction during module graph deserialization. Updates serialization logic to mark JavaScript-like output chunks with encoding metadata: server-side chunks as .latin1, client-side chunks as .binary, and non-JavaScript loaders as .binary.
Encoding Validation Test
test/bundler/compile-client-chunk-encoding.test.ts
Introduces new test that validates encoding behavior with non-ASCII characters. Creates a standalone executable from a project with a TypeScript client logging non-ASCII text and verifies the compiled binary executes correctly with proper output.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: avoiding heap-copying of $bunfs module sources by restoring the encoding field during deserialization.
Description check ✅ Passed The description comprehensively covers both required sections: a detailed 'Summary' explaining root cause, measurement, fix, test, and safety notes; plus explicit 'How did you verify' via the test additions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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: 1

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

Inline comments:
In `@test/bundler/compile-client-chunk-encoding.test.ts`:
- Around line 62-65: Remove the unconditional stderr check and instead only
inspect or assert stderr when the subprocess failed: keep the Promise.all call
that sets stdout, stderr, and exitCode and keep the
expect(stdout).toContain("CLIENT_OUTPUT: こんにちは"); then, right before asserting
exitCode, if (exitCode !== 0) include a check or a test failure that
logs/asserts stderr (e.g., fail with stderr content or expect(stderr).toBe("")
inside that conditional) and finally expect(exitCode).toBe(0); refer to the
variables proc, stdout, stderr, and exitCode from the snippet when making this
change.
🪄 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: c72fd49d-f2ef-44ae-8836-90387819a798

📥 Commits

Reviewing files that changed from the base of the PR and between e194566 and 99d24d3.

📒 Files selected for processing (2)
  • src/StandaloneModuleGraph.zig
  • test/bundler/compile-client-chunk-encoding.test.ts

Comment on lines +62 to +65
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toContain("CLIENT_OUTPUT: こんにちは");
expect(exitCode).toBe(0);

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.

⚠️ Potential issue | 🟠 Major

Avoid unconditional empty-stderr assertion for runtime subprocesses.

Line 63 can fail in debug ASAN runs due known warning noise on JS-executing subprocesses. Keep stdout as the primary signal, and only dump/assert stderr when exitCode !== 0 right before the exit-code assertion.

Suggested adjustment
-    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
-    expect(stderr).toBe("");
-    expect(stdout).toContain("CLIENT_OUTPUT: こんにちは");
-    expect(exitCode).toBe(0);
+    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+    expect(stdout).toContain("CLIENT_OUTPUT: こんにちは");
+    if (exitCode !== 0) {
+      expect(stderr).toBe("");
+    }
+    expect(exitCode).toBe(0);

Based on learnings, runtime subprocess tests that execute JS may emit ASAN warning lines on stderr in debug builds, and this repo pattern avoids unconditional stderr === "" in that path.

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

In `@test/bundler/compile-client-chunk-encoding.test.ts` around lines 62 - 65,
Remove the unconditional stderr check and instead only inspect or assert stderr
when the subprocess failed: keep the Promise.all call that sets stdout, stderr,
and exitCode and keep the expect(stdout).toContain("CLIENT_OUTPUT: こんにちは");
then, right before asserting exitCode, if (exitCode !== 0) include a check or a
test failure that logs/asserts stderr (e.g., fail with stderr content or
expect(stderr).toBe("") inside that conditional) and finally
expect(exitCode).toBe(0); refer to the variables proc, stdout, stderr, and
exitCode from the snippet when making this change.

Comment on lines 557 to +562
.encoding = switch (output_file.loader) {
.js, .jsx, .ts, .tsx => .latin1,
// Server-side chunks are printed with target=.bun → ascii_only=true,
// so their bytes are guaranteed Latin-1 safe and can back a static
// ExternalStringImpl without copying. Client-side chunks use
// target=.browser and may contain raw UTF-8.
.js, .jsx, .ts, .tsx => if ((output_file.side orelse .server) == .server) .latin1 else .binary,

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.

🔴 The "guaranteed Latin-1 safe" assumption doesn't hold for --banner/--footer (and source hashbangs / // <path> comments): those are appended raw via j.pushStatic() in postProcessJSChunk.zig and never go through the printer's ascii_only escaping. Before this PR the dropped encoding field meant such bytes were decoded via cloneUTF8; now a server chunk containing e.g. --banner='// © 2024' or --banner='globalThis.msg = "héllo"' will be wrapped as a Latin-1 ExternalStringImpl and JSC will see © / héllo. Consider scanning the final chunk for bytes ≥ 0x80 (e.g. bun.strings.isAllASCII) before choosing .latin1, or routing banner/footer through the same ASCII-escaping path as the printer.

Extended reasoning...

What the bug is

This PR's optimization rests on the invariant stated in the new comment at src/StandaloneModuleGraph.zig:558-561: "Server-side chunks are printed with target=.bun → ascii_only=true, so their bytes are guaranteed Latin-1 safe." That invariant is true for everything emitted by js_printer, but it is not true for the final chunk bytes, because postProcessJSChunk.zig concatenates several raw byte slices around the printer output without escaping:

  • j.pushStatic(hashbang) — line 284 (sourced from the input file's first line)
  • j.pushStatic(banner) — line 314 (sourced verbatim from --banner / Bun.build({ banner }))
  • the // <pretty path> per-file comment when !minify_whitespace — around line 387+ (file paths can contain non-ASCII)
  • j.pushStatic(ctx.c.options.footer) — line 549

StringJoiner.pushStatic just stores the slice; there is no quoteForJSON/ascii_only pass over these bytes. c.options.banner and c.options.footer come straight from args.option("--banner") / args.option("--footer") in src/cli/Arguments.zig:1088 (and from the JS config in bundle_v2.zig) with no transformation.

Why this is a regression introduced by this PR

Prior to this PR, fromBytes() did not copy module.encoding, so every File defaulted to .binary and toWTFString() always took the bun.String.cloneUTF8(this.contents) branch. That branch decodes the bytes as UTF-8, so a banner containing © (bytes c2 a9) or é (bytes c3 a9) round-tripped correctly into the WTF string handed to JSC's parser.

After this PR, server JS chunks are tagged .latin1 and fromBytes() now propagates that tag, so toWTFString() calls bun.String.createStaticExternal(this.contents, true). BunString__createStaticExternal (src/bun.js/bindings/BunString.cpp) reinterpret-casts the buffer as const LChar* and constructs an ExternalStringImpl with is8Bit() == true — i.e. each byte becomes one Latin-1 code unit with no UTF-8 detection. The resulting bun.String is used directly as .source_code in ModuleLoader.zig, so JSC parses the mojibake.

Step-by-step proof

Take bun build --compile --banner='globalThis.msg = "héllo"' entry.ts:

  1. The CLI stores the banner as the raw UTF-8 bytes 68 c3 a9 6c 6c 6f for héllo.
  2. postProcessJSChunk pushes those bytes verbatim at line 314; the rest of the chunk (printer output) is pure ASCII because ascii_only=true.
  3. toBytes() sees loader=.js, side=.server → writes encoding = .latin1.
  4. At runtime, fromBytes() now copies .encoding = .latin1 (line 352 of this PR).
  5. toWTFString() hits the .latin1 arm → createStaticExternal(contents, true) → WTF string whose code units are 0x68, 0xC3, 0xA9, 0x6C, 0x6C, 0x6F.
  6. JSC parses globalThis.msg = "héllo"; at runtime globalThis.msg === "héllo", not "héllo".

For a comment-only banner like // © 2024 Société the corruption is cosmetic (it only affects Function.prototype.toString/stack traces), but any banner/footer that contains a non-ASCII string literal — or a non-ASCII identifier — produces incorrect runtime values or a SyntaxError. The hashbang and per-file path comment cases are rarer but follow the same mechanism (e.g. a source file under a directory named prébuild/).

Why nothing else guards against it

There is no isAllASCII check anywhere between postProcessJSChunk and StandaloneModuleGraph.toBytes, and no CLI validation rejecting non-ASCII --banner/--footer. The only thing that previously made this safe was the very bug this PR fixes (the dropped encoding field forcing cloneUTF8).

Suggested fix

Cheapest correct fix: in toBytes(), gate .latin1 on the actual bytes rather than on side alone, e.g.

.js, .jsx, .ts, .tsx => if ((output_file.side orelse .server) == .server and
    bun.strings.isAllASCII(output_file.value.buffer.bytes)) .latin1 else .binary,

This keeps the zero-copy win for the overwhelmingly common case (no banner, or ASCII banner) and falls back to cloneUTF8 only when the chunk genuinely contains UTF-8. Alternatively, route banner/footer/hashbang through ASCII escaping when ascii_only is set so the invariant actually holds.

sosukesuzuki and others added 8 commits May 4, 2026 10:37
StandaloneModuleGraph.fromBytes() was not copying the serialized
`encoding` field into File, leaving it at the default `.binary`.
This caused File.toWTFString() to always take the cloneUTF8 path,
copying every embedded module source into WebKit Malloc even though
the bytes already live in the mmap-backed __BUN section.

Server-side JS chunks are printed with target=.bun (ascii_only=true),
so their bytes are Latin-1 safe and can back a static ExternalStringImpl
without copying. Client-side chunks (target=.browser) may contain raw
UTF-8, so toBytes() now records them as .binary to keep the cloneUTF8
path when the server imports one.
bun build --compile auto-appends .exe on Windows, so spawning
[outfile] failed with ENOENT on all three Windows CI shards
(2019 x64, 2019 x64-baseline, Windows 11 aarch64).
The test fails deterministically (4 in-runner attempts) on the
Windows 11 ARM64 CI shard. Other --compile-using and ARM64-bun tests
also fail on the same shard (napi.test.ts, complex-workspace.test.ts),
suggesting an issue specific to that runner that is unrelated to the
encoding change. x64 Windows runs (2019 x64 / x64-baseline) pass with
the .exe suffix fix from the previous commit.
Revert the todoIf skip so Windows ARM64 actually runs the test, and
print embeddedFiles, src length, JP-presence, importPath, build/run
stdout/stderr/exitCode so the failure mode is visible in CI logs.
The previous version used path.join(import.meta.dir, js.name). On
Windows the bunfs mount surfaces as B:\\~BUN\\root, so join returned
a backslash-separated absolute path that the module loader could not
resolve back to the embedded chunk (ENOENT). A relative specifier
("./" + js.name) avoids platform-specific path handling entirely and
still exercises the ModuleLoader -> File.toWTFString() path this test
is guarding.

Diagnostic console.logs are removed now that the failure is understood.
CI revealed a separate bug in bun's Windows resolver for compiled
binaries: dynamic import() inside a compiled server resolves "./foo"
relative to the process executable (B/~BUN/root/app.exe) rather than
the importing module, so the embedded sibling chunk is not found.
That is unrelated to the encoding fix in this PR and will be tracked
separately. For now, skip the test on Windows so it guards the encoding
behavior on POSIX, where it actually reaches File.toWTFString().
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/standalone-graph-avoid-source-clone branch from 99d24d3 to cd0cb10 Compare May 4, 2026 10:37
expect(stdout).toContain("CLIENT_OUTPUT: こんにちは");
expect(exitCode).toBe(0);
},
60_000,

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.

🟡 nit: per test/CLAUDE.md § "No timeouts" ("CRITICAL: Do not set a timeout on tests. Bun already has timeouts."), drop the trailing 60_000 argument here. Sibling compile tests like compile-argv.test.ts rely on the harness-level timeout instead of a per-test one.

Extended reasoning...

What this is

test/CLAUDE.md:118-120 documents a repo-wide test convention:

No timeouts

CRITICAL: Do not set a timeout on tests. Bun already has timeouts.

The new test passes 60_000 as the third positional argument to test():

test.skipIf(isWindows)(
  "compiled client-side chunk with non-ASCII source can be imported on the server",
  async () => { ... },
  60_000,
);

That third argument is the per-test timeout in bun:test, so this is exactly what the CLAUDE.md rule prohibits.

Why the rule exists / why nothing else mitigates it

The repo's test runner already applies its own timeout policy at the harness level. Per-test timeouts in this codebase tend to either (a) mask harness-level slow-test detection or (b) drift out of sync with CI's actual budgets. The CLAUDE.md entry is marked CRITICAL specifically so new tests don't reintroduce them. Nothing in the file needs the override — the test does a single bun build --compile plus one subprocess run, which is the same workload as compile-argv.test.ts and compile-process-execargv.test.ts, neither of which set an explicit timeout.

Step-by-step

  1. test/CLAUDE.md:120 says "Do not set a timeout on tests."
  2. test/bundler/compile-client-chunk-encoding.test.ts:67 (added in this PR) passes 60_000 as the timeout argument to test().
  3. → newly-introduced violation of a documented repo convention.

There is one pre-existing violator (compile-sourcemap-internal.test.ts:63 also passes 60_000), but that's pre-existing debt — the CLAUDE.md rule is unambiguous and this PR shouldn't add another instance.

Impact

Convention/style only — there's no functional bug here. The test will run identically with or without the argument in the common case; the only effect is diverging from the repo's stated test-authoring rules.

Fix

Delete the trailing 60_000, on line 67 so the call becomes:

test.skipIf(isWindows)(
  "compiled client-side chunk with non-ASCII source can be imported on the server",
  async () => {
    ...
  },
);

Jarred-Sumner pushed a commit that referenced this pull request May 29, 2026
…n contents are ASCII (#31557)

`from_bytes()` was hardcoding `encoding: Encoding::Binary` for every
embedded file instead of reading the per-module value serialized by
`to_bytes()`, so `File::to_wtf_string()` always took the `clone_utf8`
path — a fresh heap copy of the entire bundled source at module load —
instead of the zero-copy `create_static_external` path that wraps the
kernel-mmapped `.bun` section directly.

Honoring the serialized encoding alone is unsafe: `to_bytes()`
previously tagged JS as `Latin1` purely by loader type, but `--banner` /
`--footer` / hashbang and client-side (`target=browser`) chunks are
concatenated verbatim as UTF-8 by `postProcessJSChunk`, so non-ASCII
bytes there would mojibake under a Latin-1 `ExternalStringImpl` (e.g.
`--footer 'console.log("résumé")'` → `résumé`). This PR gates the
`Latin1` tag in `to_bytes()` on
`strings::first_non_ascii(buf_bytes).is_none()` — a build-time SIMD scan
— so the runtime only takes the zero-copy path when it's actually safe,
and falls back to `clone_utf8` (today's behaviour) otherwise.

The printer escapes non-ASCII for server-side JS by default, so the
common case (no banner/footer, no client chunks imported on the server)
gets the zero-copy path. On a `bun build --compile --bytecode` binary
with a 40 MB bundle, that avoids one 40 MB allocation + walk at startup
and 40 MB of pinned heap that should have stayed evictable file-backed
pages.

Adds `compile/FooterNonAsciiUTF8` and `compile/BannerNonAsciiUTF8` tests
that fail (mojibake) without the ASCII guard.

(Supersedes the `.zig`-only #29319, which targets the no-longer-compiled
reference implementation and gates on `side == .server` rather than
verifying the bytes — that still mojibakes a non-ASCII `--footer` on a
server chunk.)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Closing as stale: this PR predates the Rust rewrite. Every src/ file it modifies has since been removed or relocated on main (Zig sources deleted; src/bun.js/ reorganized into src/jsc/), so it can no longer merge.

If the underlying change is still wanted, it will need to be redone against the current Rust/C++ tree. Apologies for the churn, and thank you for the contribution.

@robobun robobun closed this Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants