From 99dec3f68965cb1bc3458918bd6bca25a0b73d75 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 29 Mar 2026 14:28:23 +0200 Subject: [PATCH 1/4] fix(inotify): report .closed events separately from .modified events --- src/backend/FSEvents.zig | 1 + src/backend/INotify.zig | 5 ++++- src/backend/KQueue.zig | 1 + src/backend/KQueueDir.zig | 1 + src/backend/Windows.zig | 1 + src/main.zig | 3 +++ src/nightwatch.zig | 1 + src/nightwatch_test.zig | 42 +++++++++++++++++++++++++++++++++++++++ src/types.zig | 5 +++++ 9 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/backend/FSEvents.zig b/src/backend/FSEvents.zig index 53dc69d..3726d97 100644 --- a/src/backend/FSEvents.zig +++ b/src/backend/FSEvents.zig @@ -6,6 +6,7 @@ const ObjectType = types.ObjectType; pub const watches_recursively = true; // FSEventStreamCreate watches the entire subtree pub const detects_file_modifications = true; +pub const emits_close_events = false; handler: *Handler, stream: ?*anyopaque, // FSEventStreamRef diff --git a/src/backend/INotify.zig b/src/backend/INotify.zig index eb71d23..67d7242 100644 --- a/src/backend/INotify.zig +++ b/src/backend/INotify.zig @@ -35,6 +35,7 @@ pub fn Create(comptime variant: InterfaceType) type { pub const watches_recursively = false; pub const detects_file_modifications = true; + pub const emits_close_events = true; pub const polling = variant == .polling; const WatchEntry = struct { path: []u8, is_dir: bool }; @@ -342,8 +343,10 @@ pub fn Create(comptime variant: InterfaceType) type { if (is_dir and self.has_watch_for_path(full_path)) continue; break :blk .deleted; - } else if (ev.mask & (IN.MODIFY | IN.CLOSE_WRITE) != 0) + } else if (ev.mask & IN.MODIFY != 0) .modified + else if (ev.mask & IN.CLOSE_WRITE != 0) + .closed else continue; try self.handler.change(full_path, event_type, object_type); diff --git a/src/backend/KQueue.zig b/src/backend/KQueue.zig index 6aed25f..1bf325d 100644 --- a/src/backend/KQueue.zig +++ b/src/backend/KQueue.zig @@ -6,6 +6,7 @@ const ObjectType = types.ObjectType; pub const watches_recursively = false; pub const detects_file_modifications = true; +pub const emits_close_events = false; handler: *Handler, kq: std.posix.fd_t, diff --git a/src/backend/KQueueDir.zig b/src/backend/KQueueDir.zig index a7574e7..fd27548 100644 --- a/src/backend/KQueueDir.zig +++ b/src/backend/KQueueDir.zig @@ -6,6 +6,7 @@ const ObjectType = types.ObjectType; pub const watches_recursively = false; pub const detects_file_modifications = false; +pub const emits_close_events = false; pub const WatchEntry = struct { fd: std.posix.fd_t, is_file: bool }; handler: *Handler, diff --git a/src/backend/Windows.zig b/src/backend/Windows.zig index ea4f046..ad477b9 100644 --- a/src/backend/Windows.zig +++ b/src/backend/Windows.zig @@ -6,6 +6,7 @@ const ObjectType = types.ObjectType; pub const watches_recursively = true; // ReadDirectoryChangesW with bWatchSubtree=1 pub const detects_file_modifications = true; +pub const emits_close_events = false; const windows = std.os.windows; diff --git a/src/main.zig b/src/main.zig index d322b14..9508587 100644 --- a/src/main.zig +++ b/src/main.zig @@ -50,12 +50,14 @@ const CliHandler = struct { const color: std.io.tty.Color = switch (event_type) { .created => .green, .modified => .blue, + .closed => .bright_black, .deleted => .red, .renamed => .magenta, }; const event_label = switch (event_type) { .created => "create ", .modified => "modify ", + .closed => "close ", .deleted => "delete ", .renamed => "rename ", }; @@ -160,6 +162,7 @@ fn usage(out: std.fs.File) !void { \\Events printed to stdout (columns: event type path): \\ create a file or directory was created \\ modify a file was modified + \\ close a file was closed after writing (Linux only) \\ delete a file or directory was deleted \\ rename a file or directory was renamed \\ diff --git a/src/nightwatch.zig b/src/nightwatch.zig index d0a20cc..4b891a8 100644 --- a/src/nightwatch.zig +++ b/src/nightwatch.zig @@ -114,6 +114,7 @@ pub fn Create(comptime variant: Variant) type { /// `watch()` do receive per-file `NOTE_WRITE` events and will report /// modifications. pub const detects_file_modifications = Backend.detects_file_modifications; + pub const emits_close_events = Backend.emits_close_events; /// Create a new watcher. /// diff --git a/src/nightwatch_test.zig b/src/nightwatch_test.zig index 86bf11d..323c56c 100644 --- a/src/nightwatch_test.zig +++ b/src/nightwatch_test.zig @@ -245,6 +245,42 @@ fn testModifyFile(comptime Watcher: type, allocator: std.mem.Allocator) !void { try std.testing.expect(th.hasChange(file_path, .modified, .file)); } +fn testCloseFile(comptime Watcher: type, allocator: std.mem.Allocator) !void { + if (comptime !Watcher.emits_close_events) return error.SkipZigTest; + + const TH = MakeTestHandler(Watcher); + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TH.init(allocator); + defer th.deinit(); + + const file_path = try std.fs.path.join(allocator, &.{ tmp, "data.txt" }); + defer allocator.free(file_path); + { + const f = try std.fs.createFileAbsolute(file_path, .{}); + f.close(); + } + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + { + const f = try std.fs.openFileAbsolute(file_path, .{ .mode = .write_only }); + defer f.close(); + try f.writeAll("hello nightwatch\n"); + } + + try drainEvents(Watcher, &watcher); + try std.testing.expect(th.hasChange(file_path, .modified, .file)); + try std.testing.expect(th.hasChange(file_path, .closed, .file)); +} + fn testDeleteFile(comptime Watcher: type, allocator: std.mem.Allocator) !void { const TH = MakeTestHandler(Watcher); @@ -578,6 +614,12 @@ test "writing to a file emits a 'modified' event" { } } +test "closing a file after writing emits a 'closed' event (inotify only)" { + inline for (comptime std.enums.values(nw.Variant)) |variant| { + try testCloseFile(nw.Create(variant), std.testing.allocator); + } +} + test "deleting a file emits a 'deleted' event" { inline for (comptime std.enums.values(nw.Variant)) |variant| { try testDeleteFile(nw.Create(variant), std.testing.allocator); diff --git a/src/types.zig b/src/types.zig index 07b64fc..86d6dd6 100644 --- a/src/types.zig +++ b/src/types.zig @@ -7,6 +7,11 @@ pub const EventType = enum { created, /// A file's contents were modified. modified, + /// A file was closed. + /// + /// Only delivered by INotfiy (Linux) and only if the file was opened + /// for writing. + closed, /// A file or directory was deleted. deleted, /// A file or directory was renamed or moved. From f6158c82407f5a7987b288eeb67d0484b9577501 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 29 Mar 2026 14:59:14 +0200 Subject: [PATCH 2/4] feat: add backend flags for rename events --- src/backend/FSEvents.zig | 2 ++ src/backend/INotify.zig | 2 ++ src/backend/KQueue.zig | 2 ++ src/backend/KQueueDir.zig | 2 ++ src/backend/Windows.zig | 2 ++ src/nightwatch.zig | 2 ++ src/nightwatch_test.zig | 57 +++++++++++++++++++++++++++++++++++---- 7 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/backend/FSEvents.zig b/src/backend/FSEvents.zig index 3726d97..4624f2c 100644 --- a/src/backend/FSEvents.zig +++ b/src/backend/FSEvents.zig @@ -7,6 +7,8 @@ const ObjectType = types.ObjectType; pub const watches_recursively = true; // FSEventStreamCreate watches the entire subtree pub const detects_file_modifications = true; pub const emits_close_events = false; +pub const emits_rename_for_files = true; +pub const emits_rename_for_dirs = true; handler: *Handler, stream: ?*anyopaque, // FSEventStreamRef diff --git a/src/backend/INotify.zig b/src/backend/INotify.zig index 67d7242..7ec8050 100644 --- a/src/backend/INotify.zig +++ b/src/backend/INotify.zig @@ -36,6 +36,8 @@ pub fn Create(comptime variant: InterfaceType) type { pub const watches_recursively = false; pub const detects_file_modifications = true; pub const emits_close_events = true; + pub const emits_rename_for_files = true; + pub const emits_rename_for_dirs = true; pub const polling = variant == .polling; const WatchEntry = struct { path: []u8, is_dir: bool }; diff --git a/src/backend/KQueue.zig b/src/backend/KQueue.zig index 1bf325d..aeee5fd 100644 --- a/src/backend/KQueue.zig +++ b/src/backend/KQueue.zig @@ -7,6 +7,8 @@ const ObjectType = types.ObjectType; pub const watches_recursively = false; pub const detects_file_modifications = true; pub const emits_close_events = false; +pub const emits_rename_for_files = false; +pub const emits_rename_for_dirs = true; handler: *Handler, kq: std.posix.fd_t, diff --git a/src/backend/KQueueDir.zig b/src/backend/KQueueDir.zig index fd27548..0cf816f 100644 --- a/src/backend/KQueueDir.zig +++ b/src/backend/KQueueDir.zig @@ -7,6 +7,8 @@ const ObjectType = types.ObjectType; pub const watches_recursively = false; pub const detects_file_modifications = false; pub const emits_close_events = false; +pub const emits_rename_for_files = false; +pub const emits_rename_for_dirs = true; pub const WatchEntry = struct { fd: std.posix.fd_t, is_file: bool }; handler: *Handler, diff --git a/src/backend/Windows.zig b/src/backend/Windows.zig index ad477b9..050bf9c 100644 --- a/src/backend/Windows.zig +++ b/src/backend/Windows.zig @@ -7,6 +7,8 @@ const ObjectType = types.ObjectType; pub const watches_recursively = true; // ReadDirectoryChangesW with bWatchSubtree=1 pub const detects_file_modifications = true; pub const emits_close_events = false; +pub const emits_rename_for_files = true; +pub const emits_rename_for_dirs = true; const windows = std.os.windows; diff --git a/src/nightwatch.zig b/src/nightwatch.zig index 4b891a8..7fa107a 100644 --- a/src/nightwatch.zig +++ b/src/nightwatch.zig @@ -115,6 +115,8 @@ pub fn Create(comptime variant: Variant) type { /// modifications. pub const detects_file_modifications = Backend.detects_file_modifications; pub const emits_close_events = Backend.emits_close_events; + pub const emits_rename_for_files = Backend.emits_rename_for_files; + pub const emits_rename_for_dirs = Backend.emits_rename_for_dirs; /// Create a new watcher. /// diff --git a/src/nightwatch_test.zig b/src/nightwatch_test.zig index 323c56c..8230599 100644 --- a/src/nightwatch_test.zig +++ b/src/nightwatch_test.zig @@ -394,15 +394,56 @@ fn testRenameFile(comptime Watcher: type, allocator: std.mem.Allocator) !void { try std.fs.renameAbsolute(src_path, dst_path); try drainEvents(Watcher, &watcher); - if (builtin.os.tag == .linux) { - try std.testing.expect(th.hasRename(src_path, dst_path)); + if (comptime Watcher.emits_rename_for_files) { + // INotify delivers a paired atomic rename callback; FSEvents/Windows + // deliver individual .renamed change events per path. + const has_rename = th.hasRename(src_path, dst_path) or + th.hasChange(src_path, .renamed, .file); + try std.testing.expect(has_rename); } else { - const has_old = th.hasChange(src_path, .renamed, .file) or th.hasChange(src_path, .deleted, .file); - const has_new = th.hasChange(dst_path, .renamed, .file) or th.hasChange(dst_path, .created, .file); - try std.testing.expect(has_old or has_new); + // KQueue/KQueueDir: file rename appears as delete + create. + try std.testing.expect(th.hasChange(src_path, .deleted, .file)); + try std.testing.expect(th.hasChange(dst_path, .created, .file)); } } +fn testRenameDir(comptime Watcher: type, allocator: std.mem.Allocator) !void { + if (comptime !Watcher.emits_rename_for_dirs) return error.SkipZigTest; + + const TH = MakeTestHandler(Watcher); + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TH.init(allocator); + defer th.deinit(); + + const src_path = try std.fs.path.join(allocator, &.{ tmp, "before" }); + defer allocator.free(src_path); + const dst_path = try std.fs.path.join(allocator, &.{ tmp, "after" }); + defer allocator.free(dst_path); + + try std.fs.makeDirAbsolute(src_path); + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + try std.fs.renameAbsolute(src_path, dst_path); + try drainEvents(Watcher, &watcher); + + // All backends with emits_rename_for_dirs=true deliver at least a rename + // event for the source path. INotify delivers a paired rename callback; + // KQueue/KQueueDir deliver change(.renamed, .dir) for the old path only; + // FSEvents/Windows deliver change(.renamed, .dir) for both paths. + const has_rename = th.hasRename(src_path, dst_path) or + th.hasChange(src_path, .renamed, .dir); + try std.testing.expect(has_rename); +} + fn testUnwatchedDir(comptime Watcher: type, allocator: std.mem.Allocator) !void { const TH = MakeTestHandler(Watcher); @@ -644,6 +685,12 @@ test "renaming a file is reported correctly per-platform" { } } +test "renaming a directory emits a rename event" { + inline for (comptime std.enums.values(nw.Variant)) |variant| { + try testRenameDir(nw.Create(variant), std.testing.allocator); + } +} + test "an unwatched directory produces no events" { inline for (comptime std.enums.values(nw.Variant)) |variant| { try testUnwatchedDir(nw.Create(variant), std.testing.allocator); From 4e5ff53d0dd5ae9afb58dbb597c5cffe3bc42313 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 29 Mar 2026 16:52:57 +0200 Subject: [PATCH 3/4] refactor(windows): add tests to windows test script --- test_manual.ps1 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test_manual.ps1 b/test_manual.ps1 index 1d65abf..cee98fe 100644 --- a/test_manual.ps1 +++ b/test_manual.ps1 @@ -55,6 +55,22 @@ Start-Sleep -Milliseconds 400 Write-Host "[op] rmdir subdir" Remove-Item -Path "$TESTDIR\subdir" +Start-Sleep -Milliseconds 400 + +Write-Host "[op] mkdir dirA" +New-Item -ItemType Directory -Path "$TESTDIR\dirA" | Out-Null +Start-Sleep -Milliseconds 400 + +Write-Host "[op] touch dirA\file3.txt" +New-Item -ItemType File -Path "$TESTDIR\dirA\file3.txt" | Out-Null +Start-Sleep -Milliseconds 400 + +Write-Host "[op] rename dirA -> dirB" +Rename-Item -Path "$TESTDIR\dirA" -NewName "dirB" +Start-Sleep -Milliseconds 400 + +Write-Host "[op] rmdir dirB (and contents)" +Remove-Item -Recurse -Force -Path "$TESTDIR\dirB" Start-Sleep -Milliseconds 500 Write-Host "" From ecdb311c8953be2d72d563a86e2044e260f2c8ac Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 29 Mar 2026 16:53:23 +0200 Subject: [PATCH 4/4] fix(windows): seed path_types to avoid unknown paths --- src/backend/Windows.zig | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/backend/Windows.zig b/src/backend/Windows.zig index 050bf9c..ddec850 100644 --- a/src/backend/Windows.zig +++ b/src/backend/Windows.zig @@ -168,8 +168,11 @@ fn thread_fn( continue; }; // Determine object_type: try GetFileAttributesW; cache result. - const object_type: ObjectType = if (event_type == .deleted) blk: { - // Path no longer exists; use cached type if available. + // For deleted paths and the old name in a rename, the path no + // longer exists at event time so GetFileAttributesW would fail; + // use the cached type instead. + const object_type: ObjectType = if (event_type == .deleted or + info.Action == FILE_ACTION_RENAMED_OLD_NAME) blk: { const cached = path_types.fetchRemove(full_path); break :blk if (cached) |kv| blk2: { allocator.free(kv.key); @@ -251,6 +254,36 @@ pub fn add_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8) if (win32.ReadDirectoryChangesW(handle, buf.ptr, buf_size, 1, notify_filter, null, &w.overlapped, null) == 0) return error.WatchFailed; try self.watches.put(allocator, owned_path, w); + // Seed path_types with pre-existing entries so delete/rename events for + // paths that existed before this watch started can resolve their ObjectType. + self.scan_path_types(allocator, owned_path); +} + +// Walk root recursively and seed path_types with the type of every entry. +// Called from add_watch (mutex already held) so pre-existing paths are +// known before any FILE_ACTION_REMOVED or FILE_ACTION_RENAMED_OLD_NAME fires. +fn scan_path_types(self: *@This(), allocator: std.mem.Allocator, root: []const u8) void { + var dir = std.fs.openDirAbsolute(root, .{ .iterate = true }) catch return; + defer dir.close(); + var iter = dir.iterate(); + while (iter.next() catch return) |entry| { + const ot: ObjectType = switch (entry.kind) { + .directory => .dir, + .file => .file, + else => continue, + }; + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const full_path = std.fmt.bufPrint(&path_buf, "{s}\\{s}", .{ root, entry.name }) catch continue; + const gop = self.path_types.getOrPut(allocator, full_path) catch continue; + if (!gop.found_existing) { + gop.key_ptr.* = allocator.dupe(u8, full_path) catch { + _ = self.path_types.remove(full_path); + continue; + }; + } + gop.value_ptr.* = ot; + if (ot == .dir) self.scan_path_types(allocator, gop.key_ptr.*); + } } pub fn remove_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8) void {