diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 1e45ac9687a1..6a7dfbcf6306 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2445,9 +2445,9 @@ impl<'a> Resolver<'a> { } } - /// Bust the directory cache for the given path. - /// See `assertValidCacheKey` for requirements on the input + /// Bust the directory cache for the given path, which may end in a separator. pub fn bust_dir_cache(&mut self, path: &[u8]) -> bool { + let path = strings::without_trailing_slash_windows_path(path); Self::assert_valid_cache_key(path); let first_bust = self.fs_mut().fs.bust_entries_cache(path); let second_bust = self.dir_cache_mut().remove(path); @@ -2469,10 +2469,7 @@ impl<'a> Resolver<'a> { specifier: &[u8], ) -> bool { if bun_paths::is_absolute(specifier) { - let dir = bun_paths::dirname_platform(specifier, bun_paths::Platform::AUTO); - let a = self.bust_dir_cache(dir); - let b = self.bust_dir_cache(specifier); - return a || b; + return self.bust_dir_cache_and_parent(specifier); } if !(specifier.starts_with(b"./") || specifier.starts_with(b"../")) { @@ -2487,10 +2484,16 @@ impl<'a> Resolver<'a> { bun_paths::Platform::AUTO, specifier, ); - let dir = bun_paths::dirname_platform(joined, bun_paths::Platform::AUTO); + self.bust_dir_cache_and_parent(joined) + } + + fn bust_dir_cache_and_parent(&mut self, path: &[u8]) -> bool { + // Strip before taking the dirname: on Windows the dirname of `a\hello\` is `a\hello`. + let path = strings::without_trailing_slash_windows_path(path); + let dir = bun_paths::dirname_platform(path, bun_paths::Platform::AUTO); let a = self.bust_dir_cache(dir); - let b = self.bust_dir_cache(joined); + let b = self.bust_dir_cache(path); a || b } diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 6c6d3657029c..80a63dd27c6b 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -99,6 +99,33 @@ devTest("importing a file before it is created", { await c.expectMessage("value: 456"); }, }); +// The dev server busts the resolver's directory cache after every failed +// resolution. The cache keys derived from a specifier ending in a slash kept +// that slash, which fails the resolver's cache key assertion in debug and ASAN +// builds and took the whole dev server down instead of showing the error. +devTest("importing a directory with a trailing slash before it is created", { + files: { + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.ts"], + }), + "index.ts": ` + import { abc } from './second/'; + console.log('value: ' + abc); + `, + }, + async test(dev) { + await using c = await dev.client("/", { + errors: [`index.ts:1:21: error: Could not resolve: "./second/"`], + }); + + await c.expectReload(async () => { + await dev.write("index.ts", `console.log('value: ' + 789);`); + }); + + await c.expectMessage("value: 789"); + }, +}); devTest("default export same-scope handling", { files: { "index.html": emptyHtmlFile({ diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index eaca7232abc3..2c0f3115279c 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -579,3 +579,57 @@ describe.concurrent("modules that fail to print", () => { expect(exitCode).toBe(1); }); }); + +describe.concurrent("bun build --watch", () => { + // Resolves once `needle` has been written to `stream`. The stream only ends + // early if the process dies, in which case the output so far (the crash + // report) is the error message. + async function outputUntil(stream: ReadableStream, needle: string): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let output = ""; + try { + while (!output.includes(needle)) { + const { value, done } = await reader.read(); + if (done) throw new Error(`stream ended before ${JSON.stringify(needle)} appeared. Output:\n${output}`); + output += decoder.decode(value, { stream: true }); + } + } finally { + reader.releaseLock(); + } + return output; + } + + // In watch mode every failed resolution busts the resolver's directory + // cache for the specifier. The cache keys derived from these specifiers + // ended in a separator, which fails the resolver's cache key assertion in + // debug and ASAN builds, so the process aborted instead of reporting the + // resolution error. + test.each([ + ["relative import ending in a slash", (_dir: string) => "./missing/"], + ["absolute import ending in a slash", (dir: string) => join(dir, "missing") + "/"], + ["absolute import with a doubled separator", (dir: string) => dir + path.sep + path.sep + "missing"], + ])("reports an unresolvable %s and keeps watching", async (_kind, specifierFor) => { + using dir = tempDir("build-watch-trailing-slash", {}); + const specifier = specifierFor(String(dir)); + const entry = join(String(dir), "index.ts"); + writeFileSync(entry, `import ${JSON.stringify(specifier)};\n`); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--watch", "index.ts", "--outdir", "dist"], + env: bunEnv, + cwd: String(dir), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + await outputUntil(proc.stderr, `error: Could not resolve: "${specifier}"`); + expect(proc.exitCode).toBeNull(); + + // The failed build left the watcher running: fixing the entry point rebuilds. + writeFileSync(entry, `console.log("fixed");\n`); + expect(await outputUntil(proc.stdout, "index.js")).toContain("Bundled 1 module"); + expect(await Bun.file(join(String(dir), "dist", "index.js")).text()).toContain("fixed"); + }); +});