Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/bun_core/env_var.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,16 @@ new!(pub BUN_ENABLE_CRASH_REPORTING: boolean, "BUN_ENABLE_CRASH_REPORTING", {});
// so nothing it spawned outlives it. See `src/io/ParentDeathWatchdog.rs`.
new!(pub BUN_FEATURE_FLAG_NO_ORPHANS: boolean, "BUN_FEATURE_FLAG_NO_ORPHANS", { default: false });
new!(pub BUN_FEATURE_FLAG_DUMP_CODE: string, "BUN_FEATURE_FLAG_DUMP_CODE", {});
// TODO(markovejnovic): It's unclear why the default here is 100_000, but this was legacy behavior
// so we'll keep it for now.
new!(pub BUN_INOTIFY_COALESCE_INTERVAL: unsigned, "BUN_INOTIFY_COALESCE_INTERVAL", { default: 100_000 });
// Nanoseconds the filesystem watcher waits for additional events after
// the first read returns, so a single editor save (which typically emits
// several events a few ms apart) is delivered as one `on_file_update` call.
// The old 0.1 ms default was too short to coalesce real-world save bursts
// and caused `--hot` to re-evaluate the entry point once per kernel event.
//
// Despite the name this is honoured by all three watcher backends
// (inotify, kqueue, and `ReadDirectoryChangesW`); the Windows backend
// rounds to milliseconds.
new!(pub BUN_INOTIFY_COALESCE_INTERVAL: unsigned, "BUN_INOTIFY_COALESCE_INTERVAL", { default: 10_000_000 });
new!(pub BUN_INSPECT: string, "BUN_INSPECT", { default: b"" });
new!(pub BUN_INSPECT_CONNECT_TO: string, "BUN_INSPECT_CONNECT_TO", { default: b"" });
new!(pub BUN_INSPECT_PRELOAD: string, "BUN_INSPECT_PRELOAD", {});
Expand Down
13 changes: 10 additions & 3 deletions src/bun_core/env_var.zig
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,16 @@ pub const BUN_ENABLE_CRASH_REPORTING = New(kind.boolean, "BUN_ENABLE_CRASH_REPOR
/// so nothing it spawned outlives it. See `src/ParentDeathWatchdog.zig`.
pub const BUN_FEATURE_FLAG_NO_ORPHANS = New(kind.boolean, "BUN_FEATURE_FLAG_NO_ORPHANS", .{ .default = false });
pub const BUN_FEATURE_FLAG_DUMP_CODE = New(kind.string, "BUN_FEATURE_FLAG_DUMP_CODE", .{});
/// TODO(markovejnovic): It's unclear why the default here is 100_000, but this was legacy behavior
/// so we'll keep it for now.
pub const BUN_INOTIFY_COALESCE_INTERVAL = New(kind.unsigned, "BUN_INOTIFY_COALESCE_INTERVAL", .{ .default = 100_000 });
/// Nanoseconds the filesystem watcher waits for additional events after
/// the first read returns, so a single editor save (which typically emits
/// several events a few ms apart) is delivered as one `onFileUpdate` call.
/// The old 0.1 ms default was too short to coalesce real-world save bursts
/// and caused `--hot` to re-evaluate the entry point once per kernel event.
///
/// Despite the name this is honoured by all three watcher backends
/// (inotify, kqueue, and `ReadDirectoryChangesW`); the Windows backend
/// rounds to milliseconds.
pub const BUN_INOTIFY_COALESCE_INTERVAL = New(kind.unsigned, "BUN_INOTIFY_COALESCE_INTERVAL", .{ .default = 10_000_000 });
pub const BUN_INSPECT = New(kind.string, "BUN_INSPECT", .{ .default = "" });
pub const BUN_INSPECT_CONNECT_TO = New(kind.string, "BUN_INSPECT_CONNECT_TO", .{ .default = "" });
pub const BUN_INSPECT_PRELOAD = New(kind.string, "BUN_INSPECT_PRELOAD", .{});
Expand Down
11 changes: 11 additions & 0 deletions src/jsc/hot_reloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,17 @@ where
}

