From 2c3d6160ad9c100bc4ff60a73813e5023308541d Mon Sep 17 00:00:00 2001 From: Georgijs <48869301+gvilums@users.noreply.github.com> Date: Wed, 31 Jan 2024 22:06:33 -0800 Subject: [PATCH] `--watch` and `--hot` on windows (#8607) * draft impl of windows watcher * synchronous watcher * working standalone watcher * in progress changes to watcher * make watcher non-global * prepare watcher for windows impl * add windows watcher scaffold and clean up imports * fix inotify * make watch code more generic over platforms * fix visibility * watcher starts without error * printing changes works * basic windows watching works * handle process exit from watcher * cleanup in process cloning * clean up logging and panic handling * fix hot reload test on windows * misc cleanup around watcher * make watch test actually useful * [autofix.ci] apply automated fixes * remove old files * clean up watchers * update .gitignore * rework windows watcher into single watcher instance watching top level project dir * use non-strict utf16 conversion * change to contains * fix mac and linux compile * add baseline in crash report (#8606) * allow linking bins that do not exist. (#8605) * fix linux compile * fix linux compile (again) * remove outdated todo --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: dave caruso --- .gitignore | 331 ++++++------ src/__global.zig | 2 + src/bun.js/event_loop.zig | 12 +- src/bun.js/javascript.zig | 45 +- src/bun.js/module_loader.zig | 4 +- src/bun.js/node/path_watcher.zig | 18 +- src/bun.js/node/win_watcher.zig | 61 +-- src/bun.zig | 196 ++++++- src/cli.zig | 8 + src/resolver/resolve_path.zig | 17 + src/string_immutable.zig | 16 + src/watcher.zig | 818 +++++++++++++++++++----------- src/windows.zig | 54 +- test/cli/hot/hot.test.ts | 18 +- test/cli/watch/watch.test.ts | 33 +- test/js/node/fs/fs-stream.link.js | 1 - 16 files changed, 1045 insertions(+), 589 deletions(-) delete mode 120000 test/js/node/fs/fs-stream.link.js diff --git a/.gitignore b/.gitignore index 8721435d6b7681..8373c489d1cf4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,166 +1,167 @@ -.DS_Store -zig-cache -packages/*/*.wasm -*.o -*.a -profile.json - -node_modules -.envrc -.swcrc -yarn.lock -dist -*.tmp -*.log -*.out.js -*.out.refresh.js -**/package-lock.json -build -*.wat -zig-out -pnpm-lock.yaml -README.md.template -src/deps/zig-clap/example -src/deps/zig-clap/README.md -src/deps/zig-clap/.github -src/deps/zig-clap/.gitattributes -out -outdir - -.trace -cover -coverage -coverv -*.trace -github -out.* -out -.parcel-cache -esbuilddir -*.bun -parceldist -esbuilddir -outdir/ -outcss -.next -txt.js -.idea -.vscode/cpp* -.vscode/clang* - -node_modules_* -*.jsb -*.zip -bun-zigld -bun-singlehtreaded -bun-nomimalloc -bun-mimalloc -examples/lotta-modules/bun-yday -examples/lotta-modules/bun-old -examples/lotta-modules/bun-nofscache - -src/node-fallbacks/out/* -src/node-fallbacks/node_modules -sign.json -release/ -*.dmg -sign.*.json -packages/debug-* -packages/bun-cli/postinstall.js -packages/bun-*/bun -packages/bun-*/bun-profile -packages/bun-*/debug-bun -packages/bun-*/*.o -packages/bun-cli/postinstall.js - -packages/bun-cli/bin/* -bun-test-scratch -misctools/fetch - -src/deps/libiconv -src/deps/openssl -src/tests.zig -*.blob -src/deps/s2n-tls -.npm -.npm.gz - -bun-binary - -src/deps/PLCrashReporter/ - -*.dSYM -*.crash -misctools/sha -packages/bun-wasm/*.mjs -packages/bun-wasm/*.cjs -packages/bun-wasm/*.map -packages/bun-wasm/*.js -packages/bun-wasm/*.d.ts -packages/bun-wasm/*.d.cts -packages/bun-wasm/*.d.mts -*.bc - -src/fallback.version -src/runtime.version -*.sqlite -*.database -*.db -misctools/machbench -*.big -.eslintcache - -/bun-webkit - -src/deps/c-ares/build -src/bun.js/bindings-obj -src/bun.js/debug-bindings-obj - -failing-tests.txt -test.txt -myscript.sh - -cold-jsc-start -cold-jsc-start.d - -/test.ts -/test.js - -src/js/out/modules* -src/js/out/functions* -src/js/out/tmp -src/js/out/DebugPath.h - -make-dev-stats.csv - -.uuid -tsconfig.tsbuildinfo - -test/js/bun/glob/fixtures -*.lib -*.pdb -CMakeFiles -build.ninja -.ninja_deps -.ninja_log -CMakeCache.txt -cmake_install.cmake -compile_commands.json - -*.lib -x64 -**/*.vcxproj* -**/*.sln* -**/*.dir -**/*.pdb - -/.webkit-cache -/.cache -/src/deps/libuv -/build-*/ - -.vs - -**/.verdaccio-db.json -/test-report.md +.DS_Store +zig-cache +packages/*/*.wasm +*.o +*.a +profile.json + +node_modules +.envrc +.swcrc +yarn.lock +dist +*.tmp +*.log +*.out.js +*.out.refresh.js +**/package-lock.json +build +*.wat +zig-out +pnpm-lock.yaml +README.md.template +src/deps/zig-clap/example +src/deps/zig-clap/README.md +src/deps/zig-clap/.github +src/deps/zig-clap/.gitattributes +out +outdir + +.trace +cover +coverage +coverv +*.trace +github +out.* +out +.parcel-cache +esbuilddir +*.bun +parceldist +esbuilddir +outdir/ +outcss +.next +txt.js +.idea +.vscode/cpp* +.vscode/clang* + +node_modules_* +*.jsb +*.zip +bun-zigld +bun-singlehtreaded +bun-nomimalloc +bun-mimalloc +examples/lotta-modules/bun-yday +examples/lotta-modules/bun-old +examples/lotta-modules/bun-nofscache + +src/node-fallbacks/out/* +src/node-fallbacks/node_modules +sign.json +release/ +*.dmg +sign.*.json +packages/debug-* +packages/bun-cli/postinstall.js +packages/bun-*/bun +packages/bun-*/bun-profile +packages/bun-*/debug-bun +packages/bun-*/*.o +packages/bun-cli/postinstall.js + +packages/bun-cli/bin/* +bun-test-scratch +misctools/fetch + +src/deps/libiconv +src/deps/openssl +src/tests.zig +*.blob +src/deps/s2n-tls +.npm +.npm.gz + +bun-binary + +src/deps/PLCrashReporter/ + +*.dSYM +*.crash +misctools/sha +packages/bun-wasm/*.mjs +packages/bun-wasm/*.cjs +packages/bun-wasm/*.map +packages/bun-wasm/*.js +packages/bun-wasm/*.d.ts +packages/bun-wasm/*.d.cts +packages/bun-wasm/*.d.mts +*.bc + +src/fallback.version +src/runtime.version +*.sqlite +*.database +*.db +misctools/machbench +*.big +.eslintcache + +/bun-webkit + +src/deps/c-ares/build +src/bun.js/bindings-obj +src/bun.js/debug-bindings-obj + +failing-tests.txt +test.txt +myscript.sh + +cold-jsc-start +cold-jsc-start.d + +/testdir +/test.ts +/test.js + +src/js/out/modules* +src/js/out/functions* +src/js/out/tmp +src/js/out/DebugPath.h + +make-dev-stats.csv + +.uuid +tsconfig.tsbuildinfo + +test/js/bun/glob/fixtures +*.lib +*.pdb +CMakeFiles +build.ninja +.ninja_deps +.ninja_log +CMakeCache.txt +cmake_install.cmake +compile_commands.json + +*.lib +x64 +**/*.vcxproj* +**/*.sln* +**/*.dir +**/*.pdb + +/.webkit-cache +/.cache +/src/deps/libuv +/build-*/ + +.vs + +**/.verdaccio-db.json +/test-report.md /test-report.json \ No newline at end of file diff --git a/src/__global.zig b/src/__global.zig index fcb03d76d7b464..4c20917964d5e2 100644 --- a/src/__global.zig +++ b/src/__global.zig @@ -69,6 +69,8 @@ pub fn setThreadName(name: StringTypes.stringZ) void { _ = std.os.prctl(.SET_NAME, .{@intFromPtr(name.ptr)}) catch 0; } else if (Environment.isMac) { _ = std.c.pthread_setname_np(name); + } else if (Environment.isWindows) { + // _ = std.os.SetThreadDescription(std.os.GetCurrentThread(), name); } } diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index fee9a9f27dc402..04308920282dd3 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -1178,14 +1178,10 @@ pub const EventLoop = struct { } if (!loop.isActive()) { - if (comptime Environment.isWindows) { - bun.todo(@src(), {}); - } else { - if (this.forever_timer == null) { - var t = uws.Timer.create(loop, this); - t.set(this, &noopForeverTimer, 1000 * 60 * 4, 1000 * 60 * 4); - this.forever_timer = t; - } + if (this.forever_timer == null) { + var t = uws.Timer.create(loop, this); + t.set(this, &noopForeverTimer, 1000 * 60 * 4, 1000 * 60 * 4); + this.forever_timer = t; } } diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index d06d8b0701e555..85be94f81352aa 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -83,6 +83,7 @@ const PendingResolution = @import("../resolver/resolver.zig").PendingResolution; const ThreadSafeFunction = JSC.napi.ThreadSafeFunction; const PackageManager = @import("../install/install.zig").PackageManager; const IPC = @import("ipc.zig"); +pub const GenericWatcher = @import("../watcher.zig"); const ModuleLoader = JSC.ModuleLoader; const FetchFlags = JSC.FetchFlags; @@ -430,22 +431,22 @@ pub const ImportWatcher = union(enum) { pub fn start(this: ImportWatcher) !void { switch (this) { - inline .hot => |watcher| try watcher.start(), - inline .watch => |watcher| try watcher.start(), + inline .hot => |w| try w.start(), + inline .watch => |w| try w.start(), else => {}, } } - pub inline fn watchlist(this: ImportWatcher) Watcher.WatchListArray { + pub inline fn watchlist(this: ImportWatcher) GenericWatcher.WatchList { return switch (this) { - inline .hot, .watch => |wacher| wacher.watchlist, + inline .hot, .watch => |w| w.watchlist, else => .{}, }; } - pub inline fn indexOf(this: ImportWatcher, hash: Watcher.HashType) ?u32 { + pub inline fn indexOf(this: ImportWatcher, hash: GenericWatcher.HashType) ?u32 { return switch (this) { - inline .hot, .watch => |wacher| wacher.indexOf(hash), + inline .hot, .watch => |w| w.indexOf(hash), else => null, }; } @@ -454,7 +455,7 @@ pub const ImportWatcher = union(enum) { this: ImportWatcher, fd: StoredFileDescriptorType, file_path: string, - hash: Watcher.HashType, + hash: GenericWatcher.HashType, loader: options.Loader, dir_fd: StoredFileDescriptorType, package_json: ?*PackageJSON, @@ -2142,7 +2143,7 @@ pub const VirtualMachine = struct { pub fn reloadEntryPoint(this: *VirtualMachine, entry_path: []const u8) !*JSInternalPromise { this.has_loaded = false; this.main = entry_path; - this.main_hash = bun.JSC.Watcher.getHash(entry_path); + this.main_hash = GenericWatcher.getHash(entry_path); try this.entry_point.generate( this.allocator, @@ -2180,7 +2181,7 @@ pub const VirtualMachine = struct { pub fn reloadEntryPointForTestRunner(this: *VirtualMachine, entry_path: []const u8) !*JSInternalPromise { this.has_loaded = false; this.main = entry_path; - this.main_hash = bun.JSC.Watcher.getHash(entry_path); + this.main_hash = GenericWatcher.getHash(entry_path); this.eventLoop().ensureWaker(); @@ -3074,11 +3075,10 @@ extern fn BunDebugger__willHotReload() void; pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime reload_immediately: bool) type { return struct { - const watcher = @import("../watcher.zig"); - pub const Watcher = watcher.NewWatcher(*@This()); + pub const Watcher = GenericWatcher.NewWatcher(*@This()); const Reloader = @This(); - onAccept: std.ArrayHashMapUnmanaged(@This().Watcher.HashType, bun.BabyList(OnAcceptCallback), bun.ArrayIdentityContext, false) = .{}, + onAccept: std.ArrayHashMapUnmanaged(GenericWatcher.HashType, bun.BabyList(OnAcceptCallback), bun.ArrayIdentityContext, false) = .{}, ctx: *Ctx, verbose: bool = false, @@ -3217,7 +3217,7 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime // - Directories outside the root directory // - Directories inside node_modules if (std.mem.indexOf(u8, file_path, "node_modules") == null and std.mem.indexOf(u8, file_path, watch.fs.top_level_dir) != null) { - watch.addDirectory(dir_fd, file_path, @This().Watcher.getHash(file_path), false) catch {}; + watch.addDirectory(dir_fd, file_path, GenericWatcher.getHash(file_path), false) catch {}; } } @@ -3250,9 +3250,9 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime pub fn onFileUpdate( this: *@This(), - events: []watcher.WatchEvent, + events: []GenericWatcher.WatchEvent, changed_files: []?[:0]u8, - watchlist: watcher.Watchlist, + watchlist: GenericWatcher.WatchList, ) void { var slice = watchlist.slice(); const file_paths = slice.items(.file_path); @@ -3314,6 +3314,13 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime } }, .directory => { + if (comptime Environment.isWindows) { + // on windows we receive file events for all items affected by a directory change + // so we only need to clear the directory cache. all other effects will be handled + // by the file events + resolver.bustDirCache(file_path); + continue; + } var affected_buf: [128][]const u8 = undefined; var entries_option: ?*Fs.FileSystem.RealFS.EntriesOption = null; @@ -3364,7 +3371,7 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime resolver.bustDirCache(file_path); if (entries_option) |dir_ent| { - var last_file_hash: @This().Watcher.HashType = std.math.maxInt(@This().Watcher.HashType); + var last_file_hash: GenericWatcher.HashType = std.math.maxInt(GenericWatcher.HashType); for (affected) |changed_name_| { const changed_name: []const u8 = if (comptime Environment.isMac) @@ -3377,14 +3384,14 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime var prev_entry_id: usize = std.math.maxInt(usize); if (loader != .file) { var path_string: bun.PathString = undefined; - var file_hash: @This().Watcher.HashType = last_file_hash; + var file_hash: GenericWatcher.HashType = last_file_hash; const abs_path: string = brk: { if (dir_ent.entries.get(@as([]const u8, @ptrCast(changed_name)))) |file_ent| { // reset the file descriptor file_ent.entry.cache.fd = .zero; file_ent.entry.need_stat = true; path_string = file_ent.entry.abs_path; - file_hash = @This().Watcher.getHash(path_string.slice()); + file_hash = GenericWatcher.getHash(path_string.slice()); for (hashes, 0..) |hash, entry_id| { if (hash == file_hash) { if (file_descriptors[entry_id] != .zero) { @@ -3412,7 +3419,7 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime @memcpy(_on_file_update_path_buf[file_path_without_trailing_slash.len..][0..changed_name.len], changed_name); const path_slice = _on_file_update_path_buf[0 .. file_path_without_trailing_slash.len + changed_name.len + 1]; - file_hash = @This().Watcher.getHash(path_slice); + file_hash = GenericWatcher.getHash(path_slice); break :brk path_slice; } }; diff --git a/src/bun.js/module_loader.zig b/src/bun.js/module_loader.zig index 9ffc358cfe9e47..e1f7f21d56117f 100644 --- a/src/bun.js/module_loader.zig +++ b/src/bun.js/module_loader.zig @@ -401,7 +401,7 @@ pub const RuntimeTranspilerStore = struct { var fd: ?StoredFileDescriptorType = null; var package_json: ?*PackageJSON = null; - const hash = JSC.Watcher.getHash(path.text); + const hash = JSC.GenericWatcher.getHash(path.text); switch (vm.bun_watcher) { .hot, .watch => { @@ -1447,7 +1447,7 @@ pub const ModuleLoader = struct { .js, .jsx, .ts, .tsx, .json, .toml, .text => { jsc_vm.transpiled_count += 1; jsc_vm.bundler.resetStore(); - const hash = JSC.Watcher.getHash(path.text); + const hash = JSC.GenericWatcher.getHash(path.text); const is_main = jsc_vm.main.len == path.text.len and jsc_vm.main_hash == hash and strings.eqlLong(jsc_vm.main, path.text, false); diff --git a/src/bun.js/node/path_watcher.zig b/src/bun.js/node/path_watcher.zig index c2740a753d4ab1..63cd1d5f278ab1 100644 --- a/src/bun.js/node/path_watcher.zig +++ b/src/bun.js/node/path_watcher.zig @@ -13,6 +13,7 @@ const StoredFileDescriptorType = bun.StoredFileDescriptorType; const string = bun.string; const JSC = bun.JSC; const VirtualMachine = JSC.VirtualMachine; +const GenericWatcher = @import("../../watcher.zig"); const sync = @import("../../sync.zig"); const Semaphore = sync.Semaphore; @@ -21,7 +22,6 @@ var default_manager_mutex: Mutex = Mutex.init(); var default_manager: ?*PathWatcherManager = null; pub const PathWatcherManager = struct { - const GenericWatcher = @import("../../watcher.zig"); const options = @import("../../options.zig"); pub const Watcher = GenericWatcher.NewWatcher(*PathWatcherManager); const log = Output.scoped(.PathWatcherManager, false); @@ -43,7 +43,7 @@ pub const PathWatcherManager = struct { path: [:0]const u8, dirname: string, refs: u32 = 0, - hash: Watcher.HashType, + hash: GenericWatcher.HashType, }; fn refPendingTask(this: *PathWatcherManager) bool { @@ -96,7 +96,7 @@ pub const PathWatcherManager = struct { .is_file = false, .path = cloned_path, .dirname = cloned_path, - .hash = Watcher.getHash(cloned_path), + .hash = GenericWatcher.getHash(cloned_path), .refs = 1, }; _ = try this.file_paths.put(cloned_path, result); @@ -110,7 +110,7 @@ pub const PathWatcherManager = struct { .path = cloned_path, // if is really a file we need to get the dirname .dirname = std.fs.path.dirname(cloned_path) orelse cloned_path, - .hash = Watcher.getHash(cloned_path), + .hash = GenericWatcher.getHash(cloned_path), .refs = 1, }; _ = try this.file_paths.put(cloned_path, result); @@ -154,7 +154,7 @@ pub const PathWatcherManager = struct { this: *PathWatcherManager, events: []GenericWatcher.WatchEvent, changed_files: []?[:0]u8, - watchlist: GenericWatcher.Watchlist, + watchlist: GenericWatcher.WatchList, ) void { var slice = watchlist.slice(); const file_paths = slice.items(.file_path); @@ -197,7 +197,7 @@ pub const PathWatcherManager = struct { if (event.op.write or event.op.delete or event.op.rename) { const event_type: PathWatcher.EventType = if (event.op.delete or event.op.rename or event.op.move_to) .rename else .change; - const hash = Watcher.getHash(file_path); + const hash = GenericWatcher.getHash(file_path); for (watchers) |w| { if (w) |watcher| { @@ -268,7 +268,7 @@ pub const PathWatcherManager = struct { const len = file_path_without_trailing_slash.len + changed_name.len; const path_slice = _on_file_update_path_buf[0 .. len + 1]; - const hash = Watcher.getHash(path_slice); + const hash = GenericWatcher.getHash(path_slice); // skip consecutive duplicates const event_type: PathWatcher.EventType = .rename; // renaming folders, creating folder or files will be always be rename @@ -688,7 +688,7 @@ pub const PathWatcher = struct { has_pending_directories: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), closed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), pub const ChangeEvent = struct { - hash: PathWatcherManager.Watcher.HashType = 0, + hash: GenericWatcher.HashType = 0, event_type: EventType = .change, time_stamp: i64 = 0, }; @@ -805,7 +805,7 @@ pub const PathWatcher = struct { } } - pub fn emit(this: *PathWatcher, path: string, hash: PathWatcherManager.Watcher.HashType, time_stamp: i64, is_file: bool, event_type: EventType) void { + pub fn emit(this: *PathWatcher, path: string, hash: GenericWatcher.HashType, time_stamp: i64, is_file: bool, event_type: EventType) void { const time_diff = time_stamp - this.last_change_event.time_stamp; // skip consecutive duplicates if ((this.last_change_event.time_stamp == 0 or time_diff > 1) or this.last_change_event.event_type != event_type and this.last_change_event.hash != hash) { diff --git a/src/bun.js/node/win_watcher.zig b/src/bun.js/node/win_watcher.zig index c94608d3efaadc..bcff289446d352 100644 --- a/src/bun.js/node/win_watcher.zig +++ b/src/bun.js/node/win_watcher.zig @@ -9,17 +9,15 @@ const JSC = bun.JSC; const VirtualMachine = JSC.VirtualMachine; const StoredFileDescriptorType = bun.StoredFileDescriptorType; const Output = bun.Output; +const Watcher = @import("../../watcher.zig"); var default_manager: ?*PathWatcherManager = null; // TODO: make this a generic so we can reuse code with path_watcher // TODO: we probably should use native instead of libuv abstraction here for better performance pub const PathWatcherManager = struct { - const GenericWatcher = @import("../../watcher.zig"); const options = @import("../../options.zig"); - pub const Watcher = GenericWatcher.NewWatcher(*PathWatcherManager); const log = Output.scoped(.PathWatcherManager, false); - main_watcher: *Watcher, watchers: bun.BabyList(?*PathWatcher) = .{}, watcher_count: u32 = 0, @@ -85,55 +83,31 @@ pub const PathWatcherManager = struct { var this = PathWatcherManager.new(.{ .file_paths = bun.StringHashMap(PathInfo).init(bun.default_allocator), .watchers = watchers, - .main_watcher = undefined, .vm = vm, .watcher_count = 0, }); errdefer this.destroy(); - this.main_watcher = try Watcher.init( - this, - vm.bundler.fs, - bun.default_allocator, - ); - - errdefer this.main_watcher.deinit(false); - - try this.main_watcher.start(); return this; } - fn _addDirectory(this: *PathWatcherManager, _: *PathWatcher, path: PathInfo) !void { - const fd = path.fd; - try this.main_watcher.addDirectory(fd, path.path, path.hash, false); - } - fn registerWatcher(this: *PathWatcherManager, watcher: *PathWatcher) !void { - { - if (this.watcher_count == this.watchers.len) { - this.watcher_count += 1; - this.watchers.push(bun.default_allocator, watcher) catch |err| { - this.watcher_count -= 1; - return err; - }; - } else { - var watchers = this.watchers.slice(); - for (watchers, 0..) |w, i| { - if (w == null) { - watchers[i] = watcher; - this.watcher_count += 1; - break; - } + if (this.watcher_count == this.watchers.len) { + this.watcher_count += 1; + this.watchers.push(bun.default_allocator, watcher) catch |err| { + this.watcher_count -= 1; + return err; + }; + } else { + var watchers = this.watchers.slice(); + for (watchers, 0..) |w, i| { + if (w == null) { + watchers[i] = watcher; + this.watcher_count += 1; + break; } } } - - const path = watcher.path; - if (path.is_file) { - try this.main_watcher.addFile(path.fd, path.path, path.hash, options.Loader.file, .zero, null, false); - } else { - try this._addDirectory(watcher, path); - } } fn _incrementPathRef(this: *PathWatcherManager, file_path: [:0]const u8) void { @@ -152,7 +126,6 @@ pub const PathWatcherManager = struct { path.refs -= 1; if (path.refs == 0) { const path_ = path.path; - this.main_watcher.remove(path.hash); _ = this.file_paths.remove(path_); bun.default_allocator.free(path_); } @@ -198,8 +171,6 @@ pub const PathWatcherManager = struct { return; } - this.main_watcher.deinit(false); - if (this.watcher_count > 0) { while (this.watchers.popOrNull()) |watcher| { if (watcher) |w| { @@ -242,7 +213,7 @@ pub const PathWatcher = struct { const log = Output.scoped(.PathWatcher, false); pub const ChangeEvent = struct { - hash: PathWatcherManager.Watcher.HashType = 0, + hash: Watcher.HashType = 0, event_type: EventType = .change, time_stamp: i64 = 0, }; @@ -330,7 +301,7 @@ pub const PathWatcher = struct { return this; } - pub fn emit(this: *PathWatcher, path: string, hash: PathWatcherManager.Watcher.HashType, time_stamp: i64, is_file: bool, event_type: EventType) void { + pub fn emit(this: *PathWatcher, path: string, hash: Watcher.HashType, time_stamp: i64, is_file: bool, event_type: EventType) void { const time_diff = time_stamp - this.last_change_event.time_stamp; // skip consecutive duplicates if ((this.last_change_event.time_stamp == 0 or time_diff > 1) or this.last_change_event.event_type != event_type and this.last_change_event.hash != hash) { diff --git a/src/bun.zig b/src/bun.zig index d8194653244131..353c466b622aa0 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -1397,9 +1397,31 @@ pub const failing_allocator = std.mem.Allocator{ .ptr = undefined, .vtable = &.{ pub fn reloadProcess( allocator: std.mem.Allocator, clear_terminal: bool, -) void { - const PosixSpawn = posix.spawn; +) noreturn { + if (clear_terminal) { + Output.flush(); + Output.disableBuffering(); + Output.resetTerminalAll(); + } const bun = @This(); + + if (comptime Environment.isWindows) { + // this assumes that our parent process assigned us to a job object (see runWatcherManager) + var procinfo: std.os.windows.PROCESS_INFORMATION = undefined; + win32.spawnProcessCopy(allocator, &procinfo, false, false) catch |err| { + Output.panic("Error while reloading process: {s}", .{@errorName(err)}); + }; + + // terminate the current process + const rc = bun.windows.TerminateProcess(@ptrFromInt(std.math.maxInt(usize)), 0); + if (rc == 0) { + const err = bun.windows.GetLastError(); + Output.panic("Error while reloading process: {s}", .{@tagName(err)}); + } else { + Output.panic("Unexpected error while reloading process\n", .{}); + } + } + const PosixSpawn = posix.spawn; const dupe_argv = allocator.allocSentinel(?[*:0]const u8, bun.argv().len, null) catch unreachable; for (bun.argv(), dupe_argv) |src, *dest| { dest.* = (allocator.dupeZ(u8, src) catch unreachable).ptr; @@ -1424,13 +1446,6 @@ pub fn reloadProcess( // we clone envp so that the memory address of environment variables isn't the same as the libc one const envp = @as([*:null]?[*:0]const u8, @ptrCast(environ.ptr)); - // Clear the terminal - if (clear_terminal) { - Output.flush(); - Output.disableBuffering(); - Output.resetTerminalAll(); - } - // macOS doesn't have CLOEXEC, so we must go through posix_spawn if (comptime Environment.isMac) { var actions = PosixSpawn.Actions.init() catch unreachable; @@ -1454,7 +1469,9 @@ pub fn reloadProcess( .err => |err| { Output.panic("Unexpected error while reloading: {d} {s}", .{ err.errno, @tagName(err.getErrno()) }); }, - .result => |_| {}, + .result => |_| { + Output.panic("Unexpected error while reloading: posix_spawn returned a result", .{}); + }, } } else if (comptime Environment.isPosix) { const on_before_reload_process_linux = struct { @@ -1468,10 +1485,8 @@ pub fn reloadProcess( envp, ); Output.panic("Unexpected error while reloading: {s}", .{@errorName(err)}); - } else if (comptime Environment.isWindows) { - @panic("TODO on Windows!"); } else { - @panic("Unsupported platform"); + @compileError("unsupported platform for reloadProcess"); } } pub var auto_reload_on_crash = false; @@ -1879,10 +1894,13 @@ pub const posix = struct { }; pub const win32 = struct { + const w = std.os.windows; pub var STDOUT_FD: FileDescriptor = undefined; pub var STDERR_FD: FileDescriptor = undefined; pub var STDIN_FD: FileDescriptor = undefined; + const watcherChildEnv: [:0]const u16 = strings.toUTF16LiteralZ("_BUN_WATCHER_CHILD"); + pub fn stdio(i: anytype) FileDescriptor { return switch (i) { 0 => STDIN_FD, @@ -1891,6 +1909,158 @@ pub const win32 = struct { else => @panic("Invalid stdio fd"), }; } + + pub fn isWatcherChild() bool { + var buf: [1]u16 = undefined; + return windows.GetEnvironmentVariableW(@constCast(watcherChildEnv.ptr), &buf, 1) > 0; + } + + pub fn becomeWatcherManager(allocator: std.mem.Allocator) noreturn { + // this process will be the parent of the child process that actually runs the script + // based on https://devblogs.microsoft.com/oldnewthing/20130405-00/?p=4743 + const job = windows.CreateJobObjectA(null, null); + const iocp = windows.CreateIoCompletionPort(windows.INVALID_HANDLE_VALUE, null, 0, 1) orelse { + Output.panic("Failed to create IOCP\n", .{}); + }; + var assoc = windows.JOBOBJECT_ASSOCIATE_COMPLETION_PORT{ + .CompletionKey = job, + .CompletionPort = iocp, + }; + if (windows.SetInformationJobObject(job, windows.JobObjectAssociateCompletionPortInformation, &assoc, @sizeOf(windows.JOBOBJECT_ASSOCIATE_COMPLETION_PORT)) == 0) { + const err = windows.GetLastError(); + Output.panic("Failed to associate completion port: {s}\n", .{@tagName(err)}); + } + + var procinfo: std.os.windows.PROCESS_INFORMATION = undefined; + spawnProcessCopy(allocator, &procinfo, true, true) catch |err| { + Output.panic("Failed to spawn process: {s}\n", .{@errorName(err)}); + }; + if (windows.AssignProcessToJobObject(job, procinfo.hProcess) == 0) { + const err = windows.GetLastError(); + Output.panic("Failed to assign process to job object: {s}\n", .{@tagName(err)}); + } + if (windows.ResumeThread(procinfo.hThread) == 0) { + const err = windows.GetLastError(); + Output.panic("Failed to resume child process: {s}\n", .{@tagName(err)}); + } + + var completion_code: w.DWORD = 0; + var completion_key: w.ULONG_PTR = 0; + var overlapped: ?*w.OVERLAPPED = null; + var last_pid: w.DWORD = 0; + while (true) { + if (w.kernel32.GetQueuedCompletionStatus(iocp, &completion_code, &completion_key, &overlapped, w.INFINITE) == 0) { + const err = windows.GetLastError(); + Output.panic("Failed to query completion status: {s}\n", .{@tagName(err)}); + } + // only care about events concerning our job object (theoretically unnecessary) + if (completion_key != @intFromPtr(job)) { + continue; + } + if (completion_code == windows.JOB_OBJECT_MSG_EXIT_PROCESS) { + last_pid = @truncate(@intFromPtr(overlapped)); + } else if (completion_code == windows.JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO) { + break; + } + } + // NOTE: for now we always exit with a zero exit code. + // This is because there's no straightforward way to communicate the exit code + // of subsequently spawned child processes to the original parent process. + Global.exit(0); + } + + pub fn spawnProcessCopy( + allocator: std.mem.Allocator, + procinfo: *std.os.windows.PROCESS_INFORMATION, + suspended: bool, + setChild: bool, + ) !void { + var flags: std.os.windows.DWORD = w.CREATE_UNICODE_ENVIRONMENT; + if (suspended) { + // see CREATE_SUSPENDED at + // https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags + flags |= 0x00000004; + } + + const image_path = &w.peb().ProcessParameters.ImagePathName; + var wbuf: WPathBuffer = undefined; + @memcpy(wbuf[0..image_path.Length], image_path.Buffer); + wbuf[image_path.Length] = 0; + + const image_pathZ = wbuf[0..image_path.Length :0]; + + const kernelenv = w.kernel32.GetEnvironmentStringsW(); + var newenv: ?[]u16 = null; + defer { + if (kernelenv) |envptr| { + _ = w.kernel32.FreeEnvironmentStringsW(envptr); + } + if (newenv) |ptr| { + allocator.free(ptr); + } + } + + if (setChild) { + var size: usize = 0; + if (kernelenv) |ptr| { + // check that env is non-empty + if (ptr[0] != 0 or ptr[1] != 0) { + // array is terminated by two nulls + while (ptr[size] != 0 or ptr[size + 1] != 0) size += 1; + size += 1; + } + } + // now ptr[size] is the first null + const buf = try allocator.alloc(u16, size + watcherChildEnv.len + 4); + if (kernelenv) |ptr| { + @memcpy(buf[0..size], ptr); + } + @memcpy(buf[size .. size + watcherChildEnv.len], watcherChildEnv); + buf[size + watcherChildEnv.len] = '='; + buf[size + watcherChildEnv.len + 1] = '1'; + buf[size + watcherChildEnv.len + 2] = 0; + buf[size + watcherChildEnv.len + 3] = 0; + newenv = buf; + } + + const env: ?[*]u16 = if (newenv) |e| e.ptr else kernelenv; + + var startupinfo = w.STARTUPINFOW{ + .cb = @sizeOf(w.STARTUPINFOW), + .lpReserved = null, + .lpDesktop = null, + .lpTitle = null, + .dwX = 0, + .dwY = 0, + .dwXSize = 0, + .dwYSize = 0, + .dwXCountChars = 0, + .dwYCountChars = 0, + .dwFillAttribute = 0, + .dwFlags = w.STARTF_USESTDHANDLES, + .wShowWindow = 0, + .cbReserved2 = 0, + .lpReserved2 = null, + .hStdInput = std.io.getStdIn().handle, + .hStdOutput = std.io.getStdOut().handle, + .hStdError = std.io.getStdErr().handle, + }; + const rc = w.kernel32.CreateProcessW( + image_pathZ, + w.kernel32.GetCommandLineW(), + null, + null, + 1, + flags, + env, + null, + &startupinfo, + procinfo, + ); + if (rc == 0) { + Output.panic("Unexpected error while reloading process\n", .{}); + } + } }; pub usingnamespace if (@import("builtin").target.os.tag != .windows) posix else win32; diff --git a/src/cli.zig b/src/cli.zig index 12e726d3e64675..0bb6dbf966f777 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -1137,6 +1137,14 @@ pub const Command = struct { if (comptime Command.Tag.uses_global_options.get(command)) { ctx.args = try Arguments.parse(allocator, &ctx, command); } + + if (comptime Environment.isWindows) { + if (ctx.debug.hot_reload == .watch and !bun.isWatcherChild()) { + // this is noreturn + bun.becomeWatcherManager(allocator); + } + } + return ctx; } }; diff --git a/src/resolver/resolve_path.zig b/src/resolver/resolve_path.zig index f8859c317249f0..d644b3ad9e47fe 100644 --- a/src/resolver/resolve_path.zig +++ b/src/resolver/resolve_path.zig @@ -48,6 +48,23 @@ inline fn @"is ../"(slice: []const u8) bool { return strings.hasPrefixComptime(slice, "../"); } +const ParentEqual = enum { + parent, + equal, + unrelated, +}; + +pub fn isParentOrEqual(parent_: []const u8, child: []const u8) ParentEqual { + var parent = parent_; + while (parent.len > 0 and isSepAny(parent[parent.len - 1])) { + parent = parent[0 .. parent.len - 1]; + } + if (std.mem.indexOf(u8, child, parent) != 0) return .unrelated; + if (child.len == parent.len) return .equal; + if (isSepAny(child[parent.len])) return .parent; + return .unrelated; +} + pub fn getIfExistsLongestCommonPathGeneric(input: []const []const u8, comptime platform: Platform) ?[]const u8 { const separator = comptime platform.separator(); const isPathSeparator = comptime platform.getSeparatorFunc(); diff --git a/src/string_immutable.zig b/src/string_immutable.zig index 166b49a3788db2..c51436b00c283e 100644 --- a/src/string_immutable.zig +++ b/src/string_immutable.zig @@ -54,6 +54,22 @@ pub fn toUTF16Literal(comptime str: []const u8) []const u16 { }; } +pub fn toUTF16LiteralZ(comptime str: []const u8) [:0]const u16 { + return comptime brk: { + comptime var output: [str.len + 1]u16 = undefined; + + for (str, 0..) |c, i| { + output[i] = c; + } + output[str.len] = 0; + + const Static = struct { + pub const literal: [:0]const u16 = output[0..str.len :0]; + }; + break :brk Static.literal; + }; +} + pub const OptionalUsize = std.meta.Int(.unsigned, @bitSizeOf(usize) - 1); pub fn indexOfAny(slice: string, comptime str: anytype) ?OptionalUsize { switch (comptime str.len) { diff --git a/src/watcher.zig b/src/watcher.zig index 55d160ae9eeea6..360a102895b945 100644 --- a/src/watcher.zig +++ b/src/watcher.zig @@ -1,4 +1,3 @@ -const Fs = @import("./fs.zig"); const std = @import("std"); const bun = @import("root").bun; const string = bun.string; @@ -6,59 +5,31 @@ const Output = bun.Output; const Global = bun.Global; const Environment = bun.Environment; const strings = bun.strings; -const MutableString = bun.MutableString; const stringZ = bun.stringZ; -const StoredFileDescriptorType = bun.StoredFileDescriptorType; const FeatureFlags = bun.FeatureFlags; -const default_allocator = bun.default_allocator; -const C = bun.C; -const c = std.c; const options = @import("./options.zig"); -const IndexType = @import("./allocators.zig").IndexType; - -const os = std.os; const Mutex = @import("./lock.zig").Lock; const Futex = @import("./futex.zig"); pub const WatchItemIndex = u16; -const NoWatchItem: WatchItemIndex = std.math.maxInt(WatchItemIndex); const PackageJSON = @import("./resolver/package_json.zig").PackageJSON; +const log = bun.Output.scoped(.watcher, false); + const WATCHER_MAX_LIST = 8096; -pub const INotify = struct { - pub const IN_CLOEXEC = std.os.O.CLOEXEC; - pub const IN_NONBLOCK = std.os.O.NONBLOCK; - - pub const IN_ACCESS = 0x00000001; - pub const IN_MODIFY = 0x00000002; - pub const IN_ATTRIB = 0x00000004; - pub const IN_CLOSE_WRITE = 0x00000008; - pub const IN_CLOSE_NOWRITE = 0x00000010; - pub const IN_CLOSE = IN_CLOSE_WRITE | IN_CLOSE_NOWRITE; - pub const IN_OPEN = 0x00000020; - pub const IN_MOVED_FROM = 0x00000040; - pub const IN_MOVED_TO = 0x00000080; - pub const IN_MOVE = IN_MOVED_FROM | IN_MOVED_TO; - pub const IN_CREATE = 0x00000100; - pub const IN_DELETE = 0x00000200; - pub const IN_DELETE_SELF = 0x00000400; - pub const IN_MOVE_SELF = 0x00000800; - pub const IN_ALL_EVENTS = 0x00000fff; - - pub const IN_UNMOUNT = 0x00002000; - pub const IN_Q_OVERFLOW = 0x00004000; - pub const IN_IGNORED = 0x00008000; - - pub const IN_ONLYDIR = 0x01000000; - pub const IN_DONT_FOLLOW = 0x02000000; - pub const IN_EXCL_UNLINK = 0x04000000; - pub const IN_MASK_ADD = 0x20000000; - - pub const IN_ISDIR = 0x40000000; - pub const IN_ONESHOT = 0x80000000; +const INotify = struct { + loaded_inotify: bool = false, + inotify_fd: EventListIndex = 0, + + eventlist: EventListBuffer = undefined, + eventlist_ptrs: [128]*const INotifyEvent = undefined, + + watch_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), + coalesce_interval: isize = 100_000, pub const EventListIndex = c_int; + const EventListBuffer = [@sizeOf([128]INotifyEvent) + (128 * bun.MAX_PATH_BYTES + (128 * @alignOf(INotifyEvent)))]u8; pub const INotifyEvent = extern struct { watch_descriptor: c_int, @@ -76,62 +47,48 @@ pub const INotify = struct { return bun.sliceTo(@as([*:0]u8, @ptrFromInt(@intFromPtr(&this.name_len) + @sizeOf(u32))), 0)[0.. :0]; } }; - pub var inotify_fd: EventListIndex = 0; - pub var loaded_inotify = false; - const EventListBuffer = [@sizeOf([128]INotifyEvent) + (128 * bun.MAX_PATH_BYTES + (128 * @alignOf(INotifyEvent)))]u8; - var eventlist: EventListBuffer = undefined; - var eventlist_ptrs: [128]*const INotifyEvent = undefined; - - var watch_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); - - const watch_file_mask = std.os.linux.IN.EXCL_UNLINK | std.os.linux.IN.MOVE_SELF | std.os.linux.IN.DELETE_SELF | std.os.linux.IN.MOVED_TO | std.os.linux.IN.MODIFY; - const watch_dir_mask = std.os.linux.IN.EXCL_UNLINK | std.os.linux.IN.DELETE | std.os.linux.IN.DELETE_SELF | std.os.linux.IN.CREATE | std.os.linux.IN.MOVE_SELF | std.os.linux.IN.ONLYDIR | std.os.linux.IN.MOVED_TO; - - pub fn watchPath(pathname: [:0]const u8) !EventListIndex { - std.debug.assert(loaded_inotify); - const old_count = watch_count.fetchAdd(1, .Release); - defer if (old_count == 0) Futex.wake(&watch_count, 10); - return std.os.inotify_add_watchZ(inotify_fd, pathname, watch_file_mask); + pub fn watchPath(this: *INotify, pathname: [:0]const u8) !EventListIndex { + std.debug.assert(this.loaded_inotify); + const old_count = this.watch_count.fetchAdd(1, .Release); + defer if (old_count == 0) Futex.wake(&this.watch_count, 10); + const watch_file_mask = std.os.linux.IN.EXCL_UNLINK | std.os.linux.IN.MOVE_SELF | std.os.linux.IN.DELETE_SELF | std.os.linux.IN.MOVED_TO | std.os.linux.IN.MODIFY; + return std.os.inotify_add_watchZ(this.inotify_fd, pathname, watch_file_mask); } - pub fn watchDir(pathname: [:0]const u8) !EventListIndex { - std.debug.assert(loaded_inotify); - const old_count = watch_count.fetchAdd(1, .Release); - defer if (old_count == 0) Futex.wake(&watch_count, 10); - return std.os.inotify_add_watchZ(inotify_fd, pathname, watch_dir_mask); + pub fn watchDir(this: *INotify, pathname: [:0]const u8) !EventListIndex { + std.debug.assert(this.loaded_inotify); + const old_count = this.watch_count.fetchAdd(1, .Release); + defer if (old_count == 0) Futex.wake(&this.watch_count, 10); + const watch_dir_mask = std.os.linux.IN.EXCL_UNLINK | std.os.linux.IN.DELETE | std.os.linux.IN.DELETE_SELF | std.os.linux.IN.CREATE | std.os.linux.IN.MOVE_SELF | std.os.linux.IN.ONLYDIR | std.os.linux.IN.MOVED_TO; + return std.os.inotify_add_watchZ(this.inotify_fd, pathname, watch_dir_mask); } - pub fn unwatch(wd: EventListIndex) void { - std.debug.assert(loaded_inotify); - _ = watch_count.fetchSub(1, .Release); - std.os.inotify_rm_watch(inotify_fd, wd); + pub fn unwatch(this: *INotify, wd: EventListIndex) void { + std.debug.assert(this.loaded_inotify); + _ = this.watch_count.fetchSub(1, .Release); + std.os.inotify_rm_watch(this.inotify_fd, wd); } - pub fn isRunning() bool { - return loaded_inotify; - } - - var coalesce_interval: isize = 100_000; - pub fn init() !void { - std.debug.assert(!loaded_inotify); - loaded_inotify = true; + pub fn init(this: *INotify, _: []const u8) !void { + std.debug.assert(!this.loaded_inotify); + this.loaded_inotify = true; if (bun.getenvZ("BUN_INOTIFY_COALESCE_INTERVAL")) |env| { - coalesce_interval = std.fmt.parseInt(isize, env, 10) catch 100_000; + this.coalesce_interval = std.fmt.parseInt(isize, env, 10) catch 100_000; } - inotify_fd = try std.os.inotify_init1(IN_CLOEXEC); + this.inotify_fd = try std.os.inotify_init1(std.os.linux.IN.CLOEXEC); } - pub fn read() ![]*const INotifyEvent { - std.debug.assert(loaded_inotify); + pub fn read(this: *INotify) ![]*const INotifyEvent { + std.debug.assert(this.loaded_inotify); restart: while (true) { - Futex.wait(&watch_count, 0, null) catch unreachable; + Futex.wait(&this.watch_count, 0, null) catch unreachable; const rc = std.os.system.read( - inotify_fd, - @as([*]u8, @ptrCast(@alignCast(&eventlist))), + this.inotify_fd, + @as([*]u8, @ptrCast(@alignCast(&this.eventlist))), @sizeOf(EventListBuffer), ); @@ -145,16 +102,16 @@ pub const INotify = struct { // we do a 0.1ms sleep to try to coalesce events better if (len < (@sizeOf(EventListBuffer) / 2)) { var fds = [_]std.os.pollfd{.{ - .fd = inotify_fd, + .fd = this.inotify_fd, .events = std.os.POLL.IN | std.os.POLL.ERR, .revents = 0, }}; - var timespec = std.os.timespec{ .tv_sec = 0, .tv_nsec = coalesce_interval }; + var timespec = std.os.timespec{ .tv_sec = 0, .tv_nsec = this.coalesce_interval }; if ((std.os.ppoll(&fds, ×pec, null) catch 0) > 0) { while (true) { const new_rc = std.os.system.read( - inotify_fd, - @as([*]u8, @ptrCast(@alignCast(&eventlist))) + len, + this.inotify_fd, + @as([*]u8, @ptrCast(@alignCast(&this.eventlist))) + len, @sizeOf(EventListBuffer) - len, ); switch (std.os.errno(new_rc)) { @@ -186,14 +143,14 @@ pub const INotify = struct { var i: u32 = 0; while (i < len) : (i += @sizeOf(INotifyEvent)) { @setRuntimeSafety(false); - const event = @as(*INotifyEvent, @ptrCast(@alignCast(eventlist[i..][0..@sizeOf(INotifyEvent)]))); + const event = @as(*INotifyEvent, @ptrCast(@alignCast(this.eventlist[i..][0..@sizeOf(INotifyEvent)]))); i += event.name_len; - eventlist_ptrs[count] = event; + this.eventlist_ptrs[count] = event; count += 1; } - return eventlist_ptrs[0..count]; + return this.eventlist_ptrs[0..count]; }, .AGAIN => continue :restart, .INVAL => return error.ShortRead, @@ -205,10 +162,10 @@ pub const INotify = struct { unreachable; } - pub fn stop() void { - if (inotify_fd != 0) { - _ = bun.sys.close(bun.toFD(inotify_fd)); - inotify_fd = 0; + pub fn stop(this: *INotify) void { + if (this.inotify_fd != 0) { + _ = bun.sys.close(bun.toFD(this.inotify_fd)); + this.inotify_fd = 0; } } }; @@ -217,69 +174,205 @@ const DarwinWatcher = struct { pub const EventListIndex = u32; const KEvent = std.c.Kevent; + // Internal - pub var changelist: [128]KEvent = undefined; + changelist: [128]KEvent = undefined, // Everything being watched - pub var eventlist: [WATCHER_MAX_LIST]KEvent = undefined; - pub var eventlist_index: EventListIndex = 0; - - pub var fd: i32 = 0; + eventlist: [WATCHER_MAX_LIST]KEvent = undefined, + eventlist_index: EventListIndex = 0, - pub fn init() !void { - std.debug.assert(fd == 0); + fd: i32 = 0, - fd = try std.os.kqueue(); - if (fd == 0) return error.KQueueError; + pub fn init(this: *DarwinWatcher, _: []const u8) !void { + this.fd = try std.os.kqueue(); + if (this.fd == 0) return error.KQueueError; } - pub fn isRunning() bool { - return fd != 0; + pub fn stop(this: *DarwinWatcher) void { + if (this.fd != 0) { + _ = bun.sys.close(this.fd); + } + this.fd = 0; } +}; + +const WindowsWatcher = struct { + mutex: Mutex = Mutex.init(), + iocp: w.HANDLE = undefined, + watcher: DirWatcher = undefined, + + const w = std.os.windows; + 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) Error!void { + const filter = w.FILE_NOTIFY_CHANGE_FILE_NAME | w.FILE_NOTIFY_CHANGE_DIR_NAME | w.FILE_NOTIFY_CHANGE_LAST_WRITE | w.FILE_NOTIFY_CHANGE_CREATION; + 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 Error.ReadDirectoryChangesFailed; + } + } + }; + + 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 stop() void { - if (fd != 0) { - _ = bun.sys.close(fd); + 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 _ = w.kernel32.CloseHandle(handle); - fd = 0; + this.iocp = try w.CreateIoCompletionPort(handle, null, 0, 1); + errdefer _ = w.kernel32.CloseHandle(this.iocp); + + this.watcher = .{ .dirHandle = handle }; } -}; -pub const Placeholder = struct { - pub const EventListIndex = u32; + const Timeout = enum(w.DWORD) { + infinite = w.INFINITE, + minimal = 1, + none = 0, + }; - pub var eventlist: [WATCHER_MAX_LIST]EventListIndex = undefined; - pub var eventlist_index: EventListIndex = 0; + // wait until new events are available + pub fn next(this: *WindowsWatcher, timeout: Timeout) !?EventIterator { + try this.watcher.prepare(); + + 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 == w.Win32Error.IMEOUT) { + return null; + } else { + log("GetQueuedCompletionStatus failed: {s}", .{@tagName(err)}); + return Error.IocpFailed; + } + } - pub fn isRunning() bool { - return true; + if (overlapped) |ptr| { + // ignore possible spurious events + if (ptr != &this.watcher.overlapped) { + continue; + } + if (nbytes == 0) { + // shutdown notification + // TODO close handles? + return Error.IocpFailed; + } + return EventIterator{ .watcher = &this.watcher }; + } else { + log("GetQueuedCompletionStatus returned no overlapped event", .{}); + return Error.IocpFailed; + } + } } - pub fn init() !void {} + pub fn stop(this: *WindowsWatcher) void { + w.CloseHandle(this.watcher.dirHandle); + w.CloseHandle(this.iocp); + } }; const PlatformWatcher = if (Environment.isMac) DarwinWatcher else if (Environment.isLinux) INotify +else if (Environment.isWindows) + WindowsWatcher else - Placeholder; - -pub const WatchItem = struct { - file_path: string, - // filepath hash for quick comparison - hash: u32, - eventlist_index: PlatformWatcher.EventListIndex, - loader: options.Loader, - fd: StoredFileDescriptorType, - count: u32, - parent_hash: u32, - kind: Kind, - package_json: ?*PackageJSON, - - pub const Kind = enum { file, directory }; -}; + @compileError("Unsupported platform"); pub const WatchEvent = struct { index: WatchItemIndex, @@ -332,11 +425,21 @@ pub const WatchEvent = struct { pub fn fromINotify(this: *WatchEvent, event: INotify.INotifyEvent, index: WatchItemIndex) void { this.* = WatchEvent{ .op = Op{ - .delete = (event.mask & INotify.IN_DELETE_SELF) > 0 or (event.mask & INotify.IN_DELETE) > 0, - .metadata = false, - .rename = (event.mask & INotify.IN_MOVE_SELF) > 0, - .move_to = (event.mask & INotify.IN_MOVED_TO) > 0, - .write = (event.mask & INotify.IN_MODIFY) > 0, + .delete = (event.mask & std.os.linux.IN.DELETE_SELF) > 0 or (event.mask & std.os.linux.IN.DELETE) > 0, + .rename = (event.mask & std.os.linux.IN.MOVE_SELF) > 0, + .move_to = (event.mask & std.os.linux.IN.MOVED_TO) > 0, + .write = (event.mask & std.os.linux.IN.MODIFY) > 0, + }, + .index = index, + }; + } + + pub fn fromFileNotify(this: *WatchEvent, event: WindowsWatcher.FileEvent, index: WatchItemIndex) void { + this.* = WatchEvent{ + .op = Op{ + .delete = event.action == .Removed, + .rename = event.action == .RenamedOld, + .write = event.action == .Modified, }, .index = index, }; @@ -351,13 +454,33 @@ pub const WatchEvent = struct { }; }; -pub const Watchlist = std.MultiArrayList(WatchItem); +pub const WatchItem = struct { + file_path: string, + // filepath hash for quick comparison + hash: u32, + loader: options.Loader, + fd: bun.FileDescriptor, + count: u32, + parent_hash: u32, + kind: Kind, + package_json: ?*PackageJSON, + eventlist_index: if (Environment.isLinux) PlatformWatcher.EventListIndex else u0 = 0, + + pub const Kind = enum { file, directory }; +}; + +pub const WatchList = std.MultiArrayList(WatchItem); +pub const HashType = u32; + +pub fn getHash(filepath: string) HashType { + return @as(HashType, @truncate(bun.hash(filepath))); +} pub fn NewWatcher(comptime ContextType: type) type { return struct { const Watcher = @This(); - watchlist: Watchlist, + watchlist: WatchList, watched_count: usize = 0, mutex: Mutex, @@ -365,12 +488,10 @@ pub fn NewWatcher(comptime ContextType: type) type { // User-facing watch_events: [128]WatchEvent = undefined, - changed_filepaths: [128]?[:0]u8 = std.mem.zeroes([128]?[:0]u8), + changed_filepaths: [128]?[:0]u8 = [_]?[:0]u8{null} ** 128, - fs: *Fs.FileSystem, - // this is what kqueue knows about - fd: StoredFileDescriptorType, ctx: ContextType, + fs: *bun.fs.FileSystem, allocator: std.mem.Allocator, watchloop_handle: ?std.Thread.Id = null, cwd: string, @@ -378,42 +499,33 @@ pub fn NewWatcher(comptime ContextType: type) type { running: bool = true, close_descriptors: bool = false, - pub const HashType = u32; - pub const WatchListArray = Watchlist; + evict_list: [WATCHER_MAX_LIST]WatchItemIndex = undefined, + evict_list_i: WatchItemIndex = 0, - var evict_list: [WATCHER_MAX_LIST]WatchItemIndex = undefined; + const no_watch_item: WatchItemIndex = std.math.maxInt(WatchItemIndex); - pub fn getHash(filepath: string) HashType { - return @as(HashType, @truncate(bun.hash(filepath))); - } - - pub fn init(ctx: ContextType, fs: *Fs.FileSystem, allocator: std.mem.Allocator) !*Watcher { + pub fn init(ctx: ContextType, fs: *bun.fs.FileSystem, allocator: std.mem.Allocator) !*Watcher { const watcher = try allocator.create(Watcher); errdefer allocator.destroy(watcher); - if (!PlatformWatcher.isRunning()) { - try PlatformWatcher.init(); - } - watcher.* = Watcher{ .fs = fs, - .fd = .zero, .allocator = allocator, .watched_count = 0, .ctx = ctx, - .watchlist = Watchlist{}, + .watchlist = WatchList{}, .mutex = Mutex.init(), .cwd = fs.top_level_dir, }; + try PlatformWatcher.init(&watcher.platform, fs.top_level_dir); + return watcher; } pub fn start(this: *Watcher) !void { - if (!Environment.isWindows) { - std.debug.assert(this.watchloop_handle == null); - this.thread = try std.Thread.spawn(.{}, Watcher.watchLoop, .{this}); - } + std.debug.assert(this.watchloop_handle == null); + this.thread = try std.Thread.spawn(.{}, Watcher.watchLoop, .{this}); } pub fn deinit(this: *Watcher, close_descriptors: bool) void { @@ -440,10 +552,6 @@ pub fn NewWatcher(comptime ContextType: type) type { // This must only be called from the watcher thread pub fn watchLoop(this: *Watcher) !void { - if (Environment.isWindows) { - @compileError("watchLoop should not be used on Windows"); - } - this.watchloop_handle = std.Thread.getCurrentId(); Output.Source.configureNamedThread("File Watcher"); @@ -452,7 +560,7 @@ pub fn NewWatcher(comptime ContextType: type) type { this._watchLoop() catch |err| { this.watchloop_handle = null; - PlatformWatcher.stop(); + this.platform.stop(); if (this.running) { this.ctx.onError(err); } @@ -471,70 +579,39 @@ pub fn NewWatcher(comptime ContextType: type) type { allocator.destroy(this); } - pub fn remove(this: *Watcher, hash: HashType) void { - this.mutex.lock(); - defer this.mutex.unlock(); - if (this.indexOf(hash)) |index| { - const fds = this.watchlist.items(.fd); - const fd = fds[index]; - _ = bun.sys.close(fd); - this.watchlist.swapRemove(index); - } - } - - var evict_list_i: WatchItemIndex = 0; - - pub fn removeAtIndex(_: *Watcher, index: WatchItemIndex, hash: HashType, parents: []HashType, comptime kind: WatchItem.Kind) void { - std.debug.assert(index != NoWatchItem); - - evict_list[evict_list_i] = index; - evict_list_i += 1; - - if (comptime kind == .directory) { - for (parents) |parent| { - if (parent == hash) { - evict_list[evict_list_i] = @as(WatchItemIndex, @truncate(parent)); - evict_list_i += 1; - } - } - } - } - pub fn flushEvictions(this: *Watcher) void { - if (evict_list_i == 0) return; - defer evict_list_i = 0; + if (this.evict_list_i == 0) return; + defer this.evict_list_i = 0; // swapRemove messes up the order // But, it only messes up the order if any elements in the list appear after the item being removed // So if we just sort the list by the biggest index first, that should be fine std.sort.pdq( WatchItemIndex, - evict_list[0..evict_list_i], + this.evict_list[0..this.evict_list_i], {}, comptime std.sort.desc(WatchItemIndex), ); var slice = this.watchlist.slice(); const fds = slice.items(.fd); - var last_item = NoWatchItem; + var last_item = no_watch_item; - for (evict_list[0..evict_list_i]) |item| { + for (this.evict_list[0..this.evict_list_i]) |item| { // catch duplicates, since the list is sorted, duplicates will appear right after each other if (item == last_item) continue; - // close the file descriptors here. this should automatically remove it from being watched too. - _ = bun.sys.close(fds[item]); - - // if (Environment.isLinux) { - // INotify.unwatch(event_list_ids[item]); - // } - + if (!Environment.isWindows) { + // on mac and linux we can just close the file descriptor + // TODO do we need to call inotify_rm_watch on linux? + _ = bun.sys.close(fds[item]); + } last_item = item; } - last_item = NoWatchItem; + last_item = no_watch_item; // This is split into two passes because reading the slice while modified is potentially unsafe. - for (evict_list[0..evict_list_i]) |item| { + for (this.evict_list[0..this.evict_list_i]) |item| { if (item == last_item) continue; this.watchlist.swapRemove(item); last_item = item; @@ -543,7 +620,7 @@ pub fn NewWatcher(comptime ContextType: type) type { fn _watchLoop(this: *Watcher) !void { if (Environment.isMac) { - std.debug.assert(DarwinWatcher.fd > 0); + std.debug.assert(this.platform.fd > 0); const KEvent = std.c.Kevent; var changelist_array: [128]KEvent = std.mem.zeroes([128]KEvent); @@ -552,7 +629,7 @@ pub fn NewWatcher(comptime ContextType: type) type { defer Output.flush(); var count_ = std.os.system.kevent( - DarwinWatcher.fd, + this.platform.fd, @as([*]KEvent, changelist), 0, @as([*]KEvent, changelist), @@ -566,7 +643,7 @@ pub fn NewWatcher(comptime ContextType: type) type { const remain = 128 - count_; var timespec = std.os.timespec{ .tv_sec = 0, .tv_nsec = 100_000 }; const extra = std.os.system.kevent( - DarwinWatcher.fd, + this.platform.fd, @as([*]KEvent, changelist[@as(usize, @intCast(count_))..].ptr), 0, @as([*]KEvent, changelist[@as(usize, @intCast(count_))..].ptr), @@ -613,7 +690,7 @@ pub fn NewWatcher(comptime ContextType: type) type { restart: while (true) { defer Output.flush(); - var events = try INotify.read(); + var events = try this.platform.read(); if (events.len == 0) continue :restart; // TODO: is this thread safe? @@ -685,50 +762,96 @@ pub fn NewWatcher(comptime ContextType: type) type { } } } else if (Environment.isWindows) { - @compileError("watchLoop should not be used on Windows"); - } - } - - pub fn indexOf(this: *Watcher, hash: HashType) ?u32 { - for (this.watchlist.items(.hash), 0..) |other, i| { - if (hash == other) { - return @as(u32, @truncate(i)); + var buf: bun.PathBuffer = undefined; + const root = this.fs.top_level_dir; + @memcpy(buf[0..root.len], root); + const needs_slash = root.len == 0 or !bun.strings.charIsAnySlash(root[root.len - 1]); + if (needs_slash) { + buf[root.len] = '\\'; } - } - return null; - } + const baseidx = if (needs_slash) root.len + 1 else root.len; + restart: while (true) { + 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 = try this.platform.next(timeout) orelse break; + // after the first wait, we want to start coalescing events, so we wait for a minimal amount of time + timeout = WindowsWatcher.Timeout.minimal; + 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[baseidx..], []const u16, event.filename, false); + const eventpath = buf[0 .. baseidx + 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| { + var path = path_; + if (path.len > 0 and bun.strings.charIsAnySlash(path[path.len - 1])) { + path = path[0 .. path.len - 1]; + } + // log("checking path: {s}\n", .{path}); + // check if the current change applies to this item + // if so, add it to the eventlist + const rel = bun.path.isParentOrEqual(eventpath, path); + // 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 + if (rel == .parent and (event.action != .Removed or event.action != .RenamedOld)) continue; + this.watch_events[event_id].fromFileNotify(event, @truncate(item_idx)); + event_id += 1; + } + } + } + if (event_id == 0) { + continue :restart; + } - pub fn addFile( - this: *Watcher, - fd: StoredFileDescriptorType, - file_path: string, - hash: HashType, - loader: options.Loader, - dir_fd: StoredFileDescriptorType, - package_json: ?*PackageJSON, - comptime copy_file_path: bool, - ) !void { - // This must lock due to concurrent transpiler - this.mutex.lock(); - defer this.mutex.unlock(); + // log("event_id: {d}\n", .{event_id}); - if (this.indexOf(hash)) |index| { - if (comptime FeatureFlags.atomic_file_watcher) { - // On Linux, the file descriptor might be out of date. - if (fd.int() > 0) { - var fds = this.watchlist.items(.fd); - fds[index] = fd; + var all_events = this.watch_events[0..event_id]; + std.sort.pdq(WatchEvent, all_events, {}, WatchEvent.sortByIndex); + + var last_event_index: usize = 0; + var last_event_id: INotify.EventListIndex = std.math.maxInt(INotify.EventListIndex); + + for (all_events, 0..) |_, i| { + // if (all_events[i].name_len > 0) { + // this.changed_filepaths[name_off] = temp_name_list[all_events[i].name_off]; + // all_events[i].name_off = name_off; + // name_off += 1; + // } + + 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) continue :restart; + all_events = all_events[0 .. last_event_index + 1]; + + log("calling onFileUpdate (all_events.len = {d})", .{all_events.len}); + + this.ctx.onFileUpdate(all_events, this.changed_filepaths[0 .. last_event_index + 1], this.watchlist); } - return; } - - try this.appendFileMaybeLock(fd, file_path, hash, loader, dir_fd, package_json, copy_file_path, false); } fn appendFileAssumeCapacity( this: *Watcher, - fd: StoredFileDescriptorType, + fd: bun.FileDescriptor, file_path: string, hash: HashType, loader: options.Loader, @@ -736,7 +859,15 @@ pub fn NewWatcher(comptime ContextType: type) type { package_json: ?*PackageJSON, comptime copy_file_path: bool, ) !void { - var index: PlatformWatcher.EventListIndex = std.math.maxInt(PlatformWatcher.EventListIndex); + if (comptime Environment.isWindows) { + // on windows we can only watch items that are in the directory tree of the top level dir + const rel = bun.path.isParentOrEqual(this.fs.top_level_dir, file_path); + if (rel == .unrelated) { + Output.warn("File {s} is not in the project directory and will not be watched\n", .{file_path}); + return; + } + } + const watchlist_id = this.watchlist.len; const file_path_: string = if (comptime copy_file_path) @@ -744,13 +875,24 @@ pub fn NewWatcher(comptime ContextType: type) type { else file_path; + var item = WatchItem{ + .file_path = file_path_, + .fd = fd, + .hash = hash, + .count = 0, + .loader = loader, + .parent_hash = parent_hash, + .package_json = package_json, + .kind = .file, + }; + if (comptime Environment.isMac) { const KEvent = std.c.Kevent; // https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/kqueue.2.html var event = std.mem.zeroes(KEvent); - event.flags = c.EV_ADD | c.EV_CLEAR | c.EV_ENABLE; + event.flags = std.c.EV_ADD | std.c.EV_CLEAR | std.c.EV_ENABLE; // we want to know about the vnode event.filter = std.c.EVFILT_VNODE; @@ -768,7 +910,7 @@ pub fn NewWatcher(comptime ContextType: type) type { // - We register the event here. // our while(true) loop above receives notification of changes to any of the events created here. _ = std.os.system.kevent( - DarwinWatcher.fd, + this.platform.fd, @as([]KEvent, events[0..1]).ptr, 1, @as([]KEvent, events[0..1]).ptr, @@ -782,37 +924,35 @@ pub fn NewWatcher(comptime ContextType: type) type { // buf[file_path_to_use_.len] = 0; var buf = file_path_.ptr; const slice: [:0]const u8 = buf[0..file_path_.len :0]; - index = try INotify.watchPath(slice); + item.eventlist_index = try this.platform.watchPath(slice); } - this.watchlist.appendAssumeCapacity(.{ - .file_path = file_path_, - .fd = fd, - .hash = hash, - .count = 0, - .eventlist_index = index, - .loader = loader, - .parent_hash = parent_hash, - .package_json = package_json, - .kind = .file, - }); + this.watchlist.appendAssumeCapacity(item); } fn appendDirectoryAssumeCapacity( this: *Watcher, - stored_fd: StoredFileDescriptorType, + stored_fd: bun.FileDescriptor, file_path: string, hash: HashType, comptime copy_file_path: bool, ) !WatchItemIndex { + if (comptime Environment.isWindows) { + // on windows we can only watch items that are in the directory tree of the top level dir + const rel = bun.path.isParentOrEqual(this.fs.top_level_dir, file_path); + if (rel == .unrelated) { + Output.warn("Directory {s} is not in the project directory and will not be watched\n", .{file_path}); + return no_watch_item; + } + } + const fd = brk: { if (stored_fd.int() > 0) break :brk stored_fd; const dir = try std.fs.cwd().openDir(file_path, .{}); break :brk bun.toFD(dir.fd); }; - const parent_hash = Watcher.getHash(Fs.PathName.init(file_path).dirWithTrailingSlash()); - var index: PlatformWatcher.EventListIndex = std.math.maxInt(PlatformWatcher.EventListIndex); + const parent_hash = getHash(bun.fs.PathName.init(file_path).dirWithTrailingSlash()); const file_path_: string = if (comptime copy_file_path) bun.asByteSlice(try this.allocator.dupeZ(u8, file_path)) @@ -821,13 +961,24 @@ pub fn NewWatcher(comptime ContextType: type) type { const watchlist_id = this.watchlist.len; + var item = WatchItem{ + .file_path = file_path_, + .fd = fd, + .hash = hash, + .count = 0, + .loader = options.Loader.file, + .parent_hash = parent_hash, + .kind = .directory, + .package_json = null, + }; + if (Environment.isMac) { const KEvent = std.c.Kevent; // https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/kqueue.2.html var event = std.mem.zeroes(KEvent); - event.flags = c.EV_ADD | c.EV_CLEAR | c.EV_ENABLE; + event.flags = std.c.EV_ADD | std.c.EV_CLEAR | std.c.EV_ENABLE; // we want to know about the vnode event.filter = std.c.EVFILT_VNODE; @@ -849,7 +1000,7 @@ pub fn NewWatcher(comptime ContextType: type) type { // - We register the event here. // our while(true) loop above receives notification of changes to any of the events created here. _ = std.os.system.kevent( - DarwinWatcher.fd, + this.platform.fd, @as([]KEvent, events[0..1]).ptr, 1, @as([]KEvent, events[0..1]).ptr, @@ -862,53 +1013,22 @@ pub fn NewWatcher(comptime ContextType: type) type { bun.copy(u8, &buf, file_path_to_use_); buf[file_path_to_use_.len] = 0; const slice: [:0]u8 = buf[0..file_path_to_use_.len :0]; - index = try INotify.watchDir(slice); + item.eventlist_index = try this.platform.watchDir(slice); } - this.watchlist.appendAssumeCapacity(.{ - .file_path = file_path_, - .fd = fd, - .hash = hash, - .count = 0, - .eventlist_index = index, - .loader = options.Loader.file, - .parent_hash = parent_hash, - .kind = .directory, - .package_json = null, - }); + this.watchlist.appendAssumeCapacity(item); return @as(WatchItemIndex, @truncate(this.watchlist.len - 1)); } - pub inline fn isEligibleDirectory(this: *Watcher, dir: string) bool { - return strings.indexOf(dir, this.fs.top_level_dir) != null and strings.indexOf(dir, "node_modules") == null; - } - - pub fn addDirectory( - this: *Watcher, - fd: StoredFileDescriptorType, - file_path: string, - hash: HashType, - comptime copy_file_path: bool, - ) !void { - this.mutex.lock(); - defer this.mutex.unlock(); - - if (this.indexOf(hash) != null) { - return; - } - - try this.watchlist.ensureUnusedCapacity(this.allocator, 1); - - _ = try this.appendDirectoryAssumeCapacity(fd, file_path, hash, copy_file_path); - } + // Below is platform-independent pub fn appendFileMaybeLock( this: *Watcher, - fd: StoredFileDescriptorType, + fd: bun.FileDescriptor, file_path: string, hash: HashType, loader: options.Loader, - dir_fd: StoredFileDescriptorType, + dir_fd: bun.FileDescriptor, package_json: ?*PackageJSON, comptime copy_file_path: bool, comptime lock: bool, @@ -916,10 +1036,10 @@ pub fn NewWatcher(comptime ContextType: type) type { if (comptime lock) this.mutex.lock(); defer if (comptime lock) this.mutex.unlock(); std.debug.assert(file_path.len > 1); - const pathname = Fs.PathName.init(file_path); + const pathname = bun.fs.PathName.init(file_path); const parent_dir = pathname.dirWithTrailingSlash(); - const parent_dir_hash: HashType = Watcher.getHash(parent_dir); + const parent_dir_hash: HashType = getHash(parent_dir); var parent_watch_item: ?WatchItemIndex = null; const autowatch_parent_dir = (comptime FeatureFlags.watch_directories) and this.isEligibleDirectory(parent_dir); @@ -928,7 +1048,7 @@ pub fn NewWatcher(comptime ContextType: type) type { if (dir_fd.int() > 0) { const fds = watchlist_slice.items(.fd); - if (std.mem.indexOfScalar(StoredFileDescriptorType, fds, dir_fd)) |i| { + if (std.mem.indexOfScalar(bun.FileDescriptor, fds, dir_fd)) |i| { parent_watch_item = @as(WatchItemIndex, @truncate(i)); } } @@ -965,17 +1085,101 @@ pub fn NewWatcher(comptime ContextType: type) type { } } + inline fn isEligibleDirectory(this: *Watcher, dir: string) bool { + return strings.contains(dir, this.fs.top_level_dir) and !strings.contains(dir, "node_modules"); + } + pub fn appendFile( this: *Watcher, - fd: StoredFileDescriptorType, + fd: bun.FileDescriptor, file_path: string, hash: HashType, loader: options.Loader, - dir_fd: StoredFileDescriptorType, + dir_fd: bun.FileDescriptor, package_json: ?*PackageJSON, comptime copy_file_path: bool, ) !void { return appendFileMaybeLock(this, fd, file_path, hash, loader, dir_fd, package_json, copy_file_path, true); } + + pub fn addDirectory( + this: *Watcher, + fd: bun.FileDescriptor, + file_path: string, + hash: HashType, + comptime copy_file_path: bool, + ) !void { + this.mutex.lock(); + defer this.mutex.unlock(); + + if (this.indexOf(hash) != null) { + return; + } + + try this.watchlist.ensureUnusedCapacity(this.allocator, 1); + + _ = try this.appendDirectoryAssumeCapacity(fd, file_path, hash, copy_file_path); + } + + pub fn addFile( + this: *Watcher, + fd: bun.FileDescriptor, + file_path: string, + hash: HashType, + loader: options.Loader, + dir_fd: bun.FileDescriptor, + package_json: ?*PackageJSON, + comptime copy_file_path: bool, + ) !void { + // This must lock due to concurrent transpiler + this.mutex.lock(); + defer this.mutex.unlock(); + + if (this.indexOf(hash)) |index| { + if (comptime FeatureFlags.atomic_file_watcher) { + // On Linux, the file descriptor might be out of date. + if (fd.int() > 0) { + var fds = this.watchlist.items(.fd); + fds[index] = fd; + } + } + return; + } + + try this.appendFileMaybeLock(fd, file_path, hash, loader, dir_fd, package_json, copy_file_path, false); + } + + pub fn indexOf(this: *Watcher, hash: HashType) ?u32 { + for (this.watchlist.items(.hash), 0..) |other, i| { + if (hash == other) { + return @as(u32, @truncate(i)); + } + } + return null; + } + + pub fn remove(this: *Watcher, hash: HashType) void { + this.mutex.lock(); + defer this.mutex.unlock(); + if (this.indexOf(hash)) |index| { + this.removeAtIndex(@truncate(index), hash, &[_]HashType{}, .file); + } + } + + pub fn removeAtIndex(this: *Watcher, index: WatchItemIndex, hash: HashType, parents: []HashType, comptime kind: WatchItem.Kind) void { + std.debug.assert(index != no_watch_item); + + this.evict_list[this.evict_list_i] = index; + this.evict_list_i += 1; + + if (comptime kind == .directory) { + for (parents) |parent| { + if (parent == hash) { + this.evict_list[this.evict_list_i] = @as(WatchItemIndex, @truncate(parent)); + this.evict_list_i += 1; + } + } + } + } }; } diff --git a/src/windows.zig b/src/windows.zig index 054994619f8261..ab4da2601b96f0 100644 --- a/src/windows.zig +++ b/src/windows.zig @@ -3011,10 +3011,62 @@ pub fn translateNTStatusToErrno(err: win32.NTSTATUS) bun.C.E { pub extern "kernel32" fn GetHostNameW( lpBuffer: PWSTR, nSize: c_int, -) BOOL; +) callconv(windows.WINAPI) BOOL; /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppathw pub extern "kernel32" fn GetTempPathW( nBufferLength: DWORD, // [in] lpBuffer: LPCWSTR, // [out] ) DWORD; + +pub extern "kernel32" fn CreateJobObjectA( + lpJobAttributes: ?*anyopaque, // [in, optional] + lpName: ?LPCSTR, // [in, optional] +) callconv(windows.WINAPI) HANDLE; + +pub extern "kernel32" fn AssignProcessToJobObject( + hJob: HANDLE, // [in] + hProcess: HANDLE, // [in] +) callconv(windows.WINAPI) BOOL; + +pub extern "kernel32" fn ResumeThread( + hJob: HANDLE, // [in] +) callconv(windows.WINAPI) DWORD; + +pub const JOBOBJECT_ASSOCIATE_COMPLETION_PORT = extern struct { + CompletionKey: windows.PVOID, + CompletionPort: HANDLE, +}; + +pub const JobObjectAssociateCompletionPortInformation: DWORD = 7; + +pub extern "kernel32" fn SetInformationJobObject( + hJob: HANDLE, + JobObjectInformationClass: DWORD, + lpJobObjectInformation: LPVOID, + cbJobObjectInformationLength: DWORD, +) callconv(windows.WINAPI) BOOL; + +// Found experimentally: +// #include +// #include +// +// int main() { +// printf("%ld\n", JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO); +// printf("%ld\n", JOB_OBJECT_MSG_EXIT_PROCESS); +// } +// +// Output: +// 4 +// 7 +pub const JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO = 4; +pub const JOB_OBJECT_MSG_EXIT_PROCESS = 7; + +pub extern "kernel32" fn OpenProcess( + dwDesiredAccess: DWORD, + bInheritHandle: BOOL, + dwProcessId: DWORD, +) callconv(windows.WINAPI) ?HANDLE; + +// https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights +pub const PROCESS_QUERY_LIMITED_INFORMATION: DWORD = 0x1000; diff --git a/test/cli/hot/hot.test.ts b/test/cli/hot/hot.test.ts index facffa042b0dd6..c3c97fc782066b 100644 --- a/test/cli/hot/hot.test.ts +++ b/test/cli/hot/hot.test.ts @@ -2,10 +2,10 @@ import { spawn } from "bun"; import { expect, it } from "bun:test"; import { bunExe, bunEnv, tempDirWithFiles, bunRun, bunRunAsScript } from "harness"; -import { readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs"; +import { readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, copyFileSync } from "fs"; import { join } from "path"; -const hotRunnerRoot = join(import.meta.dir, "/hot-runner-root.js"); +const hotRunnerRoot = join(import.meta.dir, "hot-runner-root.js"); it("should hot reload when file is overwritten", async () => { const root = hotRunnerRoot; @@ -169,7 +169,8 @@ it("should not hot reload when a random file is written", async () => { }); it("should hot reload when a file is deleted and rewritten", async () => { - const root = hotRunnerRoot; + const root = hotRunnerRoot + ".tmp.js"; + copyFileSync(hotRunnerRoot, root); const runner = spawn({ cmd: [bunExe(), "--hot", "run", root], env: bunEnv, @@ -182,7 +183,7 @@ it("should hot reload when a file is deleted and rewritten", async () => { async function onReload() { const contents = readFileSync(root, "utf-8"); - unlinkSync(root); + rmSync(root); writeFileSync(root, contents); } @@ -205,12 +206,13 @@ it("should hot reload when a file is deleted and rewritten", async () => { if (any) await onReload(); } - + rmSync(root); expect(reloadCounter).toBe(3); }); it("should hot reload when a file is renamed() into place", async () => { - const root = hotRunnerRoot; + const root = hotRunnerRoot + ".tmp.js"; + copyFileSync(hotRunnerRoot, root); const runner = spawn({ cmd: [bunExe(), "--hot", "run", root], env: bunEnv, @@ -227,6 +229,8 @@ it("should hot reload when a file is renamed() into place", async () => { await 1; writeFileSync(root + ".tmpfile", contents); await 1; + rmSync(root); + await 1; renameSync(root + ".tmpfile", root); await 1; } @@ -250,6 +254,6 @@ it("should hot reload when a file is renamed() into place", async () => { if (any) await onReload(); } - + rmSync(root); expect(reloadCounter).toBe(3); }); diff --git a/test/cli/watch/watch.test.ts b/test/cli/watch/watch.test.ts index fc4d65a1dadd19..a4cf98aac5e9ff 100644 --- a/test/cli/watch/watch.test.ts +++ b/test/cli/watch/watch.test.ts @@ -1,14 +1,14 @@ -import { describe, test, expect, afterEach } from "bun:test"; +import { it, expect, afterEach } from "bun:test"; import type { Subprocess } from "bun"; import { spawn } from "bun"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { bunExe, bunEnv } from "harness"; let watchee: Subprocess; -describe("bun --watch", () => { +it("should watch files", async () => { const cwd = mkdtempSync(join(tmpdir(), "bun-test-")); const path = join(cwd, "watchee.js"); @@ -16,16 +16,25 @@ describe("bun --watch", () => { writeFileSync(path, `console.log(${i});`); }; - test("should watch files", async () => { - watchee = spawn({ - cwd, - cmd: [bunExe(), "--watch", "watchee.js"], - env: bunEnv, - stdout: "inherit", - stderr: "inherit", - }); - await Bun.sleep(2000); + let i = 0; + updateFile(i); + watchee = spawn({ + cwd, + cmd: [bunExe(), "--watch", "watchee.js"], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", }); + + for await (const line of watchee.stdout) { + if (i == 10) break; + var str = new TextDecoder().decode(line); + expect(str).toContain(`${i}`); + i++; + updateFile(i); + } + rmSync(path); }); afterEach(() => { diff --git a/test/js/node/fs/fs-stream.link.js b/test/js/node/fs/fs-stream.link.js deleted file mode 120000 index 0cadae0e54f349..00000000000000 --- a/test/js/node/fs/fs-stream.link.js +++ /dev/null @@ -1 +0,0 @@ -./test/bun.js/fs-stream.js \ No newline at end of file