Skip to content

fs.watch: deliver every event, and merge a record only into an event the listener has not received - #44008

Open
robobun wants to merge 15 commits into
robobun/5db8d916/fs-watch-recursive-subdir-self-eventsfrom
robobun/5db8d916/fs-watch-deliver-every-event
Open

robobun wants to merge 15 commits into
robobun/5db8d916/fs-watch-recursive-subdir-self-eventsfrom
robobun/5db8d916/fs-watch-deliver-every-event

Conversation

@robobun

@robobun robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #44005

Problem

  • fs.watch() reports one 'rename' when an entry is removed and created again (Node: two). The listener never hears of the creation.
  • ChangeEvent::should_emit (src/runtime/node/path_watcher.rs:226) drops an event when the listener's previous event had the same path and type at most 1 ms before.

Fix

  • ChangeEvent and emit_unsuppressed are removed (Linux, macOS, FreeBSD).
  • PathWatcher::emit_record drops a record only when it equals the listener's last event and the listener has not received that event. The listener then runs after the change.
  • Verified: test/js/node/watch/fs.watch.test.ts, 29 new Linux tests, 20 fail without src/. macOS and FreeBSD: compiled, not run.
  • Self-reviewed twice: 65 concerns raised, 41 addressed, 24 listed in Notes.

Background

Downsides

  • Floods give more listener calls. 200000 appends to one file: 29731 (main 403, Node 17023). Appends and chmod in turn: 200000 and a 4.5 s timer delay (main 506, Node 44160).
  • Windows keeps the bug.
Notes

Stack. #44007 and #43715, then this PR. The base of this PR is the branch of #44007. The branch of #43715 is merged into this branch (bec5bed), so this PR is safe whichever of the two lands first. Both branches are merged with main at a4f1429. Until #43715 merges, the diff of this PR shows deliver_one, the queue undelivered and five tests that belong to #43715. Without #44007 a recursive watch reports each change to a subdirectory twice once the suppression is gone.

Other open PRs.

Event counts. Linux x64, release builds, 100 runs for each row, fs.watch(dir), the actions run in one synchronous block. A sentinel entry marks the end, so the rows use no timers. c is change, r is rename. The main column is the base of this PR, which has the rule of main.

Actions on one name Node v26.3.0 main This PR
rmdir, mkdir r, r r r, r
mkdir, rmdir r, r r r, r
unlink, create r, r r r, r
rename a b, rename b a r, r, r, r r, r, r r, r, r, r
rename a b over b r, r r, r r, r
chmod, append c, c c c, c
two appends c c c
one writeFileSync c c c
watched file: chmod, append, unlink c, c, c, r, r c, r, r c, c, c, r, r
watched file: two appends, unlink c, c, r, r c, r, r c, c, r, r

Rows equal to Node in every run: main 3 of 10, this PR 10 of 10.

Other measurements. Same builds. The host had a load average of 340 to 420, so each timing has a wide range. The runs of the three runtimes were made in turn.

