Skip to content

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

Open
robobun wants to merge 2 commits into
mainfrom
farm/13037e2a/fix-transpiler-async-error-uaf
Open

Fix use-after-free in Bun.Transpiler async transform() errors#29958
robobun wants to merge 2 commits into
mainfrom
farm/13037e2a/fix-transpiler-async-error-uaf

Conversation

@robobun

@robobun robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

What

TransformTask.run() creates a MimallocArena, points the transpiler at it, parses the input, and destroys the arena before returning. When parsing fails, the lexer/parser write error messages whose text is allocated from that arena into this.log. TransformTask.then() later calls this.log.toJS() on the JS thread to build the rejection value, which reads the (now destroyed) arena memory.

ASAN reports this as use-after-poison once the freed arena pages are reused and re-poisoned by a subsequent task, so it only shows up under load.

Fix

Collect parse diagnostics into a temporary arena-backed Log during run() and appendToWithRecycled(&this.log, true) before the arena is torn down, so the strings are duplicated into bun.default_allocator. This mirrors what RuntimeTranspilerStore already does for the same reason.

Repro

const transpiler = new Bun.Transpiler({ loader: "ts" });
const promises = [];
for (let i = 0; i < 1200; i++) {
  promises.push(transpiler.transform("const x = ;", "ts").catch(e => String(e.message ?? e)));
}
await Promise.all(promises);

Before: ASAN use-after-poison in logger.Data.clonelogger.Msg.cloneBuildMessage.createLog.toJSTransformTask.then (or a straight SIGSEGV at higher concurrency).
After: resolves with the expected BuildMessage errors.

Found by Fuzzilli.

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@Jarred-Sumner has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 35 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a210e4d1-1f1b-4af3-a37c-b25c75e065de

📥 Commits

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

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

Review rate limit: 0/5 reviews remaining, refill in 7 minutes and 35 seconds.

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

@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:37 AM PT - May 4th, 2026

@robobun, your commit 3586dbf has 2 failures in Build #51025 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29958

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

bun-29958 --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.

LGTM — small, well-understood UAF fix that mirrors the existing RuntimeTranspilerStore pattern, with a regression test.

Extended reasoning...

Overview

This PR fixes a use-after-free in Bun.Transpiler#transform() (the async variant). TransformTask.run() runs on a worker thread, creates a per-task MimallocArena, and points the transpiler at it. Parse diagnostics were being written into this.log with text slices that pointed into arena memory; the arena was destroyed at the end of run(), but then() later read this.log on the JS thread to build the rejection — reading freed memory. The fix routes diagnostics through a temporary arena-backed Log and, in a defer that fires before arena.deinit(), calls log.appendToWithRecycled(&this.log, true) to deep-copy message text/notes into bun.default_allocator. A new test hammers 1200 concurrent failing transforms to exercise the path that previously tripped ASAN.

Security risks

None. This is internal allocator-lifetime bookkeeping for diagnostic strings; there's no change to parsing semantics, trust boundaries, or input handling. The fix strictly removes a memory-safety hazard (UAF → potential crash/corruption).

Level of scrutiny

Low–medium. The diff is ~5 lines of Zig in one function plus a test. It exactly mirrors an established pattern at src/bun.js/RuntimeTranspilerStore.zig:323-327 and src/bun.js/api/server/HTMLBundle.zig:330. I verified cloneToWithRecycled in src/logger.zig:802-828 allocates the cloned text/notes from other.msgs.allocator (here bun.default_allocator), so the copies survive arena teardown. Defer ordering is correct: the clone defer is registered after defer arena.deinit(), so it executes first.

Other factors

  • The only finding is a non-blocking nit: Log.deinit() doesn't free the StringBuilder/notes buffers that cloneToWithRecycled allocates, so each failing async transform leaks a small buffer. This is a pre-existing structural limitation of logger.Log (TODO at logger.zig:846), shared by every other cloneToWithRecycled caller, error-path-only, and strictly better than the UAF it replaces. It's appropriately flagged as a follow-up rather than a blocker.
  • log.level is propagated from this.log.level, preserving the user-configured logLevel.
  • No CODEOWNERS cover this path; no outstanding human review comments.

Comment thread src/bun.js/api/JSTranspiler.zig Outdated
@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author

