diff --git a/Cargo.lock b/Cargo.lock
index df85e88428..3192d55a58 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6345,6 +6345,7 @@ dependencies = [
"libmimalloc-sys",
"mach2",
"mimalloc",
+ "notify",
"perry-diagnostics",
"perry-dispatch",
"perry-parser",
diff --git a/changelog.d/9613-fs-watch-os-events.md b/changelog.d/9613-fs-watch-os-events.md
new file mode 100644
index 0000000000..5695a227f7
--- /dev/null
+++ b/changelog.d/9613-fs-watch-os-events.md
@@ -0,0 +1,63 @@
+**fix(runtime): `fs.watch` / `fsPromises.watch` use OS change notifications instead of re-walking the tree every 25 ms (#9591)**
+
+Every watcher was a 25 ms `setInterval` whose tick re-walked the WHOLE watch
+target (`read_dir` + `symlink_metadata` per entry) and diffed two maps, on the
+main thread: ~3.4 µs per file per tick, 40 ticks a second. Watching 3 000
+files for 5 s cost 2.03 s of CPU (41 % of a core) against node's 0.03 s.
+claude-code watches its cwd; the field session's cwd held 362 295 files, which
+extrapolates to ~1.2 s of walking per 25 ms schedule — a wedged event loop,
+#9588's symptom exactly.
+
+The OS now reports changes. `notify` (inotify on Linux/Android,
+`ReadDirectoryChangesW` on Windows, kqueue on the BSDs) or, on macOS, FSEvents
+bound at runtime through `dlopen` (`watch_fsevents.rs`) runs on its own thread
+and queues each event; the queue follows the event pump's producer protocol
+(push, then `js_notify_main_thread()`), and a runtime pump slot drains it once
+per loop turn and routes each event to the watchers it concerns —
+`'rename'` for create / remove / move, `'change'` for data and metadata
+writes, filenames relative to the watched root. Nothing walks anything on a
+timer any more. Watching 3 000 files for 4 s now costs single-digit
+milliseconds of CPU, and a change surfaces in milliseconds instead of at the
+next tick.
+
+Instances mirror libuv's sharing: non-recursive watchers share one instance
+per JS thread, refcounted by canonical root — `fs.inotify.max_user_instances`
+defaults to 128 and a chokidar / `tsc --watch` style consumer opens one
+`fs.watch` per directory, so one instance per watcher would have failed at the
+129th. Recursive watchers get their own instance, because notify keys its
+per-path bookkeeping by path and a recursive root sharing an instance with a
+non-recursive watch of one of its subdirectories would clobber it. Liveness
+moved from the ref'd interval timer to a new runtime has-active slot
+(`register_runtime_has_active`, the counterpart of `register_runtime_pump`), so
+`persistent: false`, `ref()`, `unref()` and `close()` release the loop as before.
+Backend errors (an exhausted inotify watch table while a recursive watcher
+adds a new subdirectory, for instance) now reach `'error'` listeners as
+Node-shaped fs errors, or the uncaught-exception path when there is none.
+
+macOS does not use notify's FSEvents backend on purpose: `fsevent-sys` links
+CoreServices through `#[link]` metadata that does not survive perry's custom
+link step, and every `fs` importer retains the watcher (the module table pins
+`js_fs_watch`), so every such binary would have needed `-framework
+CoreServices` — an umbrella that drags CoreFoundation — on its link line, the
+launch-time cost perry keeps off console binaries. The ten CoreFoundation /
+CoreServices entry points are resolved with `dlopen` at the first `fs.watch`
+call instead; a program that never watches never loads the framework, and the
+link line is unchanged. Flag classification follows libuv (rename beats
+change; latency 0.05 s so bursts coalesce as under node), delivery uses a
+private dispatch queue (libdispatch is in libSystem), and the stream is
+rebuilt when the path set changes, as libuv and notify do.
+
+The walker survives only as the fallback for when the OS watch cannot be
+established (watch limit, unsupported target, or `PERRY_FS_WATCH_POLL=1` as a
+diagnostic switch). It runs on its own thread — the walk never blocks the loop
+— and paces itself to 5 % of one core: each walk's duration times 20, clamped
+to [25 ms, 5007 ms] (the old cadence at the bottom, `fs.watchFile`'s default at
+the top). A 3 000-file tree polls every ~200 ms under it; the 362 k-file tree
+every 5 s, off the main thread, instead of 40 times a second on it.
+
+Verification: `crates/perry/tests/issue_9591_fs_watch_native_events.rs` is the
+issue's bar — watch 3 000 files for a 4 s window, assert < 5 % of a core AND
+that a new file is reported within a second (the unfixed walker burns ~1.6 s
+in that window); the same for the forced poller at a 12.5 % budget.
+`test-files/test_gap_9591_fs_watch_events.ts` pins the event contract against
+node for the callback, single-file, recursive and promise-iterator forms.
diff --git a/changelog.d/9614-bunfs-root.md b/changelog.d/9614-bunfs-root.md
new file mode 100644
index 0000000000..f9c51eac52
--- /dev/null
+++ b/changelog.d/9614-bunfs-root.md
@@ -0,0 +1,13 @@
+### Bun compatibility
+
+- **`perry compile --bunfs-root
` now compiles source extracted from a
+ Bun standalone executable without a host `/$bunfs` mount.** Static imports,
+ re-exports, literal dynamic imports, and literal `require()` calls retain
+ their Bun virtual paths while resolving against the extracted directory.
+ Canonical real paths keep a module imported through both spellings to one
+ identity, including modules below an extracted `node_modules` directory.
+
+ Literal mapped files are embedded under their original `/$bunfs/root/...`
+ names, so `node:fs` and `Bun.file()` reads keep working after the source tree
+ moves. Missing module mappings produce a focused diagnostic, and mapped
+ paths cannot traverse or follow symlinks outside the configured root.
diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml
index d7822cf070..cfece76e13 100644
--- a/crates/perry-runtime/Cargo.toml
+++ b/crates/perry-runtime/Cargo.toml
@@ -449,3 +449,12 @@ sha2 = "0.11"
# the generated `[u16; 128]` arrays land in the binary. Already vetted in the
# workspace lock (allsorts / lopdf depend on it).
encoding_rs = "0.8"
+
+# #9591: OS change notifications behind `fs.watch` / `fsPromises.watch`
+# (inotify / ReadDirectoryChangesW / kqueue), replacing the timer-driven full
+# tree walk. See `fs/dir_glob_watch/watch_backend.rs`. NOT on macOS: notify's
+# FSEvents backend needs `-framework CoreServices` on every link line that
+# retains the watcher (every `fs` importer), which perry keeps off console
+# binaries for launch time; `watch_fsevents.rs` binds FSEvents via dlopen.
+[target.'cfg(not(target_os = "macos"))'.dependencies]
+notify.workspace = true
diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs
index 545d27af23..60c370aff6 100644
--- a/crates/perry-runtime/src/bun_compat/mod.rs
+++ b/crates/perry-runtime/src/bun_compat/mod.rs
@@ -281,6 +281,13 @@ fn mime_type_for_path(path: &str) -> &'static str {
}
fn read_file_or_reject(path: &str) -> Result, f64> {
+ if let Some(bytes) = crate::embedded::lookup(path) {
+ return Ok(bytes.to_vec());
+ }
+ if crate::embedded::is_virtual_path(path) {
+ let error = std::io::Error::new(std::io::ErrorKind::NotFound, "virtual file not found");
+ return Err(unsafe { crate::fs::build_fs_error_value(&error, "open", path) });
+ }
std::fs::read(path)
.map_err(|err| unsafe { crate::fs::build_fs_error_value(&err, "open", path) })
}
@@ -319,11 +326,16 @@ extern "C" fn bun_file_bytes(closure: *const ClosureHeader) -> f64 {
extern "C" fn bun_file_exists(closure: *const ClosureHeader) -> f64 {
let path = value_to_string(captured(closure));
- promise_value(bool_value(
+ let exists = if crate::embedded::lookup(&path).is_some() {
+ true
+ } else if crate::embedded::is_virtual_path(&path) {
+ false
+ } else {
std::fs::metadata(&path)
- .map(|m| m.is_file())
- .unwrap_or(false),
- ))
+ .map(|metadata| metadata.is_file())
+ .unwrap_or(false)
+ };
+ promise_value(bool_value(exists))
}
fn json_parse_promise(bytes: &[u8]) -> f64 {
@@ -368,9 +380,15 @@ pub extern "C" fn js_bun_file(path: f64) -> f64 {
let obj = js_object_alloc(0, 9);
set_field(obj, BUN_FILE_PATH_KEY, path_value);
set_field(obj, b"name", path_value);
- let size = std::fs::metadata(&path_string)
- .map(|m| m.len())
- .unwrap_or(0);
+ let size = if let Some(bytes) = crate::embedded::lookup(&path_string) {
+ bytes.len() as u64
+ } else if crate::embedded::is_virtual_path(&path_string) {
+ 0
+ } else {
+ std::fs::metadata(&path_string)
+ .map(|metadata| metadata.len())
+ .unwrap_or(0)
+ };
set_field(obj, b"size", size as f64);
set_field(
obj,
diff --git a/crates/perry-runtime/src/embedded.rs b/crates/perry-runtime/src/embedded.rs
index 7dcc6dad38..e5380f9ce7 100644
--- a/crates/perry-runtime/src/embedded.rs
+++ b/crates/perry-runtime/src/embedded.rs
@@ -30,6 +30,10 @@ use crate::value::{js_nanbox_pointer, JSValue, TAG_TRUE};
/// Bun's `$bunfs/`. `fs` and `readEmbedded` strip it before lookup; the
/// import-attribute lowering hands user code a `$perryfs/` string.
pub const VIRTUAL_PREFIX: &str = "$perryfs/";
+/// Bun standalone executables expose extracted files through this absolute
+/// virtual prefix. Perry retains the full path as the registry key so user
+/// code can keep passing the original string to `node:fs` and `Bun.file()`.
+pub const BUNFS_ROOT_PREFIX: &str = "/$bunfs/root/";
/// One embedded file. `bytes` points into the binary's read-only data and is
/// valid for the life of the process.
@@ -95,12 +99,14 @@ pub fn lookup(path: &str) -> Option<&'static [u8]> {
reg.iter().find(|a| a.name == key).map(|a| a.bytes)
}
-/// True if `path` is an embedded-asset *virtual* path (carries the `$perryfs/`
-/// prefix), independent of whether it actually resolves. `fs` uses this to treat
-/// an unresolved `$perryfs/...` path as missing rather than attempting a real
-/// disk read of the literal string. Actual presence is [`lookup`].
+/// True if `path` is an embedded-asset virtual path (carries the `$perryfs/`
+/// or `/$bunfs/root/` prefix), independent of whether it actually resolves.
+/// `fs` uses this to treat an unresolved virtual path as missing rather than
+/// attempting a real disk read of the literal string. Actual presence is
+/// [`lookup`].
pub fn is_virtual_path(path: &str) -> bool {
- path.replace('\\', "/").starts_with(VIRTUAL_PREFIX)
+ let unified = path.replace('\\', "/");
+ unified.starts_with(VIRTUAL_PREFIX) || unified.starts_with(BUNFS_ROOT_PREFIX)
}
/// Snapshot of `(name, size)` for every embedded asset, in registration order.
@@ -311,6 +317,7 @@ mod tests {
assert_eq!(lookup("$perryfs\\embed-test\\asset.txt"), Some(DATA));
// `is_virtual_path` is a pure prefix test; presence is `lookup`.
assert!(is_virtual_path("$perryfs/anything"));
+ assert!(is_virtual_path("/$bunfs/root/assets/help.zst"));
assert!(!is_virtual_path("not/registered.txt"));
assert!(lookup("not/registered.txt").is_none());
assert!(lookup("$perryfs/not-registered").is_none());
diff --git a/crates/perry-runtime/src/fs/dir_glob_watch.rs b/crates/perry-runtime/src/fs/dir_glob_watch.rs
index 4a48e9d365..b189df3aca 100644
--- a/crates/perry-runtime/src/fs/dir_glob_watch.rs
+++ b/crates/perry-runtime/src/fs/dir_glob_watch.rs
@@ -10,6 +10,9 @@ use super::*;
mod glob;
mod opendir;
mod watch;
+mod watch_backend;
+#[cfg(target_os = "macos")]
+mod watch_fsevents;
// Re-export the opendir entry points consumed cross-module (callbacks.rs,
// node_submodules/fs_promises.rs) plus the unmangled FFI sync symbol.
diff --git a/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs b/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs
index 3502a4755d..68e127ee70 100644
--- a/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs
+++ b/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs
@@ -10,19 +10,20 @@ use crate::fs::{encoded_string_ptr, fs_encoding_option};
use crate::string::js_string_from_bytes;
use std::cell::RefCell;
-use std::collections::{BTreeMap, HashMap, VecDeque};
+use std::collections::{HashMap, VecDeque};
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::sync::Once;
+use super::watch_backend::{self, Backend, RawEvent, Source, WatchError, WatchEvent};
+
use crate::closure::{
js_closure_alloc, js_closure_get_capture_f64, js_closure_set_capture_f64,
js_register_closure_arity, ClosureHeader,
};
-const FS_WATCH_POLL_INTERVAL_MS: f64 = 25.0;
const WATCH_FILE_DEFAULT_INTERVAL_MS: f64 = 5007.0;
#[derive(Clone, Copy)]
@@ -31,32 +32,17 @@ struct WatchListener {
once: bool,
}
-#[derive(Clone, PartialEq, Eq)]
-struct WatchEntry {
- is_file: bool,
- is_dir: bool,
- is_symlink: bool,
- len: u64,
- mode: u32,
- modified_ns: i128,
- created_ns: i128,
-}
-
-type WatchSnapshot = BTreeMap;
-
-#[derive(Clone)]
-struct WatchEvent {
- event_type: &'static str,
- filename: String,
-}
-
struct FsWatchState {
path: String,
recursive: bool,
encoding: String,
object_value: f64,
- timer_id: i64,
- snapshot: WatchSnapshot,
+ /// The OS watch (or the poll thread) behind this watcher; dropping the
+ /// state releases it. See `watch_backend` (#9591).
+ backend: Backend,
+ /// `persistent` at creation, then `ref()` / `unref()`. A ref'd watcher
+ /// keeps the event loop alive through `fs_watch_has_active`.
+ refed: bool,
listeners: HashMap>,
signal: f64,
abort_listener: f64,
@@ -94,10 +80,12 @@ struct PromiseWatchState {
recursive: bool,
encoding: String,
object_value: f64,
- timer_id: i64,
+ /// Started lazily by the first `next()` (Node starts the FSEvent handle
+ /// when the async generator body first runs); `None` until then and
+ /// again once closed.
+ backend: Option,
persistent: bool,
active: bool,
- snapshot: WatchSnapshot,
queue: VecDeque,
pending: VecDeque<*mut crate::promise::Promise>,
signal: f64,
@@ -264,100 +252,6 @@ fn remove_abort_listener(signal: f64, listener: f64) {
}
}
-fn metadata_time_ns(time: std::io::Result) -> i128 {
- time.ok()
- .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
- .map(|d| d.as_nanos() as i128)
- .unwrap_or(0)
-}
-
-fn watch_entry_from_metadata(meta: &fs::Metadata) -> WatchEntry {
- let ft = meta.file_type();
- #[cfg(unix)]
- let mode = meta.permissions().mode();
- #[cfg(not(unix))]
- let mode = if meta.permissions().readonly() {
- 0o444
- } else {
- 0o666
- };
- WatchEntry {
- is_file: ft.is_file(),
- is_dir: ft.is_dir(),
- is_symlink: ft.is_symlink(),
- len: meta.len(),
- mode,
- modified_ns: metadata_time_ns(meta.modified()),
- created_ns: metadata_time_ns(meta.created()),
- }
-}
-
-fn relative_path(root: &Path, path: &Path) -> String {
- path.strip_prefix(root)
- .unwrap_or(path)
- .to_string_lossy()
- .replace('\\', "/")
-}
-
-fn walk_watch_dir(root: &Path, dir: &Path, recursive: bool, out: &mut WatchSnapshot) {
- let Ok(entries) = fs::read_dir(dir) else {
- return;
- };
- let mut paths: Vec = entries.flatten().map(|entry| entry.path()).collect();
- paths.sort();
- for path in paths {
- let Ok(meta) = fs::symlink_metadata(&path) else {
- continue;
- };
- let rel = relative_path(root, &path);
- out.insert(rel, watch_entry_from_metadata(&meta));
- if recursive && meta.is_dir() {
- walk_watch_dir(root, &path, true, out);
- }
- }
-}
-
-fn snapshot_watch_target(path: &str, recursive: bool) -> std::io::Result {
- let root = Path::new(path);
- let meta = fs::symlink_metadata(root)?;
- let mut snapshot = WatchSnapshot::new();
- if meta.is_dir() {
- walk_watch_dir(root, root, recursive, &mut snapshot);
- } else {
- let name = root
- .file_name()
- .map(|name| name.to_string_lossy().into_owned())
- .unwrap_or_else(|| path.to_string());
- snapshot.insert(name, watch_entry_from_metadata(&meta));
- }
- Ok(snapshot)
-}
-
-fn diff_watch_snapshots(previous: &WatchSnapshot, current: &WatchSnapshot) -> Vec {
- let mut events = Vec::new();
- let mut keys = BTreeMap::::new();
- for key in previous.keys() {
- keys.insert(key.clone(), ());
- }
- for key in current.keys() {
- keys.insert(key.clone(), ());
- }
- for key in keys.keys() {
- match (previous.get(key), current.get(key)) {
- (None, Some(_)) | (Some(_), None) => events.push(WatchEvent {
- event_type: "rename",
- filename: key.clone(),
- }),
- (Some(a), Some(b)) if a != b => events.push(WatchEvent {
- event_type: "change",
- filename: key.clone(),
- }),
- _ => {}
- }
- }
- events
-}
-
fn stat_snapshot(path: &str) -> Option {
let meta = fs::metadata(path).ok()?;
let ft = meta.file_type();
@@ -602,15 +496,25 @@ fn emit_watch_file_change(
fn close_fs_watcher(id: usize) {
let removed = FS_WATCHERS.with(|watchers| watchers.borrow_mut().remove(&id));
- let Some(mut state) = removed else {
+ let Some(state) = removed else {
return;
};
- crate::timer::clearInterval(state.timer_id);
- crate::async_hooks::destroy(state.async_ids.async_id);
- remove_abort_listener(state.signal, state.abort_listener);
- let close_listeners = take_event_listeners(&mut state.listeners, "close");
+ let FsWatchState {
+ backend,
+ object_value,
+ mut listeners,
+ signal,
+ abort_listener,
+ async_ids,
+ ..
+ } = state;
+ // Release the OS watch before any user code runs.
+ drop(backend);
+ crate::async_hooks::destroy(async_ids.async_id);
+ remove_abort_listener(signal, abort_listener);
+ let close_listeners = take_event_listeners(&mut listeners, "close");
for listener in close_listeners {
- emit_listener0(state.object_value, listener.callback);
+ emit_listener0(object_value, listener.callback);
}
}
@@ -633,9 +537,7 @@ fn close_promise_watcher_return(id: usize) -> Vec<*mut crate::promise::Promise>
let Some(state) = removed else {
return Vec::new();
};
- if state.timer_id != 0 {
- crate::timer::clearInterval(state.timer_id);
- }
+ drop(state.backend);
remove_abort_listener(state.signal, state.abort_listener);
state.pending.into_iter().collect()
}
@@ -646,11 +548,8 @@ fn abort_promise_watcher(id: usize, reason: f64) -> Vec<*mut crate::promise::Pro
let Some(state) = watchers.get_mut(&id) else {
return Vec::new();
};
- if state.timer_id != 0 {
- crate::timer::clearInterval(state.timer_id);
- }
+ state.backend = None;
remove_abort_listener(state.signal, state.abort_listener);
- state.timer_id = 0;
state.active = false;
state.signal = undefined_value();
state.abort_listener = undefined_value();
@@ -754,69 +653,273 @@ fn reject_promise(promise: *mut crate::promise::Promise, reason: f64) {
);
}
-extern "C" fn fs_watcher_poll_impl(closure: *const ClosureHeader) -> f64 {
- let id = js_closure_get_capture_f64(closure, 0) as usize;
- let deliveries = FS_WATCHERS.with(|watchers| {
- let mut watchers = watchers.borrow_mut();
- let Some(state) = watchers.get_mut(&id) else {
- return Vec::new();
+// ============================================================================
+// #9591 — delivery. Events arrive on the backend's queue (notify's thread or
+// the poll thread); the runtime pump slot below drains them once per event-
+// loop turn and routes each to the JS watchers it concerns.
+// ============================================================================
+
+/// One routed event, keyed by watcher id only. The JS values delivery needs
+/// (the watcher object, its listeners, a pending promise) are read from the
+/// state maps at delivery time, right before they are rooted — never cached
+/// across another watcher's callback, where a copying collection would leave
+/// a cached value stale. A watcher closed by an earlier callback in the same
+/// batch simply drops its later events, as Node does after `close()`.
+enum Delivery {
+ Event { id: usize, event: WatchEvent },
+ Error { id: usize, error: WatchError },
+}
+
+fn ensure_pump_registered() {
+ static REGISTER: Once = Once::new();
+ REGISTER.call_once(|| {
+ crate::stdlib_pump::register_runtime_pump(2, fs_watch_pump_extern);
+ crate::stdlib_pump::register_runtime_has_active(0, fs_watch_has_active_extern);
+ });
+}
+
+extern "C" fn fs_watch_pump_extern() {
+ pump_fs_watch_events();
+}
+
+extern "C" fn fs_watch_has_active_extern() -> i32 {
+ i32::from(fs_watch_has_active())
+}
+
+/// A ref'd `fs.watch` handle or a started, persistent `fsPromises.watch`
+/// iterator keeps the event loop alive — the role the ref'd interval timer
+/// played before #9591.
+fn fs_watch_has_active() -> bool {
+ FS_WATCHERS.with(|watchers| watchers.borrow().values().any(|state| state.refed))
+ || PROMISE_WATCHERS.with(|watchers| {
+ watchers
+ .borrow()
+ .values()
+ .any(|state| state.active && state.persistent && !state.closed)
+ })
+}
+
+/// Drain the backend queue: route every raw event to the watchers it
+/// concerns (state maps borrowed, no JS runs), then deliver (borrows
+/// released, JS runs).
+fn pump_fs_watch_events() {
+ let raw = watch_backend::drain_queue();
+ if raw.is_empty() {
+ return;
+ }
+ let mut deliveries = Vec::new();
+ for item in raw {
+ route_raw_event(item, &mut deliveries);
+ }
+ for delivery in deliveries {
+ match delivery {
+ Delivery::Event { id, event } => deliver_event(id, event),
+ Delivery::Error { id, error } => deliver_error(id, error),
+ }
+ }
+}
+
+fn route_raw_event(item: RawEvent, out: &mut Vec) {
+ match item {
+ RawEvent::Polled { id, event } => out.push(Delivery::Event { id, event }),
+ RawEvent::Native {
+ source,
+ path,
+ class,
+ } => {
+ for (id, filename) in native_targets(source, &path) {
+ out.push(Delivery::Event {
+ id,
+ event: WatchEvent {
+ event_type: class.node_name(),
+ filename,
+ },
+ });
+ }
+ }
+ RawEvent::Error {
+ source,
+ paths,
+ error,
+ } => {
+ for id in error_targets(source, &paths) {
+ out.push(Delivery::Error {
+ id,
+ error: error.clone(),
+ });
+ }
+ }
+ }
+}
+
+/// The watchers an OS event on `path` from `source` is delivered to, with the
+/// filename each reports. An own instance has exactly one owner; a shared
+/// instance's event is offered to every shared watcher whose root is the
+/// path or its parent (`filename_for` applies the depth rule).
+fn native_targets(source: Source, path: &Path) -> Vec<(usize, String)> {
+ let mut out = Vec::new();
+ let mut consider = |id: usize, backend: &Backend, recursive: bool| {
+ let root = match (source, backend) {
+ (Source::Own(owner), Backend::Own { root, .. }) if owner == id => root,
+ (Source::Shared, Backend::Shared { root }) => root,
+ _ => return,
};
- let current = snapshot_watch_target(&state.path, state.recursive).unwrap_or_default();
- let events = diff_watch_snapshots(&state.snapshot, ¤t);
- state.snapshot = current;
- events
- .into_iter()
- .map(|event| {
- let callbacks = take_event_listeners(&mut state.listeners, "change");
- (state.object_value, callbacks, event, state.encoding.clone())
- })
- .collect()
+ if let Some(filename) = watch_backend::filename_for(root, path, recursive) {
+ out.push((id, filename));
+ }
+ };
+ FS_WATCHERS.with(|watchers| {
+ for (id, state) in watchers.borrow().iter() {
+ consider(*id, &state.backend, state.recursive);
+ }
});
- for (object_value, callbacks, event, encoding) in deliveries {
- emit_fs_watch_event(object_value, callbacks, &event, &encoding);
+ PROMISE_WATCHERS.with(|watchers| {
+ for (id, state) in watchers.borrow().iter() {
+ if let Some(backend) = &state.backend {
+ consider(*id, backend, state.recursive);
+ }
+ }
+ });
+ out
+}
+
+fn error_targets(source: Source, paths: &[PathBuf]) -> Vec {
+ if let Source::Own(id) = source {
+ return vec![id];
}
- undefined_value()
+ let mut out = Vec::new();
+ let mut consider = |id: usize, backend: &Backend| {
+ if let Backend::Shared { root } = backend {
+ if watch_backend::error_concerns_root(root, paths) {
+ out.push(id);
+ }
+ }
+ };
+ FS_WATCHERS.with(|watchers| {
+ for (id, state) in watchers.borrow().iter() {
+ consider(*id, &state.backend);
+ }
+ });
+ PROMISE_WATCHERS.with(|watchers| {
+ for (id, state) in watchers.borrow().iter() {
+ if let Some(backend) = &state.backend {
+ consider(*id, backend);
+ }
+ }
+ });
+ out
}
-extern "C" fn promise_watcher_poll_impl(closure: *const ClosureHeader) -> f64 {
- let id = js_closure_get_capture_f64(closure, 0) as usize;
- let actions = PROMISE_WATCHERS.with(|watchers| {
+/// Deliver one event to watcher `id`: a callback watcher's `'change'`
+/// listeners, or a promise watcher's oldest pending `next()` (queued when
+/// none is waiting). Ids are unique across both maps.
+fn deliver_event(id: usize, event: WatchEvent) {
+ let fs_target = FS_WATCHERS.with(|watchers| {
let mut watchers = watchers.borrow_mut();
- let Some(state) = watchers.get_mut(&id) else {
- return Vec::new();
- };
+ let state = watchers.get_mut(&id)?;
+ let callbacks = take_event_listeners(&mut state.listeners, "change");
+ Some((state.object_value, callbacks, state.encoding.clone()))
+ });
+ if let Some((object_value, callbacks, encoding)) = fs_target {
+ emit_fs_watch_event(object_value, callbacks, &event, &encoding);
+ return;
+ }
+ let mut event = Some(event);
+ let promise_target = PROMISE_WATCHERS.with(|watchers| {
+ let mut watchers = watchers.borrow_mut();
+ let state = watchers.get_mut(&id)?;
if state.closed {
- return Vec::new();
+ return None;
}
- let current = snapshot_watch_target(&state.path, state.recursive).unwrap_or_default();
- let events = diff_watch_snapshots(&state.snapshot, ¤t);
- state.snapshot = current;
- let mut actions = Vec::new();
- for event in events {
- if let Some(promise) = state.pending.pop_front() {
- actions.push((promise, event, state.encoding.clone()));
- } else {
- state.queue.push_back(event);
+ match state.pending.pop_front() {
+ Some(promise) => Some((promise, state.encoding.clone())),
+ None => {
+ state
+ .queue
+ .push_back(event.take().expect("event consumed once"));
+ None
}
}
- actions
});
- for (promise, event, encoding) in actions {
+ if let (Some((promise, encoding)), Some(event)) = (promise_target, event) {
resolve_promise_with_event(promise, event, encoding);
}
- undefined_value()
+}
+
+/// Deliver a backend error to watcher `id` as a Node-shaped fs error: the
+/// callback watcher's `'error'` listeners (uncaught when there are none), or
+/// the promise watcher's pending `next()` rejections, after which the
+/// iterator is finished.
+fn deliver_error(id: usize, error: WatchError) {
+ let fs_target = FS_WATCHERS.with(|watchers| {
+ let mut watchers = watchers.borrow_mut();
+ let state = watchers.get_mut(&id)?;
+ let callbacks = take_event_listeners(&mut state.listeners, "error");
+ Some((state.object_value, callbacks, state.path.clone()))
+ });
+ if let Some((object_value, callbacks, path)) = fs_target {
+ emit_fs_watch_error(object_value, callbacks, &error.to_io_error(), &path);
+ return;
+ }
+ let promise_path = PROMISE_WATCHERS.with(|watchers| {
+ let watchers = watchers.borrow();
+ let state = watchers.get(&id)?;
+ if state.closed {
+ return None;
+ }
+ Some(state.path.clone())
+ });
+ if let Some(path) = promise_path {
+ let scope = crate::gc::RuntimeHandleScope::new();
+ let reason = unsafe { build_fs_error_value(&error.to_io_error(), "watch", &path) };
+ let reason_handle = scope.root_nanbox_f64(reason);
+ let pending = abort_promise_watcher(id, reason_handle.get_nanbox_f64());
+ for promise in pending {
+ reject_promise(promise, reason_handle.get_nanbox_f64());
+ }
+ }
+}
+
+/// `'error'` delivery. With no listener Node's EventEmitter throws the error
+/// as an uncaught exception; do the same through the process funnel.
+fn emit_fs_watch_error(
+ object_value: f64,
+ callbacks: Vec,
+ error: &std::io::Error,
+ path: &str,
+) {
+ let raw_callbacks: Vec = callbacks.iter().map(|listener| listener.callback).collect();
+ let scope = crate::gc::RuntimeHandleScope::new();
+ let callback_handles = scope.root_nanbox_f64_slice(&raw_callbacks);
+ let object_handle = scope.root_nanbox_f64(object_value);
+ let err_value = unsafe { build_fs_error_value(error, "watch", path) };
+ if callbacks.is_empty() {
+ crate::os::emit_process_uncaught_exception(err_value);
+ return;
+ }
+ let err_handle = scope.root_nanbox_f64(err_value);
+ let refreshed_callbacks =
+ crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&callback_handles);
+ let prev_this = scope.root_nanbox_f64(crate::object::js_implicit_this_get());
+ for callback in refreshed_callbacks {
+ let cb = extract_closure_ptr(callback);
+ if cb.is_null() {
+ continue;
+ }
+ crate::object::js_implicit_this_set(object_handle.get_nanbox_f64());
+ with_watcher_uncaught_trap(|| {
+ crate::closure::js_closure_call1(cb, err_handle.get_nanbox_f64());
+ });
+ crate::object::js_implicit_this_set(prev_this.get_nanbox_f64());
+ }
}
fn start_promise_watcher(id: usize, state: &mut PromiseWatchState) {
if state.active || state.closed {
return;
}
- let timer_callback = poll_closure_value(promise_watcher_poll_impl as *const u8, id);
- let timer_id = crate::timer::setInterval(timer_callback as i64, FS_WATCH_POLL_INTERVAL_MS);
- if !state.persistent {
- crate::timer::js_timer_unref(timer_id);
- }
- state.timer_id = timer_id;
+ ensure_pump_registered();
+ state.backend = Some(Backend::start(id, &state.path, state.recursive));
state.active = true;
}
@@ -877,8 +980,8 @@ extern "C" fn fs_watcher_ref_impl(closure: *const ClosureHeader) -> f64 {
let id = js_closure_get_capture_f64(closure, 0) as usize;
let self_value = js_closure_get_capture_f64(closure, 1);
FS_WATCHERS.with(|watchers| {
- if let Some(state) = watchers.borrow().get(&id) {
- crate::timer::js_timer_ref(state.timer_id);
+ if let Some(state) = watchers.borrow_mut().get_mut(&id) {
+ state.refed = true;
}
});
self_value
@@ -888,8 +991,8 @@ extern "C" fn fs_watcher_unref_impl(closure: *const ClosureHeader) -> f64 {
let id = js_closure_get_capture_f64(closure, 0) as usize;
let self_value = js_closure_get_capture_f64(closure, 1);
FS_WATCHERS.with(|watchers| {
- if let Some(state) = watchers.borrow().get(&id) {
- crate::timer::js_timer_unref(state.timer_id);
+ if let Some(state) = watchers.borrow_mut().get_mut(&id) {
+ state.refed = false;
}
});
self_value
@@ -1168,8 +1271,6 @@ extern "C" fn promise_watcher_self_impl(closure: *const ClosureHeader) -> f64 {
fn ensure_watch_method_arities() {
static REGISTER: Once = Once::new();
REGISTER.call_once(|| {
- js_register_closure_arity(fs_watcher_poll_impl as *const u8, 0);
- js_register_closure_arity(promise_watcher_poll_impl as *const u8, 0);
js_register_closure_arity(watch_file_poll_impl as *const u8, 0);
js_register_closure_arity(fs_watcher_abort_impl as *const u8, 0);
js_register_closure_arity(promise_watcher_abort_impl as *const u8, 0);
@@ -1459,7 +1560,8 @@ fn normalized_watch_args(arg1: f64, arg2: f64) -> (f64, Option) {
}
}
-/// `fs.watch(path[, options][, listener])` — polling-backed watcher.
+/// `fs.watch(path[, options][, listener])` — OS-event-backed watcher (#9591);
+/// see `watch_backend`.
#[no_mangle]
pub extern "C" fn js_fs_watch(path_value: f64, arg1: f64, arg2: f64) -> f64 {
validate::validate_path("filename", path_value);
@@ -1475,20 +1577,18 @@ pub extern "C" fn js_fs_watch(path_value: f64, arg1: f64, arg2: f64) -> f64 {
Ok(signal) => signal,
Err(err) => crate::exception::js_throw(err),
};
- let snapshot = match snapshot_watch_target(&path, recursive) {
- Ok(snapshot) => snapshot,
- Err(err) => unsafe {
+ // Node throws ENOENT & co. at call time; one stat answers that without
+ // the pre-#9591 full walk of the target.
+ if let Err(err) = fs::symlink_metadata(&path) {
+ unsafe {
crate::exception::js_throw(build_fs_error_value(&err, "watch", &path));
- },
- };
+ }
+ }
let id = next_watch_id();
let object_value = build_fs_watcher_object(id);
let async_ids = crate::async_hooks::init_resource("FSEVENTWRAP", object_value, true);
- let timer_callback = poll_closure_value(fs_watcher_poll_impl as *const u8, id);
- let timer_id = crate::timer::setInterval(timer_callback as i64, FS_WATCH_POLL_INTERVAL_MS);
- if !persistent {
- crate::timer::js_timer_unref(timer_id);
- }
+ ensure_pump_registered();
+ let backend = Backend::start(id, &path, recursive);
let abort_listener = signal
.map(|signal| add_abort_listener(signal, id, fs_watcher_abort_impl))
.unwrap_or_else(undefined_value);
@@ -1505,8 +1605,8 @@ pub extern "C" fn js_fs_watch(path_value: f64, arg1: f64, arg2: f64) -> f64 {
recursive,
encoding,
object_value,
- timer_id,
- snapshot,
+ backend,
+ refed: persistent,
listeners,
signal: signal_value,
abort_listener,
@@ -1624,16 +1724,14 @@ pub extern "C" fn js_fs_promises_watch(path_value: f64, options_value: f64) -> f
Ok(signal) => signal,
Err(err) => crate::exception::js_throw(err),
};
- // Snapshot the watch target at creation time. This serves two purposes:
- // 1. It validates the path synchronously, matching Node's `watch()` which
- // throws (ENOENT etc.) at call time rather than at first iteration.
- // 2. It seeds an initial baseline for the state.
- let initial_snapshot = match snapshot_watch_target(&path, recursive) {
- Ok(snapshot) => snapshot,
- Err(err) => unsafe {
+ // Validate the path synchronously, matching Node's `watch()` which throws
+ // (ENOENT etc.) at call time rather than at first iteration. The OS watch
+ // itself starts on the first `next()` (`start_promise_watcher`).
+ if let Err(err) = fs::symlink_metadata(&path) {
+ unsafe {
crate::exception::js_throw(build_fs_error_value(&err, "watch", &path));
- },
- };
+ }
+ }
let id = next_watch_id();
let object_value = build_promise_watcher_object(id);
let abort_listener = signal
@@ -1654,10 +1752,9 @@ pub extern "C" fn js_fs_promises_watch(path_value: f64, options_value: f64) -> f
recursive,
encoding,
object_value,
- timer_id: 0,
+ backend: None,
persistent,
active: false,
- snapshot: initial_snapshot,
queue: VecDeque::new(),
pending: VecDeque::new(),
signal: signal_value,
diff --git a/crates/perry-runtime/src/fs/dir_glob_watch/watch_backend.rs b/crates/perry-runtime/src/fs/dir_glob_watch/watch_backend.rs
new file mode 100644
index 0000000000..9ce81296c3
--- /dev/null
+++ b/crates/perry-runtime/src/fs/dir_glob_watch/watch_backend.rs
@@ -0,0 +1,900 @@
+//! Change-detection backend for `fs.watch` / `fsPromises.watch` (#9591).
+//!
+//! Before #9591 every watcher was a 25 ms `setInterval` whose tick re-walked
+//! the WHOLE watch target (`read_dir` + `symlink_metadata` per entry) and
+//! diffed two `BTreeMap`s — ~3.4 µs per file per tick, 40 ticks a second, on
+//! the main thread. A 3 000-file tree cost 41 % of a core; claude-code watches
+//! its cwd, and a 362 k-file cwd extrapolates to ~1.2 s of walking per 25 ms
+//! schedule, which is a wedged event loop (#9588's symptom exactly).
+//!
+//! Now the OS tells us. A [`NativeInstance`] is inotify on Linux/Android,
+//! `ReadDirectoryChangesW` on Windows and kqueue on the BSDs (all through
+//! `notify`), and FSEvents on macOS (`watch_fsevents`, bound at runtime via
+//! `dlopen` so no binary grows a CoreServices load command). Each backend runs
+//! on its own thread and hands events to [`EventQueue`], which follows the
+//! event pump's producer protocol — push, then `js_notify_main_thread()` — so
+//! the main loop wakes once per burst and delivers from a runtime pump slot
+//! (`stdlib_pump::register_runtime_pump`). Nothing walks anything on a timer.
+//!
+//! Instances, mirroring libuv's sharing:
+//!
+//! * **Non-recursive watchers share one instance per JS thread**, refcounted
+//! by canonical root. libuv shares one inotify fd per loop for the same
+//! reason: `fs.inotify.max_user_instances` defaults to 128, and a
+//! chokidar / `tsc --watch` style consumer opens one `fs.watch` per
+//! directory — thousands of them. One instance per watcher would fail
+//! with EMFILE at the 129th directory.
+//! * **Recursive watchers get their own instance.** notify keys its per-path
+//! bookkeeping (inotify's wd map, FSEvents' `recursive_info`) by path, so a
+//! recursive root and a non-recursive watch of one of its subdirectories on
+//! the SAME instance would overwrite each other's mode and, on unwatch,
+//! remove each other's descriptors. Separate instances have separate maps.
+//!
+//! Every event carries its [`Source`], so the router only offers a shared
+//! instance's events to shared watchers (root == path, or root == parent) and
+//! an own instance's events to its one owner. One known imperfection: a
+//! shared instance watching both `/a` and `/a/f` (a directory and a file
+//! inside it) sees two inotify descriptors report the same write as two
+//! events with the same path, and each watcher receives both. Node itself
+//! routinely delivers two `'change'` events per write on Linux, so consumers
+//! already debounce.
+//!
+//! The walker survives only as [`PollHandle`]: the fallback when the native
+//! watch cannot be established (inotify watch limit, an unsupported target,
+//! or `PERRY_FS_WATCH_POLL=1`). It runs on its own thread — the walk never
+//! blocks the loop — and paces itself to at most 1/[`POLL_DUTY_DIVISOR`] of
+//! one core: each walk's duration times [`POLL_DUTY_DIVISOR`], clamped to
+//! [[`POLL_MIN_INTERVAL_MS`], [`POLL_MAX_INTERVAL_MS`]].
+
+use std::cell::RefCell;
+use std::collections::{BTreeMap, HashMap, VecDeque};
+use std::fs;
+#[cfg(unix)]
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::{Arc, Mutex, OnceLock};
+use std::thread::JoinHandle;
+use std::time::{Duration, Instant};
+
+#[cfg(target_os = "macos")]
+use super::watch_fsevents::NativeInstance;
+#[cfg(not(target_os = "macos"))]
+use native::NativeInstance;
+
+/// Lower bound of the fallback poller's cadence — the pre-#9591 fixed rate,
+/// kept for tiny trees where a walk is cheaper than the interval.
+pub(super) const POLL_MIN_INTERVAL_MS: u64 = 25;
+/// Upper bound of the fallback poller's cadence: `fs.watchFile`'s default
+/// interval, Node's own number for "polling is all we have".
+pub(super) const POLL_MAX_INTERVAL_MS: u64 = 5007;
+/// The fallback poller sleeps `POLL_DUTY_DIVISOR` × the last walk's duration
+/// between walks, so it never exceeds 1/20 = 5 % of one core.
+pub(super) const POLL_DUTY_DIVISOR: u32 = 20;
+
+/// Set `PERRY_FS_WATCH_POLL=1` to skip the OS watcher and use the walker for
+/// every watcher (a diagnostic escape hatch; also how the fallback is tested).
+const FORCE_POLL_ENV: &str = "PERRY_FS_WATCH_POLL";
+
+// ============================================================================
+// Snapshot types — the fallback poller's diff domain.
+// ============================================================================
+
+#[derive(Clone, PartialEq, Eq)]
+pub(super) struct WatchEntry {
+ is_file: bool,
+ is_dir: bool,
+ is_symlink: bool,
+ len: u64,
+ mode: u32,
+ modified_ns: i128,
+ created_ns: i128,
+}
+
+pub(super) type WatchSnapshot = BTreeMap;
+
+/// One `(eventType, filename)` pair as `fs.watch` reports it.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(super) struct WatchEvent {
+ pub(super) event_type: &'static str,
+ pub(super) filename: String,
+}
+
+fn metadata_time_ns(time: std::io::Result) -> i128 {
+ time.ok()
+ .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
+ .map(|d| d.as_nanos() as i128)
+ .unwrap_or(0)
+}
+
+fn watch_entry_from_metadata(meta: &fs::Metadata) -> WatchEntry {
+ let ft = meta.file_type();
+ #[cfg(unix)]
+ let mode = meta.permissions().mode();
+ #[cfg(not(unix))]
+ let mode = if meta.permissions().readonly() {
+ 0o444
+ } else {
+ 0o666
+ };
+ WatchEntry {
+ is_file: ft.is_file(),
+ is_dir: ft.is_dir(),
+ is_symlink: ft.is_symlink(),
+ len: meta.len(),
+ mode,
+ modified_ns: metadata_time_ns(meta.modified()),
+ created_ns: metadata_time_ns(meta.created()),
+ }
+}
+
+/// `path` relative to `root`, with forward slashes.
+pub(super) fn relative_path(root: &Path, path: &Path) -> String {
+ path.strip_prefix(root)
+ .unwrap_or(path)
+ .to_string_lossy()
+ .replace('\\', "/")
+}
+
+fn walk_watch_dir(root: &Path, dir: &Path, recursive: bool, out: &mut WatchSnapshot) {
+ let Ok(entries) = fs::read_dir(dir) else {
+ return;
+ };
+ let mut paths: Vec = entries.flatten().map(|entry| entry.path()).collect();
+ paths.sort();
+ for path in paths {
+ let Ok(meta) = fs::symlink_metadata(&path) else {
+ continue;
+ };
+ let rel = relative_path(root, &path);
+ out.insert(rel, watch_entry_from_metadata(&meta));
+ if recursive && meta.is_dir() {
+ walk_watch_dir(root, &path, true, out);
+ }
+ }
+}
+
+/// Full walk of `path` (one level, or the whole tree when `recursive`).
+/// This is the operation #9591 took off the main thread's 25 ms timer.
+pub(super) fn snapshot_watch_target(path: &str, recursive: bool) -> std::io::Result {
+ let root = Path::new(path);
+ let meta = fs::symlink_metadata(root)?;
+ let mut snapshot = WatchSnapshot::new();
+ if meta.is_dir() {
+ walk_watch_dir(root, root, recursive, &mut snapshot);
+ } else {
+ let name = root
+ .file_name()
+ .map(|name| name.to_string_lossy().into_owned())
+ .unwrap_or_else(|| path.to_string());
+ snapshot.insert(name, watch_entry_from_metadata(&meta));
+ }
+ Ok(snapshot)
+}
+
+pub(super) fn diff_watch_snapshots(
+ previous: &WatchSnapshot,
+ current: &WatchSnapshot,
+) -> Vec {
+ let mut events = Vec::new();
+ let mut keys = BTreeMap::::new();
+ for key in previous.keys() {
+ keys.insert(key.clone(), ());
+ }
+ for key in current.keys() {
+ keys.insert(key.clone(), ());
+ }
+ for key in keys.keys() {
+ match (previous.get(key), current.get(key)) {
+ (None, Some(_)) | (Some(_), None) => events.push(WatchEvent {
+ event_type: "rename",
+ filename: key.clone(),
+ }),
+ (Some(a), Some(b)) if a != b => events.push(WatchEvent {
+ event_type: "change",
+ filename: key.clone(),
+ }),
+ _ => {}
+ }
+ }
+ events
+}
+
+// ============================================================================
+// Raw events — what the producers (the OS backend's thread, the poll thread)
+// queue.
+// ============================================================================
+
+/// Node's two `fs.watch` event names.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(super) enum EventClass {
+ Rename,
+ Change,
+}
+
+impl EventClass {
+ pub(super) fn node_name(self) -> &'static str {
+ match self {
+ EventClass::Rename => "rename",
+ EventClass::Change => "change",
+ }
+ }
+}
+
+/// Which native instance produced an event — the router only offers an event
+/// to the watchers registered on that instance (see the module docs).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(super) enum Source {
+ /// This thread's shared non-recursive instance.
+ Shared,
+ /// The private instance owned by watcher `id`.
+ Own(usize),
+}
+
+/// A backend failure, in a form that crosses threads and clones freely
+/// (`std::io::Error` does neither); rebuilt into an `io::Error` for the
+/// Node-shaped error object at delivery.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(super) struct WatchError {
+ pub(super) errno: Option,
+ pub(super) message: String,
+}
+
+impl WatchError {
+ pub(super) fn from_io(err: &std::io::Error) -> Self {
+ WatchError {
+ errno: err.raw_os_error(),
+ message: err.to_string(),
+ }
+ }
+
+ pub(super) fn generic(message: impl Into) -> Self {
+ WatchError {
+ errno: None,
+ message: message.into(),
+ }
+ }
+
+ pub(super) fn to_io_error(&self) -> std::io::Error {
+ match self.errno {
+ Some(code) => std::io::Error::from_raw_os_error(code),
+ None => std::io::Error::other(self.message.clone()),
+ }
+ }
+}
+
+pub(super) enum RawEvent {
+ /// An OS observation of `path`, not yet attributed to a watcher.
+ Native {
+ source: Source,
+ path: PathBuf,
+ class: EventClass,
+ },
+ /// The fallback poller's diff for watcher `id` — already relative.
+ Polled { id: usize, event: WatchEvent },
+ /// A backend error; `paths` is what the backend attached (may be empty).
+ /// Constructed only by the `notify` backend — on macOS the FSEvents driver
+ /// reports failures at construction time instead, so the variant is
+ /// consumption-only there.
+ #[cfg_attr(target_os = "macos", allow(dead_code))]
+ Error {
+ source: Source,
+ paths: Vec,
+ error: WatchError,
+ },
+}
+
+// ============================================================================
+// The per-thread event queue — producer side of the event-pump protocol.
+// ============================================================================
+
+pub(super) struct EventQueue {
+ items: Mutex>,
+ /// Mirror of `items.len()` so the per-tick pump can skip the mutex when
+ /// nothing is queued (one relaxed load per event-loop turn).
+ len: AtomicUsize,
+}
+
+impl EventQueue {
+ fn new() -> Self {
+ EventQueue {
+ items: Mutex::new(VecDeque::new()),
+ len: AtomicUsize::new(0),
+ }
+ }
+
+ /// Queue a batch and wake the consumer ONCE. Safe from any thread.
+ pub(super) fn push_all(&self, batch: impl IntoIterator) {
+ let mut items = self.items.lock().unwrap_or_else(|e| e.into_inner());
+ let before = items.len();
+ items.extend(batch);
+ let after = items.len();
+ self.len.store(after, Ordering::Release);
+ drop(items);
+ if after != before {
+ crate::event_pump::js_notify_main_thread();
+ }
+ }
+
+ fn drain(&self) -> Vec {
+ if self.len.load(Ordering::Acquire) == 0 {
+ return Vec::new();
+ }
+ let mut items = self.items.lock().unwrap_or_else(|e| e.into_inner());
+ let out: Vec = items.drain(..).collect();
+ self.len.store(0, Ordering::Release);
+ out
+ }
+}
+
+crate::perry_thread_local! {
+ // One queue per JS thread: a watcher created on a `perry/thread` worker
+ // is drained by that worker's pump, never by the main thread's.
+ static QUEUE: Arc = Arc::new(EventQueue::new());
+}
+
+fn queue_handle() -> Arc {
+ QUEUE.with(Arc::clone)
+}
+
+/// Take everything queued for this thread. Called by the pump each turn.
+pub(super) fn drain_queue() -> Vec {
+ QUEUE.with(|queue| queue.drain())
+}
+
+// ============================================================================
+// Native instances — `notify` everywhere but macOS (see `watch_fsevents`).
+// ============================================================================
+
+#[cfg(not(target_os = "macos"))]
+mod native {
+ use super::*;
+ use notify::event::ModifyKind;
+ use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
+
+ /// Map one notify event onto Node's vocabulary.
+ ///
+ /// Node subscribes to create / delete / move / modify / attribute changes
+ /// and nothing else — libuv's inotify mask has no `IN_ACCESS` /
+ /// `IN_CLOSE_WRITE`, so `Access` events are dropped rather than surfacing
+ /// as a third `'change'` per write. `Other` covers rescan / mount /
+ /// overflow notices that libuv also does not forward.
+ pub(super) fn classify(source: Source, event: Event) -> Vec {
+ let class = match event.kind {
+ EventKind::Create(_)
+ | EventKind::Remove(_)
+ | EventKind::Modify(ModifyKind::Name(_)) => EventClass::Rename,
+ EventKind::Modify(_) | EventKind::Any => EventClass::Change,
+ EventKind::Access(_) | EventKind::Other => return Vec::new(),
+ };
+ event
+ .paths
+ .into_iter()
+ .map(|path| RawEvent::Native {
+ source,
+ path,
+ class,
+ })
+ .collect()
+ }
+
+ #[cfg(unix)]
+ const ENOENT_CODE: i32 = libc::ENOENT;
+ #[cfg(not(unix))]
+ const ENOENT_CODE: i32 = 2;
+ #[cfg(unix)]
+ const ENOSPC_CODE: i32 = libc::ENOSPC;
+ #[cfg(not(unix))]
+ const ENOSPC_CODE: i32 = 28;
+
+ fn errno_error(code: i32) -> WatchError {
+ WatchError {
+ errno: Some(code),
+ message: std::io::Error::from_raw_os_error(code).to_string(),
+ }
+ }
+
+ pub(super) fn watch_error_from_notify(error: notify::Error) -> (Vec, WatchError) {
+ let notify::Error { kind, paths } = error;
+ let error = match kind {
+ notify::ErrorKind::Io(err) => WatchError::from_io(&err),
+ notify::ErrorKind::PathNotFound => errno_error(ENOENT_CODE),
+ notify::ErrorKind::MaxFilesWatch => errno_error(ENOSPC_CODE),
+ notify::ErrorKind::WatchNotFound => WatchError::generic("watch not found"),
+ notify::ErrorKind::Generic(message) => WatchError::generic(message),
+ notify::ErrorKind::InvalidConfig(_) => {
+ WatchError::generic("invalid watcher configuration")
+ }
+ };
+ (paths, error)
+ }
+
+ // As visible as `Backend::Own`, which carries it (private_interfaces).
+ pub(in crate::fs::dir_glob_watch) struct NativeInstance(RecommendedWatcher);
+
+ impl NativeInstance {
+ pub(super) fn new(source: Source, queue: Arc) -> Result {
+ let handler = move |result: notify::Result| match result {
+ Ok(event) => queue.push_all(classify(source, event)),
+ Err(error) => {
+ let (paths, error) = watch_error_from_notify(error);
+ queue.push_all(std::iter::once(RawEvent::Error {
+ source,
+ paths,
+ error,
+ }));
+ }
+ };
+ // The interval only matters where `RecommendedWatcher` is notify's
+ // own `PollWatcher` (targets with no OS facility); inotify and
+ // ReadDirectoryChangesW ignore it. Match the fallback poller's ceiling.
+ RecommendedWatcher::new(
+ handler,
+ Config::default().with_poll_interval(Duration::from_millis(POLL_MAX_INTERVAL_MS)),
+ )
+ .map(NativeInstance)
+ .map_err(|error| watch_error_from_notify(error).1)
+ }
+
+ pub(super) fn watch(&mut self, root: &Path, recursive: bool) -> Result<(), WatchError> {
+ let mode = if recursive {
+ RecursiveMode::Recursive
+ } else {
+ RecursiveMode::NonRecursive
+ };
+ self.0
+ .watch(root, mode)
+ .map_err(|error| watch_error_from_notify(error).1)
+ }
+
+ pub(super) fn unwatch(&mut self, root: &Path) {
+ let _ = self.0.unwatch(root);
+ }
+ }
+}
+
+fn new_native_watcher(source: Source) -> Result {
+ NativeInstance::new(source, queue_handle())
+}
+
+/// This thread's shared instance for non-recursive watchers.
+struct SharedInstance {
+ watcher: NativeInstance,
+ /// canonical root → number of JS watchers registered on it. The OS watch
+ /// is added on 0 → 1 and removed on 1 → 0 (libuv's per-path refcount).
+ refs: HashMap,
+}
+
+crate::perry_thread_local! {
+ // Outer `None`: never tried. `Some(None)`: construction failed — every
+ // non-recursive watcher on this thread uses the poller from then on.
+ static SHARED: RefCell