diff --git a/src/runtime/api/JSTranspiler.zig b/src/runtime/api/JSTranspiler.zig index 7d231902efe..4f557ffd757 100644 --- a/src/runtime/api/JSTranspiler.zig +++ b/src/runtime/api/JSTranspiler.zig @@ -452,6 +452,8 @@ pub const TransformTask = struct { transpiler: bun.Transpiler = undefined, js_instance: *JSTranspiler, log: logger.Log, + log_string_buf: []u8 = &.{}, + log_notes_buf: []logger.Data = &.{}, err: ?anyerror = null, macro_map: MacroMap = MacroMap{}, tsconfig: ?*TSConfigJSON = null, @@ -500,9 +502,12 @@ pub const TransformTask = struct { var ast_scope = ast_memory_allocator.enter(allocator); defer ast_scope.exit(); + var log = logger.Log.init(allocator); + log.level = this.log.level; + defer this.cloneArenaLog(&log); + this.transpiler.setAllocator(allocator); - this.transpiler.setLog(&this.log); - this.log.msgs.allocator = bun.default_allocator; + this.transpiler.setLog(&log); const jsx = if (this.tsconfig != null) this.tsconfig.?.mergeJSX(this.transpiler.options.jsx) @@ -586,8 +591,36 @@ pub const TransformTask = struct { return promise.resolve(this.global, value); } + /// Clone `src` (whose message strings live in the per-run arena) into + /// `this.log` before the arena is destroyed. The backing buffers are + /// tracked on `this` so `deinit()` can free them. + fn cloneArenaLog(this: *TransformTask, src: *logger.Log) void { + this.log.warnings += src.warnings; + this.log.errors += src.errors; + if (src.msgs.items.len == 0) return; + + var string_builder = bun.StringBuilder{}; + var notes_count: usize = 0; + for (src.msgs.items) |msg| { + msg.count(&string_builder); + notes_count += msg.notes.len; + } + bun.handleOom(string_builder.allocate(bun.default_allocator)); + this.log_string_buf = if (string_builder.cap > 0) string_builder.ptr.?[0..string_builder.cap] else &.{}; + this.log_notes_buf = bun.handleOom(bun.default_allocator.alloc(logger.Data, notes_count)); + + bun.handleOom(this.log.msgs.ensureUnusedCapacity(src.msgs.items.len)); + var note_i: usize = 0; + for (src.msgs.items) |msg| { + this.log.msgs.appendAssumeCapacity(msg.cloneWithBuilder(this.log_notes_buf[note_i..], &string_builder)); + note_i += msg.notes.len; + } + } + pub fn deinit(this: *TransformTask) void { this.log.deinit(); + bun.default_allocator.free(this.log_string_buf); + bun.default_allocator.free(this.log_notes_buf); this.input_code.deinitAndUnprotect(); this.output_code.deref(); // tsconfig is owned by JSTranspiler, not by TransformTask. diff --git a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts new file mode 100644 index 00000000000..ab8a774e91c --- /dev/null +++ b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; + +test("async transform() parse errors do not use arena memory after free", async () => { + const transpiler = new Bun.Transpiler({ loader: "ts" }); + const promises: Promise[] = []; + for (let i = 0; i < 1200; i++) { + promises.push( + transpiler.transform("const x = ;", "ts").then( + () => { + throw new Error("expected parse error"); + }, + e => String(e.message ?? e), + ), + ); + } + const results = await Promise.all(promises); + for (const msg of results) { + expect(msg).toContain("Unexpected"); + } +});