CI failures are unrelated to this change:

  • test/cli/install/bun-create.test.ts on Windows 11 aarch64: GitHub returned 403 rate limiting. Same failure on builds 49274, 49273, 49272, 49270, 49261; farm/8be201e1/bun-create-github-ratelimit is already tracking it.
  • test/js/web/fetch/fetch-http2-client.test.ts on Debian 13 x64-asan: ASSERTION FAILED: wasRemoved in WTF::AtomStringImpl::remove from the experimental HTTP/2 client. Same flake on builds 49278, 49273, 49272, 49270, 49268, 49261, 49259; farm/9c5297fb/deflake-h2-client-rst-test is already tracking it.

This PR only touches src/bun.js/api/JSTranspiler.zig; the new regression test test/js/bun/transpiler/transpiler-async-error-uaf.test.ts passed on all platforms.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix the leak.

@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a follow-up that tracks the StringBuilder buffer and notes array allocated by cloneToWithRecycled on the destination Log and frees them in Log.deinit(). This plugs the error-path leak here and the pre-existing one in the other cloneToWithRecycled callers (RuntimeTranspilerStore, BundleThread, ParseTask, HTMLBundle, etc.).

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

The leak follow-up looks correct to me — recycled_string_bufs stores exactly what StringBuilder.allocate returned and is freed with the same msgs.allocator — but it changes Log.deinit() semantics for every cloneToWithRecycled caller (bundle_v2, BundleThread, ParseTask, RuntimeTranspilerStore, npm manifest parse, HTMLBundle, isolated install), including the by-value Log copies in npm.zig:1860 and BundleThread.zig:154/161, so it's worth a human pass.

Extended reasoning...

Overview

