-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Fix use-after-free in Bun.Transpiler async transform error messages #30229
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { expect, test } from "bun:test"; | ||
|
|
||
| test("concurrent async transform() with parse errors produces correct error messages", async () => { | ||
| const transpiler = new Bun.Transpiler(); | ||
|
|
||
| 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"); | ||
|
Comment on lines
+6
to
+13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assert rejection status explicitly to prevent false positives. On Line 8 and Line 22, 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 |
||
| } | ||
| }); | ||
|
|
||
| test("concurrent async transform() with redeclaration errors produces correct error messages", async () => { | ||
| const transpiler = new Bun.Transpiler(); | ||
|
|
||
| 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)); | ||
| } | ||
|
|
||
| const errors = await Promise.all(promises); | ||
| for (const err of errors) { | ||
| expect(String(err)).toContain(`"x"`); | ||
| } | ||
| }); | ||
There was a problem hiding this comment.
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-clonesLocation.fileandLocation.line_text(viaLocation.clone, src/logger.zig:113-123), butLocation.deinitis a no-op (src/logger.zig:149), so the newmsg.deinit(bun.default_allocator)inTransformTask.deinitnever frees them. Every rejected asynctransform()now leaks at least twobun.default_allocatorallocations per error message (and per note); previously these strings lived in the per-task arena and were reclaimed byarena.deinit(). Either makeLocation.deinitfreefile/line_text, or free them explicitly in theTransformTask.deinitloop.Extended reasoning...
What the bug is
The PR fixes a use-after-free by deep-cloning each log message out of the per-task
MimallocArenaintobun.default_allocatorbefore the arena is torn down, and then freeing those clones inTransformTask.deinit. However, the clone path duplicates more than the deinit path frees:Msg.clone→Data.clone→Location.clonecallsallocator.dupe(u8, this.file)andallocator.dupe(u8, this.line_text.?)(src/logger.zig:113-123), butLocation.deinitis literallypub 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) callsloc.deinit()(no-op) and then frees onlydata.text. So the clonedlocation.fileandlocation.line_textstrings are leaked.Code path that triggers it
TransformTask.run()parses invalid source; the lexer/parser appends an errorMsgwhosedata.locationis populated byLocation.initOrNull(src/logger.zig:163-193) —file = source.path.text(e.g."input.js") andline_text = <slice of source.contents>.defer for (this.log.msgs.items) |*msg| msg.* = bun.handleOom(msg.clone(bun.default_allocator));runs, which callsData.clone→Location.clone, duping bothfileandline_textintobun.default_allocator. The same happens for every note (Msg.cloneclones each note'sDatatoo).TransformTask.deinit()runsfor (this.log.msgs.items) |*msg| msg.deinit(bun.default_allocator);→Data.deinitfreesdata.textand calls the no-opLocation.deinit; thenMsg.deinitfrees the notes slice.fileandline_textare never freed.Why existing code doesn't prevent it
Location.deinithas always been a no-op because in every other caller theLocationstrings are borrowed slices (intosource.path.text/source.contentsor into an arena). Before this PR,TransformTaskwas in the same boat: the strings lived in the per-taskMimallocArenaand were reclaimed wholesale byarena.deinit(). This PR is the first place that putsLocation.clone's heap-owned copies intobun.default_allocatorand expectsmsg.deinitto clean them up — butmsg.deinitwas 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"):Msgwithdata.location = { file: "input.js", line_text: "const x = 1; const x = 2;", ... }and one noteDatawith its own location pointing at the original declaration.deferclones the message:Location.clonedupes"input.js"(8 bytes) and"const x = 1; const x = 2;"(25 bytes) fordata.location, and again for the note's location — fourbun.default_allocator.dupecalls.then()rejects the promise, thendeinit()runs:msg.deinit→data.deinitfreesdata.text, callsLocation.deinit(no-op); iterates notes, frees each note'stext, callsLocation.deinit(no-op); frees the notes slice.allocator.free. With 100 iterations × 4 allocations ≈ 400 leaked allocations for this one test.How to fix
Either:
TransformTask.deinitloop (and for each note), since this is the only place that owns heap-allocatedLocationstrings inbun.default_allocator; orStringBuilder-basedMsg.cloneWithBuilderpath so all cloned strings share a single allocation that can be freed in one shot; orLocation.deinitactually freefileandline_text(riskier — other callers pass borrowed slices and would double-free/invalid-free).