Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/runtime/node/path_watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,13 @@ impl Linux {
name
}
} else if name.is_empty() {
if ev.mask & (IN::ATTRIB | IN::MODIFY | IN::DELETE_SELF | IN::MOVE_SELF)
!= 0
{
// The wd of the parent reports it, under its name.
oi += 1;
continue;
}
Comment thread
robobun marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
owner_subpath
} else {
join_z_buf_spill::<platform::Posix>(
Expand Down
102 changes: 102 additions & 0 deletions test/js/node/watch/fs.watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,108 @@ 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<void>)[]) {
const events: string[] = [];
let sentinel: { name: string; resolve: () => void } | undefined;
const failed = Promise.withResolvers<never>();
// 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", failed.reject);
watcher.on("close", () => failed.reject(new Error("the watcher closed")));
try {
Comment thread
robobun marked this conversation as resolved.
const seen: string[][] = [];
for (const [i, step] of steps.entries()) {
await Promise.race([step(), failed.promise]);
const { promise, resolve } = Promise.withResolvers<void>();
sentinel = { name: `sentinel-${i}`, resolve };
fs.closeSync(fs.openSync(path.join(root, sentinel.name), "w"));
await Promise.race([promise, failed.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.
Expand Down
Loading