diff --git a/src/runtime/node/path_watcher.rs b/src/runtime/node/path_watcher.rs index a91afd43132e..36f98b3e7cc1 100644 --- a/src/runtime/node/path_watcher.rs +++ b/src/runtime/node/path_watcher.rs @@ -1018,19 +1018,7 @@ impl Linux { }; let is_dir_child = ev.mask & IN::ISDIR != 0; - let event_type: WatchEventKind = if ev.mask - & (IN::CREATE - | IN::DELETE - | IN::DELETE_SELF - | IN::MOVE_SELF - | IN::MOVED_FROM - | IN::MOVED_TO) - != 0 - { - WatchEventKind::Rename - } else { - WatchEventKind::Change - }; + let is_structural = ev.mask & !(IN::ATTRIB | IN::MODIFY | IN::ISDIR) != 0; // Dispatch to every owner of this wd. The recursive branch below calls // `addOne`/`walkAndAdd`, which insert into `wd_map` via `getOrPut` and @@ -1075,6 +1063,19 @@ impl Linux { ) }; + // libuv: a directory's attribute change is "rename" (IN_ISDIR is + // outside IN_ATTRIB|IN_MODIFY). node's recursive watcher drops it. + let event_type = if is_structural { + WatchEventKind::Rename + } else if !is_dir_child { + WatchEventKind::Change + } else if watcher_recursive { + oi += 1; + continue; + } else { + WatchEventKind::Rename + }; + // Build the path relative to this owner's root. let rel: &[u8] = if watcher_is_file { path::basename(watcher_path) diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 956643c1909d..a54d0cd0cab1 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -620,6 +620,67 @@ describe("fs.watch", () => { ]); }); + // libuv maps every inotify mask bit outside IN_ATTRIB|IN_MODIFY to "rename", + // and the kernel sets IN_ISDIR on every event about a directory, so node + // reports a directory's attribute change as "rename" and a file's as "change". + // https://github.com/oven-sh/bun/issues/43066 + async function collectWatchEventsUntil( + target: string, + options: fs.WatchOptions, + count: number, + act: () => void, + ): Promise<[string, string | null][]> { + const events: [string, string | null][] = []; + const { promise, resolve, reject } = Promise.withResolvers<[string, string | null][]>(); + const watcher = fs.watch(target, options, (eventType, filename) => { + events.push([eventType, filename]); + if (events.length === count) resolve(events.slice()); + }); + watcher.once("error", reject); + try { + act(); + return await promise; + } finally { + watcher.close(); + } + } + + const toggleMode = (p: string) => fs.chmodSync(p, fs.statSync(p).mode ^ 0o001); + + test.skipIf(!isLinux)("changing the attributes of the watched directory reports rename", async () => { + using dir = tempDir("fs-watch-chmod-self", { "sub": {} }); + const target = path.join(String(dir), "sub"); + expect(await collectWatchEventsUntil(target, {}, 1, () => toggleMode(target))).toEqual([["rename", "sub"]]); + }); + + test.skipIf(!isLinux)("changing the attributes of a subdirectory reports rename, of a file change", async () => { + using dir = tempDir("fs-watch-chmod-child", { "sub": {}, "f.txt": "x" }); + const root = String(dir); + const events = await collectWatchEventsUntil(root, {}, 2, () => { + toggleMode(path.join(root, "sub")); + toggleMode(path.join(root, "f.txt")); + }); + expect(events).toEqual([ + ["rename", "sub"], + ["change", "f.txt"], + ]); + }); + + // node's recursive watcher is not libuv: an attribute change of a directory, + // the root or one below it, is not reported at all. A file's still is. + test.skipIf(!isLinux)("a recursive watch does not report an attribute change of a directory", async () => { + using dir = tempDir("fs-watch-chmod-recursive", { "sub": {}, "f.txt": "x" }); + const root = String(dir); + // inotify delivers events in order, so a directory event would land + // before the chmod of f.txt. + const events = await collectWatchEventsUntil(root, { recursive: true }, 1, () => { + toggleMode(root); + toggleMode(path.join(root, "sub")); + toggleMode(path.join(root, "f.txt")); + }); + expect(events).toEqual([["change", "f.txt"]]); + }); + // 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.