Skip to content

Fix use-after-free in Bun.Transpiler async transform error messages#30229

Closed
robobun wants to merge 2 commits into
mainfrom
farm/b9ad935d/fix-transpiler-transform-error-uaf
Closed

Fix use-after-free in Bun.Transpiler async transform error messages#30229
robobun wants to merge 2 commits into
mainfrom
farm/b9ad935d/fix-transpiler-transform-error-uaf

Conversation

@robobun

@robobun robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.Transpiler.prototype.transform() rejects with corrupted error messages (or crashes under ASAN) when parsing fails and multiple transforms run concurrently.

Why

TransformTask.run() creates a per-task MimallocArena and sets it as the transpiler's allocator. When parsing fails, the parser and lexer allocate error message text (and notes) in that arena via allocPrint(p.allocator, ...). The arena is destroyed via defer arena.deinit() when run() returns from the worker thread.

Later, then() runs on the JS thread and calls this.log.toJS(), which clones each message by reading msg.data.text. By then the text points into the destroyed arena, so the read is a use-after-free. In release builds this surfaces as garbage bytes in the rejection message; in ASAN debug builds it aborts with use-after-poison.

How

Before the arena is torn down, deep-clone each log message into bun.default_allocator so the text survives until the promise is rejected. Msg.clone() already recursively copies data.text, location, and notes.

Test

Added test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts which queues 100 concurrent transform() calls with invalid source and asserts every rejection contains the expected parser error text. Before this change the test fails with garbage bytes in the message (and segfaults) on the stock release build, and aborts under ASAN.

When transform() fails with parse errors, the error message text is
allocated in a per-task arena that is destroyed before then() runs on
the JS thread. Reading the log messages in then() accessed freed memory,
producing corrupted error text in release builds and an ASAN
use-after-poison crash in debug builds.

Deep-clone the log messages into the default allocator before the arena
is torn down so the error text survives until the promise is rejected.
@github-actions github-actions Bot added the claude label May 4, 2026
@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:20 PM PT - May 3rd, 2026

@robobun, your commit 0416262 has 1 failures in Build #50885 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30229

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

bun-30229 --bun

@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 didn't find any bugs and the defer ordering looks correct, but this is a memory-lifetime fix in Zig touching arena/allocator interaction, so it's worth a human sanity-check (in particular whether the cloned msg contents are eventually freed, since Log.deinit only does clearAndFree on the list).

Extended reasoning...

Overview

This PR fixes a use-after-free in Bun.Transpiler's async transform() error path. TransformTask.run() parses source using a per-task MimallocArena; log messages produced by the parser hold string slices pointing into that arena. The arena is torn down when run() returns, but then() later reads this.log on the JS thread to build the rejection error, which accessed freed memory. The fix adds a defer that deep-clones every Msg in this.log.msgs into bun.default_allocator before the arena is destroyed. A new regression test exercises 100 concurrent failing transforms and asserts the error text is intact.

Security risks

None. This is an internal memory-ownership correction on an error path; no new inputs, parsing, auth, or network surface.

Level of scrutiny

Moderate. The change is only 4 lines of production code, but it's lifetime-sensitive Zig where defer ordering matters. I verified that the new defer is registered after defer arena.deinit() and therefore executes before the arena is freed, so the clone reads valid memory. Msg.clone deep-clones data.text, location (file/line_text), and recursively clones notes via bun.clone, so no arena pointers should survive.

Other factors

One thing worth a maintainer's eye: Log.deinit (called from TransformTask.deinit) only does msgs.clearAndFree() and does not free individual Msg contents. Previously those contents lived in the arena and were reclaimed by arena.deinit(); now they live in bun.default_allocator. If log.toJS / BuildMessage.create doesn't take ownership of those strings, this trades a UAF for a small per-error leak. That's strictly an improvement over corrupted output / ASAN crashes, but a reviewer familiar with logger.Msg ownership conventions should confirm whether that's acceptable or already the norm elsewhere. Given this is non-trivial memory management rather than a mechanical change, I'm deferring rather than auto-approving.

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1ed6c98a-1c21-433b-b45e-3c041929817f

📥 Commits

Reviewing files that changed from the base of the PR and between 2d88bae and 0416262.

📒 Files selected for processing (1)
  • src/bun.js/api/JSTranspiler.zig

Walkthrough

Defer-clone transpiler log messages to the default allocator and explicitly deinitialize per-message state on task teardown. Added two regression tests that run 100 concurrent async transforms to assert parse and redeclaration error messages remain correct.

Changes

Transpiler log memory-safety and tests

