From 50627ea36624fcfd6c0fd8786042d63c7ad4c8bd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:25:01 +0000 Subject: [PATCH 1/4] worker: resolve a relative specifier from the cwd at construction The worker thread resolved the entry point against the live FileSystem.top_level_dir, which process.chdir() rewrites. A chdir() between new Worker() and the thread's start changed which module loaded. WebWorker::create() now copies the cwd and spin() resolves from that copy through Transpiler::resolve_entry_point_from(). --- src/bundler/transpiler.rs | 41 ++++++++++++------- src/jsc/web_worker.rs | 35 ++++++++++++---- .../js/web/workers/worker-entry-point.test.ts | 41 ++++++++++++++++++- 3 files changed, 93 insertions(+), 24 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 1957363dd686..13486ed13564 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -419,10 +419,13 @@ impl<'a> Transpiler<'a> { } } - fn _resolve_entry_point(&mut self, entry_point: &[u8]) -> crate::Result { - let top_level_dir = self.fs().top_level_dir; + fn _resolve_entry_point( + &mut self, + source_dir: &[u8], + entry_point: &[u8], + ) -> crate::Result { let first = match self.resolver.resolve_with_framework( - top_level_dir, + source_dir, entry_point, bun_ast::ImportKind::EntryPointBuild, ) { @@ -446,11 +449,10 @@ impl<'a> Transpiler<'a> { prefixed.extend_from_slice(entry_point); // `Resolver::resolve` interns the path internally, // so the heap buffer can drop after the call. - if let Ok(r) = self.resolver.resolve( - top_level_dir, - &prefixed, - bun_ast::ImportKind::EntryPointBuild, - ) { + if let Ok(r) = + self.resolver + .resolve(source_dir, &prefixed, bun_ast::ImportKind::EntryPointBuild) + { if !r.flags.is_external() { return Ok(r); } @@ -463,7 +465,19 @@ impl<'a> Transpiler<'a> { /// Resolve an entry-point specifier, busting the directory cache and /// retrying once on failure before reporting the error to the log. pub fn resolve_entry_point(&mut self, entry_point: &[u8]) -> crate::Result { - match self._resolve_entry_point(entry_point) { + let top_level_dir = self.fs().top_level_dir; + self.resolve_entry_point_from(top_level_dir, entry_point) + } + + /// [`Self::resolve_entry_point`] with a relative or bare `entry_point` + /// resolved from the absolute directory `source_dir` instead of the + /// current working directory. + pub fn resolve_entry_point_from( + &mut self, + source_dir: &[u8], + entry_point: &[u8], + ) -> crate::Result { + match self._resolve_entry_point(source_dir, entry_point) { Ok(r) => self.reject_unbundleable_entry_point(r, entry_point), Err(err) => { let mut cache_bust_buf = bun_paths::path_buffer_pool::get(); @@ -476,9 +490,7 @@ impl<'a> Transpiler<'a> { // so compute `busted` directly instead. let busted: bool = 'name: { // Neither buster name below would fit `cache_bust_buf`. - if self.fs().top_level_dir.len() + entry_point.len() + 4 - > bun_paths::MAX_PATH_BYTES - { + if source_dir.len() + entry_point.len() + 4 > bun_paths::MAX_PATH_BYTES { break 'name false; } if bun_paths::is_absolute(entry_point) { @@ -502,12 +514,11 @@ impl<'a> Transpiler<'a> { // `".."` needs no platform separator rewrite. let parts: [&[u8]; 2] = [entry_point, b".."]; - let top_level_dir = self.fs().top_level_dir; let buster_name = bun_paths::resolve_path::join_abs_string_buf_z::< bun_paths::platform::Auto, >( - top_level_dir, &mut cache_bust_buf[..], &parts + source_dir, &mut cache_bust_buf[..], &parts ); self.resolver.bust_dir_cache( bun_paths::string_paths::without_trailing_slash_windows_path( @@ -518,7 +529,7 @@ impl<'a> Transpiler<'a> { // Only re-query if we previously had something cached. if busted { - if let Ok(result) = self._resolve_entry_point(entry_point) { + if let Ok(result) = self._resolve_entry_point(source_dir, entry_point) { return self.reject_unbundleable_entry_point(result, entry_point); } // ignore this error, we will print the original error diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 95d4acfeb292..d8be36a117bf 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -83,6 +83,10 @@ pub struct WebWorker { exec_argv_len: usize, inherit_exec_argv: bool, unresolved_specifier: Box<[u8]>, + /// The working directory when `new Worker()` ran. `spin()` resolves + /// `unresolved_specifier` from it, so a `process.chdir()` between + /// construction and thread start does not change which module loads. + cwd: Box<[u8]>, preloads: Vec>, name: bun_core::ZBox, @@ -304,6 +308,10 @@ impl WebWorker { log!("[{}] create", this_context_id); let spec_slice = specifier_str.to_utf8(); + // Copied, not borrowed: `process.chdir()` rewrites the buffer behind + // `top_level_dir` in place. + // SAFETY: `parent` is the calling thread's live VM. + let cwd: Box<[u8]> = Box::from(unsafe { (*parent).top_level_dir() }); let mut temp_log = bun_ast::Log::default(); // SAFETY: `parent` is the calling thread's live VM (BACKREF); borrows // are scoped to each statement. @@ -336,7 +344,13 @@ impl WebWorker { // SAFETY: `parent` is the live VM on the calling (parent) thread; // `resolve_entry_point_specifier` takes the raw pointer. if let Some(preload) = unsafe { - resolve_entry_point_specifier(parent, utf8_slice.slice(), error_message, temp_log) + resolve_entry_point_specifier( + parent, + &cwd, + utf8_slice.slice(), + error_message, + temp_log, + ) } { preloads.push(preload.to_vec().into_boxed_slice()); } @@ -413,6 +427,7 @@ impl WebWorker { exec_argv_len, inherit_exec_argv, unresolved_specifier: spec_slice.slice().to_vec().into_boxed_slice(), + cwd, preloads, name: if name_str.is_empty() { bun_core::ZBox::default() @@ -803,15 +818,16 @@ impl WebWorker { vm.as_mut().preload.clone_from(&self.preloads); // Resolve the entry point on the worker thread (the parent only stored - // the raw specifier). The returned slice is BORROWED — every exit from - // spin() goes through shutdown() which is noreturn, so a `defer free` - // here would never run anyway. + // the raw specifier and its cwd). The returned slice is BORROWED — every + // exit from 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. let path = match unsafe { resolve_entry_point_specifier( vm_ptr, + &self.cwd, &self.unresolved_specifier, &mut resolve_error, vm_log, @@ -1263,9 +1279,9 @@ fn on_unhandled_rejection( } /// Resolve a worker entry-point specifier to a path the module loader can -/// consume. The returned slice is BORROWED — it aliases `str`, the -/// standalone module graph, or the resolver's arena; the caller must NOT -/// free it. +/// consume; a relative or bare `str` resolves from `cwd`. The returned slice +/// is BORROWED — it aliases `str`, the standalone module graph, or the +/// resolver's arena; the caller must NOT free it. /// /// # Safety /// `parent` must point at this thread's live `VirtualMachine`. Passed as a raw @@ -1274,6 +1290,7 @@ fn on_unhandled_rejection( /// the single expression. unsafe fn resolve_entry_point_specifier<'s>( parent: *mut VirtualMachine, + cwd: &[u8], str: &'s [u8], error_message: &mut BunString, log: &mut bun_ast::Log, @@ -1317,7 +1334,9 @@ unsafe fn resolve_entry_point_specifier<'s>( let global = unsafe { (*parent).global }; // SAFETY: same as above — `parent`'s `transpiler` is mutated only on its // owning thread (the caller's thread per fn contract). - let resolved_entry_point = match unsafe { (*parent).transpiler.resolve_entry_point(str) } { + let resolved_entry_point = match unsafe { + (*parent).transpiler.resolve_entry_point_from(cwd, str) + } { Ok(r) => r, Err(_) => { // `global` valid for VM lifetime; safe ZST-handle deref (panics on null). diff --git a/test/js/web/workers/worker-entry-point.test.ts b/test/js/web/workers/worker-entry-point.test.ts index 6d3fbcd9d9ce..fc7aea38b25c 100644 --- a/test/js/web/workers/worker-entry-point.test.ts +++ b/test/js/web/workers/worker-entry-point.test.ts @@ -46,7 +46,9 @@ describe.concurrent("package.json imports alias as the entry point", () => { expect(stderr).toBe(""); expect(stdout).toBe("message: hi\nclosed\n"); expect(exitCode).toBe(0); - }); + // LeakSanitizer's check at exit costs 4-5 s of wall time on a debug ASAN build by itself (`bun -e 1` + // with detect_leaks=1 takes as long), which is the whole default budget. + }, 30_000); test("an alias of a builtin fires the error event", async () => { // `new Worker("node:fs")` fails to resolve. An alias of a builtin resolves to the builtin marked @@ -66,3 +68,40 @@ describe.concurrent("package.json imports alias as the entry point", () => { expect(exitCode).toBe(0); }); }); + +describe.concurrent("relative specifier resolves from the cwd at construction", () => { + // The worker thread resolves the specifier some time after `new Worker()` returns. A process.chdir() + // in between must not change which file the specifier names. Several workers are created back to + // back so that the later threads have not resolved anything yet when chdir runs. + test.each(["Worker", "worker_threads.Worker"])("%s: process.chdir() right after new Worker()", async kind => { + const post = + kind === "Worker" ? `postMessage(where);` : `require("node:worker_threads").parentPort.postMessage(where);`; + const { stdout, stderr, exitCode } = await runWorkerFixture({ + "worker.js": `const where = "top"; ${post}`, + "other/worker.js": `const where = "other"; ${post}`, + "main.js": ` + const WorkerClass = ${kind === "Worker" ? `globalThis.Worker` : `require("node:worker_threads").Worker`}; + function start() { + const worker = new WorkerClass("./worker.js"); + return new Promise(resolve => { + const done = value => { resolve(value); worker.terminate(); }; + if (WorkerClass === globalThis.Worker) { + worker.onmessage = event => done(event.data); + worker.onerror = event => done("error: " + event.message); + } else { + worker.on("message", done); + worker.on("error", error => done("error: " + error.message)); + } + }); + } + const before = [start(), start(), start(), start()]; + process.chdir("other"); + const after = start(); + Promise.all([...before, after]).then(results => console.log(JSON.stringify(results))); + `, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["top", "top", "top", "top", "other"]); + expect(exitCode).toBe(0); + }); +}); From 6007e9387af560dead805eacf57bf3e71a1f5962 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:10:19 +0000 Subject: [PATCH 2/4] test: cover a relative preload next to the entry point, group the cases with describe.each --- .../js/web/workers/worker-entry-point.test.ts | 71 ++++++++++--------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/test/js/web/workers/worker-entry-point.test.ts b/test/js/web/workers/worker-entry-point.test.ts index fc7aea38b25c..d62e21c8b6b4 100644 --- a/test/js/web/workers/worker-entry-point.test.ts +++ b/test/js/web/workers/worker-entry-point.test.ts @@ -69,39 +69,46 @@ describe.concurrent("package.json imports alias as the entry point", () => { }); }); -describe.concurrent("relative specifier resolves from the cwd at construction", () => { +describe.concurrent("relative specifier and preload resolve from the cwd at construction", () => { // The worker thread resolves the specifier some time after `new Worker()` returns. A process.chdir() - // in between must not change which file the specifier names. Several workers are created back to - // back so that the later threads have not resolved anything yet when chdir runs. - test.each(["Worker", "worker_threads.Worker"])("%s: process.chdir() right after new Worker()", async kind => { - const post = - kind === "Worker" ? `postMessage(where);` : `require("node:worker_threads").parentPort.postMessage(where);`; - const { stdout, stderr, exitCode } = await runWorkerFixture({ - "worker.js": `const where = "top"; ${post}`, - "other/worker.js": `const where = "other"; ${post}`, - "main.js": ` - const WorkerClass = ${kind === "Worker" ? `globalThis.Worker` : `require("node:worker_threads").Worker`}; - function start() { - const worker = new WorkerClass("./worker.js"); - return new Promise(resolve => { - const done = value => { resolve(value); worker.terminate(); }; - if (WorkerClass === globalThis.Worker) { - worker.onmessage = event => done(event.data); - worker.onerror = event => done("error: " + event.message); - } else { - worker.on("message", done); - worker.on("error", error => done("error: " + error.message)); - } - }); - } - const before = [start(), start(), start(), start()]; - process.chdir("other"); - const after = start(); - Promise.all([...before, after]).then(results => console.log(JSON.stringify(results))); - `, + // in between must not change which file the specifier (or a relative `preload`) names. Several + // workers are created back to back so that the later threads have not resolved anything yet when + // chdir runs. + describe.each(["Worker", "worker_threads.Worker"])("%s", kind => { + test("process.chdir() right after new Worker()", async () => { + const post = + kind === "Worker" + ? `postMessage(where + " " + globalThis.preloaded);` + : `require("node:worker_threads").parentPort.postMessage(where + " " + globalThis.preloaded);`; + const { stdout, stderr, exitCode } = await runWorkerFixture({ + "preload.js": `globalThis.preloaded = "top";`, + "worker.js": `const where = "top"; ${post}`, + "other/preload.js": `globalThis.preloaded = "other";`, + "other/worker.js": `const where = "other"; ${post}`, + "main.js": ` + const WorkerClass = ${kind === "Worker" ? `globalThis.Worker` : `require("node:worker_threads").Worker`}; + function start() { + const worker = new WorkerClass("./worker.js", { preload: "./preload.js" }); + return new Promise(resolve => { + const done = value => { resolve(value); worker.terminate(); }; + if (WorkerClass === globalThis.Worker) { + worker.onmessage = event => done(event.data); + worker.onerror = event => done("error: " + event.message); + } else { + worker.on("message", done); + worker.on("error", error => done("error: " + error.message)); + } + }); + } + const before = [start(), start(), start(), start()]; + process.chdir("other"); + const after = start(); + Promise.all([...before, after]).then(results => console.log(JSON.stringify(results))); + `, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["top top", "top top", "top top", "top top", "other other"]); + expect(exitCode).toBe(0); }); - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual(["top", "top", "top", "top", "other"]); - expect(exitCode).toBe(0); }); }); From 08644425e05771fc922e8656cadc9ed8ce226add Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:15:03 +0000 Subject: [PATCH 3/4] shorten the new comments --- src/bundler/transpiler.rs | 5 ++--- src/jsc/web_worker.rs | 11 ++++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 13486ed13564..38d2fdaf3b48 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -469,9 +469,8 @@ impl<'a> Transpiler<'a> { self.resolve_entry_point_from(top_level_dir, entry_point) } - /// [`Self::resolve_entry_point`] with a relative or bare `entry_point` - /// resolved from the absolute directory `source_dir` instead of the - /// current working directory. + /// [`Self::resolve_entry_point`], but a relative or bare `entry_point` + /// resolves from the absolute `source_dir` instead of the cwd. pub fn resolve_entry_point_from( &mut self, source_dir: &[u8], diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index d8be36a117bf..5ce4dcbec5e1 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -83,9 +83,8 @@ pub struct WebWorker { exec_argv_len: usize, inherit_exec_argv: bool, unresolved_specifier: Box<[u8]>, - /// The working directory when `new Worker()` ran. `spin()` resolves - /// `unresolved_specifier` from it, so a `process.chdir()` between - /// construction and thread start does not change which module loads. + /// The cwd when `new Worker()` ran; `unresolved_specifier` resolves from + /// it, not from wherever a later `process.chdir()` went. cwd: Box<[u8]>, preloads: Vec>, name: bun_core::ZBox, @@ -817,10 +816,8 @@ impl WebWorker { // `preload: Vec>` — clone the boxes (cheap, ≤handful). vm.as_mut().preload.clone_from(&self.preloads); - // Resolve the entry point on the worker thread (the parent only stored - // the raw specifier and its cwd). The returned slice is BORROWED — every - // exit from spin() goes through shutdown() which is noreturn, so a - // `defer free` here would never run anyway. + // Resolve the entry point here, from the cwd `create()` captured. The + // returned slice is BORROWED; every exit below ends in `shutdown()`. let mut resolve_error = BunString::EMPTY; let vm_log = vm.log_mut().unwrap(); // SAFETY: `vm_ptr` is the live worker-thread VM. From 52758cdb68b75688977925c714df5c25b7055e81 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:07:52 +0000 Subject: [PATCH 4/4] ci: retrigger