From ef01e2590e360d717a7c69981160edc6256ce19a Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 29 Mar 2026 17:40:09 +0200 Subject: [PATCH] fix(windows): pair rename src dst events to emit full .rename src dst events --- src/backend/Windows.zig | 91 +++++++++++++++++++++++++++++++---------- src/types.zig | 9 +++- 2 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/backend/Windows.zig b/src/backend/Windows.zig index 029f5d2..609e619 100644 --- a/src/backend/Windows.zig +++ b/src/backend/Windows.zig @@ -141,6 +141,14 @@ fn thread_fn( if (w.handle != triggered_handle) continue; if (bytes > 0) { var offset: usize = 0; + // FILE_ACTION_RENAMED_OLD_NAME is always immediately followed by + // FILE_ACTION_RENAMED_NEW_NAME in the same RDCW buffer. Stash the + // src path here and emit a single handler.rename(src, dst) on NEW. + var pending_rename: ?struct { + path_buf: [std.fs.max_path_bytes]u8, + path_len: usize, + object_type: ObjectType, + } = null; while (offset < bytes) { const info: *FILE_NOTIFY_INFORMATION = @ptrCast(@alignCast(w.buf[offset..].ptr)); const name_wchars = (&info.FileName).ptr[0 .. info.FileNameLength / 2]; @@ -150,29 +158,67 @@ fn thread_fn( offset += info.NextEntryOffset; continue; }; - const event_type: EventType = switch (info.Action) { - FILE_ACTION_ADDED => .created, - FILE_ACTION_REMOVED => .deleted, - FILE_ACTION_MODIFIED => .modified, - FILE_ACTION_RENAMED_OLD_NAME, FILE_ACTION_RENAMED_NEW_NAME => .renamed, - else => { - if (info.NextEntryOffset == 0) break; - offset += info.NextEntryOffset; - continue; - }, - }; var full_buf: [std.fs.max_path_bytes]u8 = undefined; const full_path = std.fmt.bufPrint(&full_buf, "{s}\\{s}", .{ w.path, name_buf[0..name_len] }) catch { if (info.NextEntryOffset == 0) break; offset += info.NextEntryOffset; continue; }; + if (info.Action == FILE_ACTION_RENAMED_OLD_NAME) { + // Path is gone; resolve type from cache before it is evicted. + const cached = path_types.fetchRemove(full_path); + const ot: ObjectType = if (cached) |kv| blk: { + allocator.free(kv.key); + break :blk kv.value; + } else .unknown; + var pr: @TypeOf(pending_rename.?) = undefined; + @memcpy(pr.path_buf[0..full_path.len], full_path); + pr.path_len = full_path.len; + pr.object_type = ot; + pending_rename = pr; + if (info.NextEntryOffset == 0) break; + offset += info.NextEntryOffset; + continue; + } + if (info.Action == FILE_ACTION_RENAMED_NEW_NAME) { + if (pending_rename) |pr| { + const src = pr.path_buf[0..pr.path_len]; + // Re-scan renamed directory contents into cache so + // subsequent delete events for children resolve correctly. + if (pr.object_type == .dir) + scan_path_types_into(allocator, path_types, full_path); + const next_entry_offset = info.NextEntryOffset; + watches_mutex.unlock(); + handler.rename(src, full_path, pr.object_type) catch |e| { + std.log.err("nightwatch: handler returned {s}, stopping watch thread", .{@errorName(e)}); + watches_mutex.lock(); + pending_rename = null; + break; + }; + watches_mutex.lock(); + pending_rename = null; + if (next_entry_offset == 0) break; + offset += next_entry_offset; + continue; + } + // Unpaired NEW_NAME: path moved into the watched tree from + // outside. Fall through as .created, matching INotify. + } + const event_type: EventType = switch (info.Action) { + FILE_ACTION_ADDED => .created, + FILE_ACTION_REMOVED => .deleted, + FILE_ACTION_MODIFIED => .modified, + FILE_ACTION_RENAMED_NEW_NAME => .created, // unpaired: moved in from outside + else => { + if (info.NextEntryOffset == 0) break; + offset += info.NextEntryOffset; + continue; + }, + }; // Determine object_type: try GetFileAttributesW; cache result. - // 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: { + // For deleted paths 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) blk: { const cached = path_types.fetchRemove(full_path); break :blk if (cached) |kv| blk2: { allocator.free(kv.key); @@ -186,7 +232,6 @@ fn thread_fn( const INVALID: windows.DWORD = 0xFFFFFFFF; const FILE_ATTRIBUTE_DIRECTORY: windows.DWORD = 0x10; const ot: ObjectType = if (attrs == INVALID) .unknown else if (attrs & FILE_ATTRIBUTE_DIRECTORY != 0) .dir else .file; - // Cache the determined type. if (ot != .unknown) { const gop = path_types.getOrPut(allocator, full_path) catch break :blk ot; if (!gop.found_existing) { @@ -197,10 +242,6 @@ fn thread_fn( } gop.value_ptr.* = ot; } - // When a directory is renamed, scan its children so that - // subsequent delete events for contents can resolve their type. - if (ot == .dir and info.Action == FILE_ACTION_RENAMED_NEW_NAME) - scan_path_types_into(allocator, path_types, full_path); break :blk ot; }; // Suppress FILE_ACTION_MODIFIED on directories: these are @@ -225,6 +266,14 @@ fn thread_fn( if (next_entry_offset == 0) break; offset += next_entry_offset; } + // Flush an unpaired OLD_NAME: path moved out of the watched tree. + // Emit as .deleted, matching INotify behaviour. + if (pending_rename) |pr| { + const src = pr.path_buf[0..pr.path_len]; + watches_mutex.unlock(); + handler.change(src, .deleted, pr.object_type) catch {}; + watches_mutex.lock(); + } } // Re-arm ReadDirectoryChangesW for the next batch. w.overlapped = std.mem.zeroes(windows.OVERLAPPED); diff --git a/src/types.zig b/src/types.zig index 86d6dd6..b3627ba 100644 --- a/src/types.zig +++ b/src/types.zig @@ -30,8 +30,13 @@ pub const EventType = enum { /// `NOTE_WRITE` events and appear as a `deleted` event for the old /// name followed by a `created` event for the new name. /// - /// - **FSEvents / Windows**: each path involved in a rename receives - /// its own `renamed` change event; the two sides are not paired. + /// - **Windows**: renames within the watched tree are delivered as a + /// single atomic `rename` callback, matching INotify behaviour. A + /// move out of the tree appears as `deleted`; a move into the tree + /// appears as `created`. + /// + /// - **FSEvents**: each path involved in a rename receives its own + /// `renamed` change event; the two sides are not paired. renamed, };