nightwatch/src/backend/Windows.zig

266 lines
12 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;
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;
};
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;
};
// Determine object_type: try GetFileAttributesW; cache result.
const object_type: ObjectType = if (event_type == .deleted) blk: {
// Path no longer exists; use cached type if available.
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;
// Cache the determined type.
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;
}
break :blk ot;
};
// 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;
}
}
// 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);
}
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);
}
}