Skip to content

Fix use-after-free in Bun.Transpiler async transform() errors#30263

Open
robobun wants to merge 4 commits into
mainfrom
farm/c9bea6ae/transpiler-async-error-uaf
Open

Fix use-after-free in Bun.Transpiler async transform() errors#30263
robobun wants to merge 4 commits into
mainfrom
farm/c9bea6ae/transpiler-async-error-uaf

Conversation

@robobun

@robobun robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

TransformTask.run() creates a per-task MimallocArena and points the transpiler's allocator at it for the duration of the parse. When the parser emits diagnostics (error text, notes arrays, location data), those allocations come from that arena. The arena is destroyed at the end of run() (worker thread), but the log messages are consumed later in then() (main thread) via log.toJS()BuildMessage.create()Msg.clone(), which reads the now-freed arena memory.

This showed up as AddressSanitizer: use-after-poison when then() ran after the destroyed arena's pages had been poisoned, and as garbage error text/notes otherwise.

Fix

Before the arena is torn down, deep-clone any accumulated log messages into bun.default_allocator. Deferred after defer arena.deinit() so it runs first (LIFO) while the arena is still valid, and covers every early-return path in run().

How did you verify your code works?

Minimal repro that crashes under ASAN before this change and passes after:

const t = new Bun.Transpiler({ loader: "tsx" });
await Promise.all(
  Array.from({ length: 5 }, () => t.transform("const {a, a} = b").catch(e => e)),
);

Added test/js/bun/transpiler/transpiler-async-error-uaf.test.ts which exercises multiple concurrent failing transforms and asserts that the error message, position, and notes are intact.

Found by Fuzzilli.

TransformTask.run() uses a per-task MimallocArena for parsing. When the
parser emits diagnostics, the message text and notes slices are
allocated from that arena. The arena is destroyed at the end of run(),
but the log messages are read later on the main thread in then() via
log.toJS(), leading to a use-after-poison / use-after-free.

Deep-clone any accumulated log messages into bun.default_allocator
before the arena is torn down so then() can safely surface them.
@github-actions github-actions Bot added the claude label May 5, 2026
@robobun

robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 AM PT - May 5th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 30263

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

bun-30263 --bun

@coderabbitai

coderabbitai Bot commented May 5, 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: c2a72011-7fc6-4263-a339-2330f0803cf0

📥 Commits

Reviewing files that changed from the base of the PR and between 4906852 and 0ec4702.

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

Walkthrough

TransformTask now transfers log-message ownership to bun.default_allocator during run and explicitly deinitializes those messages in deinit. Two concurrent tests were added to exercise async transform error handling and assert stable error objects under concurrency.

Changes

Transpiler allocator/cleanup + tests

