fix(windows): pair rename src dst events to emit full .rename src dst events

This commit is contained in:
CJ van den Berg 2026-03-29 17:40:09 +02:00
parent 03996eb5c1
commit ef01e2590e
Signed by: neurocyte
GPG key ID: 8EB1E1BB660E3FB9
2 changed files with 77 additions and 23 deletions

View file

@ -141,6 +141,14 @@ fn thread_fn(
if (w.handle != triggered_handle) continue; if (w.handle != triggered_handle) continue;
if (bytes > 0) { if (bytes > 0) {
var offset: usize = 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) { while (offset < bytes) {
const info: *FILE_NOTIFY_INFORMATION = @ptrCast(@alignCast(w.buf[offset..].ptr)); const info: *FILE_NOTIFY_INFORMATION = @ptrCast(@alignCast(w.buf[offset..].ptr));
const name_wchars = (&info.FileName).ptr[0 .. info.FileNameLength / 2]; const name_wchars = (&info.FileName).ptr[0 .. info.FileNameLength / 2];
@ -150,29 +158,67 @@ fn thread_fn(
offset += info.NextEntryOffset; offset += info.NextEntryOffset;
continue; 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; 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 { const full_path = std.fmt.bufPrint(&full_buf, "{s}\\{s}", .{ w.path, name_buf[0..name_len] }) catch {
if (info.NextEntryOffset == 0) break; if (info.NextEntryOffset == 0) break;
offset += info.NextEntryOffset; offset += info.NextEntryOffset;
continue; 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. // Determine object_type: try GetFileAttributesW; cache result.
// For deleted paths and the old name in a rename, the path no // For deleted paths the path no longer exists at event time so
// longer exists at event time so GetFileAttributesW would fail; // GetFileAttributesW would fail; use the cached type instead.
// use the cached type instead. const object_type: ObjectType = if (event_type == .deleted) blk: {
const object_type: ObjectType = if (event_type == .deleted or
info.Action == FILE_ACTION_RENAMED_OLD_NAME) blk: {
const cached = path_types.fetchRemove(full_path); const cached = path_types.fetchRemove(full_path);
break :blk if (cached) |kv| blk2: { break :blk if (cached) |kv| blk2: {
allocator.free(kv.key); allocator.free(kv.key);
@ -186,7 +232,6 @@ fn thread_fn(
const INVALID: windows.DWORD = 0xFFFFFFFF; const INVALID: windows.DWORD = 0xFFFFFFFF;
const FILE_ATTRIBUTE_DIRECTORY: windows.DWORD = 0x10; const FILE_ATTRIBUTE_DIRECTORY: windows.DWORD = 0x10;
const ot: ObjectType = if (attrs == INVALID) .unknown else if (attrs & FILE_ATTRIBUTE_DIRECTORY != 0) .dir else .file; const ot: ObjectType = if (attrs == INVALID) .unknown else if (attrs & FILE_ATTRIBUTE_DIRECTORY != 0) .dir else .file;
// Cache the determined type.
if (ot != .unknown) { if (ot != .unknown) {
const gop = path_types.getOrPut(allocator, full_path) catch break :blk ot; const gop = path_types.getOrPut(allocator, full_path) catch break :blk ot;
if (!gop.found_existing) { if (!gop.found_existing) {
@ -197,10 +242,6 @@ fn thread_fn(
} }
gop.value_ptr.* = ot; 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; break :blk ot;
}; };
// Suppress FILE_ACTION_MODIFIED on directories: these are // Suppress FILE_ACTION_MODIFIED on directories: these are
@ -225,6 +266,14 @@ fn thread_fn(
if (next_entry_offset == 0) break; if (next_entry_offset == 0) break;
offset += next_entry_offset; 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. // Re-arm ReadDirectoryChangesW for the next batch.
w.overlapped = std.mem.zeroes(windows.OVERLAPPED); w.overlapped = std.mem.zeroes(windows.OVERLAPPED);

View file

@ -30,8 +30,13 @@ pub const EventType = enum {
/// `NOTE_WRITE` events and appear as a `deleted` event for the old /// `NOTE_WRITE` events and appear as a `deleted` event for the old
/// name followed by a `created` event for the new name. /// name followed by a `created` event for the new name.
/// ///
/// - **FSEvents / Windows**: each path involved in a rename receives /// - **Windows**: renames within the watched tree are delivered as a
/// its own `renamed` change event; the two sides are not paired. /// 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, renamed,
}; };