pub fn append(&mut self, id: u32) {
// A single logical save routinely surfaces here many times
// (truncate+write × file-watch × dir-watch, all carrying the
// same path hash). Without this dedup the fixed-size buffer
// fills and `enqueue()` fires mid-`on_file_update`, which lets
// the JS thread start a reload while the watcher thread is
// still appending — and the `while` loop in `run()` then turns
// the later increments into a second reload for the same save.
if self.hashes[..self.count as usize].contains(&id) {
return;
}

if self.count == 8 {
self.enqueue();
self.count = 0;
Expand Down
10 changes: 10 additions & 0 deletions src/jsc/hot_reloader.zig
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime
}

pub fn append(this: *Task, id: u32) void {
// A single logical save routinely surfaces here many times
// (truncate+write × file-watch × dir-watch, all carrying the
// same path hash). Without this dedup the fixed-size buffer
// fills and `enqueue()` fires mid-`onFileUpdate`, which lets
// the JS thread start a reload while the watcher thread is
// still appending — and the `while` loop in `run()` then
// turns the later increments into a second reload for the
// same save.
if (std.mem.indexOfScalar(u32, this.hashes[0..this.count], id) != null) return;

if (this.count == 8) {
this.enqueue();
this.count = 0;
Expand Down
116 changes: 79 additions & 37 deletions src/watcher/INotifyWatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,28 @@ pub struct INotifyWatcher {
read_ptr: Option<ReadPtr>,

pub watch_count: AtomicU32,
/// nanoseconds
/// After the first `read()` returns events, the watcher repeatedly
/// `ppoll`s with this timeout and drains any further events that
/// arrive before it expires. Editors commonly generate several
/// inotify events for a single logical save (truncate+write, or
/// write+rename, plus matching events on the parent-directory
/// watch), often spread across a few milliseconds. If those events
/// land in separate `read()` cycles the consumer sees multiple
/// `on_file_update` calls for one save and, in `--hot` mode,
/// re-evaluates the entry point once per burst.
///
/// Nanoseconds. Overridable via `BUN_INOTIFY_COALESCE_INTERVAL`.
pub coalesce_interval: isize,
}

pub const DEFAULT_COALESCE_INTERVAL_NS: isize = 10_000_000; // 10ms
/// Safety cap on drain iterations so a file written to faster than the
/// coalesce interval cannot hold the watch loop indefinitely. In the
/// common case the loop exits on the first `ppoll` that sees no new
/// data (one `coalesce_interval` after the final event in a burst);
/// this bound only bites when writes never stop.
const MAX_COALESCE_ITERATIONS: u32 = 32;

impl Default for INotifyWatcher {
fn default() -> Self {
Self {
Expand All @@ -66,7 +84,7 @@ impl Default for INotifyWatcher {
eventlist_ptrs: [core::ptr::null(); max_count],
read_ptr: None,
watch_count: AtomicU32::new(0),
coalesce_interval: 100_000,
coalesce_interval: DEFAULT_COALESCE_INTERVAL_NS,
}
}
}
Expand Down Expand Up @@ -192,7 +210,7 @@ impl INotifyWatcher {
self.coalesce_interval = env_var::BUN_INOTIFY_COALESCE_INTERVAL
.get()
.and_then(|v| isize::try_from(v).ok())
.unwrap_or(100_000);
.unwrap_or(DEFAULT_COALESCE_INTERVAL_NS);

let raw = bun_sys::linux::inotify_init1(IN::CLOEXEC);
let errno = bun_sys::get_errno(raw);
Expand Down Expand Up @@ -247,18 +265,45 @@ impl INotifyWatcher {
return Ok(&[]);
}

// IN_MODIFY is very noisy
// we do a 0.1ms sleep to try to coalesce events better
const DOUBLE_READ_THRESHOLD: usize = Event::LARGEST_SIZE * (max_count / 2);
if read_len < DOUBLE_READ_THRESHOLD {
// IN_MODIFY is very noisy. Editors typically emit
// several events per save (truncate+write,
// write+rename, plus the parent-directory watch),
// often a few ms apart. Keep draining until the fd
// goes quiet for `coalesce_interval` so a single
// save becomes a single `on_file_update` call.
//
// The loop exits as soon as (a) `ppoll` times out
// with no new data, (b) we've accumulated enough
// bytes that the parse loop below would set
// `read_ptr` anyway (more than `max_count`
// minimum-size events), or (c) the iteration cap is
// hit. (b) and (c) keep a file that is written to
// continuously from starving the watch loop while
// still letting an ordinary save burst — a few
// dozen events over a few ms — collapse into one
// cycle.
const NS_PER_S: isize = 1_000_000_000;
let mut iterations: u32 = 0;
while read_len < size_of::<Event>() * max_count
&& iterations < MAX_COALESCE_ITERATIONS
{
let rest = &mut self.eventlist_bytes.0[read_len..];
if rest.len() < Event::LARGEST_SIZE {
break; // buffer nearly full
}

let mut fds = [system::pollfd {
fd: self.fd.native(),
events: (libc::POLLIN | libc::POLLERR) as _,
revents: 0,
}];
// POSIX requires tv_nsec < 10^9; split so a
// user-supplied interval ≥ 1 s doesn't make
// `ppoll` fail with EINVAL (which we treat as
// "quiet" and would disable coalescing).
let timespec = libc::timespec {
tv_sec: 0,
tv_nsec: self.coalesce_interval as _,
tv_sec: (self.coalesce_interval / NS_PER_S) as _,
tv_nsec: (self.coalesce_interval % NS_PER_S) as _,
};
// SAFETY: fds and timespec are valid stack locals; sigmask is null.
let poll_n = unsafe {
Expand All @@ -269,37 +314,34 @@ impl INotifyWatcher {
core::ptr::null(),
)
};
if poll_n > 0 {
'inner: loop {
let rest = &mut self.eventlist_bytes.0[read_len..];
debug_assert!(!rest.is_empty());
// SAFETY: fd valid; rest is a valid mutable buffer.
let new_rc = unsafe {
system::read(
self.fd.native(),
rest.as_mut_ptr(),
rest.len(),
)
};
let e = get_errno(new_rc);
match e {
E::SUCCESS => {
read_len += usize::try_from(new_rc).expect("int cast");
break 'outer read_len;
}
E::EAGAIN | E::EINTR => {
continue 'inner;
}
_ => {
return Err(bun_sys::Error {
errno: e as u32 as _,
syscall: bun_sys::Tag::read,
..Default::default()
});
}
if poll_n <= 0 {
break; // quiet
}

'inner: loop {
// SAFETY: fd valid; rest is a valid mutable buffer.
let new_rc = unsafe {
system::read(self.fd.native(), rest.as_mut_ptr(), rest.len())
};
let e = get_errno(new_rc);
match e {
E::SUCCESS => {
read_len += usize::try_from(new_rc).expect("int cast");
break 'inner;
}
E::EAGAIN | E::EINTR => {
continue 'inner;
}
_ => {
return Err(bun_sys::Error {
errno: e as u32 as _,
syscall: bun_sys::Tag::read,
..Default::default()
});
}
}
}
iterations += 1;
}

break 'outer read_len;
Expand Down
90 changes: 64 additions & 26 deletions src/watcher/INotifyWatcher.zig
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,25 @@ read_ptr: ?struct {
} = null,

watch_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
/// nanoseconds
coalesce_interval: isize = 100_000,
/// After the first `read()` returns events, the watcher repeatedly
/// `ppoll`s with this timeout and drains any further events that arrive
/// before it expires. Editors commonly generate several inotify events
/// for a single logical save (truncate+write, or write+rename, plus
/// matching events on the parent-directory watch), often spread across
/// a few milliseconds. If those events land in separate `read()` cycles
/// the consumer sees multiple `onFileUpdate` calls for one save and, in
/// `--hot` mode, re-evaluates the entry point once per burst.
///
/// Nanoseconds. Overridable via `BUN_INOTIFY_COALESCE_INTERVAL`.
coalesce_interval: isize = default_coalesce_interval_ns,

pub const default_coalesce_interval_ns = 10_000_000; // 10ms
/// Safety cap on drain iterations so a file written to faster than the
/// coalesce interval cannot hold the watch loop indefinitely. In the
/// common case the loop exits on the first `ppoll` that sees no new
/// data (one `coalesce_interval` after the final event in a burst);
/// this bound only bites when writes never stop.
const max_coalesce_iterations = 32;

pub const EventListIndex = c_int;
pub const Event = extern struct {
Expand Down Expand Up @@ -94,7 +111,7 @@ pub fn init(this: *INotifyWatcher, _: []const u8) !void {
bun.assert(!this.loaded);
this.loaded = true;

this.coalesce_interval = std.math.cast(isize, bun.env_var.BUN_INOTIFY_COALESCE_INTERVAL.get()) orelse 100_000;
this.coalesce_interval = std.math.cast(isize, bun.env_var.BUN_INOTIFY_COALESCE_INTERVAL.get()) orelse default_coalesce_interval_ns;

// TODO: convert to bun.sys.Error
this.fd = .fromNative(try std.posix.inotify_init1(IN.CLOEXEC));
Expand Down Expand Up @@ -133,34 +150,55 @@ pub fn read(this: *INotifyWatcher) bun.sys.Maybe([]const *align(1) Event) {
log("{f} read {} bytes", .{ this.fd, read_eventlist_bytes.len });
if (read_eventlist_bytes.len == 0) return .{ .result = &.{} };

// IN_MODIFY is very noisy
// we do a 0.1ms sleep to try to coalesce events better
const double_read_threshold = Event.largest_size * (max_count / 2);
if (read_eventlist_bytes.len < double_read_threshold) {
// IN_MODIFY is very noisy. Editors typically emit several
// events per save (truncate+write, write+rename, plus the
// parent-directory watch), often a few ms apart. Keep
// draining until the fd goes quiet for `coalesce_interval`
// so a single save becomes a single `onFileUpdate` call.
//
// The loop exits as soon as (a) `ppoll` times out with no
// new data, (b) we've accumulated enough bytes that the
// parse loop below would set `read_ptr` anyway (more than
// `max_count` minimum-size events), or (c) the iteration
// cap is hit. (b) and (c) keep a file that is written to
// continuously from starving the watch loop while still
// letting an ordinary save burst — a few dozen events
// over a few ms — collapse into one cycle.
var iterations: u32 = 0;
while (read_eventlist_bytes.len < @sizeOf(Event) * max_count and
iterations < max_coalesce_iterations) : (iterations += 1)
{
const rest = this.eventlist_bytes[read_eventlist_bytes.len..];
if (rest.len < Event.largest_size) break; // buffer nearly full

var fds = [_]std.posix.pollfd{.{
.fd = this.fd.cast(),
.events = std.posix.POLL.IN | std.posix.POLL.ERR,
.revents = 0,
}};
var timespec = std.posix.timespec{ .sec = 0, .nsec = this.coalesce_interval };
if ((std.posix.ppoll(&fds, &timespec, null) catch 0) > 0) {
inner: while (true) {
const rest = this.eventlist_bytes[read_eventlist_bytes.len..];
bun.assert(rest.len > 0);
const new_rc = std.posix.system.read(this.fd.cast(), rest.ptr, rest.len);
// Output.warn("wapa {} {} = {}", .{ this.fd, rest.len, new_rc });
const e = std.posix.errno(new_rc);
switch (e) {
.SUCCESS => {
read_eventlist_bytes.len += @intCast(new_rc);
break :outer read_eventlist_bytes;
},
.AGAIN, .INTR => continue :inner,
else => return .{ .err = .{
.errno = @truncate(@intFromEnum(e)),
.syscall = .read,
} },
}
// POSIX requires tv_nsec < 10^9; split so a
// user-supplied interval ≥ 1 s doesn't make `ppoll`
// fail with EINVAL (which the `catch 0` would
// silently turn into "quiet" and disable coalescing).
var timespec = std.posix.timespec{
.sec = @divTrunc(this.coalesce_interval, std.time.ns_per_s),
.nsec = @rem(this.coalesce_interval, std.time.ns_per_s),
};
if ((std.posix.ppoll(&fds, &timespec, null) catch 0) == 0) break; // quiet

inner: while (true) {
const new_rc = std.posix.system.read(this.fd.cast(), rest.ptr, rest.len);
const e = std.posix.errno(new_rc);
switch (e) {
.SUCCESS => {
read_eventlist_bytes.len += @intCast(new_rc);
break :inner;
},
.AGAIN, .INTR => continue :inner,
else => return .{ .err = .{
.errno = @truncate(@intFromEnum(e)),
.syscall = .read,
} },
}
}
}
Expand Down
Loading
Loading