Two commits:

  1. JSTranspiler.zig: route parse diagnostics through a temporary arena-backed Log and appendToWithRecycled(&this.log, true) before the arena is destroyed. This is the actual UAF fix and mirrors RuntimeTranspilerStore exactly.
  2. logger.zig (follow-up after Jarred's request): add recycled_string_bufs / recycled_notes_bufs to Log, populate them in cloneToWithRecycled, and free them in Log.deinit(). This addresses the small error-path leak I flagged on the first commit and also plugs the same pre-existing leak at every other cloneToWithRecycled call site.

Plus a 1200-iteration regression test that triggered the original ASAN use-after-poison.

Security risks

None. No untrusted input handling changes; this is allocator-lifetime bookkeeping.

Level of scrutiny

The first commit on its own would have been low-risk (pattern-match of an existing idiom). The second commit raises the bar: logger.Log is used pervasively across the bundler, transpiler, install, and dev server, and deinit() (aliased as clearAndFree) now performs additional frees. I checked:

  • StringBuilder.allocate returns allocator.alloc(u8, cap); the stashed slice ptr.?[0..cap] matches what StringBuilder.deinit would free, so the free is valid.
  • The bufs are allocated with other.msgs.allocator and freed with log.msgs.allocator; the only place that reassigns msgs.allocator (bundle_v2.zig:953) does so at init time before any clone, so allocator mismatch shouldn't occur.
  • ArrayListUnmanaged.clearAndFree leaves the list in a reusable .empty state, so the clearAndFree = deinit alias remains safe for logs that are cleared and reused.
  • By-value Log copies (npm.zig:1860 log.* = cloned_log; BundleThread.zig completion.log = out_log) carry the unmanaged lists correctly since they're just slice+capacity.
  • Log.reset() does not clear the new lists, so a log that is reset() repeatedly between clones will accumulate buffer pointers until deinit() — not a correctness issue, just worth noting.

I did not find a path that would double-free or free with the wrong allocator.

Other factors

A maintainer is already engaged on this PR and explicitly requested the second commit. Given that the second commit touches memory-ownership semantics in a core shared type with ~10 distinct callers across hot paths, I'd rather have a human confirm the by-value-copy and reset() interactions than auto-approve.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do not make logger.Log larger.

@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked to keep logger.Log untouched. TransformTask now clones the arena-backed diagnostics itself, stashes the StringBuilder buffer + notes array on the task, and frees them in TransformTask.deinit(). Net diff vs main is src/bun.js/api/JSTranspiler.zig only.

@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 — the rework addresses the leak by tracking log_string_buf/log_notes_buf on the task and freeing them in deinit().

Extended reasoning...

Overview

Fixes a use-after-free in Bun.Transpiler.transform() (async path). TransformTask.run() previously pointed the transpiler's log at this.log while parsing inside a per-run MimallocArena, so error message text lived in arena memory that was destroyed before then() read it on the JS thread. The fix parses into a local arena-backed Log, then cloneArenaLog() (via defer, which fires before arena.deinit()) copies each Msg into this.log using the existing Msg.count / Msg.cloneWithBuilder machinery from src/logger.zig. The two heap buffers backing the cloned strings and notes are stored on the task (log_string_buf, log_notes_buf) and freed in TransformTask.deinit(). A new regression test hammers 1200 concurrent failing transforms.

Security risks

None. This is internal memory-lifetime management for diagnostic strings; no auth, network, filesystem, or untrusted-input parsing semantics changed. The fix strictly removes a UAF that was previously reachable from user JS.

Level of scrutiny

Medium. It touches cross-thread lifetime handling, but the change is narrow and entirely contained in TransformTask: it reuses the established cloneWithBuilder pattern already used by cloneToWithRecycled in logger.zig, the defer ordering is correct (clone → ast_scope.exit()arena.deinit()), run() executes exactly once per task so the buffers are assigned once, and freeing empty slices on the success path is a no-op. The earlier broader approach that modified logger.Log was backed out in favor of this localized fix, which keeps blast radius minimal.

Other factors

My prior nit about the StringBuilder/notes buffers leaking has been addressed — deinit() now frees both, and the inline comment is resolved. Jarred requested the leak fix and the author delivered. CI passed on all platforms for the new test; the two reported failures are known pre-existing flakes (bun-create GitHub rate-limit, HTTP/2 client ASAN) unrelated to this diff.

robobun added 2 commits May 4, 2026 10:27
TransformTask.run() allocates parse error messages from a per-task
MimallocArena that is destroyed when run() returns. TransformTask.then()
later reads those messages to build the rejection value, reading freed
memory.

Use a temporary log during parsing and clone its messages into the
task's default-allocator-backed log before the arena is torn down, the
same way RuntimeTranspilerStore already does.
Instead of appendToWithRecycled (which leaks its StringBuilder/notes
allocations because Log.deinit does not free them), clone the
arena-backed diagnostics directly in TransformTask, stash the backing
buffers on the task, and free them in TransformTask.deinit().
@Jarred-Sumner Jarred-Sumner force-pushed the farm/13037e2a/fix-transpiler-async-error-uaf branch from 0621520 to 3586dbf Compare May 4, 2026 10:27

@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 — the rework addresses the leak nit by tracking the cloned string/notes buffers on TransformTask and freeing them in deinit().

Extended reasoning...

Overview

This PR fixes a use-after-free in Bun.Transpiler.transform()'s async error path. TransformTask.run() previously pointed the transpiler's log directly at this.log while parsing inside a per-run MimallocArena; parse-error message text was allocated from that arena, the arena was destroyed at the end of run(), and then() later read the freed memory via this.log.toJS(). The fix routes diagnostics through a local arena-backed log and, on scope exit, clones them into bun.default_allocator-owned buffers (log_string_buf, log_notes_buf) stored on the task, which are freed in TransformTask.deinit(). A regression test hammers 1200 concurrent failing transforms.

Security risks

None. This is internal memory-lifetime management for diagnostic strings; no auth, crypto, parsing of untrusted control data, or external I/O is touched. The change strictly reduces risk by eliminating a UAF reachable from user JS.

Level of scrutiny

Medium — it's allocator/lifetime code in the runtime, but the implementation is a near-verbatim inline of the existing, well-exercised Log.cloneToWithRecycled pattern (Msg.countStringBuilder.allocateMsg.cloneWithBuilder), with the only behavioral difference being that the two backing buffers are stashed on the task so they can be freed. Defer ordering is correct (cloneArenaLog runs before ast_scope.exit() and arena.deinit()). The success path (no diagnostics) early-returns with zero allocations, and zero-length frees in deinit() are safe.

Other factors

  • My earlier nit about appendToWithRecycled leaking the StringBuilder/notes buffers was acted on: the maintainer asked for a fix, and the final revision keeps logger.Log untouched while plugging the leak locally.
  • CI (including the ASAN job) passed; the two unrelated flakes are pre-existing and tracked.
  • Net diff is confined to TransformTask in src/runtime/api/JSTranspiler.zig plus a new test; no CODEOWNERS cover this path.
  • Removing the redundant this.log.msgs.allocator = bun.default_allocator line is safe — this.log is already initialized with bun.default_allocator in create().

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