nightwatch/src/backend/Windows.zig

382 lines
19 KiB
Zig

const std = @import("std");
const types = @import("../types.zig");
const Handler = types.Handler;
const EventType = types.EventType;
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;
const win32 = struct {
pub extern "kernel32" fn CloseHandle(hObject: windows.HANDLE) callconv(.winapi) windows.BOOL;
pub extern "kernel32" fn ReadDirectoryChangesW(
hDirectory: windows.HANDLE,
lpBuffer: *anyopaque,
nBufferLength: windows.DWORD,
bWatchSubtree: windows.BOOL,
dwNotifyFilter: windows.DWORD,
lpBytesReturned: ?*windows.DWORD,
lpOverlapped: ?*windows.OVERLAPPED,
lpCompletionRoutine: ?*anyopaque,
) callconv(.winapi) windows.BOOL;
pub extern "kernel32" fn GetQueuedCompletionStatus(
CompletionPort: windows.HANDLE,
lpNumberOfBytesTransferred: *windows.DWORD,
lpCompletionKey: *windows.ULONG_PTR,
lpOverlapped: *?*windows.OVERLAPPED,
dwMilliseconds: windows.DWORD,
) callconv(.winapi) windows.BOOL;
pub extern "kernel32" fn CreateFileW(
lpFileName: [*:0]const windows.WCHAR,
dwDesiredAccess: windows.DWORD,
dwShareMode: windows.DWORD,
lpSecurityAttributes: ?*anyopaque,
dwCreationDisposition: windows.DWORD,
dwFlagsAndAttributes: windows.DWORD,
hTemplateFile: ?windows.HANDLE,
) callconv(.winapi) windows.HANDLE;
pub extern "kernel32" fn PostQueuedCompletionStatus(
CompletionPort: windows.HANDLE,
dwNumberOfBytesTransferred: windows.DWORD,
dwCompletionKey: windows.ULONG_PTR,
lpOverlapped: ?*windows.OVERLAPPED,
) callconv(.winapi) windows.BOOL;
pub extern "kernel32" fn GetFileAttributesW(lpFileName: [*:0]const windows.WCHAR) callconv(.winapi) windows.DWORD;
};
handler: *Handler,
iocp: windows.HANDLE,
thread: ?std.Thread,
watches: std.StringHashMapUnmanaged(*Watch),
watches_mutex: std.Thread.Mutex,
path_types: std.StringHashMapUnmanaged(ObjectType),
// A completion key of zero is used to signal the background thread to exit.
const SHUTDOWN_KEY: windows.ULONG_PTR = 0;
const Watch = struct {
handle: windows.HANDLE,
buf: Buf,
overlapped: windows.OVERLAPPED,
path: []u8, // owned
};
const buf_size = 65536;
const Buf = []align(4) u8;
const FILE_NOTIFY_INFORMATION = extern struct {
NextEntryOffset: windows.DWORD,
Action: windows.DWORD,
FileNameLength: windows.DWORD,
FileName: [1]windows.WCHAR,
};
const FILE_ACTION_ADDED: windows.DWORD = 1;
const FILE_ACTION_REMOVED: windows.DWORD = 2;
const FILE_ACTION_MODIFIED: windows.DWORD = 3;
const FILE_ACTION_RENAMED_OLD_NAME: windows.DWORD = 4;
const FILE_ACTION_RENAMED_NEW_NAME: windows.DWORD = 5;
const notify_filter: windows.DWORD =
0x00000001 | // FILE_NOTIFY_CHANGE_FILE_NAME
0x00000002 | // FILE_NOTIFY_CHANGE_DIR_NAME
0x00000008 | // FILE_NOTIFY_CHANGE_SIZE
0x00000010 | // FILE_NOTIFY_CHANGE_LAST_WRITE
0x00000040; // FILE_NOTIFY_CHANGE_CREATION
pub fn init(handler: *Handler) windows.CreateIoCompletionPortError!@This() {
const iocp = try windows.CreateIoCompletionPort(windows.INVALID_HANDLE_VALUE, null, 0, 1);
return .{ .handler = handler, .iocp = iocp, .thread = null, .watches = .empty, .watches_mutex = .{}, .path_types = .empty };
}
pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void {
// Wake the background thread with a shutdown key, then wait for it.
_ = win32.PostQueuedCompletionStatus(self.iocp, 0, SHUTDOWN_KEY, null);
if (self.thread) |t| t.join();
var it = self.watches.iterator();
while (it.next()) |entry| {
const w = entry.value_ptr.*;
_ = win32.CloseHandle(w.handle);
allocator.free(w.path);
allocator.free(w.buf);
allocator.destroy(w);
}
self.watches.deinit(allocator);
var pt_it = self.path_types.iterator();
while (pt_it.next()) |entry| allocator.free(entry.key_ptr.*);
self.path_types.deinit(allocator);
_ = win32.CloseHandle(self.iocp);
}
pub fn arm(self: *@This(), allocator: std.mem.Allocator) (error{AlreadyArmed} || std.Thread.SpawnError)!void {
if (self.thread != null) return error.AlreadyArmed;
self.thread = try std.Thread.spawn(.{}, thread_fn, .{ allocator, self.iocp, &self.watches, &self.watches_mutex, &self.path_types, self.handler });
}
fn thread_fn(
allocator: std.mem.Allocator,
iocp: windows.HANDLE,
watches: *std.StringHashMapUnmanaged(*Watch),
watches_mutex: *std.Thread.Mutex,
path_types: *std.StringHashMapUnmanaged(ObjectType),
handler: *Handler,
) void {
var bytes: windows.DWORD = 0;
var key: windows.ULONG_PTR = 0;
var overlapped_ptr: ?*windows.OVERLAPPED = null;
while (true) {
// Block indefinitely until IOCP has a completion or shutdown signal.
const ok = win32.GetQueuedCompletionStatus(iocp, &bytes, &key, &overlapped_ptr, windows.INFINITE);
if (ok == 0 or key == SHUTDOWN_KEY) return;
const triggered_handle: windows.HANDLE = @ptrFromInt(key);
watches_mutex.lock();
var it = watches.iterator();
while (it.next()) |entry| {
const w = entry.value_ptr.*;
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];
var name_buf: [std.fs.max_path_bytes]u8 = undefined;
const name_len = std.unicode.utf16LeToUtf8(&name_buf, name_wchars) catch {
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];
// Cache the new path so future delete/rename events can
// resolve its type (OLD_NAME removed it from the cache).
if (pr.object_type != .unknown) cache_new: {
const gop = path_types.getOrPut(allocator, full_path) catch break :cache_new;
if (!gop.found_existing) {
gop.key_ptr.* = allocator.dupe(u8, full_path) catch {
_ = path_types.remove(full_path);
break :cache_new;
};
}
gop.value_ptr.* = pr.object_type;
}
// For dirs, also scan children into cache.
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 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);
break :blk2 kv.value;
} else .unknown;
} else blk: {
var full_path_w: [std.fs.max_path_bytes]windows.WCHAR = undefined;
const len = std.unicode.utf8ToUtf16Le(&full_path_w, full_path) catch break :blk .unknown;
full_path_w[len] = 0;
const attrs = win32.GetFileAttributesW(full_path_w[0..len :0]);
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;
if (ot != .unknown) {
const gop = path_types.getOrPut(allocator, full_path) catch break :blk ot;
if (!gop.found_existing) {
gop.key_ptr.* = allocator.dupe(u8, full_path) catch {
_ = path_types.remove(full_path);
break :blk ot;
};
}
gop.value_ptr.* = ot;
}
// A directory that appears via move-in carries existing children
// that will never generate FILE_ACTION_ADDED events. Scan its
// contents into the cache so subsequent deletes resolve their
// type. Scanning a genuinely new (empty) directory is a no-op.
if (ot == .dir and event_type == .created)
scan_path_types_into(allocator, path_types, full_path);
break :blk ot;
};
// Suppress FILE_ACTION_MODIFIED on directories: these are
// parent-directory mtime side-effects of child operations
// (delete, rename) and are not emitted by any other backend.
if (event_type == .modified and object_type == .dir) {
if (info.NextEntryOffset == 0) break;
offset += info.NextEntryOffset;
continue;
}
// Capture next_entry_offset before releasing the mutex: after unlock,
// the main thread may call remove_watch() which frees w.buf, making
// the `info` pointer (which points into w.buf) a dangling reference.
const next_entry_offset = info.NextEntryOffset;
watches_mutex.unlock();
handler.change(full_path, event_type, object_type) catch |e| {
std.log.err("nightwatch: handler returned {s}, stopping watch thread", .{@errorName(e)});
watches_mutex.lock();
break;
};
watches_mutex.lock();
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);
if (win32.ReadDirectoryChangesW(w.handle, w.buf.ptr, buf_size, 1, notify_filter, null, &w.overlapped, null) == 0)
std.log.err("nightwatch: ReadDirectoryChangesW re-arm failed for {s}, future events lost", .{entry.key_ptr.*});
break;
}
watches_mutex.unlock();
}
}
pub fn add_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8) error{ OutOfMemory, WatchFailed }!void {
self.watches_mutex.lock();
defer self.watches_mutex.unlock();
if (self.watches.contains(path)) return;
const path_w = std.unicode.utf8ToUtf16LeAllocZ(allocator, path) catch return error.WatchFailed;
defer allocator.free(path_w);
const handle = win32.CreateFileW(
path_w,
windows.GENERIC_READ,
windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE,
null,
windows.OPEN_EXISTING,
0x02000000 | 0x40000000, // FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED
null,
);
if (handle == windows.INVALID_HANDLE_VALUE) return error.WatchFailed;
errdefer _ = win32.CloseHandle(handle);
_ = windows.CreateIoCompletionPort(handle, self.iocp, @intFromPtr(handle), 0) catch return error.WatchFailed;
const buf = try allocator.alignedAlloc(u8, .fromByteUnits(4), buf_size);
errdefer allocator.free(buf);
const owned_path = try allocator.dupe(u8, path);
errdefer allocator.free(owned_path);
// Heap-allocate Watch so its address (and &w.overlapped) is stable even
// if the watches map is resized by a concurrent add_watch call.
const w = try allocator.create(Watch);
errdefer allocator.destroy(w);
w.* = .{ .handle = handle, .buf = buf, .overlapped = std.mem.zeroes(windows.OVERLAPPED), .path = owned_path };
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 and from thread_fn on directory renames so that
// FILE_ACTION_REMOVED events for children of a renamed directory can
// resolve their ObjectType from cache.
fn scan_path_types(self: *@This(), allocator: std.mem.Allocator, root: []const u8) void {
scan_path_types_into(allocator, &self.path_types, root);
}
fn scan_path_types_into(allocator: std.mem.Allocator, path_types: *std.StringHashMapUnmanaged(ObjectType), 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 = path_types.getOrPut(allocator, full_path) catch continue;
if (!gop.found_existing) {
gop.key_ptr.* = allocator.dupe(u8, full_path) catch {
_ = path_types.remove(full_path);
continue;
};
}
gop.value_ptr.* = ot;
if (ot == .dir) scan_path_types_into(allocator, path_types, gop.key_ptr.*);
}
}
pub fn remove_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8) void {
self.watches_mutex.lock();
defer self.watches_mutex.unlock();
if (self.watches.fetchRemove(path)) |entry| {
const w = entry.value;
_ = win32.CloseHandle(w.handle);
allocator.free(w.path);
allocator.free(w.buf);
allocator.destroy(w);
}
}