From db752d393eccf9c65afc1345c7a98c8559b005c5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:31:39 +0000 Subject: [PATCH 1/6] Fix Worker preload resolve error reporting 'undefined' WebWorker::create stored the stack address of temp_log in the transpiler's log pointer, then moved temp_log into a scopeguard tuple. The transpiler wrote the ModuleNotFound message through the stale pointer; the guard's temp_log stayed empty, so log.to_js() returned undefined and the thrown TypeError's message was the string 'undefined'. Move temp_log into the guard first, then call set_log with its final address. --- src/jsc/web_worker.rs | 8 +++++--- test/js/web/workers/worker.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index ceb045b8f5e6..0f725eabfa77 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -490,14 +490,16 @@ impl WebWorker { // SAFETY: `parent` is the calling thread's live VM (BACKREF). let parent_ref = unsafe { &mut *parent }; let prev_log = parent_ref.transpiler.log; - let mut temp_log = bun_ast::Log::default(); - parent_ref.transpiler.set_log(&raw mut temp_log); // RAII: log pointer restored and temp log dropped on every return path. - let mut restore = scopeguard::guard((parent_ref, temp_log), |(p, log)| { + // `temp_log` is moved into the guard FIRST, then `set_log` targets its + // final address — the transpiler stores a raw `*mut Log`, so setting it + // before the move would leave a dangling pointer to moved-from stack. + let mut restore = scopeguard::guard((parent_ref, bun_ast::Log::default()), |(p, log)| { p.transpiler.set_log(prev_log); drop(log); }); let (parent_ref, temp_log) = &mut *restore; + parent_ref.transpiler.set_log(&raw mut *temp_log); // SAFETY: caller passed valid (ptr,len) (or `(null,0)`); slice borrowed from C++. let preload_modules: &[BunString] = diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index c8696c9f84bb..c5093f317ddd 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -29,6 +29,21 @@ describe("web worker", () => { ).toThrow(/Invalid file URL/); }); + test("unresolvable module surfaces the resolve error, not 'undefined'", () => { + let caught: unknown; + try { + new Worker(new URL("worker-fixture-preload-entry.js", import.meta.url).href, { + preload: ["./this-preload-does-not-exist.js"], + }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + const message = String((caught as Error).message); + expect(message).not.toBe("undefined"); + expect(message).toContain("this-preload-does-not-exist"); + }); + test("string", async () => { const worker = new Worker(new URL("worker-fixture-preload-entry.js", import.meta.url).href, { preload: new URL("worker-fixture-preload.js", import.meta.url).href, From 0fa9dddb15bb24e96457359704c2d3355e71fa74 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:11:43 +0000 Subject: [PATCH 2/6] Read transpiler.log directly instead of via aliasing &mut parameter The previous commit fixed the address (temp_log moved into the guard before set_log), which worked on debug builds. On release, the separate &mut Log parameter to resolve_entry_point_specifier carries noalias, so the optimizer could not see resolve_entry_point's write through transpiler.log into the same allocation, and to_js() still read an empty log. Drop the log parameter; read (*parent).transpiler.log directly after the resolve call so the read and write share one provenance path. Move spin()'s vm_log binding into the None arm so it is not held as &mut across the call either. Move the regression test to its own file to avoid pre-existing 1000ms-timeout flakes in worker.test.ts on slow ASAN runners. --- src/jsc/web_worker.rs | 25 ++++++++----------- .../worker-preload-resolve-error.test.ts | 16 ++++++++++++ test/js/web/workers/worker.test.ts | 15 ----------- 3 files changed, 27 insertions(+), 29 deletions(-) create mode 100644 test/js/web/workers/worker-preload-resolve-error.test.ts diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 0f725eabfa77..125a5310d37b 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -511,12 +511,7 @@ impl WebWorker { // SAFETY: `parent_ref` is the live VM on the calling (parent) // thread — its `transpiler` is uniquely owned here. if let Some(preload) = unsafe { - resolve_entry_point_specifier( - *parent_ref, - utf8_slice.slice(), - error_message, - temp_log, - ) + resolve_entry_point_specifier(*parent_ref, utf8_slice.slice(), error_message) } { preloads.push(preload.to_vec().into_boxed_slice()); } @@ -1045,21 +1040,16 @@ impl WebWorker { // spin() goes through shutdown() which is noreturn, so a `defer free` // here would never run anyway. let mut resolve_error = BunString::empty(); - let vm_log = vm.log_mut().unwrap(); // SAFETY: `vm_ptr` is the live worker-thread VM; the fn takes a raw ptr // (no `&mut`) because `vm` is already published under `vm_lock` — see // `resolve_entry_point_specifier` Safety contract. let path = match unsafe { - resolve_entry_point_specifier( - vm_ptr, - &self.unresolved_specifier, - &mut resolve_error, - vm_log, - ) + resolve_entry_point_specifier(vm_ptr, &self.unresolved_specifier, &mut resolve_error) } { Some(p) => p, None => { vm.as_mut().exit_handler.exit_code = 1; + let vm_log = vm.log_mut().unwrap(); if vm_log.errors == 0 && !resolve_error.is_empty() { let err = resolve_error.to_utf8(); // `Log::add_error` takes `impl IntoText`; pass an owned @@ -1539,7 +1529,6 @@ unsafe fn resolve_entry_point_specifier<'s>( parent: *mut VirtualMachine, str: &'s [u8], error_message: &mut BunString, - log: &mut bun_ast::Log, ) -> Option<&'s [u8]> { // SAFETY: per fn contract; read-only field. if let Some(graph) = unsafe { (*parent).standalone_module_graph } { @@ -1640,6 +1629,14 @@ unsafe fn resolve_entry_point_specifier<'s>( Err(_) => { // `global` valid for VM lifetime; safe ZST-handle deref (panics on null). let global = JSGlobalObject::opaque_ref(global); + // `resolve_entry_point` wrote the error to `transpiler.log`. Read + // it back through the same raw pointer — a separate `&mut Log` + // parameter would be `noalias` and miss the write under + // optimization (the two point at the same allocation). + // SAFETY: `transpiler.log` is non-null after init and outlives + // this call (both callers swap in a log that lives on their + // stack or VM for the duration). + let log = unsafe { &*(*parent).transpiler.log }; let out: jsc::JsResult = (|| { let out = log.to_js(global, "Error resolving Worker entry point")?; out.to_bun_string(global) diff --git a/test/js/web/workers/worker-preload-resolve-error.test.ts b/test/js/web/workers/worker-preload-resolve-error.test.ts new file mode 100644 index 000000000000..997b07100b6b --- /dev/null +++ b/test/js/web/workers/worker-preload-resolve-error.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test"; + +test("Worker preload: unresolvable module surfaces the resolve error, not 'undefined'", () => { + let caught: unknown; + try { + new Worker(new URL("worker-fixture-preload-entry.js", import.meta.url).href, { + preload: ["./this-preload-does-not-exist.js"], + }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + const message = String((caught as Error).message); + expect(message).not.toBe("undefined"); + expect(message).toContain("this-preload-does-not-exist"); +}); diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index c5093f317ddd..c8696c9f84bb 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -29,21 +29,6 @@ describe("web worker", () => { ).toThrow(/Invalid file URL/); }); - test("unresolvable module surfaces the resolve error, not 'undefined'", () => { - let caught: unknown; - try { - new Worker(new URL("worker-fixture-preload-entry.js", import.meta.url).href, { - preload: ["./this-preload-does-not-exist.js"], - }); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = String((caught as Error).message); - expect(message).not.toBe("undefined"); - expect(message).toContain("this-preload-does-not-exist"); - }); - test("string", async () => { const worker = new Worker(new URL("worker-fixture-preload-entry.js", import.meta.url).href, { preload: new URL("worker-fixture-preload.js", import.meta.url).href, From 6ecda38230cd40cdc1bd31a4912aafcb95b8aa5b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:50:29 +0000 Subject: [PATCH 3/6] Run Worker preload resolve-error test in a subprocess The in-process new Worker() call on the leaksan lane reported the BuildMessage's external-string backing buffer as leaked (it is JS-heap-owned and freed by GC, which does not run before process exit). worker.test.ts is already on the no-validate-leaksan list for the same class of report; spawning a child with bunEnv avoids leak detection without adding another exclusion. --- .../worker-preload-resolve-error.test.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/test/js/web/workers/worker-preload-resolve-error.test.ts b/test/js/web/workers/worker-preload-resolve-error.test.ts index 997b07100b6b..45184cb4c089 100644 --- a/test/js/web/workers/worker-preload-resolve-error.test.ts +++ b/test/js/web/workers/worker-preload-resolve-error.test.ts @@ -1,16 +1,33 @@ import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; -test("Worker preload: unresolvable module surfaces the resolve error, not 'undefined'", () => { - let caught: unknown; - try { - new Worker(new URL("worker-fixture-preload-entry.js", import.meta.url).href, { - preload: ["./this-preload-does-not-exist.js"], - }); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(Error); - const message = String((caught as Error).message); - expect(message).not.toBe("undefined"); - expect(message).toContain("this-preload-does-not-exist"); +test("Worker preload: unresolvable module surfaces the resolve error, not 'undefined'", async () => { + using dir = tempDir("worker-preload-resolve-error", { + "entry.js": `postMessage("unreachable");`, + "run.js": ` + try { + new Worker(new URL("entry.js", import.meta.url).href, { + preload: ["./this-preload-does-not-exist.js"], + }); + console.log("NO_THROW"); + } catch (e) { + console.log(JSON.stringify({ isError: e instanceof Error, message: String(e.message) })); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const out = JSON.parse(stdout.trim()); + expect(out.isError).toBe(true); + expect(out.message).not.toBe("undefined"); + expect(out.message).toContain("this-preload-does-not-exist"); + expect(exitCode).toBe(0); }); From 7d784af9609f18abd81beac32a0b4ebfac6b61d2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:13:46 +0000 Subject: [PATCH 4/6] Exclude worker-preload-resolve-error.test.ts from LeakSanitizer The error path creates a BuildMessage whose to_string_fn hands an external-string backing buffer to JSC; it is freed at GC, which does not run before the subprocess exits. worker.test.ts, worker_blob.test.ts and message-channel.test.ts are already on this list for the same class of JS-heap-lifetime report. --- test/no-validate-leaksan.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index 8dde0b650a6c..9dd12f389b3d 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -394,6 +394,7 @@ test/js/node/fs/abort-signal-leak-read-write-file.test.ts test/js/node/process/process.test.js test/js/web/websocket/websocket.test.js test/js/web/workers/worker.test.ts +test/js/web/workers/worker-preload-resolve-error.test.ts test/regression/issue/11664.test.ts # ASSERTION FAILED: m_cellState == CellState::DefinitelyWhite From 17136b73000df46f50f3477ced82326e0a21c537 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:18:49 +0000 Subject: [PATCH 5/6] Surface subprocess stderr/exitCode before JSON.parse in test If the child aborts, stdout is empty and JSON.parse throws an opaque EOF error. Assert the combined { stdout, stderr, exitCode } object first so the CI failure diff shows the actual diagnostic. --- test/js/web/workers/worker-preload-resolve-error.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/js/web/workers/worker-preload-resolve-error.test.ts b/test/js/web/workers/worker-preload-resolve-error.test.ts index 45184cb4c089..75d9728f1457 100644 --- a/test/js/web/workers/worker-preload-resolve-error.test.ts +++ b/test/js/web/workers/worker-preload-resolve-error.test.ts @@ -23,11 +23,13 @@ test("Worker preload: unresolvable module surfaces the resolve error, not 'undef stdout: "pipe", stderr: "pipe", }); - const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toMatchObject({ + stdout: expect.stringContaining('"isError":true'), + exitCode: 0, + }); const out = JSON.parse(stdout.trim()); - expect(out.isError).toBe(true); expect(out.message).not.toBe("undefined"); expect(out.message).toContain("this-preload-does-not-exist"); - expect(exitCode).toBe(0); }); From ef918a1773552aca151d6c704ca3a29dcaee4f56 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:51:46 +0000 Subject: [PATCH 6/6] Simplify test back to in-process now that leaksan exclusion is in place The subprocess wrapper was added to avoid adding a leaksan exclusion, but bunEnv spreads process.env so the child inherited detect_leaks=1 anyway. With the file now on no-validate-leaksan.txt (same class as worker.test.ts), an in-process try/catch has identical LSan behavior and drops ~25 lines of scaffolding. --- .../worker-preload-resolve-error.test.ts | 45 ++++++------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/test/js/web/workers/worker-preload-resolve-error.test.ts b/test/js/web/workers/worker-preload-resolve-error.test.ts index 75d9728f1457..997b07100b6b 100644 --- a/test/js/web/workers/worker-preload-resolve-error.test.ts +++ b/test/js/web/workers/worker-preload-resolve-error.test.ts @@ -1,35 +1,16 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; -test("Worker preload: unresolvable module surfaces the resolve error, not 'undefined'", async () => { - using dir = tempDir("worker-preload-resolve-error", { - "entry.js": `postMessage("unreachable");`, - "run.js": ` - try { - new Worker(new URL("entry.js", import.meta.url).href, { - preload: ["./this-preload-does-not-exist.js"], - }); - console.log("NO_THROW"); - } catch (e) { - console.log(JSON.stringify({ isError: e instanceof Error, message: String(e.message) })); - } - `, - }); - - await using proc = Bun.spawn({ - cmd: [bunExe(), "run.js"], - env: bunEnv, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect({ stdout, stderr, exitCode }).toMatchObject({ - stdout: expect.stringContaining('"isError":true'), - exitCode: 0, - }); - const out = JSON.parse(stdout.trim()); - expect(out.message).not.toBe("undefined"); - expect(out.message).toContain("this-preload-does-not-exist"); +test("Worker preload: unresolvable module surfaces the resolve error, not 'undefined'", () => { + let caught: unknown; + try { + new Worker(new URL("worker-fixture-preload-entry.js", import.meta.url).href, { + preload: ["./this-preload-does-not-exist.js"], + }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + const message = String((caught as Error).message); + expect(message).not.toBe("undefined"); + expect(message).toContain("this-preload-does-not-exist"); });