From 9f39a6e7425c483ed9b5c21e00a14c12646f0265 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:09:31 +0000 Subject: [PATCH 01/14] watcher: watch the parent directory of imports outside cwd on POSIX is_eligible_directory() gated the parent-directory watch on the path containing the cwd as a substring. A module reached via a relative import that climbs out of the cwd (../shared/lib.ts when running from app/) or via a resolved symlink therefore got only the per-inode file watch. The first atomic rename-save (write temp + rename over: vim, sed -i, prettier, JetBrains safe-write, git checkout) replaces that inode, the kernel never delivers IN_DELETE_SELF while the transpiler's fd keeps the old inode alive, and with no directory watch there is no IN_MOVED_TO to recover from. Under --watch every later save of that file was ignored; under --hot every later reload re-transpiled the stale pre-save source from the pinned fd. On Linux/macOS the platform watchers are inode-based and have no rooted-tree restriction, so drop the cwd check there (node_modules stays excluded). Windows keeps the check because the platform watcher is a single recursive ReadDirectoryChangesW rooted at cwd. on_maybe_watch_directory now calls the same predicate instead of open-coding it. --- src/watcher/Watcher.rs | 22 +++++++++---- test/cli/hot/watch.test.ts | 66 +++++++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index 177487c6fe4c..4ea380c69aef 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -770,7 +770,20 @@ impl Watcher { #[inline] fn is_eligible_directory(&self, dir: &[u8]) -> bool { - strings::contains(dir, self.top_level_dir()) && !strings::contains(dir, b"node_modules") + if strings::contains(dir, b"node_modules") { + return false; + } + // On Windows the platform watcher is a single recursive + // ReadDirectoryChangesW rooted at `top_level_dir`; directories outside + // it cannot be watched here. On Linux/macOS the inode-based watchers + // have no such restriction, and the parent-directory watch is what + // recovers from an atomic rename-save (write temp + rename over, which + // replaces the file's inode and orphans the per-file watch), so watch + // the parent of every imported file regardless of cwd. + if cfg!(windows) { + return strings::contains(dir, self.top_level_dir()); + } + true } #[inline] @@ -953,12 +966,7 @@ impl Watcher { } pub(crate) fn on_maybe_watch_directory(&mut self, file_path: &[u8], dir_fd: Fd) { - // We don't want to watch: - // - Directories outside the root directory - // - Directories inside node_modules - if !strings::contains(file_path, b"node_modules") - && strings::contains(file_path, self.top_level_dir()) - { + if self.is_eligible_directory(file_path) { let _ = self.add_directory::(dir_fd, file_path, Self::get_hash(file_path)); } } diff --git a/test/cli/hot/watch.test.ts b/test/cli/hot/watch.test.ts index 4a6a5c2908b7..38244832ffc2 100644 --- a/test/cli/hot/watch.test.ts +++ b/test/cli/hot/watch.test.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, forEachLine, isBroken, isWindows, tempDir } from "harness"; -import { writeFile } from "node:fs/promises"; +import { rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; describe.todoIf(isBroken && isWindows)("--watch works", async () => { @@ -48,3 +48,67 @@ describe.todoIf(isBroken && isWindows)("--watch works", async () => { }); } }); + +// A module imported from outside the process cwd (monorepo sibling package, +// or the entrypoint itself when bun is launched from a different directory) +// used to get only a per-inode inotify/kqueue watch and no parent-directory +// watch. The first atomic rename-save (write temp + rename over; the default +// for vim, sed -i, prettier, JetBrains safe-write, git checkout) replaces the +// inode and orphans that watch, so the save and every later save of that file +// were missed, and under --hot the stale pre-save source was served forever +// from the pinned fd. +describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd", () => { + async function renameSave(path: string, content: string) { + await writeFile(path + ".next", content); + await rename(path + ".next", path); + } + + async function nextEval(iter: AsyncIterator): Promise { + while (true) { + const { value, done } = await iter.next(); + if (done) throw new Error("stream ended before an EVAL line"); + if (value.startsWith("EVAL ")) return value; + } + } + + for (const flag of ["--watch", "--hot"] as const) { + test.concurrent(flag, async () => { + await using dir = tempDir("watch-outside-cwd", { + "app/entry.ts": + `import { sh } from "../shared/lib.ts";\n` + + `globalThis.g = (globalThis.g ?? 0) + 1;\n` + + `console.log("EVAL g=" + globalThis.g + " shared=" + sh);\n`, + "shared/lib.ts": `export const sh = "V0";\n`, + }); + const appDir = join(String(dir), "app"); + const sharedLib = join(String(dir), "shared", "lib.ts"); + + await using proc = spawn({ + cmd: [bunExe(), flag, "--no-clear-screen", "entry.ts"], + cwd: appDir, + env: bunEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const stderr = proc.stderr.text(); + const iter = forEachLine(proc.stdout); + + expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); + + // Atomic rename-save of the out-of-cwd dependency. Before the fix this + // produced no reload at all (the per-inode watch is orphaned and the + // parent dir was not watched). + await renameSave(sharedLib, `export const sh = "V1";\n`); + const g2 = flag === "--hot" ? "2" : "1"; + expect(await nextEval(iter)).toBe(`EVAL g=${g2} shared=V1`); + + // Second rename-save on the (now new) inode. + await renameSave(sharedLib, `export const sh = "V2";\n`); + const g3 = flag === "--hot" ? "3" : "1"; + expect(await nextEval(iter)).toBe(`EVAL g=${g3} shared=V2`); + + proc.kill("SIGKILL"); + await proc.exited; + expect(await stderr).not.toContain("is not in the project directory"); + }); + } +}); From 4daf226ed9f9d43974df0fd88ee46effdf71ebae Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:56:01 +0000 Subject: [PATCH 02/14] watcher(linux): evict a per-file watch when its inode is orphaned Safety net for the parent-directory watch: add IN_ATTRIB to the per-file inotify mask and, when the pinned fd's st_nlink drops to 0 (the only event a rename-over delivers while bun still holds the old inode open; IN_DELETE_SELF waits for the last close), evict the watchlist entry so snapshot_fd_and_package_json cannot serve the stale fd on reload. IN_MOVE_SELF is treated the same. This recovers an atomic rename-save even when the parent-directory watch is absent (node_modules, or inotify_add_watch failed for the dir). Also drop the vacuous stderr not.toContain() assertion (the warning is Windows-only and the describe is skipIf(isWindows)), and add a directory-symlink test case: an in-cwd ./link/dep.ts resolved to its out-of-cwd real path hit the same gate. --- src/jsc/hot_reloader.rs | 28 ++++++++++++++++++++++++ src/watcher/INotifyWatcher.rs | 15 +++++++++++-- test/cli/hot/watch.test.ts | 40 +++++++++++++++++++++++++++++++---- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 3660c0526a98..7626d9fb5e7f 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -900,6 +900,34 @@ where }; } + // Linux safety net: the per-file inotify watch follows the + // inode. A rename-over unlinks the watched inode while bun + // still holds it open, which delivers only IN_ATTRIB for + // the link-count drop (IN_DELETE_SELF waits for the last + // close). Evict on st_nlink == 0 so the stale fd is not + // served by `snapshot_fd_and_package_json` on reload. + // IN_MOVE_SELF (the watched inode was renamed away) is + // treated the same: the module at the recorded path is + // gone. The parent-directory watch is the primary recovery + // path; this covers the case where that watch is absent. + #[cfg(any(target_os = "linux", target_os = "android"))] + if !event.op.contains(WatchOp::DELETE) + && event.op.intersects(WatchOp::METADATA | WatchOp::RENAME) + { + let fd = file_descriptors[event.index as usize]; + let orphaned = event.op.contains(WatchOp::RENAME) + || (fd.is_valid() + && bun_sys::fstat(fd) + .map(|st| st.st_nlink == 0) + .unwrap_or(false)); + if orphaned { + ctx.remove_at_index(bun_watcher::Kind::File, event.index, 0, &[]); + record_changed_path(file_path); + current_task.append(current_hash); + continue; + } + } + if self.verbose { Self::debug(format_args!( "File changed: {}", diff --git a/src/watcher/INotifyWatcher.rs b/src/watcher/INotifyWatcher.rs index 23637c21ca76..5dec03cef41c 100644 --- a/src/watcher/INotifyWatcher.rs +++ b/src/watcher/INotifyWatcher.rs @@ -130,8 +130,16 @@ impl INotifyWatcher { use bun_sys::linux::IN; debug_assert!(self.loaded); let old_count = self.watch_count.fetch_add(1, Ordering::Release); - let watch_file_mask = - IN::EXCL_UNLINK | IN::MOVE_SELF | IN::DELETE_SELF | IN::MOVED_TO | IN::MODIFY; + // IN_ATTRIB is the only event delivered when a held-open watched inode + // is unlinked by a rename-over (IN_DELETE_SELF waits for the last + // close). The hot-reloader's File arm fstats the pinned fd on + // METADATA and evicts when st_nlink == 0. + let watch_file_mask = IN::EXCL_UNLINK + | IN::MOVE_SELF + | IN::DELETE_SELF + | IN::MOVED_TO + | IN::MODIFY + | IN::ATTRIB; // SAFETY: fd is a valid inotify fd (loaded == true), pathname is NUL-terminated. let rc = unsafe { bun_sys::linux::inotify_add_watch(self.fd.native(), pathname.as_ptr(), watch_file_mask) @@ -539,6 +547,9 @@ fn watch_event_from_inotify_event(event: &Event, index: WatchItemIndex) -> Watch if (event.mask & IN::MODIFY) > 0 { op |= Op::WRITE; } + if (event.mask & IN::ATTRIB) > 0 { + op |= Op::METADATA; + } if (event.mask & IN::CREATE) > 0 { op |= Op::CREATE; } diff --git a/test/cli/hot/watch.test.ts b/test/cli/hot/watch.test.ts index 38244832ffc2..342fd3cb99fc 100644 --- a/test/cli/hot/watch.test.ts +++ b/test/cli/hot/watch.test.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, forEachLine, isBroken, isWindows, tempDir } from "harness"; -import { rename, writeFile } from "node:fs/promises"; +import { rename, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; describe.todoIf(isBroken && isWindows)("--watch works", async () => { @@ -87,9 +87,8 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" cmd: [bunExe(), flag, "--no-clear-screen", "entry.ts"], cwd: appDir, env: bunEnv, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "inherit"], }); - const stderr = proc.stderr.text(); const iter = forEachLine(proc.stdout); expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); @@ -108,7 +107,40 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" proc.kill("SIGKILL"); await proc.exited; - expect(await stderr).not.toContain("is not in the project directory"); }); } + + // A module reached through an in-cwd directory symlink is resolved to its + // real path (outside cwd) before being watched, so it hit the same gate. + test.concurrent("--hot via an in-cwd directory symlink to an out-of-cwd dir", async () => { + await using dir = tempDir("watch-symlink-outside-cwd", { + "app/entry.ts": + `import { sh } from "./link/dep.ts";\n` + + `globalThis.g = (globalThis.g ?? 0) + 1;\n` + + `console.log("EVAL g=" + globalThis.g + " shared=" + sh);\n`, + "realdir/dep.ts": `export const sh = "V0";\n`, + }); + const appDir = join(String(dir), "app"); + const realDep = join(String(dir), "realdir", "dep.ts"); + await symlink(join(String(dir), "realdir"), join(appDir, "link"), "dir"); + + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], + cwd: appDir, + env: bunEnv, + stdio: ["ignore", "pipe", "inherit"], + }); + const iter = forEachLine(proc.stdout); + + expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); + + await renameSave(realDep, `export const sh = "V1";\n`); + expect(await nextEval(iter)).toBe("EVAL g=2 shared=V1"); + + await renameSave(realDep, `export const sh = "V2";\n`); + expect(await nextEval(iter)).toBe("EVAL g=3 shared=V2"); + + proc.kill("SIGKILL"); + await proc.exited; + }); }); From 73b0f44fdff4817f11bba813d3fa41e778278988 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:57:28 +0000 Subject: [PATCH 03/14] trim comment-cop-flagged explanatory comments --- src/jsc/hot_reloader.rs | 14 ++++---------- src/watcher/INotifyWatcher.rs | 6 ++---- src/watcher/Watcher.rs | 10 +++------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 7626d9fb5e7f..4cdce7a5100a 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -900,16 +900,10 @@ where }; } - // Linux safety net: the per-file inotify watch follows the - // inode. A rename-over unlinks the watched inode while bun - // still holds it open, which delivers only IN_ATTRIB for - // the link-count drop (IN_DELETE_SELF waits for the last - // close). Evict on st_nlink == 0 so the stale fd is not - // served by `snapshot_fd_and_package_json` on reload. - // IN_MOVE_SELF (the watched inode was renamed away) is - // treated the same: the module at the recorded path is - // gone. The parent-directory watch is the primary recovery - // path; this covers the case where that watch is absent. + // inotify follows the inode: a rename-over delivers only + // IN_ATTRIB while bun holds the old inode open. Evict on + // st_nlink == 0 (or MOVE_SELF) so the reload re-opens by + // path instead of reading the stale fd. #[cfg(any(target_os = "linux", target_os = "android"))] if !event.op.contains(WatchOp::DELETE) && event.op.intersects(WatchOp::METADATA | WatchOp::RENAME) diff --git a/src/watcher/INotifyWatcher.rs b/src/watcher/INotifyWatcher.rs index 5dec03cef41c..96f06343a700 100644 --- a/src/watcher/INotifyWatcher.rs +++ b/src/watcher/INotifyWatcher.rs @@ -130,10 +130,8 @@ impl INotifyWatcher { use bun_sys::linux::IN; debug_assert!(self.loaded); let old_count = self.watch_count.fetch_add(1, Ordering::Release); - // IN_ATTRIB is the only event delivered when a held-open watched inode - // is unlinked by a rename-over (IN_DELETE_SELF waits for the last - // close). The hot-reloader's File arm fstats the pinned fd on - // METADATA and evicts when st_nlink == 0. + // IN_ATTRIB: a rename-over of a held-open watched inode delivers only + // the link-count ATTRIB (DELETE_SELF waits for the last close). let watch_file_mask = IN::EXCL_UNLINK | IN::MOVE_SELF | IN::DELETE_SELF diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index 4ea380c69aef..b56114e8eb06 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -773,13 +773,9 @@ impl Watcher { if strings::contains(dir, b"node_modules") { return false; } - // On Windows the platform watcher is a single recursive - // ReadDirectoryChangesW rooted at `top_level_dir`; directories outside - // it cannot be watched here. On Linux/macOS the inode-based watchers - // have no such restriction, and the parent-directory watch is what - // recovers from an atomic rename-save (write temp + rename over, which - // replaces the file's inode and orphans the per-file watch), so watch - // the parent of every imported file regardless of cwd. + // Windows' ReadDirectoryChangesW is rooted at cwd; on POSIX the + // parent-dir watch is what recovers from a rename-over, so allow it + // for any imported file's parent regardless of cwd. if cfg!(windows) { return strings::contains(dir, self.top_level_dir()); } From cc7369572b32f19ce2a45df1113a401823a20fc4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:00:17 +0000 Subject: [PATCH 04/14] collapse explanatory comments to single lines --- src/jsc/hot_reloader.rs | 5 +---- src/watcher/INotifyWatcher.rs | 3 +-- src/watcher/Watcher.rs | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 4cdce7a5100a..cc6c58fc3a41 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -900,10 +900,7 @@ where }; } - // inotify follows the inode: a rename-over delivers only - // IN_ATTRIB while bun holds the old inode open. Evict on - // st_nlink == 0 (or MOVE_SELF) so the reload re-opens by - // path instead of reading the stale fd. + // Rename-over of a held-open inode delivers only IN_ATTRIB; evict on st_nlink==0. #[cfg(any(target_os = "linux", target_os = "android"))] if !event.op.contains(WatchOp::DELETE) && event.op.intersects(WatchOp::METADATA | WatchOp::RENAME) diff --git a/src/watcher/INotifyWatcher.rs b/src/watcher/INotifyWatcher.rs index 96f06343a700..2e80dd88ecd6 100644 --- a/src/watcher/INotifyWatcher.rs +++ b/src/watcher/INotifyWatcher.rs @@ -130,8 +130,7 @@ impl INotifyWatcher { use bun_sys::linux::IN; debug_assert!(self.loaded); let old_count = self.watch_count.fetch_add(1, Ordering::Release); - // IN_ATTRIB: a rename-over of a held-open watched inode delivers only - // the link-count ATTRIB (DELETE_SELF waits for the last close). + // IN_ATTRIB catches the link-count drop when a held-open inode is renamed over. let watch_file_mask = IN::EXCL_UNLINK | IN::MOVE_SELF | IN::DELETE_SELF diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index b56114e8eb06..818966af3a9a 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -773,10 +773,8 @@ impl Watcher { if strings::contains(dir, b"node_modules") { return false; } - // Windows' ReadDirectoryChangesW is rooted at cwd; on POSIX the - // parent-dir watch is what recovers from a rename-over, so allow it - // for any imported file's parent regardless of cwd. if cfg!(windows) { + // ReadDirectoryChangesW is cwd-rooted; POSIX has no such restriction. return strings::contains(dir, self.top_level_dir()); } true From 05def42b92e7cb8f8673cfb302a961d504d34063 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:26:33 +0000 Subject: [PATCH 05/14] bake(DevServer): gate file rebuild on content-change ops now that IN_ATTRIB is delivered The per-file inotify mask now includes IN_ATTRIB, and DevServer shares that mask. Its Kind::File arm previously enqueued a rebuild for every delivered event, which was implicitly gated by the mask itself. Gate on WRITE/MOVE_TO/CREATE (or an evicting DELETE/RENAME/st_nlink==0) so chmod/touch/chown do not rebuild, and evict on st_nlink==0 so the per-file watch is re-armed on the new inode after a rename-over. --- src/runtime/bake/DevServer.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index f26d5e23b697..ed0b72e97e89 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -5789,9 +5789,16 @@ impl DevServer { match kind { bun_watcher::Kind::File => { - if event.op.contains(bun_watcher::Op::DELETE) - || event.op.contains(bun_watcher::Op::RENAME) - { + #[allow(unused_mut)] + let mut evict = event.op.contains(bun_watcher::Op::DELETE) + || event.op.contains(bun_watcher::Op::RENAME); + #[cfg(any(target_os = "linux", target_os = "android"))] + if !evict && event.op.contains(bun_watcher::Op::METADATA) { + let fd = slice.items_fd()[event.index as usize]; + evict = fd.is_valid() + && bun_sys::fstat(fd).map(|st| st.st_nlink == 0).unwrap_or(false); + } + if evict { // TODO: audit this line heavily self.bun_watcher.remove_at_index::( bun_watcher::Kind::File, @@ -5799,6 +5806,10 @@ impl DevServer { 0, &[], ); + } else if !event.op.intersects( + bun_watcher::Op::WRITE | bun_watcher::Op::MOVE_TO | bun_watcher::Op::CREATE, + ) { + continue; } // SAFETY: `ev_ptr` is this thread's exclusively-acquired From fd8c4fa5a4db82b596206af03ad90af2da5c0562 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:28:57 +0000 Subject: [PATCH 06/14] [autofix.ci] apply automated fixes --- src/runtime/bake/DevServer.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index ed0b72e97e89..f78d3a22d0c0 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -5796,7 +5796,9 @@ impl DevServer { if !evict && event.op.contains(bun_watcher::Op::METADATA) { let fd = slice.items_fd()[event.index as usize]; evict = fd.is_valid() - && bun_sys::fstat(fd).map(|st| st.st_nlink == 0).unwrap_or(false); + && bun_sys::fstat(fd) + .map(|st| st.st_nlink == 0) + .unwrap_or(false); } if evict { // TODO: audit this line heavily From 0d5b6b44a1071a6f2ca1045f35c9f06c3dde99a9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:09:39 +0000 Subject: [PATCH 07/14] bake(DevServer): scope the metadata-only skip to Linux Windows' create_watch_event leaves op empty for Added/RenamedNew, so the unguarded else-if would have dropped those events. Move the skip inside the existing #[cfg(linux/android)] METADATA block so Windows and macOS behavior is unchanged. --- src/runtime/bake/DevServer.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index f78d3a22d0c0..1fba44c21e49 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -5795,10 +5795,19 @@ impl DevServer { #[cfg(any(target_os = "linux", target_os = "android"))] if !evict && event.op.contains(bun_watcher::Op::METADATA) { let fd = slice.items_fd()[event.index as usize]; - evict = fd.is_valid() + if fd.is_valid() && bun_sys::fstat(fd) .map(|st| st.st_nlink == 0) - .unwrap_or(false); + .unwrap_or(false) + { + evict = true; + } else if !event.op.intersects( + bun_watcher::Op::WRITE + | bun_watcher::Op::MOVE_TO + | bun_watcher::Op::CREATE, + ) { + continue; + } } if evict { // TODO: audit this line heavily @@ -5808,10 +5817,6 @@ impl DevServer { 0, &[], ); - } else if !event.op.intersects( - bun_watcher::Op::WRITE | bun_watcher::Op::MOVE_TO | bun_watcher::Op::CREATE, - ) { - continue; } // SAFETY: `ev_ptr` is this thread's exclusively-acquired From 11033e15c37d41958ea0d8847e767f2497c286b4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:15:28 +0000 Subject: [PATCH 08/14] ci: retrigger From e95181e4a154361eca7d0d84bcc55d0bd6095d07 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:17:14 +0000 Subject: [PATCH 09/14] hot_reloader: adapt orphaned-inode eviction to raw-ptr ctx after rebase; test bare-name workspace import After rebasing onto main, `ctx` in NewHotReloader::on_file_update is a *mut Watcher and remove_at_index takes a LOCK const generic; call it the same way the adjacent DELETE eviction does. Add the workspace-root case: `import 'lib/index.js'` resolved through packages/app/node_modules/lib -> ../../lib with bun started from the workspace root. Everything is inside cwd and the real-path parent dir is watched, but the resolver's dir-entry cache is keyed by the node_modules spelling while the watchlist entry carries the real path, so the directory IN_MOVED_TO matched nothing and the rename-save was dropped. The per-file IN_ATTRIB / st_nlink==0 eviction recovers it. --- src/jsc/hot_reloader.rs | 10 +++++++- test/cli/hot/watch.test.ts | 48 +++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index cc6c58fc3a41..b938e8f2f053 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -912,7 +912,15 @@ where .map(|st| st.st_nlink == 0) .unwrap_or(false)); if orphaned { - ctx.remove_at_index(bun_watcher::Kind::File, event.index, 0, &[]); + // SAFETY: same as the DELETE eviction above. + unsafe { + (*ctx).remove_at_index::( + bun_watcher::Kind::File, + event.index, + 0, + &[], + ) + }; record_changed_path(file_path); current_task.append(current_hash); continue; diff --git a/test/cli/hot/watch.test.ts b/test/cli/hot/watch.test.ts index 342fd3cb99fc..850e31ae79fe 100644 --- a/test/cli/hot/watch.test.ts +++ b/test/cli/hot/watch.test.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, forEachLine, isBroken, isWindows, tempDir } from "harness"; -import { rename, symlink, writeFile } from "node:fs/promises"; +import { mkdir, rename, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; describe.todoIf(isBroken && isWindows)("--watch works", async () => { @@ -143,4 +143,50 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" proc.kill("SIGKILL"); await proc.exited; }); + + // Workspace package imported by bare name from the workspace root: everything + // is inside cwd and the real-path parent dir IS watched, but the resolver's + // dir-entry cache is keyed by the `node_modules/lib/` spelling while the + // watchlist entry carries the real path, so the directory IN_MOVED_TO used to + // find no matching file entry and the rename-save was dropped. The per-file + // IN_ATTRIB (st_nlink == 0) eviction recovers it. + for (const flag of ["--watch", "--hot"] as const) { + test.concurrent(`${flag} a workspace package imported by bare name through node_modules`, async () => { + await using dir = tempDir("watch-workspace-bare-import", { + "package.json": JSON.stringify({ name: "root", private: true, workspaces: ["packages/*"] }), + "packages/app/package.json": JSON.stringify({ name: "app", dependencies: { lib: "workspace:*" } }), + "packages/app/entry.ts": + `import { sh } from "lib/index.js";\n` + + `globalThis.g = (globalThis.g ?? 0) + 1;\n` + + `console.log("EVAL g=" + globalThis.g + " shared=" + sh);\n`, + "packages/lib/package.json": JSON.stringify({ name: "lib", version: "1.0.0", main: "index.js" }), + "packages/lib/index.js": `export const sh = "V0";\n`, + }); + const root = String(dir); + const libIndex = join(root, "packages", "lib", "index.js"); + await mkdir(join(root, "packages", "app", "node_modules"), { recursive: true }); + await symlink(join("..", "..", "lib"), join(root, "packages", "app", "node_modules", "lib"), "dir"); + + await using proc = spawn({ + cmd: [bunExe(), flag, "--no-clear-screen", "packages/app/entry.ts"], + cwd: root, + env: bunEnv, + stdio: ["ignore", "pipe", "inherit"], + }); + const iter = forEachLine(proc.stdout); + + expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); + + await renameSave(libIndex, `export const sh = "V1";\n`); + const g2 = flag === "--hot" ? "2" : "1"; + expect(await nextEval(iter)).toBe(`EVAL g=${g2} shared=V1`); + + await renameSave(libIndex, `export const sh = "V2";\n`); + const g3 = flag === "--hot" ? "3" : "1"; + expect(await nextEval(iter)).toBe(`EVAL g=${g3} shared=V2`); + + proc.kill("SIGKILL"); + await proc.exited; + }); + } }); From 735e51d9b2584d2e658cfd73122852e9a500bcb2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:53:19 +0000 Subject: [PATCH 10/14] watcher: report a replaced file from the inotify backend A rename-over or an unlink leaves the watched inode without a link while the watchlist's fd keeps it open, so the kernel holds back IN_DELETE_SELF. The inotify backend now asks for IN_ATTRIB and reports the file as deleted when fstat shows st_nlink == 0. It drops every other IN_ATTRIB, so consumers see no new kind of event. hot_reloader.rs and DevServer.rs go back to main. Both already handle a deleted file. A moved file (IN_MOVE_SELF) keeps its watch again, so moving a dependency away and back reloads under --hot. The parent-directory watch is now best-effort: when it cannot be set, the file is still watched, and the directory fd opened for it is closed. --- src/jsc/hot_reloader.rs | 27 ---- src/runtime/bake/DevServer.rs | 24 +-- src/watcher/INotifyWatcher.rs | 28 +++- src/watcher/Watcher.rs | 34 ++--- test/cli/hot/watch.test.ts | 272 ++++++++++++++++++++++++++++------ 5 files changed, 270 insertions(+), 115 deletions(-) diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 21b4e37c3e5d..5650ac5b85ff 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -878,33 +878,6 @@ where }; } - // Rename-over of a held-open inode delivers only IN_ATTRIB; evict on st_nlink==0. - #[cfg(any(target_os = "linux", target_os = "android"))] - if !event.op.contains(WatchOp::DELETE) - && event.op.intersects(WatchOp::METADATA | WatchOp::RENAME) - { - let fd = file_descriptors[event.index as usize]; - let orphaned = event.op.contains(WatchOp::RENAME) - || (fd.is_valid() - && bun_sys::fstat(fd) - .map(|st| st.st_nlink == 0) - .unwrap_or(false)); - if orphaned { - // SAFETY: same as the DELETE eviction above. - unsafe { - (*ctx).remove_at_index::( - bun_watcher::Kind::File, - event.index, - 0, - &[], - ) - }; - record_changed_path(file_path); - current_task.append(current_hash); - continue; - } - } - if self.verbose { Self::debug(format_args!( "File changed: {}", diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 4bda9aec4553..b3353e38ff64 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -5817,27 +5817,9 @@ impl DevServer { match kind { bun_watcher::Kind::File => { - #[allow(unused_mut)] - let mut evict = event.op.contains(bun_watcher::Op::DELETE) - || event.op.contains(bun_watcher::Op::RENAME); - #[cfg(any(target_os = "linux", target_os = "android"))] - if !evict && event.op.contains(bun_watcher::Op::METADATA) { - let fd = slice.items_fd()[event.index as usize]; - if fd.is_valid() - && bun_sys::fstat(fd) - .map(|st| st.st_nlink == 0) - .unwrap_or(false) - { - evict = true; - } else if !event.op.intersects( - bun_watcher::Op::WRITE - | bun_watcher::Op::MOVE_TO - | bun_watcher::Op::CREATE, - ) { - continue; - } - } - if evict { + if event.op.contains(bun_watcher::Op::DELETE) + || event.op.contains(bun_watcher::Op::RENAME) + { // TODO: audit this line heavily self.bun_watcher.remove_at_index::( bun_watcher::Kind::File, diff --git a/src/watcher/INotifyWatcher.rs b/src/watcher/INotifyWatcher.rs index 2e80dd88ecd6..19a6894518ac 100644 --- a/src/watcher/INotifyWatcher.rs +++ b/src/watcher/INotifyWatcher.rs @@ -130,7 +130,7 @@ impl INotifyWatcher { use bun_sys::linux::IN; debug_assert!(self.loaded); let old_count = self.watch_count.fetch_add(1, Ordering::Release); - // IN_ATTRIB catches the link-count drop when a held-open inode is renamed over. + // IN_ATTRIB is how `watched_inode_is_unlinked` learns of a link-count change. let watch_file_mask = IN::EXCL_UNLINK | IN::MOVE_SELF | IN::DELETE_SELF @@ -456,7 +456,16 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> { continue; } }; - this.watch_events[event_id] = watch_event_from_inotify_event(event, idx); + let mut watch_event = watch_event_from_inotify_event(event, idx); + if (event.mask & bun_sys::linux::IN::ATTRIB) != 0 { + if watched_inode_is_unlinked(this, idx, event.watch_descriptor) { + watch_event.op |= Op::DELETE; + } else if watch_event.op.is_empty() { + events_processed += 1; + continue; + } + } + this.watch_events[event_id] = watch_event; // Safely handle event names with bounds checking if event.name_len > 0 && (temp_name_off as usize) < temp_name_list.len() { @@ -529,6 +538,18 @@ fn process_inotify_event_batch( Ok(()) } +/// The kernel holds back IN_DELETE_SELF while the watchlist's fd keeps a replaced or removed inode open. +fn watched_inode_is_unlinked(this: &Watcher, index: WatchItemIndex, wd: EventListIndex) -> bool { + use crate::watcher_impl::WatchItemColumns; + let _guard = this.mutex.lock_guard(); + // `index` is from this cycle's snapshot; an eviction since then can have moved another item there. + if this.watchlist.items_eventlist_index().get(usize::from(index)) != Some(&wd) { + return false; + } + let fd = this.watchlist.items_fd()[usize::from(index)]; + fd.is_valid() && bun_sys::fstat(fd).is_ok_and(|stat| stat.st_nlink == 0) +} + fn watch_event_from_inotify_event(event: &Event, index: WatchItemIndex) -> WatchEvent { use bun_sys::linux::IN; let mut op = Op::empty(); @@ -544,9 +565,6 @@ fn watch_event_from_inotify_event(event: &Event, index: WatchItemIndex) -> Watch if (event.mask & IN::MODIFY) > 0 { op |= Op::WRITE; } - if (event.mask & IN::ATTRIB) > 0 { - op |= Op::METADATA; - } if (event.mask & IN::CREATE) > 0 { op |= Op::CREATE; } diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index 4103947300ef..1fd2fbb2417c 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -649,9 +649,15 @@ impl Watcher { ZStr::from_buf(&buf[..], trailing_slash.len()) }; - self.platform - .watch_dir(path) - .map_err(|e| e.with_path(file_path))? + match self.platform.watch_dir(path) { + Ok(eventlist_index) => eventlist_index, + Err(err) => { + if !stored_fd.is_valid() { + let _ = bun_sys::close(fd); + } + return Err(err.with_path(file_path)); + } + } }; self.watchlist.append_assume_capacity(WatchItem { @@ -717,22 +723,14 @@ impl Watcher { .ensure_unused_capacity(1 + usize::from(parent_watch_item.is_none())) .unwrap_or_else(|_| bun_core::out_of_memory()); - if autowatch_parent_dir { - parent_watch_item = Some(match parent_watch_item { - Some(v) => v, - None => match self.append_directory_assume_capacity::( - dir_fd, - parent_dir, - parent_dir_hash, - ) { - Err(err) => { - return Err(err.with_path(parent_dir)); - } - Ok(r) => r, - }, - }); + if autowatch_parent_dir && parent_watch_item.is_none() { + // Only recovers a replaced file: the file watch must not depend on it. + let _ = self.append_directory_assume_capacity::( + dir_fd, + parent_dir, + parent_dir_hash, + ); } - let _ = parent_watch_item; match self.append_file_assume_capacity::( fd, diff --git a/test/cli/hot/watch.test.ts b/test/cli/hot/watch.test.ts index 850e31ae79fe..c6629a5abcdc 100644 --- a/test/cli/hot/watch.test.ts +++ b/test/cli/hot/watch.test.ts @@ -1,7 +1,8 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, forEachLine, isBroken, isWindows, tempDir } from "harness"; -import { mkdir, rename, symlink, writeFile } from "node:fs/promises"; +import { bunEnv, bunExe, forEachLine, isBroken, isLinux, isWindows, tempDir } from "harness"; +import { readdirSync, readlinkSync, realpathSync } from "node:fs"; +import { chmod, link, mkdir, readFile, rename, symlink, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; describe.todoIf(isBroken && isWindows)("--watch works", async () => { @@ -49,35 +50,34 @@ describe.todoIf(isBroken && isWindows)("--watch works", async () => { } }); -// A module imported from outside the process cwd (monorepo sibling package, -// or the entrypoint itself when bun is launched from a different directory) -// used to get only a per-inode inotify/kqueue watch and no parent-directory -// watch. The first atomic rename-save (write temp + rename over; the default -// for vim, sed -i, prettier, JetBrains safe-write, git checkout) replaces the -// inode and orphans that watch, so the save and every later save of that file -// were missed, and under --hot the stale pre-save source was served forever -// from the pinned fd. -describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd", () => { - async function renameSave(path: string, content: string) { - await writeFile(path + ".next", content); - await rename(path + ".next", path); - } +// The way most editors save: write a temporary file, then rename it over the target. +async function renameSave(path: string, content: string) { + await writeFile(path + ".next", content); + await rename(path + ".next", path); +} - async function nextEval(iter: AsyncIterator): Promise { - while (true) { - const { value, done } = await iter.next(); - if (done) throw new Error("stream ended before an EVAL line"); - if (value.startsWith("EVAL ")) return value; - } +async function nextEval(iter: AsyncIterator): Promise { + while (true) { + const { value, done } = await iter.next(); + if (done) throw new Error("stream ended before an EVAL line"); + if (value.startsWith("EVAL ")) return value; } +} + +const counterEntry = (specifier: string) => + `import { sh } from ${JSON.stringify(specifier)};\n` + + `globalThis.g = (globalThis.g ?? 0) + 1;\n` + + `console.log("EVAL g=" + globalThis.g + " shared=" + sh);\n`; +// A rename-save replaces the inode. The per-file watch stays on the old inode, +// which bun holds open, so the kernel reports nothing more for it. These +// shapes had no other signal that reached the watched file: the save and every +// later save of the file were missed, and --hot kept serving the old source. +describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd", () => { for (const flag of ["--watch", "--hot"] as const) { test.concurrent(flag, async () => { await using dir = tempDir("watch-outside-cwd", { - "app/entry.ts": - `import { sh } from "../shared/lib.ts";\n` + - `globalThis.g = (globalThis.g ?? 0) + 1;\n` + - `console.log("EVAL g=" + globalThis.g + " shared=" + sh);\n`, + "app/entry.ts": counterEntry("../shared/lib.ts"), "shared/lib.ts": `export const sh = "V0";\n`, }); const appDir = join(String(dir), "app"); @@ -93,9 +93,6 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); - // Atomic rename-save of the out-of-cwd dependency. Before the fix this - // produced no reload at all (the per-inode watch is orphaned and the - // parent dir was not watched). await renameSave(sharedLib, `export const sh = "V1";\n`); const g2 = flag === "--hot" ? "2" : "1"; expect(await nextEval(iter)).toBe(`EVAL g=${g2} shared=V1`); @@ -110,14 +107,39 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" }); } - // A module reached through an in-cwd directory symlink is resolved to its - // real path (outside cwd) before being watched, so it hit the same gate. + // The replaced inode keeps a link, so the file watch cannot tell that the + // path now names another file. Only the parent-directory watch reports it. + test.concurrent("--hot a module outside cwd that has a second hard link", async () => { + await using dir = tempDir("watch-outside-cwd-hardlink", { + "app/entry.ts": counterEntry("../shared/lib.ts"), + "shared/lib.ts": `export const sh = "V0";\n`, + }); + const appDir = join(String(dir), "app"); + const sharedLib = join(String(dir), "shared", "lib.ts"); + await link(sharedLib, sharedLib + ".hardlink"); + + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], + cwd: appDir, + env: bunEnv, + stdio: ["ignore", "pipe", "inherit"], + }); + const iter = forEachLine(proc.stdout); + + expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); + + await renameSave(sharedLib, `export const sh = "V1";\n`); + expect(await nextEval(iter)).toBe("EVAL g=2 shared=V1"); + + proc.kill("SIGKILL"); + await proc.exited; + }); + + // A module reached through a directory symlink is watched under its real + // path, which is outside cwd here. test.concurrent("--hot via an in-cwd directory symlink to an out-of-cwd dir", async () => { await using dir = tempDir("watch-symlink-outside-cwd", { - "app/entry.ts": - `import { sh } from "./link/dep.ts";\n` + - `globalThis.g = (globalThis.g ?? 0) + 1;\n` + - `console.log("EVAL g=" + globalThis.g + " shared=" + sh);\n`, + "app/entry.ts": counterEntry("./link/dep.ts"), "realdir/dep.ts": `export const sh = "V0";\n`, }); const appDir = join(String(dir), "app"); @@ -144,21 +166,17 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" await proc.exited; }); - // Workspace package imported by bare name from the workspace root: everything - // is inside cwd and the real-path parent dir IS watched, but the resolver's - // dir-entry cache is keyed by the `node_modules/lib/` spelling while the - // watchlist entry carries the real path, so the directory IN_MOVED_TO used to - // find no matching file entry and the rename-save was dropped. The per-file - // IN_ATTRIB (st_nlink == 0) eviction recovers it. + // Workspace package imported by bare name from the workspace root. Everything + // is inside cwd and the real-path parent directory is watched. The resolver + // caches that directory under the `node_modules/lib/` spelling, so the + // directory event for the save finds no watched file. The watcher has to + // report the replaced file itself. for (const flag of ["--watch", "--hot"] as const) { test.concurrent(`${flag} a workspace package imported by bare name through node_modules`, async () => { await using dir = tempDir("watch-workspace-bare-import", { "package.json": JSON.stringify({ name: "root", private: true, workspaces: ["packages/*"] }), "packages/app/package.json": JSON.stringify({ name: "app", dependencies: { lib: "workspace:*" } }), - "packages/app/entry.ts": - `import { sh } from "lib/index.js";\n` + - `globalThis.g = (globalThis.g ?? 0) + 1;\n` + - `console.log("EVAL g=" + globalThis.g + " shared=" + sh);\n`, + "packages/app/entry.ts": counterEntry("lib/index.js"), "packages/lib/package.json": JSON.stringify({ name: "lib", version: "1.0.0", main: "index.js" }), "packages/lib/index.js": `export const sh = "V0";\n`, }); @@ -190,3 +208,169 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" }); } }); + +describe.skipIf(isWindows)("file watch", () => { + // The inode only gets another name and then its name back. Its watch must + // survive the first move, or nothing reports the second. + test.concurrent("--hot reloads when a dependency is moved away and then back", async () => { + await using dir = tempDir("hot-move-away-and-back", { + "entry.ts": counterEntry("./dep.ts"), + "dep.ts": `export const sh = "V0";\n`, + }); + const dep = join(String(dir), "dep.ts"); + + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const out = forEachLine(proc.stdout); + const err = forEachLine(proc.stderr); + + expect(await nextEval(out)).toBe("EVAL g=1 shared=V0"); + + await rename(dep, dep + ".away"); + // The reload for the first move fails. Wait for it, so that the move back + // is handled on its own. + while (true) { + const { value, done } = await err.next(); + if (done) throw new Error("stderr ended before the reload without dep.ts failed"); + if (value.includes("Cannot find module")) break; + } + + await rename(dep + ".away", dep); + expect(await nextEval(out)).toBe("EVAL g=2 shared=V0"); + + proc.kill("SIGKILL"); + await proc.exited; + }); + + test.concurrent("a metadata-only change of a watched file is not reported", async () => { + await using dir = tempDir("watch-metadata-only", { + "app/entry.ts": + `import "./dep.ts";\n` + + `globalThis.g = (globalThis.g ?? 0) + 1;\n` + + `console.log("EVAL g=" + globalThis.g);\n`, + "app/dep.ts": `export const x = 1;\n`, + "trace/.keep": "", + }); + const appDir = join(String(dir), "app"); + const dep = join(appDir, "dep.ts"); + const entry = join(appDir, "entry.ts"); + const trace = join(String(dir), "trace", "events.jsonl"); + + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], + cwd: appDir, + env: { ...bunEnv, BUN_WATCHER_TRACE: trace }, + stdio: ["ignore", "pipe", "inherit"], + }); + const out = forEachLine(proc.stdout); + + expect(await nextEval(out)).toBe("EVAL g=1"); + + await chmod(dep, 0o600); + await chmod(dep, 0o644); + await utimes(dep, new Date(), new Date()); + // The watcher handles events in order. Once this save is reloaded, it has + // handled the changes above. + await renameSave(entry, (await readFile(entry, "utf8")) + "// saved\n"); + expect(await nextEval(out)).toBe("EVAL g=2"); + + proc.kill("SIGKILL"); + await proc.exited; + + const reported = (await readFile(trace, "utf8")) + .split("\n") + .filter(Boolean) + .flatMap(line => Object.keys(JSON.parse(line).files)); + expect(reported.some(path => path.endsWith("/app/"))).toBe(true); + expect(reported.filter(path => path.endsWith("/dep.ts"))).toEqual([]); + }); + + // The shim makes every directory watch fail with ENOSPC, which is what + // inotify returns when fs.inotify.max_user_watches is used up. + const cc = Bun.which("cc") ?? Bun.which("gcc") ?? Bun.which("clang"); + describe.skipIf(!isLinux || !cc)("when no directory can be watched", () => { + async function startWithoutDirectoryWatches() { + const dir = tempDir("watch-dir-watch-fails", { + "shim.c": ` + #define _GNU_SOURCE + #include + #include + #include + #include + + int inotify_add_watch(int fd, const char *path, uint32_t mask) { + static int (*real)(int, const char *, uint32_t); + if (mask & IN_ONLYDIR) { + errno = ENOSPC; + return -1; + } + if (!real) real = (int (*)(int, const char *, uint32_t))dlsym(RTLD_NEXT, "inotify_add_watch"); + return real(fd, path, mask); + } + `, + "app/entry.ts": `import "./a.ts";\nimport "./b.ts";\nimport "./c.ts";\n` + counterEntry("./dep.ts"), + "app/a.ts": `export {};\n`, + "app/b.ts": `export {};\n`, + "app/c.ts": `export {};\n`, + "app/dep.ts": `export const sh = "V0";\n`, + }); + const appDir = realpathSync(join(String(dir), "app")); + const shim = join(String(dir), "shim.so"); + { + await using build = spawn({ + cmd: [cc!, "-shared", "-fPIC", "-o", shim, join(String(dir), "shim.c"), "-ldl"], + env: bunEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + if (exitCode !== 0) throw new Error(`the shim did not compile:\n${stdout}${stderr}`); + } + + const proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], + cwd: appDir, + env: { ...bunEnv, LD_PRELOAD: shim }, + stdio: ["ignore", "pipe", "inherit"], + }); + const out = forEachLine(proc.stdout); + return { + appDir, + proc, + out, + async [Symbol.asyncDispose]() { + proc.kill("SIGKILL"); + await proc.exited; + dir[Symbol.dispose](); + }, + }; + } + + test.concurrent("a file is still watched", async () => { + await using run = await startWithoutDirectoryWatches(); + expect(await nextEval(run.out)).toBe("EVAL g=1 shared=V0"); + + await writeFile(join(run.appDir, "dep.ts"), `export const sh = "V1";\n`); + expect(await nextEval(run.out)).toBe("EVAL g=2 shared=V1"); + }); + + // Each of the five modules asks for the directory watch. + test.concurrent("no attempt leaves its descriptor of the directory open", async () => { + await using run = await startWithoutDirectoryWatches(); + expect(await nextEval(run.out)).toBe("EVAL g=1 shared=V0"); + + const fds = `/proc/${run.proc.pid}/fd`; + const openOnAppDir = readdirSync(fds).filter(fd => { + try { + return readlinkSync(join(fds, fd)) === run.appDir; + } catch { + return false; + } + }); + expect(openOnAppDir.length).toBeLessThanOrEqual(1); + }); + }); +}); From 9260e76bc868ed8785c348ce9acd4845b650c761 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:39:45 +0000 Subject: [PATCH 11/14] test: a file behind a symlink that is swapped (ConfigMap volume layout) under --watch --- test/cli/hot/watch.test.ts | 43 +++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/test/cli/hot/watch.test.ts b/test/cli/hot/watch.test.ts index c6629a5abcdc..bace500686cd 100644 --- a/test/cli/hot/watch.test.ts +++ b/test/cli/hot/watch.test.ts @@ -2,7 +2,7 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, forEachLine, isBroken, isLinux, isWindows, tempDir } from "harness"; import { readdirSync, readlinkSync, realpathSync } from "node:fs"; -import { chmod, link, mkdir, readFile, rename, symlink, utimes, writeFile } from "node:fs/promises"; +import { chmod, link, mkdir, readFile, readlink, rename, rm, symlink, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; describe.todoIf(isBroken && isWindows)("--watch works", async () => { @@ -246,6 +246,47 @@ describe.skipIf(isWindows)("file watch", () => { await proc.exited; }); + // What the kubelet does to a ConfigMap or Secret volume. `config.json` is a + // link to `..data/config.json` and `..data` is a link to a directory. An + // update renames a new `..data` link over the old one and removes the old + // directory. The watched name never changes and no file is written in place. + test.concurrent("--watch reloads a file behind a symlink that is swapped", async () => { + await using dir = tempDir("watch-symlink-swap", { + "app.cjs": `console.log("EVAL v" + require("./cfg/config.json").v);\n`, + "cfg/..1/config.json": `{"v":1}`, + }); + const cfg = join(String(dir), "cfg"); + await symlink("..1", join(cfg, "..data"), "dir"); + await symlink("..data/config.json", join(cfg, "config.json")); + async function update(v: number) { + await mkdir(join(cfg, `..${v}`)); + await writeFile(join(cfg, `..${v}`, "config.json"), `{"v":${v}}`); + const old = await readlink(join(cfg, "..data")); + await symlink(`..${v}`, join(cfg, "..data_next"), "dir"); + await rename(join(cfg, "..data_next"), join(cfg, "..data")); + await rm(join(cfg, old), { recursive: true }); + } + + await using proc = spawn({ + cmd: [bunExe(), "--watch", "--no-clear-screen", "app.cjs"], + cwd: String(dir), + env: bunEnv, + stdio: ["ignore", "pipe", "inherit"], + }); + const out = forEachLine(proc.stdout); + + expect(await nextEval(out)).toBe("EVAL v1"); + + await update(2); + expect(await nextEval(out)).toBe("EVAL v2"); + + await update(3); + while ((await nextEval(out)) !== "EVAL v3"); + + proc.kill("SIGKILL"); + await proc.exited; + }); + test.concurrent("a metadata-only change of a watched file is not reported", async () => { await using dir = tempDir("watch-metadata-only", { "app/entry.ts": From 634aef3f78ec507cb6d60ea432bb6182b9da2137 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:22:19 +0000 Subject: [PATCH 12/14] test: scope the inotify-specific watch cases to Linux --- test/cli/hot/watch.test.ts | 62 ++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/test/cli/hot/watch.test.ts b/test/cli/hot/watch.test.ts index bace500686cd..c4df27584802 100644 --- a/test/cli/hot/watch.test.ts +++ b/test/cli/hot/watch.test.ts @@ -107,34 +107,6 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" }); } - // The replaced inode keeps a link, so the file watch cannot tell that the - // path now names another file. Only the parent-directory watch reports it. - test.concurrent("--hot a module outside cwd that has a second hard link", async () => { - await using dir = tempDir("watch-outside-cwd-hardlink", { - "app/entry.ts": counterEntry("../shared/lib.ts"), - "shared/lib.ts": `export const sh = "V0";\n`, - }); - const appDir = join(String(dir), "app"); - const sharedLib = join(String(dir), "shared", "lib.ts"); - await link(sharedLib, sharedLib + ".hardlink"); - - await using proc = spawn({ - cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], - cwd: appDir, - env: bunEnv, - stdio: ["ignore", "pipe", "inherit"], - }); - const iter = forEachLine(proc.stdout); - - expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); - - await renameSave(sharedLib, `export const sh = "V1";\n`); - expect(await nextEval(iter)).toBe("EVAL g=2 shared=V1"); - - proc.kill("SIGKILL"); - await proc.exited; - }); - // A module reached through a directory symlink is watched under its real // path, which is outside cwd here. test.concurrent("--hot via an in-cwd directory symlink to an out-of-cwd dir", async () => { @@ -209,7 +181,37 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" } }); -describe.skipIf(isWindows)("file watch", () => { +// kqueue reports a replaced or moved file in another way, and its directory +// events carry no names. These cases pin what the inotify backend reports. +describe.skipIf(!isLinux)("inotify watcher", () => { + // The replaced inode keeps a link, so the file watch cannot tell that the + // path now names another file. Only the parent-directory watch reports it. + test.concurrent("--hot a module outside cwd that has a second hard link", async () => { + await using dir = tempDir("watch-outside-cwd-hardlink", { + "app/entry.ts": counterEntry("../shared/lib.ts"), + "shared/lib.ts": `export const sh = "V0";\n`, + }); + const appDir = join(String(dir), "app"); + const sharedLib = join(String(dir), "shared", "lib.ts"); + await link(sharedLib, sharedLib + ".hardlink"); + + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], + cwd: appDir, + env: bunEnv, + stdio: ["ignore", "pipe", "inherit"], + }); + const iter = forEachLine(proc.stdout); + + expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); + + await renameSave(sharedLib, `export const sh = "V1";\n`); + expect(await nextEval(iter)).toBe("EVAL g=2 shared=V1"); + + proc.kill("SIGKILL"); + await proc.exited; + }); + // The inode only gets another name and then its name back. Its watch must // survive the first move, or nothing reports the second. test.concurrent("--hot reloads when a dependency is moved away and then back", async () => { @@ -333,7 +335,7 @@ describe.skipIf(isWindows)("file watch", () => { // The shim makes every directory watch fail with ENOSPC, which is what // inotify returns when fs.inotify.max_user_watches is used up. const cc = Bun.which("cc") ?? Bun.which("gcc") ?? Bun.which("clang"); - describe.skipIf(!isLinux || !cc)("when no directory can be watched", () => { + describe.skipIf(!cc)("when no directory can be watched", () => { async function startWithoutDirectoryWatches() { const dir = tempDir("watch-dir-watch-fails", { "shim.c": ` From 2dd0e40721a2c47a56b2a69afe271ecd50b64ca6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:24:54 +0000 Subject: [PATCH 13/14] [autofix.ci] apply automated fixes --- src/watcher/INotifyWatcher.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/watcher/INotifyWatcher.rs b/src/watcher/INotifyWatcher.rs index 19a6894518ac..1e6678718005 100644 --- a/src/watcher/INotifyWatcher.rs +++ b/src/watcher/INotifyWatcher.rs @@ -543,7 +543,12 @@ fn watched_inode_is_unlinked(this: &Watcher, index: WatchItemIndex, wd: EventLis use crate::watcher_impl::WatchItemColumns; let _guard = this.mutex.lock_guard(); // `index` is from this cycle's snapshot; an eviction since then can have moved another item there. - if this.watchlist.items_eventlist_index().get(usize::from(index)) != Some(&wd) { + if this + .watchlist + .items_eventlist_index() + .get(usize::from(index)) + != Some(&wd) + { return false; } let fd = this.watchlist.items_fd()[usize::from(index)]; From 008a31ce85ea6e0fc345cf9ea86a501cb599c907 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:29:38 +0000 Subject: [PATCH 14/14] watcher: decide that a file was replaced by what its path names now Re-cut after review. The previous heads watched the parent directory of every file outside cwd and reported a file with no link left as deleted. Both had regressions against main: a program that removes a module it imported from a temporary directory reloaded forever, and an in-place save of a module outside cwd could lose a write. The inotify backend still asks for IN_ATTRIB on each watched file. On that event it now asks inotify for the watch of the file's path. A different watch descriptor means the path names another inode: the file was replaced, and it is reported as deleted. A path that is gone, or that names the same inode, reports nothing, as on main. Paths under node_modules are left alone. Watcher.rs is back to main: no directory is watched that main does not watch. Task::append skips a hash it already holds, because a replaced file inside cwd is now reported by its directory and by itself. --- src/jsc/hot_reloader.rs | 5 + src/watcher/INotifyWatcher.rs | 65 +++--- src/watcher/Watcher.rs | 50 ++--- test/bake/dev/html.test.ts | 32 +++ test/cli/hot/watch.test.ts | 390 +++++++++++++++------------------- 5 files changed, 270 insertions(+), 272 deletions(-) diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 5650ac5b85ff..9f96c7943c03 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -496,6 +496,11 @@ where } pub(crate) fn append(&mut self, id: u32) { + // A replaced file is reported by its directory and by itself. + if self.hashes[..self.count as usize].contains(&id) { + return; + } + if self.count == 8 { self.enqueue(); self.count = 0; diff --git a/src/watcher/INotifyWatcher.rs b/src/watcher/INotifyWatcher.rs index 1e6678718005..a2f4d2d5360e 100644 --- a/src/watcher/INotifyWatcher.rs +++ b/src/watcher/INotifyWatcher.rs @@ -5,7 +5,7 @@ use core::ffi::c_int; use core::mem::{align_of, size_of}; use core::sync::atomic::{AtomicU32, Ordering}; -use bun_core::{ZStr, env_var, output as Output}; +use bun_core::{ZStr, env_var, output as Output, strings}; use bun_paths::MAX_PATH_BYTES; use bun_sys::{self, Fd}; use bun_threading::Futex; @@ -74,6 +74,12 @@ impl Default for INotifyWatcher { pub(crate) type EventListIndex = c_int; +/// IN_ATTRIB reports the link-count change of an inode that a rename replaces: see `file_was_replaced`. +const WATCH_FILE_MASK: u32 = { + use bun_sys::linux::IN; + IN::EXCL_UNLINK | IN::MOVE_SELF | IN::DELETE_SELF | IN::MOVED_TO | IN::MODIFY | IN::ATTRIB +}; + #[repr(C)] pub struct Event { pub watch_descriptor: EventListIndex, @@ -127,19 +133,11 @@ impl Event { impl INotifyWatcher { pub(crate) fn watch_path(&mut self, pathname: &ZStr) -> bun_sys::Result { - use bun_sys::linux::IN; debug_assert!(self.loaded); let old_count = self.watch_count.fetch_add(1, Ordering::Release); - // IN_ATTRIB is how `watched_inode_is_unlinked` learns of a link-count change. - let watch_file_mask = IN::EXCL_UNLINK - | IN::MOVE_SELF - | IN::DELETE_SELF - | IN::MOVED_TO - | IN::MODIFY - | IN::ATTRIB; // SAFETY: fd is a valid inotify fd (loaded == true), pathname is NUL-terminated. let rc = unsafe { - bun_sys::linux::inotify_add_watch(self.fd.native(), pathname.as_ptr(), watch_file_mask) + bun_sys::linux::inotify_add_watch(self.fd.native(), pathname.as_ptr(), WATCH_FILE_MASK) }; bun_core::scoped_log!(watcher, "inotify_add_watch({}) = {}", self.fd, rc); let result = if rc < 0 { @@ -458,7 +456,7 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> { }; let mut watch_event = watch_event_from_inotify_event(event, idx); if (event.mask & bun_sys::linux::IN::ATTRIB) != 0 { - if watched_inode_is_unlinked(this, idx, event.watch_descriptor) { + if file_was_replaced(this, idx, event.watch_descriptor) { watch_event.op |= Op::DELETE; } else if watch_event.op.is_empty() { events_processed += 1; @@ -538,21 +536,36 @@ fn process_inotify_event_batch( Ok(()) } -/// The kernel holds back IN_DELETE_SELF while the watchlist's fd keeps a replaced or removed inode open. -fn watched_inode_is_unlinked(this: &Watcher, index: WatchItemIndex, wd: EventListIndex) -> bool { - use crate::watcher_impl::WatchItemColumns; - let _guard = this.mutex.lock_guard(); - // `index` is from this cycle's snapshot; an eviction since then can have moved another item there. - if this - .watchlist - .items_eventlist_index() - .get(usize::from(index)) - != Some(&wd) - { - return false; - } - let fd = this.watchlist.items_fd()[usize::from(index)]; - fd.is_valid() && bun_sys::fstat(fd).is_ok_and(|stat| stat.st_nlink == 0) +/// inotify returns another watch descriptor for the path of a watched file once that path names another inode. +fn file_was_replaced(this: &Watcher, index: WatchItemIndex, wd: EventListIndex) -> bool { + use crate::watcher_impl::{WatchItemColumns, WatchItemKind}; + let mut buf = bun_paths::path_buffer_pool::get(); + let path = { + let _guard = this.mutex.lock_guard(); + let i = usize::from(index); + // `index` is from this cycle's snapshot; an eviction since then can have moved another item there. + if this.watchlist.items_eventlist_index().get(i) != Some(&wd) + || this.watchlist.items_kind()[i] != WatchItemKind::File + { + return false; + } + let file_path: &[u8] = &this.watchlist.items_file_path()[i]; + if file_path.len() >= buf.len() || strings::contains(file_path, b"node_modules") { + return false; + } + buf[..file_path.len()].copy_from_slice(file_path); + buf[file_path.len()] = 0; + ZStr::from_buf(&buf[..], file_path.len()) + }; + // SAFETY: the inotify fd stays open until this thread stops it; `path` is NUL-terminated. + let now = unsafe { + bun_sys::linux::inotify_add_watch( + this.platform.fd.native(), + path.as_ptr(), + WATCH_FILE_MASK | bun_sys::linux::IN::MASK_ADD, + ) + }; + now >= 0 && now != wd } fn watch_event_from_inotify_event(event: &Event, index: WatchItemIndex) -> WatchEvent { diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index 1fd2fbb2417c..59cdd4020637 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -649,15 +649,9 @@ impl Watcher { ZStr::from_buf(&buf[..], trailing_slash.len()) }; - match self.platform.watch_dir(path) { - Ok(eventlist_index) => eventlist_index, - Err(err) => { - if !stored_fd.is_valid() { - let _ = bun_sys::close(fd); - } - return Err(err.with_path(file_path)); - } - } + self.platform + .watch_dir(path) + .map_err(|e| e.with_path(file_path))? }; self.watchlist.append_assume_capacity(WatchItem { @@ -723,14 +717,22 @@ impl Watcher { .ensure_unused_capacity(1 + usize::from(parent_watch_item.is_none())) .unwrap_or_else(|_| bun_core::out_of_memory()); - if autowatch_parent_dir && parent_watch_item.is_none() { - // Only recovers a replaced file: the file watch must not depend on it. - let _ = self.append_directory_assume_capacity::( - dir_fd, - parent_dir, - parent_dir_hash, - ); + if autowatch_parent_dir { + parent_watch_item = Some(match parent_watch_item { + Some(v) => v, + None => match self.append_directory_assume_capacity::( + dir_fd, + parent_dir, + parent_dir_hash, + ) { + Err(err) => { + return Err(err.with_path(parent_dir)); + } + Ok(r) => r, + }, + }); } + let _ = parent_watch_item; match self.append_file_assume_capacity::( fd, @@ -769,14 +771,7 @@ impl Watcher { #[inline] fn is_eligible_directory(&self, dir: &[u8]) -> bool { - if strings::contains(dir, b"node_modules") { - return false; - } - if cfg!(windows) { - // ReadDirectoryChangesW is cwd-rooted; POSIX has no such restriction. - return strings::contains(dir, self.top_level_dir()); - } - true + strings::contains(dir, self.top_level_dir()) && !strings::contains(dir, b"node_modules") } #[inline] @@ -959,7 +954,12 @@ impl Watcher { } pub(crate) fn on_maybe_watch_directory(&mut self, file_path: &[u8], dir_fd: Fd) { - if self.is_eligible_directory(file_path) { + // We don't want to watch: + // - Directories outside the root directory + // - Directories inside node_modules + if !strings::contains(file_path, b"node_modules") + && strings::contains(file_path, self.top_level_dir()) + { let _ = self.add_directory::(dir_fd, file_path, Self::get_hash(file_path)); } } diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index f76c1c330789..502af990698a 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -1,4 +1,5 @@ // HTML tests are tests relating to HTML files themselves. +import { renameSync, writeFileSync } from "node:fs"; import { devTest, emptyHtmlFile } from "../bake-harness"; devTest("html file is watched", { @@ -444,3 +445,34 @@ devTest("editing a file imported from outside the project root hot-reloads", { await c.expectMessage("three"); }, }); +devTest("replacing a file imported from outside the project root by rename hot-reloads", { + // This pins the inotify watcher. The Windows watcher does not watch files outside the project directory. + skip: ["win32", "darwin"], + files: { + "web/index.html": emptyHtmlFile({ + scripts: ["index.ts"], + }), + "web/index.ts": ` + import { value } from "../outside/dep"; + console.log(value); + import.meta.hot.accept(); + `, + "outside/dep.ts": ` + export const value = "one"; + `, + }, + cwd: "web", + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("one"); + const dep = dev.join("outside/dep.ts"); + for (const value of ["two", "three"]) { + { + await using _batch = await dev.batchChanges(); + writeFileSync(dep + ".next", `export const value = "${value}";`); + renameSync(dep + ".next", dep); + } + await c.expectMessage(value); + } + }, +}); diff --git a/test/cli/hot/watch.test.ts b/test/cli/hot/watch.test.ts index c4df27584802..485b1841f71f 100644 --- a/test/cli/hot/watch.test.ts +++ b/test/cli/hot/watch.test.ts @@ -1,8 +1,7 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, forEachLine, isBroken, isLinux, isWindows, tempDir } from "harness"; -import { readdirSync, readlinkSync, realpathSync } from "node:fs"; -import { chmod, link, mkdir, readFile, readlink, rename, rm, symlink, utimes, writeFile } from "node:fs/promises"; +import { chmod, link, mkdir, readFile, rename, rm, symlink, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; describe.todoIf(isBroken && isWindows)("--watch works", async () => { @@ -50,7 +49,7 @@ describe.todoIf(isBroken && isWindows)("--watch works", async () => { } }); -// The way most editors save: write a temporary file, then rename it over the target. +// What vim and `sed -i` do: write a temporary file, then rename it over the target. async function renameSave(path: string, content: string) { await writeFile(path + ".next", content); await rename(path + ".next", path); @@ -64,86 +63,54 @@ async function nextEval(iter: AsyncIterator): Promise { } } +const counted = (rest: string) => + `globalThis.g = (globalThis.g ?? 0) + 1;\n` + `console.log("EVAL g=" + globalThis.g + " " + ${rest});\n`; const counterEntry = (specifier: string) => - `import { sh } from ${JSON.stringify(specifier)};\n` + - `globalThis.g = (globalThis.g ?? 0) + 1;\n` + - `console.log("EVAL g=" + globalThis.g + " shared=" + sh);\n`; - -// A rename-save replaces the inode. The per-file watch stays on the old inode, -// which bun holds open, so the kernel reports nothing more for it. These -// shapes had no other signal that reached the watched file: the save and every -// later save of the file were missed, and --hot kept serving the old source. -describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd", () => { + `import { sh } from ${JSON.stringify(specifier)};\n` + counted(`"shared=" + sh`); + +async function watcherTrace(path: string): Promise { + return (await readFile(path, "utf8")) + .split("\n") + .filter(Boolean) + .flatMap(line => Object.keys(JSON.parse(line).files)); +} + +// A rename over a file replaces its inode. The inotify watch of the file stays +// on the old inode, and the kernel reports nothing more for it while bun holds +// it open. Where no directory event leads back to the file, that save and each +// later save of the file were missed. +describe.skipIf(!isLinux)("a watched file that is replaced by rename is reloaded", () => { for (const flag of ["--watch", "--hot"] as const) { - test.concurrent(flag, async () => { + const g = (n: number) => (flag === "--hot" ? n : 1); + + test.concurrent(`${flag} a module outside cwd`, async () => { await using dir = tempDir("watch-outside-cwd", { "app/entry.ts": counterEntry("../shared/lib.ts"), "shared/lib.ts": `export const sh = "V0";\n`, }); - const appDir = join(String(dir), "app"); const sharedLib = join(String(dir), "shared", "lib.ts"); await using proc = spawn({ cmd: [bunExe(), flag, "--no-clear-screen", "entry.ts"], - cwd: appDir, + cwd: join(String(dir), "app"), env: bunEnv, stdio: ["ignore", "pipe", "inherit"], }); - const iter = forEachLine(proc.stdout); + const out = forEachLine(proc.stdout); - expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); + expect(await nextEval(out)).toBe("EVAL g=1 shared=V0"); await renameSave(sharedLib, `export const sh = "V1";\n`); - const g2 = flag === "--hot" ? "2" : "1"; - expect(await nextEval(iter)).toBe(`EVAL g=${g2} shared=V1`); + expect(await nextEval(out)).toBe(`EVAL g=${g(2)} shared=V1`); - // Second rename-save on the (now new) inode. + // The watch has to be on the new inode now. await renameSave(sharedLib, `export const sh = "V2";\n`); - const g3 = flag === "--hot" ? "3" : "1"; - expect(await nextEval(iter)).toBe(`EVAL g=${g3} shared=V2`); - - proc.kill("SIGKILL"); - await proc.exited; - }); - } - - // A module reached through a directory symlink is watched under its real - // path, which is outside cwd here. - test.concurrent("--hot via an in-cwd directory symlink to an out-of-cwd dir", async () => { - await using dir = tempDir("watch-symlink-outside-cwd", { - "app/entry.ts": counterEntry("./link/dep.ts"), - "realdir/dep.ts": `export const sh = "V0";\n`, - }); - const appDir = join(String(dir), "app"); - const realDep = join(String(dir), "realdir", "dep.ts"); - await symlink(join(String(dir), "realdir"), join(appDir, "link"), "dir"); - - await using proc = spawn({ - cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], - cwd: appDir, - env: bunEnv, - stdio: ["ignore", "pipe", "inherit"], + expect(await nextEval(out)).toBe(`EVAL g=${g(3)} shared=V2`); }); - const iter = forEachLine(proc.stdout); - - expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); - - await renameSave(realDep, `export const sh = "V1";\n`); - expect(await nextEval(iter)).toBe("EVAL g=2 shared=V1"); - - await renameSave(realDep, `export const sh = "V2";\n`); - expect(await nextEval(iter)).toBe("EVAL g=3 shared=V2"); - - proc.kill("SIGKILL"); - await proc.exited; - }); - // Workspace package imported by bare name from the workspace root. Everything - // is inside cwd and the real-path parent directory is watched. The resolver - // caches that directory under the `node_modules/lib/` spelling, so the - // directory event for the save finds no watched file. The watcher has to - // report the replaced file itself. - for (const flag of ["--watch", "--hot"] as const) { + // The real-path directory of the package is inside cwd and is watched. + // The resolver caches it under the `node_modules/lib/` spelling, so its + // directory event finds no watched file. test.concurrent(`${flag} a workspace package imported by bare name through node_modules`, async () => { await using dir = tempDir("watch-workspace-bare-import", { "package.json": JSON.stringify({ name: "root", private: true, workspaces: ["packages/*"] }), @@ -163,55 +130,140 @@ describe.skipIf(isWindows)("picks up atomic rename-save of a module outside cwd" env: bunEnv, stdio: ["ignore", "pipe", "inherit"], }); - const iter = forEachLine(proc.stdout); + const out = forEachLine(proc.stdout); - expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); + expect(await nextEval(out)).toBe("EVAL g=1 shared=V0"); await renameSave(libIndex, `export const sh = "V1";\n`); - const g2 = flag === "--hot" ? "2" : "1"; - expect(await nextEval(iter)).toBe(`EVAL g=${g2} shared=V1`); + expect(await nextEval(out)).toBe(`EVAL g=${g(2)} shared=V1`); await renameSave(libIndex, `export const sh = "V2";\n`); - const g3 = flag === "--hot" ? "3" : "1"; - expect(await nextEval(iter)).toBe(`EVAL g=${g3} shared=V2`); + expect(await nextEval(out)).toBe(`EVAL g=${g(3)} shared=V2`); + }); + + test.concurrent(`${flag} the entry point, outside cwd`, async () => { + const entry = (value: string) => counted(JSON.stringify("shared=" + value)); + await using dir = tempDir("watch-entry-outside-cwd", { + "app/.keep": "", + "scripts/entry.ts": entry("V0"), + }); + const entryPath = join(String(dir), "scripts", "entry.ts"); + + await using proc = spawn({ + cmd: [bunExe(), flag, "--no-clear-screen", "../scripts/entry.ts"], + cwd: join(String(dir), "app"), + env: bunEnv, + stdio: ["ignore", "pipe", "inherit"], + }); + const out = forEachLine(proc.stdout); + + expect(await nextEval(out)).toBe("EVAL g=1 shared=V0"); - proc.kill("SIGKILL"); - await proc.exited; + await renameSave(entryPath, entry("V1")); + expect(await nextEval(out)).toBe(`EVAL g=${g(2)} shared=V1`); + + await renameSave(entryPath, entry("V2")); + expect(await nextEval(out)).toBe(`EVAL g=${g(3)} shared=V2`); + }); + } + + // With a second hard link the replaced inode is neither removed nor + // without a link, so only what its path names now tells that it was replaced. + for (const hardLink of [false, true]) { + const suffix = hardLink ? " and has a second hard link" : ""; + + test.concurrent(`--hot a module that is behind a directory symlink${suffix}`, async () => { + await using dir = tempDir("watch-symlink-outside-cwd", { + "app/entry.ts": counterEntry("./link/dep.ts"), + "realdir/dep.ts": `export const sh = "V0";\n`, + }); + const appDir = join(String(dir), "app"); + const realDep = join(String(dir), "realdir", "dep.ts"); + await symlink(join(String(dir), "realdir"), join(appDir, "link"), "dir"); + if (hardLink) await link(realDep, realDep + ".hardlink"); + + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], + cwd: appDir, + env: bunEnv, + stdio: ["ignore", "pipe", "inherit"], + }); + const out = forEachLine(proc.stdout); + + expect(await nextEval(out)).toBe("EVAL g=1 shared=V0"); + + await renameSave(realDep, `export const sh = "V1";\n`); + expect(await nextEval(out)).toBe("EVAL g=2 shared=V1"); + + await renameSave(realDep, `export const sh = "V2";\n`); + expect(await nextEval(out)).toBe("EVAL g=3 shared=V2"); }); } -}); -// kqueue reports a replaced or moved file in another way, and its directory -// events carry no names. These cases pin what the inotify backend reports. -describe.skipIf(!isLinux)("inotify watcher", () => { - // The replaced inode keeps a link, so the file watch cannot tell that the - // path now names another file. Only the parent-directory watch reports it. test.concurrent("--hot a module outside cwd that has a second hard link", async () => { await using dir = tempDir("watch-outside-cwd-hardlink", { "app/entry.ts": counterEntry("../shared/lib.ts"), "shared/lib.ts": `export const sh = "V0";\n`, }); - const appDir = join(String(dir), "app"); const sharedLib = join(String(dir), "shared", "lib.ts"); await link(sharedLib, sharedLib + ".hardlink"); await using proc = spawn({ cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], - cwd: appDir, + cwd: join(String(dir), "app"), env: bunEnv, stdio: ["ignore", "pipe", "inherit"], }); - const iter = forEachLine(proc.stdout); + const out = forEachLine(proc.stdout); - expect(await nextEval(iter)).toBe("EVAL g=1 shared=V0"); + expect(await nextEval(out)).toBe("EVAL g=1 shared=V0"); await renameSave(sharedLib, `export const sh = "V1";\n`); - expect(await nextEval(iter)).toBe("EVAL g=2 shared=V1"); + expect(await nextEval(out)).toBe("EVAL g=2 shared=V1"); + }); - proc.kill("SIGKILL"); - await proc.exited; + // Inside cwd the directory reports the file too. Both reports must count as one. + test.concurrent("--hot evaluates once for three modules that are replaced in one batch", async () => { + const dep = (name: string, value: string) => `export const ${name} = "${value}";\n`; + await using dir = tempDir("hot-replace-batch", { + "entry.ts": + `import { a } from "./a.ts";\nimport { b } from "./b.ts";\nimport { c } from "./c.ts";\n` + + counted("a + b + c"), + "a.ts": dep("a", "a0"), + "b.ts": dep("b", "b0"), + "c.ts": dep("c", "c0"), + }); + const path = (name: string) => join(String(dir), name + ".ts"); + + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdio: ["ignore", "pipe", "inherit"], + }); + const out = forEachLine(proc.stdout); + + expect(await nextEval(out)).toBe("EVAL g=1 a0b0c0"); + + // The stopped process finds the three saves waiting and reads them as one batch. + process.kill(proc.pid, "SIGSTOP"); + try { + await renameSave(path("a"), dep("a", "a1")); + await renameSave(path("b"), dep("b", "b1")); + await renameSave(path("c"), dep("c", "c1")); + } finally { + process.kill(proc.pid, "SIGCONT"); + } + expect(await nextEval(out)).toBe("EVAL g=2 a1b1c1"); + + // A second evaluation for that batch would show here as g=3 with c1. + await renameSave(path("c"), dep("c", "c2")); + expect(await nextEval(out)).toBe("EVAL g=3 a1b1c2"); }); +}); +// These pass without the check for a replaced file. They pin what it must not change. +describe.skipIf(!isLinux)("inotify file watch", () => { // The inode only gets another name and then its name back. Its watch must // survive the first move, or nothing reports the second. test.concurrent("--hot reloads when a dependency is moved away and then back", async () => { @@ -243,58 +295,13 @@ describe.skipIf(!isLinux)("inotify watcher", () => { await rename(dep + ".away", dep); expect(await nextEval(out)).toBe("EVAL g=2 shared=V0"); - - proc.kill("SIGKILL"); - await proc.exited; - }); - - // What the kubelet does to a ConfigMap or Secret volume. `config.json` is a - // link to `..data/config.json` and `..data` is a link to a directory. An - // update renames a new `..data` link over the old one and removes the old - // directory. The watched name never changes and no file is written in place. - test.concurrent("--watch reloads a file behind a symlink that is swapped", async () => { - await using dir = tempDir("watch-symlink-swap", { - "app.cjs": `console.log("EVAL v" + require("./cfg/config.json").v);\n`, - "cfg/..1/config.json": `{"v":1}`, - }); - const cfg = join(String(dir), "cfg"); - await symlink("..1", join(cfg, "..data"), "dir"); - await symlink("..data/config.json", join(cfg, "config.json")); - async function update(v: number) { - await mkdir(join(cfg, `..${v}`)); - await writeFile(join(cfg, `..${v}`, "config.json"), `{"v":${v}}`); - const old = await readlink(join(cfg, "..data")); - await symlink(`..${v}`, join(cfg, "..data_next"), "dir"); - await rename(join(cfg, "..data_next"), join(cfg, "..data")); - await rm(join(cfg, old), { recursive: true }); - } - - await using proc = spawn({ - cmd: [bunExe(), "--watch", "--no-clear-screen", "app.cjs"], - cwd: String(dir), - env: bunEnv, - stdio: ["ignore", "pipe", "inherit"], - }); - const out = forEachLine(proc.stdout); - - expect(await nextEval(out)).toBe("EVAL v1"); - - await update(2); - expect(await nextEval(out)).toBe("EVAL v2"); - - await update(3); - while ((await nextEval(out)) !== "EVAL v3"); - - proc.kill("SIGKILL"); - await proc.exited; }); + // The watcher handles events in order. In both cases the reload of the saved + // entry shows that it has handled the change before it. test.concurrent("a metadata-only change of a watched file is not reported", async () => { await using dir = tempDir("watch-metadata-only", { - "app/entry.ts": - `import "./dep.ts";\n` + - `globalThis.g = (globalThis.g ?? 0) + 1;\n` + - `console.log("EVAL g=" + globalThis.g);\n`, + "app/entry.ts": `import "./dep.ts";\n` + counted(`"entry"`), "app/dep.ts": `export const x = 1;\n`, "trace/.keep": "", }); @@ -311,109 +318,50 @@ describe.skipIf(!isLinux)("inotify watcher", () => { }); const out = forEachLine(proc.stdout); - expect(await nextEval(out)).toBe("EVAL g=1"); + expect(await nextEval(out)).toBe("EVAL g=1 entry"); await chmod(dep, 0o600); await chmod(dep, 0o644); await utimes(dep, new Date(), new Date()); - // The watcher handles events in order. Once this save is reloaded, it has - // handled the changes above. await renameSave(entry, (await readFile(entry, "utf8")) + "// saved\n"); - expect(await nextEval(out)).toBe("EVAL g=2"); + expect(await nextEval(out)).toBe("EVAL g=2 entry"); proc.kill("SIGKILL"); await proc.exited; - - const reported = (await readFile(trace, "utf8")) - .split("\n") - .filter(Boolean) - .flatMap(line => Object.keys(JSON.parse(line).files)); + const reported = await watcherTrace(trace); expect(reported.some(path => path.endsWith("/app/"))).toBe(true); expect(reported.filter(path => path.endsWith("/dep.ts"))).toEqual([]); }); - // The shim makes every directory watch fail with ENOSPC, which is what - // inotify returns when fs.inotify.max_user_watches is used up. - const cc = Bun.which("cc") ?? Bun.which("gcc") ?? Bun.which("clang"); - describe.skipIf(!cc)("when no directory can be watched", () => { - async function startWithoutDirectoryWatches() { - const dir = tempDir("watch-dir-watch-fails", { - "shim.c": ` - #define _GNU_SOURCE - #include - #include - #include - #include - - int inotify_add_watch(int fd, const char *path, uint32_t mask) { - static int (*real)(int, const char *, uint32_t); - if (mask & IN_ONLYDIR) { - errno = ENOSPC; - return -1; - } - if (!real) real = (int (*)(int, const char *, uint32_t))dlsym(RTLD_NEXT, "inotify_add_watch"); - return real(fd, path, mask); - } - `, - "app/entry.ts": `import "./a.ts";\nimport "./b.ts";\nimport "./c.ts";\n` + counterEntry("./dep.ts"), - "app/a.ts": `export {};\n`, - "app/b.ts": `export {};\n`, - "app/c.ts": `export {};\n`, - "app/dep.ts": `export const sh = "V0";\n`, - }); - const appDir = realpathSync(join(String(dir), "app")); - const shim = join(String(dir), "shim.so"); - { - await using build = spawn({ - cmd: [cc!, "-shared", "-fPIC", "-o", shim, join(String(dir), "shim.c"), "-ldl"], - env: bunEnv, - stdio: ["ignore", "pipe", "pipe"], - }); - const [stdout, stderr, exitCode] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); - if (exitCode !== 0) throw new Error(`the shim did not compile:\n${stdout}${stderr}`); - } - - const proc = spawn({ - cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], - cwd: appDir, - env: { ...bunEnv, LD_PRELOAD: shim }, - stdio: ["ignore", "pipe", "inherit"], - }); - const out = forEachLine(proc.stdout); - return { - appDir, - proc, - out, - async [Symbol.asyncDispose]() { - proc.kill("SIGKILL"); - await proc.exited; - dir[Symbol.dispose](); - }, - }; - } - - test.concurrent("a file is still watched", async () => { - await using run = await startWithoutDirectoryWatches(); - expect(await nextEval(run.out)).toBe("EVAL g=1 shared=V0"); - - await writeFile(join(run.appDir, "dep.ts"), `export const sh = "V1";\n`); - expect(await nextEval(run.out)).toBe("EVAL g=2 shared=V1"); + // A removed module is not a replaced one. A program that removes a fixture + // it imported from a temporary directory must not start again for that. + test.concurrent("a removed file outside cwd is not reported", async () => { + await using dir = tempDir("watch-removed-outside-cwd", { + "app/entry.ts": counterEntry("../shared/lib.ts"), + "shared/lib.ts": `export const sh = "V0";\n`, + "trace/.keep": "", }); + const appDir = join(String(dir), "app"); + const trace = join(String(dir), "trace", "events.jsonl"); - // Each of the five modules asks for the directory watch. - test.concurrent("no attempt leaves its descriptor of the directory open", async () => { - await using run = await startWithoutDirectoryWatches(); - expect(await nextEval(run.out)).toBe("EVAL g=1 shared=V0"); - - const fds = `/proc/${run.proc.pid}/fd`; - const openOnAppDir = readdirSync(fds).filter(fd => { - try { - return readlinkSync(join(fds, fd)) === run.appDir; - } catch { - return false; - } - }); - expect(openOnAppDir.length).toBeLessThanOrEqual(1); + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.ts"], + cwd: appDir, + env: { ...bunEnv, BUN_WATCHER_TRACE: trace }, + stdio: ["ignore", "pipe", "inherit"], }); + const out = forEachLine(proc.stdout); + + expect(await nextEval(out)).toBe("EVAL g=1 shared=V0"); + + await rm(join(String(dir), "shared", "lib.ts")); + await renameSave(join(appDir, "entry.ts"), counted(`"without the import"`)); + expect(await nextEval(out)).toBe("EVAL g=2 without the import"); + + proc.kill("SIGKILL"); + await proc.exited; + const reported = await watcherTrace(trace); + expect(reported.some(path => path.endsWith("/app/"))).toBe(true); + expect(reported.filter(path => path.endsWith("/lib.ts"))).toEqual([]); }); });