From 32c69f254eda6eb48e2df8d2ad1ad5784a08073e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:30:14 +0000 Subject: [PATCH 1/3] fs.watch(Linux, recursive): report a change to a subdirectory once Under a recursive watch each subdirectory has an inotify watch of its own. A change to the subdirectory itself (IN_ATTRIB, IN_MODIFY, IN_DELETE_SELF, IN_MOVE_SELF) reaches that watch without a name. The watch of the parent directory reports the same change under the name of the subdirectory. The reader reported both records. The per-handler duplicate suppression dropped the second one only when the two were adjacent and at most 1 ms apart. A rename of a subdirectory with content, a rename over an empty directory, and a removal while a file descriptor holds the directory open each reported the subdirectory one more time. The reader now skips the nameless records of a subdirectory's own watch. IN_UNMOUNT has no twin on the parent and is reported as before. --- src/runtime/node/path_watcher.rs | 19 ++++++ test/js/node/watch/fs.watch.test.ts | 98 +++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/src/runtime/node/path_watcher.rs b/src/runtime/node/path_watcher.rs index 2b1f30bd81df..6fabe2a1dc2f 100644 --- a/src/runtime/node/path_watcher.rs +++ b/src/runtime/node/path_watcher.rs @@ -669,6 +669,15 @@ compile_error!("path_watcher: unsupported target"); /// Linux: one inotify fd, one blocking reader thread, wd → {PathWatcher, subpath} map. /// Recursive watches are implemented by walking the tree at subscribe time and adding /// a wd per directory, then adding new subdirectories as they appear (IN_CREATE|IN_ISDIR). +/// +/// What a recursive watch reports. Node's recursive watcher on Linux compares +/// `stat()` results in JS and reports less (only `b` for `mv a b`), so these +/// rules are not Node's: +/// - an entry is reported under its path relative to the root; +/// - a change to a subdirectory itself is reported once, by the wd of its parent; +/// - each entry found in a new directory is reported as "rename". One created +/// while the directory is walked can be reported twice; +/// - a queue overflow is reported as `("change", null)`. #[cfg(any(target_os = "linux", target_os = "android"))] #[derive(Default)] pub(crate) struct Linux { @@ -1090,6 +1099,16 @@ impl Linux { name } } else if name.is_empty() { + if ev.mask & (IN::ATTRIB | IN::MODIFY | IN::DELETE_SELF | IN::MOVE_SELF) + != 0 + { + // A change to the subdirectory itself. The wd of its + // parent reports it under the name of the subdirectory. + // The kernel sends no such twin for IN_UNMOUNT, or for + // the root of a filesystem mounted inside the tree. + oi += 1; + continue; + } owner_subpath } else { join_z_buf_spill::( diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 8ec50a08f25c..95fabaa5cb93 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -620,6 +620,104 @@ describe("fs.watch", () => { ]); }); + // The events of each step, for a watch on `root`. A sentinel file is created + // after each step and events arrive in order, so its event marks the point + // where every earlier event was delivered. + async function eventsOfEachStep(root: string, options: fs.WatchOptions, steps: (() => void | Promise)[]) { + const events: string[] = []; + let sentinel: { name: string; resolve: () => void; reject: (err: unknown) => void } | undefined; + const watcher = fs.watch(root, options, (eventType, filename) => { + events.push(`${eventType}:${filename}`); + if (filename === sentinel?.name) sentinel.resolve(); + }); + watcher.on("error", err => sentinel?.reject(err)); + try { + const seen: string[][] = []; + for (const [i, step] of steps.entries()) { + await step(); + const { promise, resolve, reject } = Promise.withResolvers(); + sentinel = { name: `sentinel-${i}`, resolve, reject }; + fs.closeSync(fs.openSync(path.join(root, sentinel.name), "w")); + await promise; + seen.push(events.splice(0).filter(event => !event.includes("sentinel-"))); + } + return seen; + } finally { + watcher.close(); + } + } + + // Under a recursive watch a subdirectory has an inotify watch of its own, and + // the watch of its parent reports the subdirectory by name. A change to the + // subdirectory itself reaches both. It is reported once, under its name. + describe.skipIf(!isLinux)("recursive watch reports a change to a subdirectory once", () => { + const recursive = { recursive: true }; + // busybox before 1.36 has no `touch -m`. + const canTouchModificationTime = + isLinux && Bun.which("touch") !== null && Bun.spawnSync({ cmd: ["touch", "-m", testDir] }).exitCode === 0; + + test("removed", async () => { + using dir = tempDir("fs-watch-rec-rmdir", { "views": {} }); + const views = path.join(String(dir), "views"); + expect(await eventsOfEachStep(String(dir), recursive, [() => fs.rmdirSync(views)])).toEqual([["rename:views"]]); + }); + + test("renamed, empty", async () => { + using dir = tempDir("fs-watch-rec-mv-empty", { "a": {} }); + const rename = () => fs.renameSync(path.join(String(dir), "a"), path.join(String(dir), "b")); + expect(await eventsOfEachStep(String(dir), recursive, [rename])).toEqual([["rename:a", "rename:b"]]); + }); + + test("renamed, with content", async () => { + using dir = tempDir("fs-watch-rec-mv-content", { "a": { "nested": {} } }); + const rename = () => fs.renameSync(path.join(String(dir), "a"), path.join(String(dir), "b")); + expect(await eventsOfEachStep(String(dir), recursive, [rename])).toEqual([ + ["rename:a", "rename:b", "rename:b/nested"], + ]); + }); + + test("renamed over an empty directory", async () => { + using dir = tempDir("fs-watch-rec-mv-over", { "a": { "nested": {} }, "b": {} }); + const rename = () => fs.renameSync(path.join(String(dir), "a"), path.join(String(dir), "b")); + expect(await eventsOfEachStep(String(dir), recursive, [rename])).toEqual([ + ["rename:a", "rename:b", "rename:b/nested"], + ]); + }); + + // A change of only the modification time is IN_MODIFY, where utimes() gives + // IN_ATTRIB. + test.skipIf(!canTouchModificationTime)("modification time changed", async () => { + using dir = tempDir("fs-watch-rec-mtime", { "views": {} }); + const touch = async () => { + await using proc = Bun.spawn({ cmd: ["touch", "-m", path.join(String(dir), "views")], stderr: "pipe" }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }; + expect(await eventsOfEachStep(String(dir), recursive, [touch])).toEqual([["change:views"]]); + }); + + // The kernel reports the removal to the subdirectory's own watch only when + // the last reference to the directory goes away. + test("removed while a file descriptor holds it open", async () => { + using dir = tempDir("fs-watch-rec-rmdir-held", { "views": {} }); + const views = path.join(String(dir), "views"); + const fd = fs.openSync(views, "r"); + let closed = false; + const close = () => { + if (!closed) fs.closeSync(fd); + closed = true; + }; + try { + expect(await eventsOfEachStep(String(dir), recursive, [() => fs.rmdirSync(views), close])).toEqual([ + ["rename:views"], + [], + ]); + } finally { + close(); + } + }); + }); + // Past fs.inotify.max_queued_events the kernel drops events and queues one // IN_Q_OVERFLOW; Bun reports it as ('change', null) on every watcher sharing // the inotify fd, the same shape node uses for overflow on Windows. From 2af12a9fb33e07571c71913ce493fe938b333ce2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:20:02 +0000 Subject: [PATCH 2/3] ci: retrigger From 4769350c2d9e7c7577cba6f3a4206595bedb54bd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:32:30 +0000 Subject: [PATCH 3/3] fs.watch: shorten the comments, and fail a recursive watch test on a watcher error --- src/runtime/node/path_watcher.rs | 14 +------------- test/js/node/watch/fs.watch.test.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/runtime/node/path_watcher.rs b/src/runtime/node/path_watcher.rs index 6fabe2a1dc2f..f29ad2fb96b7 100644 --- a/src/runtime/node/path_watcher.rs +++ b/src/runtime/node/path_watcher.rs @@ -669,15 +669,6 @@ compile_error!("path_watcher: unsupported target"); /// Linux: one inotify fd, one blocking reader thread, wd → {PathWatcher, subpath} map. /// Recursive watches are implemented by walking the tree at subscribe time and adding /// a wd per directory, then adding new subdirectories as they appear (IN_CREATE|IN_ISDIR). -/// -/// What a recursive watch reports. Node's recursive watcher on Linux compares -/// `stat()` results in JS and reports less (only `b` for `mv a b`), so these -/// rules are not Node's: -/// - an entry is reported under its path relative to the root; -/// - a change to a subdirectory itself is reported once, by the wd of its parent; -/// - each entry found in a new directory is reported as "rename". One created -/// while the directory is walked can be reported twice; -/// - a queue overflow is reported as `("change", null)`. #[cfg(any(target_os = "linux", target_os = "android"))] #[derive(Default)] pub(crate) struct Linux { @@ -1102,10 +1093,7 @@ impl Linux { if ev.mask & (IN::ATTRIB | IN::MODIFY | IN::DELETE_SELF | IN::MOVE_SELF) != 0 { - // A change to the subdirectory itself. The wd of its - // parent reports it under the name of the subdirectory. - // The kernel sends no such twin for IN_UNMOUNT, or for - // the root of a filesystem mounted inside the tree. + // The wd of the parent reports it, under its name. oi += 1; continue; } diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 95fabaa5cb93..6c90efa4bca0 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -625,20 +625,24 @@ describe("fs.watch", () => { // where every earlier event was delivered. async function eventsOfEachStep(root: string, options: fs.WatchOptions, steps: (() => void | Promise)[]) { const events: string[] = []; - let sentinel: { name: string; resolve: () => void; reject: (err: unknown) => void } | undefined; + let sentinel: { name: string; resolve: () => void } | undefined; + const failed = Promise.withResolvers(); + // An error between two steps rejects the next wait. + failed.promise.catch(() => {}); const watcher = fs.watch(root, options, (eventType, filename) => { events.push(`${eventType}:${filename}`); if (filename === sentinel?.name) sentinel.resolve(); }); - watcher.on("error", err => sentinel?.reject(err)); + watcher.on("error", failed.reject); + watcher.on("close", () => failed.reject(new Error("the watcher closed"))); try { const seen: string[][] = []; for (const [i, step] of steps.entries()) { - await step(); - const { promise, resolve, reject } = Promise.withResolvers(); - sentinel = { name: `sentinel-${i}`, resolve, reject }; + await Promise.race([step(), failed.promise]); + const { promise, resolve } = Promise.withResolvers(); + sentinel = { name: `sentinel-${i}`, resolve }; fs.closeSync(fs.openSync(path.join(root, sentinel.name), "w")); - await promise; + await Promise.race([promise, failed.promise]); seen.push(events.splice(0).filter(event => !event.includes("sentinel-"))); } return seen;