refactor: add support for atomic rename file watcher events on linux

This commit is contained in:
CJ van den Berg 2026-02-20 10:44:00 +01:00
parent 348c2055da
commit fa24db89ce
Signed by: neurocyte
GPG key ID: 8EB1E1BB660E3FB9
4 changed files with 132 additions and 34 deletions

View file

@ -805,6 +805,20 @@ pub fn file_modified(self: *Self, file_path: []const u8) void {
} }
} }
pub fn file_renamed(self: *Self, from_path: []const u8, to_path: []const u8) OutOfMemoryError!void {
for (self.files.items) |*file| {
if (!std.mem.eql(u8, file.path, from_path)) continue;
const new_path = try self.allocator.dupe(u8, to_path);
self.allocator.free(file.path);
file.path = new_path;
file.mtime = std.time.nanoTimestamp();
self.longest_file_path = @max(self.longest_file_path, to_path.len);
self.sort_files_by_mtime();
return;
}
return self.file_added(to_path);
}
pub fn file_deleted(self: *Self, file_path: []const u8) void { pub fn file_deleted(self: *Self, file_path: []const u8) void {
for (self.files.items, 0..) |file, i| { for (self.files.items, 0..) |file, i| {
if (!std.mem.eql(u8, file.path, file_path)) continue; if (!std.mem.eql(u8, file.path, file_path)) continue;

View file

@ -13,6 +13,8 @@ pub const EventType = enum {
created, created,
modified, modified,
deleted, deleted,
/// Only produced on macOS and Windows where the OS gives no pairing info.
/// On Linux, paired renames are emitted as a { "FW", "rename", from, to } message instead.
renamed, renamed,
}; };
@ -23,6 +25,7 @@ const SpawnError = error{ OutOfMemory, ThespianSpawnFailed };
/// Watch a path (file or directory) for changes. The caller will receive: /// Watch a path (file or directory) for changes. The caller will receive:
/// .{ "FW", "change", path, event_type } /// .{ "FW", "change", path, event_type }
/// where event_type is a file_watcher.EventType tag string: "created", "modified", "deleted", "renamed" /// where event_type is a file_watcher.EventType tag string: "created", "modified", "deleted", "renamed"
/// On Linux, paired renames produce: .{ "FW", "rename", from_path, to_path }
pub fn watch(path: []const u8) FileWatcherError!void { pub fn watch(path: []const u8) FileWatcherError!void {
return send(.{ "watch", path }); return send(.{ "watch", path });
} }
@ -120,17 +123,34 @@ const LinuxBackend = struct {
} }
} }
fn drain(self: *LinuxBackend, parent: tp.pid_ref) !void { fn drain(self: *LinuxBackend, allocator: std.mem.Allocator, parent: tp.pid_ref) !void {
const InotifyEvent = extern struct { const InotifyEvent = extern struct {
wd: i32, wd: i32,
mask: u32, mask: u32,
cookie: u32, cookie: u32,
len: u32, len: u32,
}; };
// A pending MOVED_FROM waiting to be paired with a MOVED_TO by cookie.
const PendingRename = struct {
cookie: u32,
path: []u8, // owned by drain's allocator
};
var buf: [4096]u8 align(@alignOf(InotifyEvent)) = undefined; var buf: [4096]u8 align(@alignOf(InotifyEvent)) = undefined;
var pending_renames: std.ArrayListUnmanaged(PendingRename) = .empty;
defer {
// Any unpaired MOVED_FROM means the file was moved out of the watched tree.
for (pending_renames.items) |r| {
parent.send(.{ "FW", "change", r.path, EventType.deleted }) catch {}; // moved outside watched tree
allocator.free(r.path);
}
pending_renames.deinit(allocator);
}
while (true) { while (true) {
const n = std.posix.read(self.inotify_fd, &buf) catch |e| switch (e) { const n = std.posix.read(self.inotify_fd, &buf) catch |e| switch (e) {
error.WouldBlock => return, error.WouldBlock => break,
else => return e, else => return e,
}; };
var offset: usize = 0; var offset: usize = 0;
@ -143,22 +163,50 @@ const LinuxBackend = struct {
std.mem.sliceTo(buf[name_offset..][0..ev.len], 0) std.mem.sliceTo(buf[name_offset..][0..ev.len], 0)
else else
""; "";
const event_type: EventType = if (ev.mask & IN.CREATE != 0)
.created var full_buf: [std.fs.max_path_bytes]u8 = undefined;
else if (ev.mask & (IN.DELETE | IN.DELETE_SELF) != 0) const full_path: []const u8 = if (name.len > 0)
.deleted try std.fmt.bufPrint(&full_buf, "{s}/{s}", .{ watched_path, name })
else if (ev.mask & (IN.MODIFY | IN.CLOSE_WRITE) != 0)
.modified
else if (ev.mask & (IN.MOVED_FROM | IN.MOVED_TO | IN.MOVE_SELF) != 0)
.renamed
else else
continue; watched_path;
if (name.len > 0) {
var full_buf: [std.fs.max_path_bytes]u8 = undefined; if (ev.mask & IN.MOVED_FROM != 0) {
const full_path = try std.fmt.bufPrint(&full_buf, "{s}/{s}", .{ watched_path, name }); // Park it, we may receive a paired MOVED_TO with the same cookie.
try parent.send(.{ "FW", "change", full_path, event_type }); try pending_renames.append(allocator, .{
.cookie = ev.cookie,
.path = try allocator.dupe(u8, full_path),
});
} else if (ev.mask & IN.MOVED_TO != 0) {
// Look for a paired MOVED_FROM.
var found: ?usize = null;
for (pending_renames.items, 0..) |r, i| {
if (r.cookie == ev.cookie) {
found = i;
break;
}
}
if (found) |i| {
// Complete rename pair: emit a single atomic rename message.
const r = pending_renames.swapRemove(i);
defer allocator.free(r.path);
try parent.send(.{ "FW", "rename", r.path, full_path });
} else {
// No paired MOVED_FROM, file was moved in from outside the watched tree.
try parent.send(.{ "FW", "change", full_path, EventType.created });
}
} else if (ev.mask & IN.MOVE_SELF != 0) {
// The watched directory itself was renamed/moved away.
try parent.send(.{ "FW", "change", full_path, EventType.deleted });
} else { } else {
try parent.send(.{ "FW", "change", watched_path, event_type }); const event_type: EventType = if (ev.mask & IN.CREATE != 0)
.created
else if (ev.mask & (IN.DELETE | IN.DELETE_SELF) != 0)
.deleted
else if (ev.mask & (IN.MODIFY | IN.CLOSE_WRITE) != 0)
.modified
else
continue;
try parent.send(.{ "FW", "change", full_path, event_type });
} }
} }
} }
@ -227,7 +275,8 @@ const MacosBackend = struct {
} }
} }
fn drain(self: *MacosBackend, parent: tp.pid_ref) !void { fn drain(self: *MacosBackend, allocator: std.mem.Allocator, parent: tp.pid_ref) !void {
_ = allocator;
var events: [64]std.posix.Kevent = undefined; var events: [64]std.posix.Kevent = undefined;
const immediate: std.posix.timespec = .{ .sec = 0, .nsec = 0 }; const immediate: std.posix.timespec = .{ .sec = 0, .nsec = 0 };
const n = std.posix.kevent(self.kq, &.{}, &events, &immediate) catch return; const n = std.posix.kevent(self.kq, &.{}, &events, &immediate) catch return;
@ -350,7 +399,8 @@ const WindowsBackend = struct {
} }
} }
fn drain(self: *WindowsBackend, parent: tp.pid_ref) !void { fn drain(self: *WindowsBackend, allocator: std.mem.Allocator, parent: tp.pid_ref) !void {
_ = allocator;
var bytes: windows.DWORD = 0; var bytes: windows.DWORD = 0;
var key: windows.ULONG_PTR = 0; var key: windows.ULONG_PTR = 0;
var overlapped_ptr: ?*windows.OVERLAPPED = null; var overlapped_ptr: ?*windows.OVERLAPPED = null;
@ -453,13 +503,13 @@ const Process = struct {
var err_msg: []const u8 = undefined; var err_msg: []const u8 = undefined;
if (try cbor.match(m.buf, .{ "fd", tp.extract(&tag), "read_ready" })) { if (try cbor.match(m.buf, .{ "fd", tp.extract(&tag), "read_ready" })) {
self.backend.drain(self.parent.ref()) catch |e| self.logger.err("drain", e); self.backend.drain(self.allocator, self.parent.ref()) catch |e| self.logger.err("drain", e);
self.backend.arm(); self.backend.arm();
} else if (try cbor.match(m.buf, .{ "fd", tp.extract(&tag), "read_error", tp.extract(&err_code), tp.extract(&err_msg) })) { } else if (try cbor.match(m.buf, .{ "fd", tp.extract(&tag), "read_error", tp.extract(&err_code), tp.extract(&err_msg) })) {
self.logger.print("fd read error on {s}: ({d}) {s}", .{ tag, err_code, err_msg }); self.logger.print("fd read error on {s}: ({d}) {s}", .{ tag, err_code, err_msg });
self.backend.arm(); self.backend.arm();
} else if (builtin.os.tag == .windows and try cbor.match(m.buf, .{"FW_poll"})) { } else if (builtin.os.tag == .windows and try cbor.match(m.buf, .{"FW_poll"})) {
self.backend.drain(self.parent.ref()) catch |e| self.logger.err("drain", e); self.backend.drain(self.allocator, self.parent.ref()) catch |e| self.logger.err("drain", e);
self.backend.arm(); self.backend.arm();
} else if (try cbor.match(m.buf, .{ "watch", tp.extract(&path) })) { } else if (try cbor.match(m.buf, .{ "watch", tp.extract(&path) })) {
self.backend.add_watch(self.allocator, path) catch |e| self.logger.err("watch", e); self.backend.add_watch(self.allocator, path) catch |e| self.logger.err("watch", e);

View file

@ -422,12 +422,15 @@ const Process = struct {
var eol_mode: Buffer.EolModeTag = @intFromEnum(Buffer.EolMode.lf); var eol_mode: Buffer.EolModeTag = @intFromEnum(Buffer.EolMode.lf);
var event_type: file_watcher.EventType = undefined; var event_type: file_watcher.EventType = undefined;
var from_path: []const u8 = undefined;
if (try cbor.match(m.buf, .{ "FW", "change", tp.extract(&path), tp.extract(&event_type) })) { if (try cbor.match(m.buf, .{ "FW", "rename", tp.extract(&from_path), tp.extract(&path) })) {
self.handle_file_watch_rename(from_path, path);
} else if (try cbor.match(m.buf, .{ "FW", "change", tp.extract(&path), tp.extract(&event_type) })) {
self.handle_file_watch_event(path, event_type); self.handle_file_watch_event(path, event_type);
} else if (try cbor.match(m.buf, .{ "walk_tree_dir", tp.extract(&project_directory), tp.extract(&path) })) { } else if (try cbor.match(m.buf, .{ "walk_tree_dir", tp.extract(&project_directory), tp.extract(&path) })) {
var abs_buf: [std.fs.max_path_bytes]u8 = undefined; var abs_buf: [std.fs.max_path_bytes]u8 = undefined;
const abs_path = std.fmt.bufPrint(&abs_buf, "{s}{c}{s}", .{ project_directory, std.fs.path.sep, path }) catch return; const abs_path = std.fmt.bufPrint(&abs_buf, "{s}{c}{s}", .{ project_directory, std.fs.path.sep, path }) catch project_directory;
file_watcher.watch(abs_path) catch |e| self.logger.err("file_watcher.watch_dir", e); file_watcher.watch(abs_path) catch |e| self.logger.err("file_watcher.watch_dir", e);
} else if (try cbor.match(m.buf, .{ "walk_tree_entry", tp.extract(&project_directory), tp.more })) { } else if (try cbor.match(m.buf, .{ "walk_tree_entry", tp.extract(&project_directory), tp.more })) {
if (self.projects.get(project_directory)) |project| if (self.projects.get(project_directory)) |project|
@ -555,24 +558,52 @@ const Process = struct {
} }
} }
fn handle_file_watch_event(self: *Process, abs_path: []const u8, event_type: file_watcher.EventType) void { fn project_for_path(self: *Process, abs_path: []const u8) ?struct { project: *Project, rel_path: []const u8 } {
std.log.debug("file_watch_event: {s} {s}", .{ @tagName(event_type), abs_path });
var it = self.projects.iterator(); var it = self.projects.iterator();
while (it.next()) |entry| { while (it.next()) |entry| {
const dir = entry.key_ptr.*; const dir = entry.key_ptr.*;
if (!std.mem.startsWith(u8, abs_path, dir)) continue; if (!std.mem.startsWith(u8, abs_path, dir)) continue;
if (abs_path.len <= dir.len or abs_path[dir.len] != std.fs.path.sep) continue; if (abs_path.len <= dir.len or abs_path[dir.len] != std.fs.path.sep) continue;
const rel_path = abs_path[dir.len + 1 ..]; return .{ .project = entry.value_ptr.*, .rel_path = abs_path[dir.len + 1 ..] };
const project = entry.value_ptr.*; }
switch (event_type) { return null;
.created => project.file_added(rel_path) catch |e| self.logger.err("file_watcher.file_added", e), }
.modified => project.file_modified(rel_path),
.deleted => project.file_deleted(rel_path), fn handle_file_watch_rename(self: *Process, abs_from: []const u8, abs_to: []const u8) void {
.renamed => project.file_deleted(rel_path), std.log.debug("file_watch_event: rename {s} -> {s}", .{ abs_from, abs_to });
} const src = self.project_for_path(abs_from);
return; const dst = self.project_for_path(abs_to);
if (src) |s| {
if (dst) |d| {
if (s.project == d.project) {
s.project.file_renamed(s.rel_path, d.rel_path) catch |e| self.logger.err("file_watcher.file_renamed", e);
} else {
s.project.file_deleted(s.rel_path);
d.project.file_added(d.rel_path) catch |e| self.logger.err("file_watcher.file_added", e);
}
} else {
s.project.file_deleted(s.rel_path);
}
} else if (dst) |d| {
d.project.file_added(d.rel_path) catch |e| self.logger.err("file_watcher.file_added", e);
} else {
self.parent.send(.{ "FW", "rename", abs_from, abs_to }) catch {};
}
}
fn handle_file_watch_event(self: *Process, abs_path: []const u8, event_type: file_watcher.EventType) void {
std.log.debug("file_watch_event: {s} {s}", .{ @tagName(event_type), abs_path });
if (self.project_for_path(abs_path)) |match| {
switch (event_type) {
.created => match.project.file_added(match.rel_path) catch |e| self.logger.err("file_watcher.file_added", e),
.modified => match.project.file_modified(match.rel_path),
.deleted => match.project.file_deleted(match.rel_path),
.renamed => match.project.file_deleted(match.rel_path),
}
} else {
self.parent.send(.{ "FW", "change", abs_path, event_type }) catch {};
} }
self.parent.send(.{ "FW", "change", abs_path, event_type }) catch {};
} }
fn open(self: *Process, project_directory: []const u8) (SpawnError || std.fs.Dir.OpenError)!void { fn open(self: *Process, project_directory: []const u8) (SpawnError || std.fs.Dir.OpenError)!void {

View file

@ -625,6 +625,9 @@ fn receive_safe(self: *Self, from: tp.pid_ref, m: tp.message) !void {
if (try m.match(.{ "FW", "change", tp.more })) // file watcher events if (try m.match(.{ "FW", "change", tp.more })) // file watcher events
return; return;
if (try m.match(.{ "FW", "rename", tp.more })) // file watcher rename events
return;
return tp.unexpected(m); return tp.unexpected(m);
} }