Layer / File(s) Summary
Core change (defer clone on run exit)
src/bun.js/api/JSTranspiler.zig
TransformTask.run adds a defer that iterates this.log.msgs.items and replaces each message with a cloned copy allocated from bun.default_allocator (using bun.handleOom).
Teardown / deinit
src/bun.js/api/JSTranspiler.zig
TransformTask.deinit now loops over this.log.msgs.items and calls msg.deinit(bun.default_allocator) for each entry before this.log.deinit().
Regression tests
test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts
Adds two tests that run 100 concurrent Bun.Transpiler().transform() calls: one asserting parse-error messages contain "Unexpected", the other asserting redeclaration-error messages contain "x".
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: fixing a use-after-free bug in Bun.Transpiler's async transform error messages.
Description check ✅ Passed The description fully addresses both required sections: 'What does this PR do?' explains the problem, root cause, and solution; 'How did you verify your code works?' includes comprehensive testing details.
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.


Review rate limit: 2/5 reviews remaining, refill in 28 minutes and 53 seconds.

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/js/bun/transpiler/transpiler-transform-error-uaf.test.ts`:
- Around line 6-13: The test currently uses .catch(e => e) which converts
rejections into fulfilled values and can mask unexpected successes; change the
logic to use Promise.allSettled on the array of transpiler.transform(...) calls
(or replace .catch with no handler and await each with try/catch), then for each
result assert result.status === "rejected" and that String(result.reason)
contains "Unexpected". Update references to the promises array and
transpiler.transform calls accordingly so each invocation's rejection status is
asserted before checking the error message.
🪄 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: 3a87ce2f-30c5-46bc-b092-c86f89f101d2

📥 Commits

Reviewing files that changed from the base of the PR and between 191edc0 and 2d88bae.

📒 Files selected for processing (2)
  • src/bun.js/api/JSTranspiler.zig
  • test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts

Comment on lines +6 to +13
const promises: Promise<unknown>[] = [];
for (let i = 0; i < 100; i++) {
promises.push(transpiler.transform("const x = ;", "js").catch(e => e));
}

const errors = await Promise.all(promises);
for (const err of errors) {
expect(String(err)).toContain("Unexpected");

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 | 🟡 Minor | ⚡ Quick win

Assert rejection status explicitly to prevent false positives.

On Line 8 and Line 22, .catch(e => e) turns failures and successes into fulfilled values. The "x" assertion can pass even if transform() unexpectedly resolves. Verify each call is actually rejected before checking message text.

Suggested fix
-  const promises: Promise<unknown>[] = [];
+  const promises: Promise<unknown>[] = [];
   for (let i = 0; i < 100; i++) {
-    promises.push(transpiler.transform("const x = ;", "js").catch(e => e));
+    promises.push(transpiler.transform("const x = ;", "js"));
   }

-  const errors = await Promise.all(promises);
-  for (const err of errors) {
-    expect(String(err)).toContain("Unexpected");
+  const results = await Promise.allSettled(promises);
+  for (const result of results) {
+    expect(result.status).toBe("rejected");
+    if (result.status === "rejected") {
+      expect(String(result.reason)).toContain("Unexpected");
+    }
   }
@@
-  const promises: Promise<unknown>[] = [];
+  const promises: Promise<unknown>[] = [];
   for (let i = 0; i < 100; i++) {
-    promises.push(transpiler.transform("const x = 1; const x = 2;", "js").catch(e => e));
+    promises.push(transpiler.transform("const x = 1; const x = 2;", "js"));
   }

-  const errors = await Promise.all(promises);
-  for (const err of errors) {
-    expect(String(err)).toContain(`"x"`);
+  const results = await Promise.allSettled(promises);
+  for (const result of results) {
+    expect(result.status).toBe("rejected");
+    if (result.status === "rejected") {
+      expect(String(result.reason)).toContain(`"x"`);
+    }
   }

Also applies to: 20-27

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

In `@test/js/bun/transpiler/transpiler-transform-error-uaf.test.ts` around lines 6
- 13, The test currently uses .catch(e => e) which converts rejections into
fulfilled values and can mask unexpected successes; change the logic to use
Promise.allSettled on the array of transpiler.transform(...) calls (or replace
.catch with no handler and await each with try/catch), then for each result
assert result.status === "rejected" and that String(result.reason) contains
"Unexpected". Update references to the promises array and transpiler.transform
calls accordingly so each invocation's rejection status is asserted before
checking the error message.

@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. JSTranspiler: fix use-after-free of log messages in async transform() #30180 - Fixes the same use-after-free of log messages in async transform() due to MimallocArena lifetime
  2. Fix use-after-free in Bun.Transpiler async transform() errors #29958 - Fixes the identical MimallocArena use-after-free in TransformTask by cloning log messages into default_allocator

🤖 Generated with Claude Code

@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29958, which already has maintainer feedback incorporated and green CI. Closing in favor of that one.

@robobun robobun closed this May 4, 2026
Comment on lines +507 to +509
defer for (this.log.msgs.items) |*msg| {
msg.* = bun.handleOom(msg.clone(bun.default_allocator));
};

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.

🔴 This introduces a memory leak: msg.clone(bun.default_allocator) deep-clones Location.file and Location.line_text (via Location.clone, src/logger.zig:113-123), but Location.deinit is a no-op (src/logger.zig:149), so the new msg.deinit(bun.default_allocator) in TransformTask.deinit never frees them. Every rejected async transform() now leaks at least two bun.default_allocator allocations per error message (and per note); previously these strings lived in the per-task arena and were reclaimed by arena.deinit(). Either make Location.deinit free file/line_text, or free them explicitly in the TransformTask.deinit loop.

Extended reasoning...

What the bug is

The PR fixes a use-after-free by deep-cloning each log message out of the per-task MimallocArena into bun.default_allocator before the arena is torn down, and then freeing those clones in TransformTask.deinit. However, the clone path duplicates more than the deinit path frees: Msg.cloneData.cloneLocation.clone calls allocator.dupe(u8, this.file) and allocator.dupe(u8, this.line_text.?) (src/logger.zig:113-123), but Location.deinit is literally pub fn deinit(_: *Location, _: std.mem.Allocator) void {} with the comment "don't really know what's safe to deinit here!" (src/logger.zig:149). Data.deinit (src/logger.zig:209-215) calls loc.deinit() (no-op) and then frees only data.text. So the cloned location.file and location.line_text strings are leaked.

Code path that triggers it

  1. TransformTask.run() parses invalid source; the lexer/parser appends an error Msg whose data.location is populated by Location.initOrNull (src/logger.zig:163-193) — file = source.path.text (e.g. "input.js") and line_text = <slice of source.contents>.
  2. The new defer for (this.log.msgs.items) |*msg| msg.* = bun.handleOom(msg.clone(bun.default_allocator)); runs, which calls Data.cloneLocation.clone, duping both file and line_text into bun.default_allocator. The same happens for every note (Msg.clone clones each note's Data too).
  3. TransformTask.deinit() runs for (this.log.msgs.items) |*msg| msg.deinit(bun.default_allocator);Data.deinit frees data.text and calls the no-op Location.deinit; then Msg.deinit frees the notes slice. file and line_text are never freed.

Why existing code doesn't prevent it

Location.deinit has always been a no-op because in every other caller the Location strings are borrowed slices (into source.path.text / source.contents or into an arena). Before this PR, TransformTask was in the same boat: the strings lived in the per-task MimallocArena and were reclaimed wholesale by arena.deinit(). This PR is the first place that puts Location.clone's heap-owned copies into bun.default_allocator and expects msg.deinit to clean them up — but msg.deinit was never designed to do that.

Impact

Every rejected async transform() leaks at least two small heap allocations (the "input.<ext>" filename and the offending source line) per error message, plus two more per note (e.g. the redeclaration error in the new test has a note with its own location). For the new regression test alone that's ~400+ leaked allocations per run. It's a small per-call leak, but it is a new leak introduced by a PR whose purpose is memory correctness, and it accumulates unbounded in long-running processes that repeatedly transpile invalid input.

Step-by-step proof

Take the second test case, transpiler.transform("const x = 1; const x = 2;", "js"):

  1. Parser emits a redeclaration error Msg with data.location = { file: "input.js", line_text: "const x = 1; const x = 2;", ... } and one note Data with its own location pointing at the original declaration.
  2. The new defer clones the message: Location.clone dupes "input.js" (8 bytes) and "const x = 1; const x = 2;" (25 bytes) for data.location, and again for the note's location — four bun.default_allocator.dupe calls.
  3. then() rejects the promise, then deinit() runs: msg.deinitdata.deinit frees data.text, calls Location.deinit (no-op); iterates notes, frees each note's text, calls Location.deinit (no-op); frees the notes slice.
  4. The four duped location strings are never passed to allocator.free. With 100 iterations × 4 allocations ≈ 400 leaked allocations for this one test.

How to fix

Either:

  • Free the location fields explicitly in the new TransformTask.deinit loop (and for each note), since this is the only place that owns heap-allocated Location strings in bun.default_allocator; or
  • Use the StringBuilder-based Msg.cloneWithBuilder path so all cloned strings share a single allocation that can be freed in one shot; or
  • Make Location.deinit actually free file and line_text (riskier — other callers pass borrowed slices and would double-free/invalid-free).

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