Node main This PR
One writeFileSync in the process: runs with one 'change', of 2000 2000 1999 1992
The same on one CPU, of 300 300 299 298
Listener makes the next write, 100 rounds: rounds done 100 1 to 3 100
JS blocked while another process appends 1000 times: events 1 2 1
JS blocked while another process appends 200000 times: events 1 236 to 347 1
Append, setImmediate, append, 200 runs: runs with two 'change' 200 1 to 2 187 to 200
The same: runs where the last listener call saw a size that was not the last 0 175 to 187 0
once loop: append other, append f, 5 ms, append f, 300 runs: runs where the loop never heard of f 0 2 0
200000 appends to one file, 20 µs listener: calls 16449 to 17895 335 to 404 17886 to 29961
The same: longest delay of a 5 ms timer 90 to 220 ms 3 to 6 ms 4 to 148 ms
The same: growth of RSS 9 to 11 MB 5 MB 9 to 11 MB
Each 10th append goes to a second file: calls, timer delay, RSS 33885 to 37205, 805 to 859 ms, 9 to 10 MB 40009 to 40119, 399 to 666 ms, 10 to 14 MB 52448 to 58461, 882 to 1115 ms, 21 to 26 MB
An append and a chmod of one file in turn: calls, timer delay, RSS 39806 to 44671, 991 to 1000 ms, 8 to 12 MB 419 to 534, 3 to 9 ms, 5 to 6 MB 200000, 4305 to 5708 ms, 65 to 94 MB
Appends to two files in turn: calls, timer delay, RSS 34948 to 35976, 784 to 873 ms, 11 to 12 MB 200000, 4242 to 4413 ms, 55 to 58 MB 200000, 4344 to 4423 ms, 61 to 78 MB
Recursive, mkdir n and a file in it: events, mean of 100, 4 runs 2.00 2.88 to 2.95 2.83 to 2.99
Recursive, mkdir -p a/b/c and a file, 4 runs 4.00 4.96 to 8.35 6.09 to 9.17
Recursive, a file moved into a new directory 2.00 2.00 2.00
Append to listener call, one append each 3 ms, median of 7 runs 60 µs 73 µs 67 µs
CPU of the watcher thread for each record, 5 runs 600 to 1600 ns 900 to 1800 ns
.text of the release binary, bytes 58299068 58299580
size_of FSWatcher, posted task, Entry, bytes 640, 496, 56 672, 496, 56
State for each listener, bytes 24 32 and a copy of the last path
  • The 512 bytes of .text and the 32 bytes of FSWatcher come from fs.watch: run nextTicks and microtasks between the events of one batch #43715. Without it this PR removes 512 bytes of .text and adds nothing to FSWatcher.
  • Node reports fewer calls than records in the floods because its inotify queue overflows. Bun reads at once and its queue to the JS thread has no bound, on main too.
  • The CPU numbers come from the tick counters in /proc. perf, valgrind, strace and bloaty are not on the machine, so there is no count of instructions.
  • The reader no longer reads the clock or hashes the path for each record. It compares the record with the last one of the listener, copies the path, and stores the flag.

Why the merge cannot lose a change. The watcher thread sets the flag before it posts an event. The JS thread clears the flag where run takes a posted batch over, before a listener call. So a set flag means that no batch arrived on the JS thread after the last event was posted, and the listener call for that event starts later than the change of each record that merged into it. The order of the batches does not matter. Both sides use SeqCst.

The flag against a number for each event. The first form of this PR gave each event a number and stored the newest delivered number. That is exact: a batch that holds older events does not stop the merge. It is wrong when a later batch runs first, which happens for a watcher that a macro created, because the macro loop and the regular loop both take its batches. The flag allows one more event for each batch that arrives. Measured by the review with the flood where each 10th append goes to a second file: 52000 to 56000 calls with the flag, 40000 with numbers.

What "identical" is. For inotify: the same mask and the same path. The kernel compares the mask, the watch and the name, not the cookie. For kqueue: the same path and kind of event. The kernel merges more there: each note of one file. An entry that the walk of a new directory found merges with the entry's own IN_CREATE or IN_MOVED_TO. Error, overflow and IN_IGNORED events, and all macOS events, are posted with no check.

