mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 18:38:55 +00:00
Compare commits
7 Commits
claude/fix
...
claude/wat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2f2aff68 | ||
|
|
03c086cd99 | ||
|
|
b1a417a1df | ||
|
|
1dfc6b12e8 | ||
|
|
48689ade28 | ||
|
|
ef2a6a2dc6 | ||
|
|
f76bd24234 |
@@ -7,6 +7,6 @@ Syntax reminders:
|
||||
|
||||
Conventions:
|
||||
|
||||
- Prefer `@import` at the **bottom** of the file.
|
||||
- It's `@import("bun")` not `@import("root").bun`
|
||||
- Prefer `@import` at the **bottom** of the file, but the auto formatter will move them so you don't need to worry about it.
|
||||
- Prefer `@import("bun")`. Not `@import("root").bun` or `@import("../bun.zig")`.
|
||||
- You must be patient with the build.
|
||||
|
||||
@@ -312,6 +312,39 @@ fn watchLoop(this: *Watcher) bun.sys.Maybe(void) {
|
||||
return .success;
|
||||
}
|
||||
|
||||
pub fn addFileDescriptorToKQueueWithoutChecks(this: *Watcher, fd: bun.FileDescriptor, watchlist_id: usize) void {
|
||||
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 = 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;
|
||||
|
||||
event.fflags = std.c.NOTE.WRITE | std.c.NOTE.RENAME | std.c.NOTE.DELETE;
|
||||
|
||||
// id
|
||||
event.ident = @intCast(fd.native());
|
||||
|
||||
// Store the hash for fast filtering later
|
||||
event.udata = watchlist_id;
|
||||
var events: [1]KEvent = .{event};
|
||||
|
||||
// This took a lot of work to figure out the right permutation
|
||||
// Basically:
|
||||
// - We register the event here.
|
||||
// our while(true) loop above receives notification of changes to any of the events created here.
|
||||
_ = std.posix.system.kevent(
|
||||
this.platform.fd.unwrap().?.native(),
|
||||
@as([]KEvent, events[0..1]).ptr,
|
||||
1,
|
||||
@as([]KEvent, events[0..1]).ptr,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
fn appendFileAssumeCapacity(
|
||||
this: *Watcher,
|
||||
fd: bun.FileDescriptor,
|
||||
@@ -350,36 +383,7 @@ fn appendFileAssumeCapacity(
|
||||
};
|
||||
|
||||
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 = 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;
|
||||
|
||||
event.fflags = std.c.NOTE.WRITE | std.c.NOTE.RENAME | std.c.NOTE.DELETE;
|
||||
|
||||
// id
|
||||
event.ident = @intCast(fd.native());
|
||||
|
||||
// Store the hash for fast filtering later
|
||||
event.udata = @as(usize, @intCast(watchlist_id));
|
||||
var events: [1]KEvent = .{event};
|
||||
|
||||
// This took a lot of work to figure out the right permutation
|
||||
// Basically:
|
||||
// - We register the event here.
|
||||
// our while(true) loop above receives notification of changes to any of the events created here.
|
||||
_ = std.posix.system.kevent(
|
||||
this.platform.fd.unwrap().?.native(),
|
||||
@as([]KEvent, events[0..1]).ptr,
|
||||
1,
|
||||
@as([]KEvent, events[0..1]).ptr,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
this.addFileDescriptorToKQueueWithoutChecks(fd, watchlist_id);
|
||||
} else if (comptime Environment.isLinux) {
|
||||
// var file_path_to_use_ = std.mem.trimRight(u8, file_path_, "/");
|
||||
// var buf: [bun.MAX_PATH_BYTES+1]u8 = undefined;
|
||||
@@ -612,6 +616,40 @@ pub fn addDirectory(
|
||||
return this.appendDirectoryAssumeCapacity(fd, file_path, hash, clone_file_path);
|
||||
}
|
||||
|
||||
pub fn addFileByPathSlow(
|
||||
this: *Watcher,
|
||||
file_path: string,
|
||||
loader: options.Loader,
|
||||
) bool {
|
||||
const hash = getHash(file_path);
|
||||
|
||||
{
|
||||
this.mutex.lock();
|
||||
defer this.mutex.unlock();
|
||||
|
||||
if (this.indexOf(hash) != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const fd: bun.FileDescriptor = if (Environment.isMac) switch (bun.sys.open(
|
||||
&(std.posix.toPosixPath(file_path) catch return false),
|
||||
bun.c.O_EVTONLY,
|
||||
0,
|
||||
)) {
|
||||
.result => |fd| fd,
|
||||
.err => return false,
|
||||
} else bun.invalid_fd;
|
||||
|
||||
return switch (this.appendFileMaybeLock(fd, file_path, hash, loader, bun.invalid_fd, null, true, true)) {
|
||||
.err => {
|
||||
fd.close();
|
||||
return false;
|
||||
},
|
||||
.result => true,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn addFile(
|
||||
this: *Watcher,
|
||||
fd: bun.FileDescriptor,
|
||||
|
||||
@@ -311,8 +311,8 @@ pub const Run = struct {
|
||||
}
|
||||
|
||||
switch (this.ctx.debug.hot_reload) {
|
||||
.hot => jsc.hot_reloader.HotReloader.enableHotModuleReloading(vm),
|
||||
.watch => jsc.hot_reloader.WatchReloader.enableHotModuleReloading(vm),
|
||||
.hot => jsc.hot_reloader.HotReloader.enableHotModuleReloading(vm, this.entry_path),
|
||||
.watch => jsc.hot_reloader.WatchReloader.enableHotModuleReloading(vm, this.entry_path),
|
||||
else => {},
|
||||
}
|
||||
|
||||
@@ -328,6 +328,7 @@ pub const Run = struct {
|
||||
promise.setHandled(vm.global.vm());
|
||||
|
||||
if (vm.hot_reload != .none or handled) {
|
||||
vm.addMainToWatcherIfNeeded();
|
||||
vm.eventLoop().tick();
|
||||
vm.eventLoop().tickPossiblyForever();
|
||||
} else {
|
||||
@@ -389,21 +390,21 @@ pub const Run = struct {
|
||||
|
||||
{
|
||||
if (this.vm.isWatcherEnabled()) {
|
||||
vm.handlePendingInternalPromiseRejection();
|
||||
vm.reportExceptionInHotReloadedModuleIfNeeded();
|
||||
|
||||
while (true) {
|
||||
while (vm.isEventLoopAlive()) {
|
||||
vm.tick();
|
||||
|
||||
// Report exceptions in hot-reloaded modules
|
||||
vm.handlePendingInternalPromiseRejection();
|
||||
vm.reportExceptionInHotReloadedModuleIfNeeded();
|
||||
|
||||
vm.eventLoop().autoTickActive();
|
||||
}
|
||||
|
||||
vm.onBeforeExit();
|
||||
|
||||
vm.handlePendingInternalPromiseRejection();
|
||||
vm.reportExceptionInHotReloadedModuleIfNeeded();
|
||||
|
||||
vm.eventLoop().tickPossiblyForever();
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ counters: Counters = .{},
|
||||
hot_reload: bun.cli.Command.HotReload = .none,
|
||||
jsc_vm: *VM = undefined,
|
||||
|
||||
/// When watch/hot mode has an unhandled rejection for a missing module,
|
||||
/// poll for the file's existence instead of crashing immediately
|
||||
missing_module_poll_timer: ?*bun.uws.Timer = null,
|
||||
missing_module_path: ?bun.String = null,
|
||||
|
||||
/// hide bun:wrap from stack traces
|
||||
/// bun:wrap is very noisy
|
||||
hide_bun_stackframes: bool = true,
|
||||
@@ -672,17 +677,173 @@ pub fn uncaughtException(this: *jsc.VirtualMachine, globalObject: *JSGlobalObjec
|
||||
// TODO maybe we want a separate code path for uncaught exceptions
|
||||
this.unhandled_error_counter += 1;
|
||||
this.exit_handler.exit_code = 1;
|
||||
|
||||
// Check if this is a missing module error in watch/hot mode
|
||||
if (this.isWatcherEnabled() and this.hot_reload != .none) {
|
||||
if (bun.api.ResolveMessage.fromJS(err)) |resolve_msg| {
|
||||
if (resolve_msg.msg.metadata == .resolve) {
|
||||
const resolve_data = resolve_msg.msg.metadata.resolve;
|
||||
if (resolve_data.err == error.ModuleNotFound) {
|
||||
const specifier = resolve_data.specifier.slice(resolve_msg.msg.data.text);
|
||||
const referrer = if (resolve_msg.referrer) |ref| ref.text else this.main;
|
||||
const referrer_dir = std.fs.path.dirname(referrer) orelse std.fs.path.dirname(this.main) orelse this.transpiler.fs.top_level_dir;
|
||||
|
||||
const absolute_path = if (std.fs.path.isAbsolute(specifier))
|
||||
specifier
|
||||
else blk: {
|
||||
var path_buf: bun.PathBuffer = undefined;
|
||||
const resolved = bun.path.joinAbsStringBuf(
|
||||
referrer_dir,
|
||||
&path_buf,
|
||||
&.{specifier},
|
||||
.auto,
|
||||
);
|
||||
break :blk this.allocator.dupe(u8, resolved) catch break :blk specifier;
|
||||
};
|
||||
|
||||
if (std.fs.path.isAbsolute(absolute_path)) {
|
||||
Output.prettyErrorln("<cyan>watcher<r>: <d>Module not found:<r> {s}", .{absolute_path});
|
||||
Output.prettyErrorln("<cyan>watcher<r>: <d>Polling every 50ms for file creation...<r>", .{});
|
||||
Output.flush();
|
||||
|
||||
if (this.missing_module_poll_timer) |existing_timer| {
|
||||
existing_timer.deinit(true);
|
||||
}
|
||||
if (this.missing_module_path) |existing_path| {
|
||||
existing_path.deref();
|
||||
}
|
||||
|
||||
this.missing_module_path = bun.String.init(absolute_path);
|
||||
this.missing_module_poll_timer = bun.uws.Timer.createFallthrough(
|
||||
this.event_loop_handle.?,
|
||||
this,
|
||||
);
|
||||
this.missing_module_poll_timer.?.set(this, pollForMissingModule, 50, 50);
|
||||
|
||||
// Don't call the normal error handler since we're polling
|
||||
return handled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.onUnhandledRejection(this, globalObject, err);
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
|
||||
pub fn handlePendingInternalPromiseRejection(this: *jsc.VirtualMachine) void {
|
||||
fn pollForMissingModule(timer: *bun.uws.Timer) callconv(.C) void {
|
||||
const this: *VirtualMachine = @ptrCast(@alignCast(timer.ext(VirtualMachine)));
|
||||
|
||||
if (this.missing_module_path) |path| {
|
||||
const path_slice = path.byteSlice();
|
||||
|
||||
// Check if file now exists
|
||||
if (bun.sys.exists(path_slice)) {
|
||||
// File exists! Stop polling and trigger reload
|
||||
if (this.missing_module_poll_timer) |t| {
|
||||
t.deinit(true);
|
||||
this.missing_module_poll_timer = null;
|
||||
}
|
||||
path.deref();
|
||||
this.missing_module_path = null;
|
||||
|
||||
// Trigger reload
|
||||
Output.prettyErrorln("<cyan>watcher<r>: <d>File<r> {s} <d>now exists, reloading...<r>", .{path_slice});
|
||||
Output.flush();
|
||||
|
||||
if (this.hot_reload == .watch) {
|
||||
const should_clear_terminal = !this.transpiler.env.hasSetNoClearTerminalOnReload(!Output.enable_ansi_colors);
|
||||
bun.reloadProcess(bun.default_allocator, should_clear_terminal, false);
|
||||
} else if (this.hot_reload == .hot) {
|
||||
// For hot reload, we need to trigger a reload through the proper channels
|
||||
// This will be handled when the process restarts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reportExceptionInHotReloadedModuleIfNeeded(this: *jsc.VirtualMachine) void {
|
||||
var promise = this.pending_internal_promise.?;
|
||||
if (promise.status(this.global.vm()) == .rejected and !promise.isHandled(this.global.vm())) {
|
||||
this.unhandledRejection(this.global, promise.result(this.global.vm()), promise.asValue());
|
||||
const result = promise.result(this.global.vm());
|
||||
|
||||
// Check if this is a ResolveMessage for a missing module in watch/hot mode
|
||||
if (this.isWatcherEnabled() and this.hot_reload != .none) {
|
||||
if (bun.api.ResolveMessage.fromJS(result)) |resolve_msg| {
|
||||
if (resolve_msg.msg.metadata == .resolve) {
|
||||
const resolve_data = resolve_msg.msg.metadata.resolve;
|
||||
|
||||
// Only handle MODULE_NOT_FOUND / ENOENT errors
|
||||
if (resolve_data.err == error.ModuleNotFound) {
|
||||
const specifier = resolve_data.specifier.slice(resolve_msg.msg.data.text);
|
||||
|
||||
// Try to resolve the specifier to an absolute path
|
||||
const referrer = if (resolve_msg.referrer) |ref| ref.text else this.main;
|
||||
const referrer_dir = std.fs.path.dirname(referrer) orelse std.fs.path.dirname(this.main) orelse this.transpiler.fs.top_level_dir;
|
||||
|
||||
// If it's already an absolute path, use it
|
||||
const absolute_path = if (std.fs.path.isAbsolute(specifier))
|
||||
specifier
|
||||
else blk: {
|
||||
// Resolve relative path
|
||||
var path_buf: bun.PathBuffer = undefined;
|
||||
const resolved = bun.path.joinAbsStringBuf(
|
||||
referrer_dir,
|
||||
&path_buf,
|
||||
&.{specifier},
|
||||
.auto,
|
||||
);
|
||||
break :blk this.allocator.dupe(u8, resolved) catch break :blk specifier;
|
||||
};
|
||||
|
||||
// Only poll if it's an absolute path
|
||||
if (std.fs.path.isAbsolute(absolute_path)) {
|
||||
Output.prettyErrorln("<cyan>watcher<r>: <d>Module not found:<r> {s}", .{absolute_path});
|
||||
Output.prettyErrorln("<cyan>watcher<r>: <d>Polling every 50ms for file creation...<r>", .{});
|
||||
Output.flush();
|
||||
|
||||
// Clean up any existing poll timer
|
||||
if (this.missing_module_poll_timer) |existing_timer| {
|
||||
existing_timer.deinit(true);
|
||||
}
|
||||
if (this.missing_module_path) |existing_path| {
|
||||
existing_path.deref();
|
||||
}
|
||||
|
||||
// Store the path
|
||||
this.missing_module_path = bun.String.init(absolute_path);
|
||||
|
||||
// Start polling timer (50ms interval)
|
||||
this.missing_module_poll_timer = bun.uws.Timer.createFallthrough(
|
||||
this.event_loop_handle.?,
|
||||
this,
|
||||
);
|
||||
this.missing_module_poll_timer.?.set(this, pollForMissingModule, 50, 50);
|
||||
|
||||
// Mark as handled so we don't also print the error
|
||||
promise.setHandled(this.global.vm());
|
||||
this.addMainToWatcherIfNeeded();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.unhandledRejection(this.global, result, promise.asValue());
|
||||
promise.setHandled(this.global.vm());
|
||||
}
|
||||
|
||||
this.addMainToWatcherIfNeeded();
|
||||
}
|
||||
|
||||
pub fn addMainToWatcherIfNeeded(this: *jsc.VirtualMachine) void {
|
||||
if (this.isWatcherEnabled()) {
|
||||
const main = this.main;
|
||||
_ = this.bun_watcher.addFileByPathSlow(main, this.transpiler.options.loader(std.fs.path.extension(main)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn defaultOnUnhandledRejection(this: *jsc.VirtualMachine, _: *JSGlobalObject, value: JSValue) void {
|
||||
@@ -1929,6 +2090,16 @@ pub fn processFetchLog(globalThis: *JSGlobalObject, specifier: bun.String, refer
|
||||
pub fn deinit(this: *VirtualMachine) void {
|
||||
this.auto_killer.deinit();
|
||||
|
||||
// Clean up missing module poll timer
|
||||
if (this.missing_module_poll_timer) |timer| {
|
||||
timer.deinit(true);
|
||||
this.missing_module_poll_timer = null;
|
||||
}
|
||||
if (this.missing_module_path) |path| {
|
||||
path.deref();
|
||||
this.missing_module_path = null;
|
||||
}
|
||||
|
||||
if (source_code_printer) |print| {
|
||||
print.getMutableBuffer().deinit();
|
||||
print.ctx.written = &.{};
|
||||
|
||||
@@ -25,6 +25,17 @@ pub const ImportWatcher = union(enum) {
|
||||
};
|
||||
}
|
||||
|
||||
pub inline fn addFileByPathSlow(
|
||||
this: ImportWatcher,
|
||||
file_path: string,
|
||||
loader: options.Loader,
|
||||
) bool {
|
||||
return switch (this) {
|
||||
inline .hot, .watch => |w| w.addFileByPathSlow(file_path, loader),
|
||||
else => true,
|
||||
};
|
||||
}
|
||||
|
||||
pub inline fn addFile(
|
||||
this: ImportWatcher,
|
||||
fd: bun.FD,
|
||||
@@ -63,6 +74,8 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
verbose: bool = false,
|
||||
pending_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
|
||||
|
||||
main: MainFile = .{},
|
||||
|
||||
tombstones: bun.StringHashMapUnmanaged(*bun.fs.FileSystem.RealFS.EntriesOption) = .{},
|
||||
|
||||
pub fn init(ctx: *Ctx, fs: *bun.fs.FileSystem, verbose: bool, clear_screen_flag: bool) *Watcher {
|
||||
@@ -105,6 +118,44 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
|
||||
pub var clear_screen = false;
|
||||
|
||||
const MainFile = struct {
|
||||
/// Includes a trailing "/"
|
||||
dir: []const u8 = "",
|
||||
dir_hash: Watcher.HashType = 0,
|
||||
|
||||
file: []const u8 = "",
|
||||
hash: Watcher.HashType = 0,
|
||||
|
||||
/// On macOS, vim's atomic save triggers a race condition:
|
||||
/// 1. Old file gets NOTE_RENAME (inode deleted)
|
||||
/// 2. We receive the event and would normally trigger reload immediately
|
||||
/// 3. But the new file isn't renamed into place yet - reload fails with ENOENT
|
||||
/// 4. New file gets renamed into place (a.js~ -> a.js)
|
||||
/// 5. Parent directory gets NOTE_WRITE
|
||||
///
|
||||
/// To fix this: when the entrypoint gets NOTE_RENAME, we set this flag
|
||||
/// and skip the reload. Then when the parent directory gets NOTE_WRITE,
|
||||
/// we check if the file exists and trigger the reload.
|
||||
is_waiting_for_dir_change: bool = false,
|
||||
|
||||
pub fn init(file: []const u8) MainFile {
|
||||
var main = MainFile{
|
||||
.file = file,
|
||||
.hash = if (file.len > 0) Watcher.getHash(file) else 0,
|
||||
.is_waiting_for_dir_change = false,
|
||||
};
|
||||
|
||||
if (std.fs.path.dirname(file)) |dir| {
|
||||
bun.assert(bun.isSliceInBuffer(dir, file));
|
||||
bun.assert(file.len > dir.len + 1);
|
||||
main.dir = file[0 .. dir.len + 1];
|
||||
main.dir_hash = Watcher.getHash(main.dir);
|
||||
}
|
||||
|
||||
return main;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Task = struct {
|
||||
count: u8 = 0,
|
||||
hashes: [8]u32,
|
||||
@@ -184,7 +235,7 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
}
|
||||
};
|
||||
|
||||
pub fn enableHotModuleReloading(this: *Ctx) void {
|
||||
pub fn enableHotModuleReloading(this: *Ctx, entry_path: ?[]const u8) void {
|
||||
if (comptime @TypeOf(this.bun_watcher) == ImportWatcher) {
|
||||
if (this.bun_watcher != .none)
|
||||
return;
|
||||
@@ -197,6 +248,7 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
reloader.* = .{
|
||||
.ctx = this,
|
||||
.verbose = Environment.enable_logs or if (@hasField(Ctx, "log")) this.log.level.atLeast(.info) else false,
|
||||
.main = MainFile.init(entry_path orelse ""),
|
||||
};
|
||||
|
||||
if (comptime @TypeOf(this.bun_watcher) == ImportWatcher) {
|
||||
@@ -312,7 +364,7 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
|
||||
switch (kind) {
|
||||
.file => {
|
||||
if (event.op.delete or event.op.rename) {
|
||||
if (event.op.delete or (event.op.rename and Environment.isMac)) {
|
||||
ctx.removeAtIndex(
|
||||
event.index,
|
||||
0,
|
||||
@@ -322,13 +374,29 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
}
|
||||
|
||||
if (this.verbose)
|
||||
debug("File changed: {s}", .{fs.relativeTo(file_path)});
|
||||
debug("File changed: {s} ({})", .{ fs.relativeTo(file_path), event });
|
||||
|
||||
if (event.op.write or event.op.delete or event.op.rename) {
|
||||
if (comptime Environment.isMac) {
|
||||
if (event.op.rename) {
|
||||
// Special case for entrypoint: defer reload until we get
|
||||
// a directory write event confirming the file exists.
|
||||
// This handles vim's atomic save which deletes the old inode
|
||||
// before the new file is renamed into place.
|
||||
if (this.main.hash == current_hash and !reload_immediately) {
|
||||
this.main.is_waiting_for_dir_change = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If we got a write event after rename, the file is back - proceed with reload
|
||||
if (this.main.is_waiting_for_dir_change and this.main.hash == current_hash) {
|
||||
this.main.is_waiting_for_dir_change = false;
|
||||
}
|
||||
}
|
||||
|
||||
current_task.append(current_hash);
|
||||
}
|
||||
|
||||
// TODO: delete events?
|
||||
},
|
||||
.directory => {
|
||||
if (comptime Environment.isWindows) {
|
||||
@@ -350,6 +418,19 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
entries_option = existing;
|
||||
}
|
||||
|
||||
if (event.op.write) {
|
||||
// Check if the entrypoint now exists after an atomic save.
|
||||
// If we previously got a NOTE_RENAME on the entrypoint (vim deleted
|
||||
// the old inode), this directory write event signals that the new
|
||||
// file has been renamed into place. Verify it exists and trigger reload.
|
||||
if (this.main.is_waiting_for_dir_change and this.main.dir_hash == current_hash) {
|
||||
if (bun.sys.faccessat(file_descriptors[event.index], std.fs.path.basename(this.main.file)) == .result) {
|
||||
this.main.is_waiting_for_dir_change = false;
|
||||
current_task.append(this.main.hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var affected_i: usize = 0;
|
||||
|
||||
// if a file descriptor is stale, we need to close it
|
||||
@@ -397,7 +478,7 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
bun.asByteSlice(changed_name_.?);
|
||||
if (changed_name.len == 0 or changed_name[0] == '~' or changed_name[0] == '.') continue;
|
||||
|
||||
const loader = (this.ctx.getLoaders().get(Fs.PathName.init(changed_name).ext) orelse .file);
|
||||
const loader = (this.ctx.getLoaders().get(Fs.PathName.findExtname(changed_name)) orelse .file);
|
||||
var prev_entry_id: usize = std.math.maxInt(usize);
|
||||
if (loader != .file) {
|
||||
var path_string: bun.PathString = undefined;
|
||||
@@ -414,6 +495,8 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
if (file_descriptors[entry_id].isValid()) {
|
||||
if (prev_entry_id != entry_id) {
|
||||
current_task.append(hashes[entry_id]);
|
||||
if (this.verbose)
|
||||
debug("Removing file: {s}", .{path_string.slice()});
|
||||
ctx.removeAtIndex(
|
||||
@as(u16, @truncate(entry_id)),
|
||||
0,
|
||||
@@ -452,7 +535,7 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
|
||||
}
|
||||
|
||||
if (this.verbose) {
|
||||
debug("Dir change: {s}", .{fs.relativeTo(file_path)});
|
||||
debug("Dir change: {s} (affecting {d}, {})", .{ fs.relativeTo(file_path), affected.len, event });
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -935,7 +935,7 @@ pub const BundleV2 = struct {
|
||||
|
||||
const pool = try this.allocator().create(ThreadPool);
|
||||
if (cli_watch_flag) {
|
||||
Watcher.enableHotModuleReloading(this);
|
||||
Watcher.enableHotModuleReloading(this, null);
|
||||
}
|
||||
// errdefer pool.destroy();
|
||||
errdefer this.graph.heap.deinit();
|
||||
|
||||
@@ -1518,8 +1518,8 @@ pub const TestCommand = struct {
|
||||
vm.hot_reload = ctx.debug.hot_reload;
|
||||
|
||||
switch (vm.hot_reload) {
|
||||
.hot => jsc.hot_reloader.HotReloader.enableHotModuleReloading(vm),
|
||||
.watch => jsc.hot_reloader.WatchReloader.enableHotModuleReloading(vm),
|
||||
.hot => jsc.hot_reloader.HotReloader.enableHotModuleReloading(vm, null),
|
||||
.watch => jsc.hot_reloader.WatchReloader.enableHotModuleReloading(vm, null),
|
||||
else => {},
|
||||
}
|
||||
|
||||
|
||||
@@ -1562,6 +1562,11 @@ pub const PathName = struct {
|
||||
ext: string,
|
||||
filename: string,
|
||||
|
||||
pub fn findExtname(_path: string) string {
|
||||
const dot = bun.strings.lastIndexOfChar(_path, '.') orelse return "";
|
||||
return _path[dot..];
|
||||
}
|
||||
|
||||
pub fn extWithoutLeadingDot(self: *const PathName) string {
|
||||
return if (self.ext.len > 0 and self.ext[0] == '.') self.ext[1..] else self.ext;
|
||||
}
|
||||
|
||||
@@ -3182,8 +3182,8 @@ pub fn faccessat(dir_fd: bun.FileDescriptor, subpath: anytype) bun.sys.Maybe(boo
|
||||
const has_sentinel = std.meta.sentinel(@TypeOf(subpath)) != null;
|
||||
|
||||
if (comptime !has_sentinel) {
|
||||
const path = std.os.toPosixPath(subpath) catch return bun.sys.Maybe(bool){ .err = Error.fromCode(.NAMETOOLONG, .access) };
|
||||
return faccessat(dir_fd, path);
|
||||
const path = std.posix.toPosixPath(subpath) catch return bun.sys.Maybe(bool){ .err = Error.fromCode(.NAMETOOLONG, .access) };
|
||||
return faccessat(dir_fd, &path);
|
||||
}
|
||||
|
||||
if (comptime Environment.isLinux) {
|
||||
|
||||
116
test/cli/watch/watch-missing-module.test.ts
Normal file
116
test/cli/watch/watch-missing-module.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import fs from "fs";
|
||||
import { bunEnv, bunExe, tempDir } from "harness";
|
||||
import { join } from "path";
|
||||
|
||||
test("watch mode should poll and reload when a missing required file is created", async () => {
|
||||
using dir = tempDir("watch-missing-module", {
|
||||
"file1.ts": `
|
||||
import { message } from "./file2.ts";
|
||||
console.log("SUCCESS:", message);
|
||||
`,
|
||||
});
|
||||
|
||||
// Start bun in watch mode (file2.ts doesn't exist yet)
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--watch", "file1.ts"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
const stdoutReader = (async () => {
|
||||
for await (const chunk of proc.stdout) {
|
||||
stdout += new TextDecoder().decode(chunk);
|
||||
}
|
||||
})();
|
||||
|
||||
const stderrReader = (async () => {
|
||||
for await (const chunk of proc.stderr) {
|
||||
stderr += new TextDecoder().decode(chunk);
|
||||
}
|
||||
})();
|
||||
|
||||
// Wait for polling to start and for output to be captured
|
||||
await Bun.sleep(1500);
|
||||
|
||||
const combined1 = stdout + stderr;
|
||||
|
||||
// Should see the polling message
|
||||
expect(combined1).toContain("Module not found");
|
||||
expect(combined1).toContain("Polling every 50ms");
|
||||
|
||||
// Now create the missing file
|
||||
fs.writeFileSync(join(String(dir), "file2.ts"), `export const message = "Hello from file2!";\n`);
|
||||
|
||||
// Wait for polling to detect it and reload
|
||||
await Bun.sleep(300);
|
||||
|
||||
proc.kill();
|
||||
await Promise.race([proc.exited, Bun.sleep(2000)]);
|
||||
|
||||
await Promise.race([Promise.all([stdoutReader, stderrReader]), Bun.sleep(500)]);
|
||||
|
||||
const combined2 = stdout + stderr;
|
||||
|
||||
// Should see the reload message
|
||||
// Note: The actual "SUCCESS" output happens after the process restart,
|
||||
// which creates a new process that we can't capture in this test.
|
||||
// But we can verify that the polling detected the file and triggered reload.
|
||||
expect(combined2).toContain("now exists, reloading");
|
||||
}, 10000);
|
||||
|
||||
test("watch mode should handle relative path imports that don't exist", async () => {
|
||||
using dir = tempDir("watch-missing-relative", {
|
||||
"index.ts": `
|
||||
import { data } from "./lib/helper.ts";
|
||||
console.log("LOADED:", data);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--watch", "index.ts"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
let output = "";
|
||||
|
||||
const reader = (async () => {
|
||||
for await (const chunk of proc.stdout) {
|
||||
output += new TextDecoder().decode(chunk);
|
||||
}
|
||||
})();
|
||||
|
||||
const errReader = (async () => {
|
||||
for await (const chunk of proc.stderr) {
|
||||
output += new TextDecoder().decode(chunk);
|
||||
}
|
||||
})();
|
||||
|
||||
await Bun.sleep(1500);
|
||||
|
||||
// Should be polling for the missing file
|
||||
expect(output).toContain("Polling every 50ms");
|
||||
|
||||
// Create the directory and file
|
||||
fs.mkdirSync(join(String(dir), "lib"), { recursive: true });
|
||||
fs.writeFileSync(join(String(dir), "lib", "helper.ts"), `export const data = 42;\n`);
|
||||
|
||||
await Bun.sleep(300);
|
||||
|
||||
proc.kill();
|
||||
await Promise.race([proc.exited, Bun.sleep(2000)]);
|
||||
await Promise.race([Promise.all([reader, errReader]), Bun.sleep(500)]);
|
||||
|
||||
// Should see the reload message
|
||||
expect(output).toContain("now exists, reloading");
|
||||
}, 10000);
|
||||
Reference in New Issue
Block a user