From 21d767694f6d14f27dddcbd8abd103a044c95af9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:18:28 +0000 Subject: [PATCH 1/4] resolver: bust the real-path alias of a symlinked directory with the dir cache A directory reached through a symlink is cached under both the symlink path and its real path. bust_dir_cache only invalidated the key it was given, so a resolve of the realpath'd result could hit a stale listing and fail with "Cannot find module ... from ''". Follow the DirInfo.abs_real_path alias and bust it too. --- src/resolver/resolver.rs | 28 ++++++++++++++++-- test/js/bun/resolve/resolve.test.ts | 46 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 9793c8333d38..927afdfbe0b9 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2445,16 +2445,38 @@ impl<'a> Resolver<'a> { /// See `assertValidCacheKey` for requirements on the input pub fn bust_dir_cache(&mut self, path: &[u8]) -> bool { Self::assert_valid_cache_key(path); + // A directory reached through a symlink is cached under two keys: the + // path as given and its real path (`DirInfo.abs_real_path`). Resolving + // a file in it returns the realpath'd result, which a follow-up + // resolve looks up under the real-path key. Bust that alias too, or it + // keeps serving a stale listing after this bust. Read the alias before + // `remove` drops the `DirInfo`. + let real_path: Option<&'static [u8]> = self + .dir_cache_mut() + .get(path) + .map(|info| { + bun_paths::string_paths::without_trailing_slash_windows_path(info.abs_real_path) + }) + .filter(|real| !real.is_empty() && *real != path); let first_bust = self.fs_mut().fs.bust_entries_cache(path); let second_bust = self.dir_cache_mut().remove(path); + let alias_bust = match real_path { + Some(real) => { + let a = self.fs_mut().fs.bust_entries_cache(real); + let b = self.dir_cache_mut().remove(real); + a || b + } + None => false, + }; bun_core::scoped_log!( ResolverDev, - "Bust {} = {}, {}", + "Bust {} = {}, {}, alias {}", bstr::BStr::new(path), first_bust, - second_bust + second_bust, + alias_bust ); - first_bust || second_bust + first_bust || second_bust || alias_bust } /// bust both the named file and a parent directory, because `./hello` can resolve diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 554b7d8e1046..b45c2510be02 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -899,6 +899,52 @@ describe.if(isWindows)("#30839 - imports entry pointing at a scoped package", () }); }); +// A directory reached through a symlink is cached under two keys: the symlink +// path and its real path. Requiring a file in it caches both listings; when a +// sibling file is created afterwards, only the symlink-side key was busted, so +// the follow-up resolve of the realpath'd result hit a stale listing and +// failed with "Cannot find module ... from ''". macOS always hits this because +// os.tmpdir() is behind the /var -> /private/var symlink. +// https://github.com/oven-sh/bun/issues/40585 +it("requires a newly created sibling .ts file in a directory behind a symlink", async () => { + using dir = tempDir("resolve-symlink-alias", { + "main.ts": ` + import { mkdir, writeFile } from "node:fs/promises"; + import { createRequire } from "node:module"; + import { join } from "node:path"; + import { pathToFileURL } from "node:url"; + + const dir = join(import.meta.dir, "link", "warm"); + await mkdir(dir, { recursive: true }); + const a = join(dir, "a.ts"); + await writeFile(a, "export const A = 1;\\n"); + createRequire(pathToFileURL(a).href)(a); + const b = join(dir, "b.ts"); + await writeFile(b, "export const B = 2;\\n"); + const result = createRequire(pathToFileURL(b).href)(b); + console.log(JSON.stringify(result)); + `, + }); + const root = String(dir); + mkdirSync(join(root, "real")); + // The symlink must exist before the child process starts, like /var on + // macOS. "junction" is the Windows-appropriate symlink kind for directories. + symlinkSync(join(root, "real"), join(root, "link"), "junction"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe('{"B":2}\n'); + expect(exitCode).toBe(0); +}); + // dirInfoCachedMaybeLog reads the rfs.entries cache without checking the union // tag. If readDirectory() previously failed with a non-ENOENT error (e.g. // EACCES), a `.err` variant is stored there; re-resolving the directory after From e2c47d2e5e124196d83932fa4130b4c99fca2966 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:32:25 +0000 Subject: [PATCH 2/4] resolver: use the file-local strings alias for without_trailing_slash_windows_path --- src/resolver/resolver.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 927afdfbe0b9..20c46765cab4 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2454,9 +2454,7 @@ impl<'a> Resolver<'a> { let real_path: Option<&'static [u8]> = self .dir_cache_mut() .get(path) - .map(|info| { - bun_paths::string_paths::without_trailing_slash_windows_path(info.abs_real_path) - }) + .map(|info| strings::without_trailing_slash_windows_path(info.abs_real_path)) .filter(|real| !real.is_empty() && *real != path); let first_bust = self.fs_mut().fs.bust_entries_cache(path); let second_bust = self.dir_cache_mut().remove(path); From d335a716e0dbd4a59e2d6ba7525efa246476b58d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:33:26 +0000 Subject: [PATCH 3/4] resolver: shorten the alias-bust comment --- src/resolver/resolver.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 20c46765cab4..bb3f4d9bfd3f 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2445,12 +2445,9 @@ impl<'a> Resolver<'a> { /// See `assertValidCacheKey` for requirements on the input pub fn bust_dir_cache(&mut self, path: &[u8]) -> bool { Self::assert_valid_cache_key(path); - // A directory reached through a symlink is cached under two keys: the - // path as given and its real path (`DirInfo.abs_real_path`). Resolving - // a file in it returns the realpath'd result, which a follow-up - // resolve looks up under the real-path key. Bust that alias too, or it - // keeps serving a stale listing after this bust. Read the alias before - // `remove` drops the `DirInfo`. + // A symlinked directory is also cached under its real path + // (`DirInfo.abs_real_path`), and resolve results are realpath'd, so a + // follow-up resolve reads the real-path key. Bust that alias too. let real_path: Option<&'static [u8]> = self .dir_cache_mut() .get(path) From 01a997bd3e49361e20ac90497d235ef38f9bb30e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:49:54 +0000 Subject: [PATCH 4/4] ci: retrigger