Layer / File(s) Summary
Allocator ownership change
src/runtime/api/JSTranspiler.zig
TransformTask.run sets this.log.msgs.allocator = bun.default_allocator and defers cloning each message into bun.default_allocator so messages survive beyond the original allocator.
Explicit per-message deinit
src/runtime/api/JSTranspiler.zig
TransformTask.deinit now iterates this.log.msgs.items and calls msg.deinit(bun.default_allocator) before calling this.log.deinit().
Build manifest
build.zig.zon
Manifest updated (file touched in this PR).
Regression tests
test/js/bun/transpiler/transpiler-async-error-uaf.test.ts
Adds two tests: concurrent transforms over multiple inputs asserting all rejections are defined objects; and eight concurrent transforms of "const {a, a} = b" asserting consistent error message, position (file, lineText), and a single note with expected text.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: addressing a use-after-free bug in Bun.Transpiler's async transform error handling, which directly matches the core changes in the PR.
Description check ✅ Passed The description provides both required sections: a clear explanation of what the PR does (the problem, root cause, and solution) and how it was verified (minimal repro and test case added).
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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/api/JSTranspiler.zig`:
- Around line 506-508: The defer loop currently swallows OOMs by falling back to
the arena-backed msg on clone failure; change the error handler to crash on OOM
using bun.handleOom() instead of returning msg.*. Replace the current catch
(e.g., "catch msg.*") on the msg.clone(bun.default_allocator) call with a catch
that forwards the error to bun.handleOom(err) (e.g., "catch |err|
bun.handleOom(err)"), and then assign the successful clone to msg.*; this
ensures OOMs abort rather than reintroducing an arena-backed message that then()
and log.toJS() might read.
🪄 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: de54cc6a-ae50-444e-bac6-d82393d8cb55

📥 Commits

Reviewing files that changed from the base of the PR and between 0a7bed5 and 4906852.

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

Comment thread src/runtime/api/JSTranspiler.zig
@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix use-after-free in Bun.Transpiler async transform() errors #29958 - Same fix for use-after-free in Bun.Transpiler async transform() error path
  2. JSTranspiler: fix use-after-free of log messages in async transform() #30180 - Same fix for use-after-free of log messages in JSTranspiler async transform()

🤖 Generated with Claude Code

Comment on lines +506 to +508
defer for (this.log.msgs.items) |*msg| {
msg.* = msg.clone(bun.default_allocator) catch msg.*;
};

@claude claude Bot May 5, 2026

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.

Done — added msg.deinit(bun.default_allocator) for each message in TransformTask.deinit() before log.deinit().

Comment thread src/runtime/api/JSTranspiler.zig Outdated
Comment thread src/runtime/api/JSTranspiler.zig Outdated
this.transpiler.setLog(&this.log);
this.log.msgs.allocator = bun.default_allocator;
defer for (this.log.msgs.items) |*msg| {
msg.* = msg.clone(bun.default_allocator) catch msg.*;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@robobun fix this to properly handle OOM

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0ec4702 — now uses bun.handleOom(msg.clone(bun.default_allocator)).

Comment on lines +506 to +508
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.

🟡 Minor: the msg.deinit(bun.default_allocator) cleanup added in TransformTask.deinit() won't actually free everything msg.clone() allocated — Location.deinit is a no-op (logger.zig:149), so the duped location.file and location.line_text strings (for the message and each note) leak on the global heap per failing async transform(). Tiny error-path-only leak and the root asymmetry is pre-existing in Location, so not a blocker for this UAF fix — but if you want full cleanup here you'd need to free those two fields manually (or fix Location.deinit).

Extended reasoning...

What the bug is

The new defer in TransformTask.run() deep-clones each log message into bun.default_allocator:

defer for (this.log.msgs.items) |*msg| {
    msg.* = bun.handleOom(msg.clone(bun.default_allocator));
};

Msg.cloneData.cloneLocation.clone (src/logger/logger.zig:113-123), which does allocator.dupe(u8, this.file) and allocator.dupe(u8, this.line_text.?). And Msg.clone also calls bun.clone(this.notes, allocator), which (per src/bun.zig:551-557) detects Data.clone and deep-clones every note, so each note's location.file / location.line_text is also duped onto bun.default_allocator.

The matching cleanup added in TransformTask.deinit():

for (this.log.msgs.items) |*msg| msg.deinit(bun.default_allocator);

calls Msg.deinitData.deinitLocation.deinit. But Location.deinit is intentionally stubbed:

// don't really know what's safe to deinit here!
pub fn deinit(_: *Location, _: std.mem.Allocator) void {}

So Data.deinit frees .text and the notes slice is freed, but every Location.file and Location.line_text that was just duped onto the global heap is never freed.

Code path that triggers it

  1. transpiler.transform("const {a, a} = b") schedules a TransformTask.
  2. run() parses on a worker thread; the parser pushes a Msg with data.location = {file: "input.tsx", line_text: "const {a, a} = b"} and one note with its own location, all allocated from the per-task MimallocArena.
  3. On scope exit, the new defer runs first (LIFO) and msg.clone(bun.default_allocator) heap-dupes: data.text, data.location.file, data.location.line_text, the notes slice, notes[0].text, notes[0].location.file, notes[0].location.line_text.
  4. arena.deinit() reclaims the originals (correct — that's the UAF fix).
  5. then() runs on the main thread, builds the JS error, then calls this.deinit().
  6. deinit() runs msg.deinit(bun.default_allocator) for each message → frees data.text, notes[0].text, and the notes slice. Location.deinit is a no-op, so the four duped location strings (2 × file + 2 × line_text) stay on the global mimalloc heap forever.

Why existing code doesn't prevent it

  • this.log.deinit() only frees the msgs list backing storage, not per-message payloads.
  • Msg.deinit is the maximally-correct API to call here and the author called it; the gap is one layer down in Location.deinit, which has carried the comment "don't really know what's safe to deinit here!" since long before this PR.
  • Before this PR, these strings lived in the per-task arena and were swept by arena.deinit() (with a UAF, which this PR correctly fixes). Moving them to bun.default_allocator is what makes the no-op Location.deinit matter.

Impact

Per failing async transform(): (1 + #notes) × (len(file) + len(line_text)) bytes leaked from the global heap. For the PR's own test ("const {a, a} = b", 1 note), that's ≈ 2 × ("input.tsx" + "const {a, a} = b") ≈ 50 bytes per call. Error-path only; the success path has no log messages and leaks nothing.

Worth noting: the same Location.clone/Location.deinit asymmetry already bites BuildMessage.createBuildMessage.finalize on this exact path (BuildMessage.zig:54/186), so this PR roughly doubles a pre-existing per-error leak rather than introducing a brand-new leak class.

Step-by-step proof

For one call to transpiler.transform("const {a, a} = b"):

Allocation in msg.clone(bun.default_allocator) Freed by msg.deinit(bun.default_allocator)?
data.text ('"a" has already been declared') Data.deinit line 214
data.location.file ("input.tsx") Location.deinit no-op line 149
data.location.line_text ("const {a, a} = b") Location.deinit no-op line 149
notes slice ([1]Data) Msg.deinit line 484
notes[0].text Data.deinit line 214
notes[0].location.file Location.deinit no-op
notes[0].location.line_text Location.deinit no-op

Four small allocations leak per failed transform.

How to fix

Either locally in TransformTask.deinit():

for (this.log.msgs.items) |*msg| {
    if (msg.data.location) |*loc| {
        bun.default_allocator.free(loc.file);
        if (loc.line_text) |lt| bun.default_allocator.free(lt);
    }
    for (msg.notes) |*note| if (note.location) |*loc| {
        bun.default_allocator.free(loc.file);
        if (loc.line_text) |lt| bun.default_allocator.free(lt);
    };
    msg.deinit(bun.default_allocator);
}

…or, better long-term, make Location.deinit actually free what Location.clone allocates (which would also fix the identical leak in BuildMessage.finalize). The latter is out of scope for this PR; flagging as a nit since the author explicitly added msg.deinit intending full cleanup.

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.

2 participants