Tests and the clause that each one holds. Each clause was changed in a debug build, and the tests named here failed.

  • The flag is cleared before the listener call, not after: "a change made while the listener runs", "a write made by the listener, 100 times", and the two tests with a writer in another process.
  • The flag is cleared at all: "a change made after the listener ran".
  • The flag is set: "writes to one file merge", "two moves onto one name merge".
  • The path is part of the key: "writes to two files do not merge". The mask is part of the key: "a mode change and a write do not merge", "a removal and a creation do not merge".
  • The walk merges with IN_CREATE and IN_MOVED_TO: the four tests of "a new directory reports an entry once".
  • Each event has a task of its own (fs.watch: run nextTicks and microtasks between the events of one batch #43715): "a once loop hears of a write that merged into the second event of a batch". It fails on this PR without the branch of fs.watch: run nextTicks and microtasks between the events of one batch #43715.
  • The held tests keep the JS thread busy until the watcher thread handled each record. They read /proc/self/fdinfo of the inotify descriptor to see the watch of a sentinel directory.

History. The rule came with the first fs.watch (#3249), which read through bun.Watcher. #29952 gave fs.watch a reader of its own. #31830 narrowed the rule, and #32962 added a way around it for one event.

Removed with the suppression. It compared wall-clock times. After a backward step of the clock, every event with the path and type of the last one was dropped until the clock passed the old value.

Cases that remain.

Self-review, open.

Suites run with the debug build. test/js/node/watch/ (0 fail), test/js/node/test/parallel/test-fs-watch* and test-fs-promises-watch* (42 of 42), the new tests 5 times (0 fail). cargo check passes for the 12 CI targets. cargo clippy passes for bun_runtime on Linux and macOS.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/watch/fs.watch.test.ts

The watcher thread posts up to 8 events as one task. The task called the
listener for each event back to back, so what the listener queued for one
event (process.nextTick, promise reactions, the continuation of an await)
ran only after the whole batch. Node makes one callback per event.

FSWatchTaskPosix::run now runs the microtask checkpoint before every event
after the first. The task queue runs it after the last event.
…pin keeps the order

The checkpoint between two events can run a continuation that spins the
event loop (expect().resolves in bun:test, Bun.build with an async plugin).
A later batch of the same watcher then ran inside that spin and its events
reached the listener before the rest of the batch that was being delivered.

Each batch now moves its events to a queue on the FSWatcher and delivers
from the front of that queue, so a batch that runs inside the spin delivers
the older events first. close() empties the queue.
The loop that delivered the watcher queue ran the microtask checkpoint
itself and held the rest of the queue while it did. A listener or a
continuation that spun the event loop in there could wait for an event
that only that suspended loop would deliver.

Each task now delivers one event and, if more events wait, queues a task
for the rest before it calls the listener. The event loop runs the
checkpoint between two events, as it does between any two tasks. A spin
inside a listener or a continuation runs the queued task, so the events
keep coming, in order. Two watchers of one directory now get each event
in turn, as in node.

Tests: the spin test covers a spin in the listener and in a continuation
and observes the checkpoint, so both cases fail without the fix. Add a
test for two watchers of one directory.
The watcher thread posts one batch per watcher. Two watchers of one
directory get each event in turn only when both batches reach the JS
thread in the same drain, so the exact interleaving is not a guarantee
and the test could fail under load.
The continuation of the first event of a rename spins the event loop
until the listener has seen the second name. Only the task queued for the
rest of the batch can deliver it while the continuation spins. With the
events held by a suspended delivery loop the spin never returned.
@robobun

robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:04 PM PT - Sep 28th, 2026

✅ @robobun, your commit 1e8fea81624b6bee4edf799b702714093acce245 passed in Build #121430! 🎉


🧪   To try this PR locally:

bunx bun-pr 44008

That installs a local version of the PR into your bun-44008 executable, so you can run:

bun-44008 --bun

@robobun

robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: open, waits for CI, review and a maintainer's decision (two questions in the last comment). The base is the branch of #44007, and this branch holds the commits of #43715. Both are merged with main at a4f1429. When #44007 merges, this PR moves to main.

How to reproduce (Linux, main, from #44005):

const fs = require("fs"), path = require("path"), os = require("os");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "w-"));
fs.mkdirSync(path.join(dir, "views"));
const events = [];
const w = fs.watch(dir, (e, f) => {
  if (f === "sentinel") { console.log(JSON.stringify(events)); return w.close(); }
  events.push(`${e}:${f}`);
});
fs.rmdirSync(path.join(dir, "views"));
fs.mkdirSync(path.join(dir, "views"));
fs.mkdirSync(path.join(dir, "sentinel"));

main prints ["rename:views"]. Node v26.3.0 and this branch print ["rename:views","rename:views"].

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/runtime/node/path_watcher.rs Outdated
Comment thread src/runtime/node/path_watcher.rs Outdated
Comment thread src/runtime/node/path_watcher.rs Outdated
Comment thread src/runtime/node/path_watcher.rs Outdated
@robobun robobun changed the title fs.watch: deliver every event and let the kernel merge identical inotify records fs.watch: deliver every event; merge a record only into an event the listener has not received Sep 26, 2026
@robobun
robobun force-pushed the robobun/5db8d916/fs-watch-deliver-every-event branch from 016aa23 to 0f59aea Compare September 26, 2026 00:18
Comment thread src/runtime/node/node_fs_watcher.rs Outdated
Comment thread src/runtime/node/node_fs_watcher.rs Outdated
Comment thread src/runtime/node/node_fs_watcher.rs Outdated
Comment thread src/runtime/node/node_fs_watcher.rs Outdated
Comment thread src/runtime/node/path_watcher.rs Outdated
Comment thread src/runtime/node/path_watcher.rs Outdated
Comment thread src/runtime/node/path_watcher.rs Outdated
Comment thread src/runtime/node/path_watcher.rs Outdated
…er has not received

The fs.watch backends for Linux, macOS and FreeBSD dropped an event when
the previous event for the same handler had the same path and the same
type ('rename' or 'change') and was at most 1 ms old. A removal and a
creation of one name are both 'rename', so a listener received one
event for the two. A listener that handled the removal and waited for
the entry to come back never heard of the creation. An attribute change
and a write are both 'change' and were dropped the same way, and so was
a write that came at most 1 ms after the listener ran.

ChangeEvent, should_emit and emit_unsuppressed are removed.

The rule also hid that the reader thread reads each record at once. The
kernel merges a record into an identical one only while that one is
unread. Node reads on the loop thread, so the records of one burst stay
unread while JS runs, and merge.

The reader now makes that merge itself. For each handler it keeps the
last record it posted. PathWatcher::emit_record drops a record that is
identical to it while the listener has not received that event.
FSWatcher publishes the number of the newest event of a batch before
the first listener call of the batch, so a listener that runs after a
dropped record sees the change of that record. A record is identical
when the kernel would merge it: same mask, cookie and path for inotify,
same path and kind of event for kqueue.
@robobun robobun changed the title fs.watch: deliver every event; merge a record only into an event the listener has not received fs.watch: deliver every event, and merge a record only into an event the listener has not received Sep 26, 2026
@robobun
robobun force-pushed the robobun/5db8d916/fs-watch-deliver-every-event branch from 0f59aea to 9f3bcab Compare September 26, 2026 00:22

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline nit, I checked the new delivered/seq handshake for a lost change: a record is dropped only when the reader's SeqCst load of delivered precedes the JS thread's store in FSWatchTaskPosix::run, and that store precedes the listener call, so every dropped record is followed by a listener run that starts after its change; tasks reach the JS thread in enqueue order, so delivered cannot regress. I also confirmed the remaining emit callers (the Linux IN_IGNORED path and the Darwin FSEvents dispatch) all sit outside the FreeBSD cfg, so the new not(any(windows, freebsd)) gate leaves no dangling call there.

Extended reasoning...

The change replaces the 1 ms per-handler dedup in the POSIX fs.watch backend with a per-handler tail record merged only while the listener has not yet received the previous identical event, threading a seq through the task entries and an AtomicU64 on FSWatcher; it touches no security-sensitive surface. The one inline finding is a stale SAFETY comment on the FreeBSD kqueue path; the concurrency invariant and the cfg gating were examined and hold, but the cross-thread merge logic and the unrun FreeBSD/macOS arms still warrant a human look.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/runtime/node/path_watcher.rs — nit: maintainers reading the FreeBSD kqueue reader get a SAFETY comment that names a function this PR removed from that target. path_watcher.rs:1638 still says "Shared access only — emit takes &self", but emit is now cfg'd out on FreeBSD (path_watcher.rs:272) and the call at path_watcher.rs:1664 is emit_record. Fix: re-verify and update the SAFETY text to name emit_record, matching the sibling comments the PR did update at path_watcher.rs:1001, 1130 and 1207.

    Why this was flagged

    The diff changes the FreeBSD kqueue dispatch at src/runtime/node/path_watcher.rs:1664 from watcher.emit(...) to watcher.emit_record(record, ...) and gates emit with #[cfg(not(any(windows, target_os = "freebsd")))] at src/runtime/node/path_watcher.rs:272, so emit does not exist in the FreeBSD build. The SAFETY comment justifying the unsafe { &*entry.watcher } deref at src/runtime/node/path_watcher.rs:1636-1639 still reads "Shared access only — emit takes &self." The PR updated the equivalent inotify-side SAFETY comments (src/runtime/node/path_watcher.rs:1001, 1130, 1207) to name emit/emit_record correctly but missed this one. No runtime effect; the base branch's comment was accurate because emit was the callee there. A correct fix names emit_record (which takes &self) so the stated invariant matches the code it covers.

    Verification: nit. Triggering condition: anyone reading the FreeBSD kqueue reader. Mechanism verified: src/runtime/node/path_watcher.rs:1636-1639 still reads "SAFETY: entry.watcher live under manager.mutex; ... Shared access only — emit takes &self." while the diff changed the call it guards (path_watcher.rs:1664) from watcher.emit(event_type, rel, entry.is_file) to `watcher.emit_record(record,… | nit.…

@robobun

robobun commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in a8371c1. The SAFETY comment of the kqueue reader now names emit_record. The commit changes comments only.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

I moved this PR back to draft. A self-review of head a8371c1 found two defects, and a rework is in progress.

  • FSWatchTaskPosix::run stores delivered with store, which is correct only when the batches of one watcher run in the order of their posts. The batches of a watcher that a macro created can run in the other order. delivered then goes down, and a later record merges into an event that the listener already received. That change gets no event.
  • Under a recursive watch, a file that rename() moves into a new directory is reported twice in about half of the runs. The walk of the new directory posts its entry with the record of an IN_CREATE, and the entry's own record is an IN_MOVED_TO, so the two do not merge.

The rework replaces the event numbers with one atomic flag, which does not depend on the order of the batches, and it adds a test for each case.

…ts own record

The watcher thread sets `FSWatcher.tail_pending` when it posts an event, and
the JS thread clears it before it calls a listener. A record merges into the
last event of a listener only while the flag is set. This does not depend on
the order in which the batches of one watcher run.

The key of a record is the inotify mask and the path, which is what the kernel
compares. The cookie is not part of it.

The walk of a new directory posts each entry as `Record::Found`, which merges
with the entry's own IN_CREATE or IN_MOVED_TO.
@robobun
robobun marked this pull request as ready for review September 27, 2026 04:13
@robobun

robobun commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author

The rework is pushed (a015f45) and the PR is ready for review again. The description has the new design and the new measurements.

@cirospaciari @dylan-conway This PR needs a decision from a maintainer before it can merge. There are three questions.

  1. The rule. fs: port Node.js v26.3.0 fs tests and fix the gaps they surface — cp error semantics, watcher event delivery, watch ignore+AbortSignal, FileHandle pull/writer, glob port, opendir/Dir, mkdtempDisposable, rmdir-recursive end-of-life, mock.fn (+119 tests) #31830, fs.watch(Linux): deliver both rename self-events and the basename when the watched path is deleted #32962 and fs.watch: report watcher queue overflow as ('change', null) #33110 kept the 1 ms rule and made exceptions to it. This PR removes the rule on Linux, macOS and FreeBSD. On Linux and FreeBSD a record then merges only into an identical event that the listener has not received. Do you accept this replacement?
  2. Windows. Remove libuv on Windows #42819 keeps should_emit in the shared PathWatcher::emit, and its Windows reader thread calls that function. This PR removes should_emit from that function. Which of the two PRs lands first, and which rule does Windows get?
  3. fs.watch: run nextTicks and microtasks between the events of one batch #43715. Until it lands, the merge makes a loop on await once(watcher, "change") miss a change that main reports (16 to 23 of 100 runs of one pattern, Node 0). Can fs.watch: run nextTicks and microtasks between the events of one batch #43715 land before this PR?

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/runtime/node/path_watcher.rs
Comment thread test/js/node/watch/fs.watch.test.ts
…eliver every event

A record merges into an event that the listener has not received. A listener
that `once` puts back in a continuation gets the second event of a batch only
when the continuation runs between the two events, which #43715 does.

`FSWatchTaskPosix::run` clears `tail_pending` where it takes a posted batch
over, before `deliver_one` calls a listener.
@robobun

robobun commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author

Both findings of the review of a015f45 are addressed in 0c1fcb0.

The once loop (red). The finding is correct, and I measured it: the loop never heard of the file in 16 to 23 of 100 runs. The merge is safe only when each event reaches a listener that a continuation put back. #43715 gives each event a task of its own, which does that. The branch of #43715 is now merged into this branch (bec5bed), so this PR is safe whichever of the two lands first. FSWatchTaskPosix::run clears the flag where it takes a posted batch over, before deliver_one calls a listener.

I did not take the other way, a merge only into the first event of a batch. The event of the walk of a new directory is never the first of its batch, so the double report of a file in a new directory comes back.

The writer (yellow). Done. The test reads the output of the child from the start, and the end of the child rejects the wait of the round with the exit code and stderr in the message.

@cirospaciari @dylan-conway This answers question 3 of my earlier comment: the order of #43715 and this PR is free now. Questions 1 and 2 are open.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

@robobun wake up!!

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun wake up!!

@robobun

robobun commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

I am here. The state of this PR:

@Jarred-Sumner The PR needs two decisions from a maintainer:

  1. The rule. fs: port Node.js v26.3.0 fs tests and fix the gaps they surface — cp error semantics, watcher event delivery, watch ignore+AbortSignal, FileHandle pull/writer, glob port, opendir/Dir, mkdtempDisposable, rmdir-recursive end-of-life, mock.fn (+119 tests) #31830, fs.watch(Linux): deliver both rename self-events and the basename when the watched path is deleted #32962 and fs.watch: report watcher queue overflow as ('change', null) #33110 kept the 1 ms rule and made exceptions to it. This PR removes it on Linux, macOS and FreeBSD. On Linux and FreeBSD a record then merges only into an identical event that the listener has not received. Is this replacement accepted?
  2. Windows. Remove libuv on Windows #42819 and fs.watch/fs.watchFile: remove unsafe from the watcher modules #40200 keep should_emit in PathWatcher::emit, and the Windows reader thread of Remove libuv on Windows #42819 calls that function. This PR removes should_emit from it. Which PR lands first, and which rule does Windows get?

The branch holds the commits of #43715, because the merge is safe only when each event has a task of its own. If #43715 lands first, the diff of this PR gets smaller.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

steipete added a commit to openclaw/bun that referenced this pull request Oct 2, 2026
Five synchronous writes on Darwin currently yield two or three file-watch callbacks because a background kqueue reader consumes events while JavaScript is still writing. Node 24 consumes the vnode event on the owning loop and delivers one callback.

Poll Darwin files through the owning JavaScript loop with EVFILT_VNODE/EV_ONESHOT and rearm after delivery, matching libuv. The watcher owns and closes its descriptor; its existing lifetime and ref/unref machinery remain responsible for JavaScript delivery. FSEvents directory watches and the Linux/FreeBSD backends retain their paths. The old Darwin file-reader route is removed.

Upstream search considered oven-sh#44008. Its pending-delivery coalescing approach still races with a background reader, so this implementation follows Node 24's deps/uv/src/unix/kqueue.c instead. The two burst regressions cover writes alone and writes followed by unlink, including Node's change-before-rename precedence.

Validation: both standalone burst repros and both new tests fail on the verified b368 Darwin release and pass on the AWS-cross-built Darwin candidate. Seven focused watch/GC regressions pass. A standalone lifecycle check passes change, rename-over, unlink, repeated rearm, 500 open/close cycles with no descriptor growth, and unref exit. Scoped-clean P2 Codex branch review. The full Linux watch suite passes (66 passed, 12 skipped), and all 12 cross-target Rust checks pass. Exact-head CI results are linked by the checks below.

Local macOS 27's relative-directory watch timeout also reproduces on untouched b368; clean macOS CI supplies directory/recursive coverage. No tests are skipped or weakened here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants