fix(fsevents): handle just the final state for coalesced events

This commit is contained in:
CJ van den Berg 2026-03-30 21:09:41 +02:00
parent ea72e0654d
commit 7d7241170f
Signed by: neurocyte
GPG key ID: 8EB1E1BB660E3FB9
2 changed files with 33 additions and 9 deletions

View file

@ -229,22 +229,37 @@ fn callback(
// an error back to the caller. Stopping the stream from inside the // an error back to the caller. Stopping the stream from inside the
// callback would require a separate signal channel and is not worth // callback would require a separate signal channel and is not worth
// the complexity; the stream will keep delivering future events. // the complexity; the stream will keep delivering future events.
if (flags & kFSEventStreamEventFlagItemCreated != 0) { // FSEvents coalesces multiple operations into a single callback with
ctx.handler.change(path, .created, ot) catch {}; // multiple flags set. Processing all flags independently produces
} // spurious duplicate events (e.g. ItemRemoved|ItemRenamed -> two
// deletes; ItemCreated|ItemRemoved -> spurious create before delete).
//
// Priority chain: Removed > Renamed > Modified > Created.
// Modified beats Created because FSEvents sets ItemCreated on writes to
// existing files when O_CREAT is used (e.g. `echo > file`), producing
// spurious create events. When both are set, the file already exists,
// so Modified is the accurate description.
//
// Exception: ItemRenamed|ItemModified on an existing path emits both
// created and modified, because a rename-in followed by a write can be
// coalesced by FSEvents into a single callback.
if (flags & kFSEventStreamEventFlagItemRemoved != 0) { if (flags & kFSEventStreamEventFlagItemRemoved != 0) {
ctx.handler.change(path, .deleted, ot) catch {}; ctx.handler.change(path, .deleted, ot) catch {};
} } else if (flags & kFSEventStreamEventFlagItemRenamed != 0) {
if (flags & kFSEventStreamEventFlagItemRenamed != 0) {
// FSEvents fires ItemRenamed for both sides of a rename unpaired. // FSEvents fires ItemRenamed for both sides of a rename unpaired.
// Normalize to created/deleted based on whether the path still exists, // Normalize to created/deleted based on whether the path still exists,
// so move-in appears as created and move-out as deleted on all platforms. // so move-in appears as created and move-out as deleted on all platforms.
const exists = if (std.fs.accessAbsolute(path, .{})) |_| true else |_| false; const exists = if (std.fs.accessAbsolute(path, .{})) |_| true else |_| false;
ctx.handler.change(path, if (exists) .created else .deleted, ot) catch {}; ctx.handler.change(path, if (exists) .created else .deleted, ot) catch {};
} // If a write was coalesced with a move-in, also emit the modify.
if (flags & kFSEventStreamEventFlagItemModified != 0) { if (exists and flags & kFSEventStreamEventFlagItemModified != 0) {
ctx.handler.change(path, .modified, ot) catch {}; ctx.handler.change(path, .modified, ot) catch {};
} }
} else if (flags & kFSEventStreamEventFlagItemModified != 0) {
ctx.handler.change(path, .modified, ot) catch {};
} else if (flags & kFSEventStreamEventFlagItemCreated != 0) {
ctx.handler.change(path, .created, ot) catch {};
}
} }
} }

View file

@ -234,6 +234,11 @@ fn testModifyFile(comptime Watcher: type, allocator: std.mem.Allocator) !void {
var watcher = try Watcher.init(allocator, &th.handler); var watcher = try Watcher.init(allocator, &th.handler);
defer watcher.deinit(); defer watcher.deinit();
try watcher.watch(tmp); try watcher.watch(tmp);
// Drain before writing: FSEvents may deliver a coalesced create+modify if the
// file was created just before the stream started. A drain here separates any
// stale creation event from the upcoming write, so the write arrives in its
// own callback with only ItemModified set.
try drainEvents(Watcher, &watcher);
{ {
const f = try std.fs.openFileAbsolute(file_path, .{ .mode = .write_only }); const f = try std.fs.openFileAbsolute(file_path, .{ .mode = .write_only });
@ -794,7 +799,9 @@ test "creating a file emits a 'created' event" {
test "writing to a file emits a 'modified' event" { test "writing to a file emits a 'modified' event" {
inline for (comptime std.enums.values(nw.Variant)) |variant| { inline for (comptime std.enums.values(nw.Variant)) |variant| {
try testModifyFile(nw.Create(variant), std.testing.allocator); testModifyFile(nw.Create(variant), std.testing.allocator) catch |e| {
if (e != error.SkipZigTest) return e;
};
} }
} }
@ -860,7 +867,9 @@ test "rename: old-name event precedes new-name event" {
test "rename-then-modify: rename event precedes the subsequent modify event" { test "rename-then-modify: rename event precedes the subsequent modify event" {
inline for (comptime std.enums.values(nw.Variant)) |variant| { inline for (comptime std.enums.values(nw.Variant)) |variant| {
try testRenameThenModify(nw.Create(variant), std.testing.allocator); testRenameThenModify(nw.Create(variant), std.testing.allocator) catch |e| {
if (e != error.SkipZigTest) return e;
};
} }
} }