Files
bun.sh/src/watcher/WindowsWatcher.zig
taylor.fish 41b1efe12c Rename disabled parameter in Output.scoped (#21769)
It's very confusing.

(For internal tracking: fixes STAB-977)
2025-08-11 20:19:34 -07:00

323 lines
11 KiB
Zig

//! Bun's filesystem watcher implementation for windows using kernel32
const WindowsWatcher = @This();
mutex: Mutex = .{},
iocp: w.HANDLE = undefined,
watcher: DirWatcher = undefined,
buf: bun.PathBuffer = undefined,
base_idx: usize = 0,
pub const EventListIndex = c_int;
const Error = error{
IocpFailed,
ReadDirectoryChangesFailed,
CreateFileFailed,
InvalidPath,
};
const Action = enum(w.DWORD) {
Added = w.FILE_ACTION_ADDED,
Removed = w.FILE_ACTION_REMOVED,
Modified = w.FILE_ACTION_MODIFIED,
RenamedOld = w.FILE_ACTION_RENAMED_OLD_NAME,
RenamedNew = w.FILE_ACTION_RENAMED_NEW_NAME,
};
const FileEvent = struct {
action: Action,
filename: []u16 = undefined,
};
const DirWatcher = struct {
// must be initialized to zero (even though it's never read or written in our code),
// otherwise ReadDirectoryChangesW will fail with INVALID_HANDLE
overlapped: w.OVERLAPPED = std.mem.zeroes(w.OVERLAPPED),
buf: [64 * 1024]u8 align(@alignOf(w.FILE_NOTIFY_INFORMATION)) = undefined,
dirHandle: w.HANDLE,
// invalidates any EventIterators
fn prepare(this: *DirWatcher) bun.sys.Maybe(void) {
const filter: w.FileNotifyChangeFilter = .{ .file_name = true, .dir_name = true, .last_write = true, .creation = true };
if (w.kernel32.ReadDirectoryChangesW(this.dirHandle, &this.buf, this.buf.len, 1, filter, null, &this.overlapped, null) == 0) {
const err = w.kernel32.GetLastError();
log("failed to start watching directory: {s}", .{@tagName(err)});
return .{ .err = .{
.errno = @intFromEnum(bun.sys.SystemErrno.init(err) orelse bun.sys.SystemErrno.EINVAL),
.syscall = .watch,
} };
}
log("read directory changes!", .{});
return .success;
}
};
const EventIterator = struct {
watcher: *DirWatcher,
offset: usize = 0,
hasNext: bool = true,
pub fn next(this: *EventIterator) ?FileEvent {
if (!this.hasNext) return null;
const info_size = @sizeOf(w.FILE_NOTIFY_INFORMATION);
const info: *w.FILE_NOTIFY_INFORMATION = @alignCast(@ptrCast(this.watcher.buf[this.offset..].ptr));
const name_ptr: [*]u16 = @alignCast(@ptrCast(this.watcher.buf[this.offset + info_size ..]));
const filename: []u16 = name_ptr[0 .. info.FileNameLength / @sizeOf(u16)];
const action: Action = @enumFromInt(info.Action);
if (info.NextEntryOffset == 0) {
this.hasNext = false;
} else {
this.offset += @as(usize, info.NextEntryOffset);
}
return FileEvent{
.action = action,
.filename = filename,
};
}
};
pub fn init(this: *WindowsWatcher, root: []const u8) !void {
var pathbuf: bun.WPathBuffer = undefined;
const wpath = bun.strings.toNTPath(&pathbuf, root);
const path_len_bytes: u16 = @truncate(wpath.len * 2);
var nt_name = w.UNICODE_STRING{
.Length = path_len_bytes,
.MaximumLength = path_len_bytes,
.Buffer = @constCast(wpath.ptr),
};
var attr = w.OBJECT_ATTRIBUTES{
.Length = @sizeOf(w.OBJECT_ATTRIBUTES),
.RootDirectory = null,
.Attributes = 0, // Note we do not use OBJ_CASE_INSENSITIVE here.
.ObjectName = &nt_name,
.SecurityDescriptor = null,
.SecurityQualityOfService = null,
};
var handle: w.HANDLE = w.INVALID_HANDLE_VALUE;
var io: w.IO_STATUS_BLOCK = undefined;
const rc = w.ntdll.NtCreateFile(
&handle,
w.FILE_LIST_DIRECTORY,
&attr,
&io,
null,
0,
w.FILE_SHARE_READ | w.FILE_SHARE_WRITE | w.FILE_SHARE_DELETE,
w.FILE_OPEN,
w.FILE_DIRECTORY_FILE | w.FILE_OPEN_FOR_BACKUP_INTENT,
null,
0,
);
if (rc != .SUCCESS) {
const err = bun.windows.Win32Error.fromNTStatus(rc);
log("failed to open directory for watching: {s}", .{@tagName(err)});
return Error.CreateFileFailed;
}
errdefer _ = bun.windows.CloseHandle(handle);
this.iocp = try w.CreateIoCompletionPort(handle, null, 0, 1);
errdefer _ = bun.windows.CloseHandle(this.iocp);
this.watcher = .{ .dirHandle = handle };
@memcpy(this.buf[0..root.len], root);
const needs_slash = root.len == 0 or !bun.strings.charIsAnySlash(root[root.len - 1]);
if (needs_slash) {
this.buf[root.len] = '\\';
}
this.base_idx = if (needs_slash) root.len + 1 else root.len;
}
const Timeout = enum(w.DWORD) {
infinite = w.INFINITE,
minimal = 1,
none = 0,
};
// wait until new events are available
pub fn next(this: *WindowsWatcher, timeout: Timeout) bun.sys.Maybe(?EventIterator) {
switch (this.watcher.prepare()) {
.err => |err| {
log("prepare() returned error", .{});
return .{ .err = err };
},
.result => {},
}
var nbytes: w.DWORD = 0;
var key: w.ULONG_PTR = 0;
var overlapped: ?*w.OVERLAPPED = null;
while (true) {
const rc = w.kernel32.GetQueuedCompletionStatus(this.iocp, &nbytes, &key, &overlapped, @intFromEnum(timeout));
if (rc == 0) {
const err = w.kernel32.GetLastError();
if (err == .TIMEOUT or err == .WAIT_TIMEOUT) {
return .{ .result = null };
} else {
log("GetQueuedCompletionStatus failed: {s}", .{@tagName(err)});
return .{ .err = .{
.errno = @intFromEnum(bun.sys.SystemErrno.init(err) orelse bun.sys.SystemErrno.EINVAL),
.syscall = .watch,
} };
}
}
if (overlapped) |ptr| {
// ignore possible spurious events
if (ptr != &this.watcher.overlapped) {
continue;
}
if (nbytes == 0) {
// shutdown notification
// TODO close handles?
log("shutdown notification in WindowsWatcher.next", .{});
return .{ .err = .{
.errno = @intFromEnum(bun.sys.SystemErrno.ESHUTDOWN),
.syscall = .watch,
} };
}
return .{ .result = EventIterator{ .watcher = &this.watcher } };
} else {
log("GetQueuedCompletionStatus returned no overlapped event", .{});
return .{ .err = .{
.errno = @truncate(@intFromEnum(bun.sys.E.INVAL)),
.syscall = .watch,
} };
}
}
}
pub fn stop(this: *WindowsWatcher) void {
w.CloseHandle(this.watcher.dirHandle);
w.CloseHandle(this.iocp);
}
pub fn watchLoopCycle(this: *bun.Watcher) bun.sys.Maybe(void) {
const buf = &this.platform.buf;
const base_idx = this.platform.base_idx;
var event_id: usize = 0;
// first wait has infinite timeout - we're waiting for the next event and don't want to spin
var timeout = WindowsWatcher.Timeout.infinite;
while (true) {
var iter = switch (this.platform.next(timeout)) {
.err => |err| return .{ .err = err },
.result => |iter| iter orelse break,
};
// after the first wait, we want to coalesce further events but don't want to wait for them
// NOTE: using a 1ms timeout would be ideal, but that actually makes the thread wait for at least 10ms more than it should
// Instead we use a 0ms timeout, which may not do as much coalescing but is more responsive.
timeout = WindowsWatcher.Timeout.none;
const item_paths = this.watchlist.items(.file_path);
log("number of watched items: {d}", .{item_paths.len});
while (iter.next()) |event| {
const convert_res = bun.strings.copyUTF16IntoUTF8(buf[base_idx..], []const u16, event.filename);
const eventpath = buf[0 .. base_idx + convert_res.written];
log("watcher update event: (filename: {s}, action: {s}", .{ eventpath, @tagName(event.action) });
// TODO this probably needs a more sophisticated search algorithm in the future
// Possible approaches:
// - Keep a sorted list of the watched paths and perform a binary search. We could use a bool to keep
// track of whether the list is sorted and only sort it when we detect a change.
// - Use a prefix tree. Potentially more efficient for large numbers of watched paths, but complicated
// to implement and maintain.
// - others that i'm not thinking of
for (item_paths, 0..) |path, item_idx| {
// check if the current change applies to this item
// if so, add it to the eventlist
const rel = bun.path.isParentOrEqual(path, eventpath);
log("checking path: {s} = .{s}", .{ path, @tagName(rel) });
// skip unrelated items
if (rel == .unrelated) continue;
// if the event is for a parent dir of the item, only emit it if it's a delete or rename
// Check if we're about to exceed the watch_events array capacity
if (event_id >= this.watch_events.len) {
// Process current batch of events
switch (processWatchEventBatch(this, event_id)) {
.err => |err| return .{ .err = err },
.result => {},
}
// Reset event_id to start a new batch
event_id = 0;
}
this.watch_events[event_id] = createWatchEvent(event, @truncate(item_idx));
event_id += 1;
}
}
}
// Process any remaining events in the final batch
if (event_id > 0) {
switch (processWatchEventBatch(this, event_id)) {
.err => |err| return .{ .err = err },
.result => {},
}
}
return .success;
}
fn processWatchEventBatch(this: *bun.Watcher, event_count: usize) bun.sys.Maybe(void) {
if (event_count == 0) {
return .success;
}
// log("event_count: {d}\n", .{event_count});
var all_events = this.watch_events[0..event_count];
std.sort.pdq(WatchEvent, all_events, {}, WatchEvent.sortByIndex);
var last_event_index: usize = 0;
var last_event_id: u32 = std.math.maxInt(u32);
for (all_events, 0..) |_, i| {
if (all_events[i].index == last_event_id) {
all_events[last_event_index].merge(all_events[i]);
continue;
}
last_event_index = i;
last_event_id = all_events[i].index;
}
if (all_events.len == 0) return .success;
all_events = all_events[0 .. last_event_index + 1];
log("calling onFileUpdate (all_events.len = {d})", .{all_events.len});
this.onFileUpdate(this.ctx, all_events, this.changed_filepaths[0 .. last_event_index + 1], this.watchlist);
return .success;
}
pub fn createWatchEvent(event: FileEvent, index: WatchItemIndex) WatchEvent {
return .{
.op = .{
.delete = event.action == .Removed,
.rename = event.action == .RenamedOld,
.write = event.action == .Modified,
},
.index = index,
};
}
const log = Output.scoped(.watcher, .visible);
const std = @import("std");
const w = std.os.windows;
const bun = @import("bun");
const Mutex = bun.Mutex;
const Output = bun.Output;
const WatchEvent = bun.Watcher.WatchEvent;
const WatchItemIndex = bun.Watcher.WatchItemIndex;