Skip to content

Replace chokidar with Bun's native fs.watch and delete the compensation subsystem #37

Closed

Description

@talkasab

Summary

Replace chokidar with Bun's built-in fs.watch(root, { recursive: true }) and delete the ~250 lines of workaround machinery that exist only to survive chokidar defects. This removes peruse's only runtime dependency.

Research date 2026-09-22, Bun 1.4.2, Linux 6.8. The full research report (web findings with URLs, empirical harness results, feasibility table, recommendation) is attached at the bottom of this issue under Full report.

What we know

The July 2026 rationale for chokidar is void

  • docs/design-history.md chose chokidar because Bun's fs.watch had "known event-reliability gaps" (oven-sh/bun discussion 1571) and said "revisit if Bun's watcher matures".
  • Bun 1.3.14 (2026-05-13) rewrote the POSIX watcher to talk to inotify / FSEvents / kqueue directly; Bun 1.4 (2026-08-20) moved it to Rust (src/runtime/node/path_watcher.rs). Fixed in that line: recursive pickup of directories created after watch(), delete-and-recreate going inert, atomic-save (temp + rename) reporting only the temp name, delete/recreate, dead-after-close-then-rewatch, EACCES crash. Bun 1.4 adds an error event for unwatchable subtrees (ENOSPC/EACCES) and emits ('change', null) on inotify queue overflow.
  • Discussion 1571's last complaint predates the rewrite by 19 months; the specific event-dropping issues (#36328, #39070, #7919, #42570, #15939) are closed as completed.

Measured on this machine (Bun 1.4.2, chokidar 4.0.3)

Mixed burst, 201 dirs / 4,001 files, 1,940 operations across 10 categories (create, modify, atomic rename-over, delete, new dir, file in new dir immediate and delayed, deep nested dir, symlink to dir, recursive rmdir):

Bun fs.watch chokidar 4.0.3
operations missed 0 / 1,940 0 / 1,940
inotify watches after start 202 4,203
extra file descriptors 0 0
readiness none needed 219 ms

Scale and edge cases:

Probe Bun fs.watch chokidar 4.0.3
real peruse repo (606 dirs, 7,774 files incl. node_modules and .git): watches / startup 606 / 15 ms 8,380 / 356 ms
4,000-dir, 20,000-file tree: watches / startup 4,041 / 85 ms 24,150 / 1,237 ms
109 writes raced against startup 109 caught 109 caught
30,000 writes during a blocked event loop 100% 100%
.git HEAD / refs / index events, relative paths yes yes
scan-breaking ENOTDIR symlink (issue #17) full coverage, no stall ready never fires, directory silently unwatched (#17 reproduced)
directory replaced via rename() caught missed entirely
timestamp-preserving in-place rewrite (documented blind spot in ARCHITECTURE) caught caught
cyclic symlink a/b/loop -> root survives survives
12 concurrent watchers 1 inotify instance, 0 extra fds same
writes after close() 0 events, no leak same

Corrections to our own documentation

  • server/index.js:589 and ARCHITECTURE say chokidar "holds fds per watched file under Bun". On Linux this is wrong. chokidar registers one inotify watch per file, but all watches live inside one inotify instance costing one fd: 8,380 watches, 0 extra fds on the real repo. The per-entry fd cost is a macOS kqueue property, which the comments at server/index.js:516 and :556 describe correctly. Consequence: on Linux the fd-derived watch budget (PERUSE_WATCH_BUDGET, ⅛ of ulimit -n) governs the wrong limit; the binding resource is fs.inotify.max_user_watches.
  • The Directories containing a dangling symlink silently lose live updates #17 stall (ENOTDIR link leaves ready pending forever and silently unwatches a directory) is a chokidar bug. Bun's watcher is immune.

Bun fs.watch semantics we rely on (pin each with a test)

Known risks and their guards

  1. No install-time ignore. A recursive watch registers node_modules and .git/objects. On this repo that is 606 watches instead of ~16, trivial; on a 30k-directory JS monorepo against a stock max_user_watches of 8192 it is exhaustion. Guard: handle the ENOSPC error event with a message naming fs.inotify.max_user_watches. Escape hatch if that proves insufficient is not chokidar but our own traversal calling fs.watch(dir, { recursive: false }) on admitted directories (~120–180 lines, full ignore control, still no dependency). Do not build pre-emptively; measure first.
  2. Queue overflow untested here (host queue is 64x default). Guard: treat a null/undefined filename as "invalidate the whole tree" (cheap: SSE clients already re-fetch the tree on any event) and add a regression test on a stock-configured machine.
  3. Active churn. Four fs.watch PRs opened in Bun the week of 2026-09-17 (#43064, #43083, #43090, #43715). Guard: add an engines floor (bun >= 1.4.0, for the error event semantics) and pin each relied-upon behavior with a test, proven RED first per project practice.
  4. macOS unverified. Bun's rewrite moved macOS to FSEvents-only, which should remove the per-path fd cost behind the fd-exhaustion saga. Guard: run the scratch harnesses (harness.js, edges.js, stress.js, attached in the report's artifact list) unchanged on a Mac before release.
  5. ENOSPC could not be reproduced here (user namespaces blocked). Guard: same macOS/stock-Linux verification pass.

Not recommended: keeping chokidar (20x kernel watches, keeps the dependency and ~250 lines of workaround, still carries the #17 stall and the rename-swap miss) or a hybrid (doubles watch count and failure surface; the fallback machinery compensates for chokidar, and without chokidar there is nothing to compensate for).

How we execute

Per CLAUDE.md the plan lives here, not in a repo document. Implementation and review by different agents/sessions; every regression test proven RED against the chokidar implementation before it is trusted.

Phase 1: pin the contract with tests (RED first)

  • engines: { "bun": ">=1.4.0" } in package.json; startup check in bin/peruse.js with a clear message.
  • Integration tests for: recursive pickup of a directory created after watch; file inside it immediately and delayed; atomic rename-over reports the target; .git/HEAD, refs, index events set git: true and are not forwarded as file events; .git/objects filtered; gitignored-dir events filtered via skipRel; undefined filename ⇒ whole-tree invalidation; error event with ENOSPC ⇒ rate-limited log naming fs.inotify.max_user_watches, never fatal; clean close() with zero events after; the Directories containing a dangling symlink silently lose live updates #17 ENOTDIR link shape keeps full coverage of siblings.
  • Decide which existing tests in test/unit/scanbreaking.test.js and test/integration/watch-recovery.test.js become "Bun handles this" tests and which are deleted with the subsystem.

Phase 2: swap the watcher

  • fs.watch(root, { recursive: true, ignore }) per project; ready resolves synchronously after the call while preserving the RuntimeClosedError contract and the "first project request waits for readiness" behavior (now trivially satisfied).
  • Move skipRel from chokidar's ignored into the event callback (plus Bun's ignore predicate for cheap early filtering).
  • Keep the ~200 ms coalescing batches and {changed, git} payload unchanged.
  • error handler with the ENOSPC-specific message.

Phase 3: delete the compensation subsystem

  • SURVIVABLE_LINK_ERRORS, findScanBreakingLinks (~server/index.js:434–497).
  • fallbackWatchers, fallbackSnapshots, reconcileTimers, verifyTimers, snapshotDirectory, dropFallback, scheduleVerification, reconcileFallback, scheduleReconcile, installFallback, recoverSubtree (~:597–612, :647–762).
  • Stall watchdog and heartbeat (~:818–845).
  • watchable / softFd gate, WATCH_BUDGET admission set, PERUSE_WATCH_BUDGET, startServer({ watchBudget }): remove, since the recursive walk cannot be capped and the fd limit is not the binding resource on Linux. Re-evaluate the ulimit re-exec in bin/peruse.js after the macOS run.
  • Remove chokidar from dependencies; bun.lock update; dependencies becomes empty.

Phase 4: platform verification

Phase 5: documentation, same commits as the code

  • ARCHITECTURE.md: dependency line (no runtime dependencies), watcher section rewritten (resource model, ignore semantics, ENOSPC, undefined-filename invalidation, symlink policy), delete the recovery/watchdog/budget sections and the "known watcher limitation" paragraphs, fix the fd-per-file claim.
  • CHANGELOG [Unreleased]: Changed (no runtime dependencies; live updates no longer stall on broken symlinks; catches directory swaps and in-place rewrites), Removed (PERUSE_WATCH_BUDGET), and the Bun ≥ 1.4 requirement. Decide at release whether the engines floor makes this a major.
  • docs/DEVLOG.md dated entry with the measurements and the doc correction.
  • README: remove any chokidar mention, add the Bun version requirement.
  • docs/design-history.md stays historical (do not edit).

Related

Full report (research agent, 2026-09-22; scratch artifacts referenced at the end are not checked in)

Can peruse drop chokidar for Bun's fs.watch?

Research date: 2026-09-22. Machine: Linux 6.8.0-139-generic x86_64, Bun 1.4.2,
chokidar 4.0.3 (from /home/talkasab/peruse/node_modules, read-only).
Nothing inside /home/talkasab/peruse was modified; the repo was only watched
(read-only) during test E5.

Bottom line: yes — drop chokidar. Every documented reason peruse chose
chokidar in July 2026 has been retired by Bun's fs.watch rewrite in v1.3.14
(2026-05-13)
, and the single failure that motivated peruse's largest piece of
watcher machinery (issue #17, the symlink scan stall) is a chokidar bug that
Bun's watcher does not have. I reproduced that stall against chokidar on this
machine today and confirmed Bun is immune. Caveats and risks are in §5.


1. Machine caveat (read this before trusting the numbers)

This host has non-default inotify sysctls:

sysctl this machine common kernel/distro default
fs.inotify.max_user_watches 1048576 8192 (kernel) / 65536–524288 (distros)
fs.inotify.max_user_instances 1048576 128
fs.inotify.max_queued_events 1048576 16384

ulimit -n soft and hard are both 1048576.

Two consequences: the ENOSPC (watch-limit exhaustion) path could not be
exercised, and the queue-overflow test (§3, S2) is weaker than it looks because
the kernel queue here is 64x the usual size. I tried to run a probe under
unshare -Ur with fs.inotify.max_user_watches=16; user-namespace creation is
blocked on this host (unshare: write failed /proc/self/uid_map: Operation not permitted), so that probe is untested, for both watchers equally.


2. Web research

2.1 The rewrite: Bun v1.3.14, 2026-05-13

https://bun.com/blog/bun-v1.3.14

"Bun's fs.watch() implementation on POSIX platforms has been completely
rewritten to talk directly to the OS file-watching APIs (inotify, FSEvents,
kqueue) instead of routing through Bun's internal bundler watcher."

Specific items from that release:

  • Recursive new-directory pickup fixed. Previously "directories created
    after the watch started were never tracked, so files inside them were
    invisible to the watcher." This was the headline recursive: true defect on
    Linux and it is fixed.
  • Delete-and-recreate fixed. "deleted-and-recreated files emit change
    events again … the new inotify watch descriptor is correctly registered on
    recreation."
  • macOS. "uses FSEvents exclusively for both files and directories, matching
    libuv's behavior and halving the thread overhead." macOS no longer spins up
    two watcher threads.

Directly relevant to peruse's history: the fd-exhaustion saga in
docs/DEVLOG.md:1400 ("under Bun each watched path holds fds (files too, ~2–3
each), stock macOS shells give 256") is a kqueue/macOS phenomenon. kqueue
requires one open descriptor per watched entry; FSEvents does not. Bun's move to
FSEvents-only on macOS removes that cost for Bun's own watcher.

2.2 recursive: true on Linux

Supported and emulated by Bun (inotify has no kernel recursion). Bun walks the
tree and adds one inotify watch per directory, including directories created
later. Confirmed empirically in §3 (E1, S1, R1–R3). The filename argument
arrives as a path relative to the watched root — the old basename-vs-relative
inconsistency was oven-sh/bun#29677, closed as
completed 2026-07-24. Every one of the ~2,700 distinct paths I observed came
back relative and correctly spelled, including .git/objects/f7/tmp_obj_YPgasB.

2.3 Event reliability: what is fixed, what is still open

Closed as completed (all in the last three months):

Issue Title Closed
#42570 a previously-watched directory path stays inert after delete+recreate (Linux) 2026-09-20
#36328 fs.watch (Linux): atomic saves never report the rename destination 2026-08-10
#39070 fs.watch on macOS reports only one file per ~40 ms window, so bulk changes are mostly lost 2026-08-15
#29677 recursive: true: inconsistent filename (basename vs relative path) 2026-07-24
#7919 fs.watch skips save events 2026-07-24

#36328 and #39070 are exactly the "drops events" reputation. Both fixed.
I re-ran the #42570 repro in all four shapes (pre-watched/not ×
recursive/not) on 1.4.2: all four caught the post-recreate write (§3, R1–R3).

Still open, Linux-relevant:

Issue Title Impact on peruse
#43065 recursive on Linux: no event for a change behind a symlink entry pointing outside the tree (regression in 1.3.14) This is peruse's desired behavior. peruse sets followSymlinks:false. Upstream considers it a bug; a fix would start delivering events peruse currently suppresses. Watch item, not a blocker.
#43066 an event about the watched directory itself differs from Node (event type, filename for trailing slash, recursive root) For a recursive root, Bun emits change with filename === undefined where Node emits nothing. peruse must null-guard the filename. Confirmed empirically.
#6955 fs.watch goes to infinite loop with big files Open since 2023-11, last touched 2026-08-04. Did not reproduce in any test here.
#14568 watch doesn't trigger on file writes on macOS Open since 2024-10, last touched 2026-08-04, i.e. before the 1.3.14 rewrite landed in that file. Probably stale; unverifiable on Linux.
#42936 Windows watcher: a ReadDirectoryChangesW buffer overflow drops the changes and nothing reloads Windows only. Fix in PR #42951 resyncs every watched path after overflow. The report notes "the inotify backend has the same gap for IN_Q_OVERFLOW" — see §5 risk 2.

Discussion oven-sh/bun#1571 (the citation in
docs/design-history.md:91) is resolved: the maintainer committed to a
built-in watch API in Dec 2022, implemented Jan 2024. Its most recent substantive
comment is from October 2024 — a user seeing fewer events than chokidar on
~350 Angular build outputs. That complaint predates the 1.3.14 rewrite by 19
months and is superseded by the closed issues above. The design-history
citation is now stale.

2.4 Bun 1.4 behavior change

From the 1.4 breaking-changes list,
oven-sh/bun#28792 (closed, 2026-04-02):

"recursive fs.watch() emits 'error' for subdirectories it cannot watch
instead of swallowing the failure"
(PR #36415)

This is a real gain for peruse: a subtree that cannot be watched (EACCES,
and presumably ENOSPC) now surfaces instead of vanishing. Verified: an
unreadable subdirectory produced EPERM EACCES: permission denied, watch '…/locked' on the watcher's error event while sibling coverage kept working.
Compare with the silent chokidar failure mode that peruse's issue #17
machinery exists to paper over.

2.5 Active churn (this matters)

Four fs.watch pull requests opened in the week before this research:

  • #43064 report a watched symlink under its own name on Linux
  • #43083 report a directory's attribute change as rename on Linux
  • #43090 watch symlink entries of a recursive tree on Linux — open as of 2026-09-17. Description: "The walk now reports symlink entries … Every event on that watch is reported as a rename of the link itself, like Node. The walk never descends into a link target, so a link to an ancestor cannot loop." Targets must be regular files or directories; others (e.g. /dev/stdout) are skipped.
  • #43715 run nextTicks and microtasks between the events of one batch

#43090 would change what peruse sees for symlinks: link entries would start
reporting, though still without descending. Since peruse coalesces and re-fetches
the tree, extra events are harmless; the no-descent guarantee is what matters and
the PR preserves it explicitly.

2.6 chokidar on Bun

No chokidar-specific Bun breakage found, and none observed here. chokidar 4.0.3
runs on Bun 1.4.2 and delegates to node:fs.watch, so the two share a backend.
That is why both show one inotify instance in §3 — the difference is entirely
in how many paths each one registers.


3. Empirical results

Scripts (all in the scratchpad): harness.js (burst), edges.js (edge cases),
stress.js (scale/queue/git), recreate.js + rewatch.js (#42570 shapes),
errs.js (error surfaces). Both watchers ran the identical workload;
chokidar with { ignoreInitial: true, followSymlinks: false }, matching
server/index.js:787.

3.1 Coverage burst — 200 dirs x 20 files, 1,940 mixed operations

Category ops Bun exact hits chokidar exact hits
A create file in existing dir 400 400 400
B append to existing file 400 400 400
C atomic rename-over (temp + rename) 300 300 300
D delete existing file 300 300 300
E1 mkdir after watcher start 100 100 100
E2 file in new dir, no delay 300 300 300
F1 deep new dir (deepNN/a/b/c) 50 50 50
F2 file in deep new dir, +150 ms 50 50 50
G symlink to a directory 20 20 20
H rm -rf a populated directory 20 20 20
Total 1940 1940 (100%) 1940 (100%)

No misses and no parent-only attributions on either side. Nothing was scored by
event type, only by whether the changed path was named — which is all peruse's
noteChange needs.

Metric Bun fs.watch chokidar 4.0.3
readiness signal none needed (see 3.2) ready at 219 ms
burst wall time 377 ms 431 ms
raw events delivered 4,335 2,510
event types rename 2887, change 1448 add 770, change 700, unlink 720, addDir 300, unlinkDir 20
distinct paths reported 2,710 2,410
fds held 9 total, +0 9 total, +0
inotify instances 1 1
inotify watches after start 202 (= 201 dirs + root) 4,203 (= 4,001 files + 201 dirs + root)
inotify watches after burst 482 4,513

Bun holds one inotify watch per DIRECTORY. chokidar holds one per FILE and per
directory — 20.8x more on this tree.
Bun is chattier per event (~2.2 raw
events per operation vs 1.3) because it does not classify; peruse coalesces into
200 ms batches anyway, so that costs nothing.

Correction to ARCHITECTURE.md: the note that "chokidar holds fds per
watched file under Bun" no longer describes Linux. Under Bun 1.4.2, 4,203
chokidar watches cost zero additional file descriptors — one shared inotify
instance with 4,203 watch descriptors. The fd-per-path cost is macOS/kqueue, as
server/index.js:538 already says in its own comment ("Crucial on macOS, where
each watched directory costs a file descriptor (kqueue, default ulimit 256)").
The peruse watch budget on Linux is therefore governing the wrong resource: the
binding limit is fs.inotify.max_user_watches, not ulimit -n.

3.2 Edge cases

Probe Bun fs.watch chokidar 4.0.3
E1 readiness race — write into 20 deep dirs N ms after the watch call, no ready wait 0 ms: 20/20; 1 ms: 20/20; 5 ms: 20/20; 25/100/400 ms: 20/20 0 ms: 0/20; 1 ms: 0/20; 5 ms: 0/20; 25 ms: 20/20; 100/400 ms: 20/20
E2 symlink policy — pre-existing link to a dir inside the root and to a dir outside it 3 watches installed; nothing reported under either link; the real path reported correctly; no descent 4 watches; reports the link entries themselves (linkin, linkout) but does not descend
E3 scan-breaking link (peruse issue #17) — symlink(root/regular.txt/sub, poisoned/bad), i.e. a path running through a regular file (ENOTDIR) readyMs n/a, watch call returned in 1 ms; healthy sibling seen: yes; poisoned dir's sibling seen: yes; no errors ready never fired (timed out at 8 s); poisoned/p.txt never reported; no error event. Issue #17 reproduced exactly.
E4 cyclic symlink — a/b/loop -> root starts in 0 ms, 3 watches, survives, catches the write starts in 4 ms, 4 watches, survives
E5 real peruse repo (606 dirs, 7,774 files, incl. node_modules and .git) ready in 15 ms, 606 inotify watches, +0 fds, +1 instance, 0 spurious events while idle ready in 356 ms, 8,380 inotify watches, +0 fds, +1 instance, 8 spurious events while idle
E5b release on close 0 watches, 0 fds remaining 0 watches, 0 fds remaining
E6 deleted watched root emits rename:sub then rename:<null filename>; close() clean, no throw, process survives emits unlinkDir:sub; close() clean
E7 events after close 50 writes after close() → 0 events, no leak 0 events, no leak
E8 20,000 writes in one burst (3.2 s / 2.4 s) 20,000/20,000 (100%), 40,000 events 20,000/20,000 (100%), 20,000 events
E9 12 concurrent watchers 1 shared inotify instance, +0 fds, 252 watches, all isolated and correct identical: 1 instance, +0 fds, 252 watches

3.3 Scale and semantics

Probe Bun fs.watch chokidar 4.0.3
S1 large tree — 4,000 dirs / 20,000 files; write into 109 scattered dirs immediately after the call returns (no ready wait for Bun; after ready for chokidar) start returns in 85 ms; 109/109 caught; 4,041 watches; +1 fd ready at 1,237 ms; 109/109; 24,150 watches; +1 fd
S2 blocked JS thread — external bash process writes 30,000 files while this process spins for 6 s 30,000/30,000 (100%), 60,000 events 30,000/30,000 (100%)
S3 .git metadata — git checkout -b, edit, add, commit under the watcher .git/HEAD, .git/HEAD.lock, refs/heads/feature, logs/HEAD, .git/index, index.lock, packed-refs.lock, AUTO_MERGE.lock, .git/objects/**, plus the working file — 21 .git paths same coverage, 14 .git paths
S4 in-place rewrite with timestamps restored (same dev:ino:mode:size:mtimeNs) detected (change:same.txt) detected (change:same.txt)
S5 ENOSPC under a tiny watch limit not testable (unshare denied) not testable

S4 is worth calling out: ARCHITECTURE.md:266 documents this shape as an
accepted blind spot of the snapshot-reconciliation fallback. inotify's IN_MODIFY
does not consult mtime, so a plain recursive fs.watch catches it. Dropping the
snapshot fallback would remove that documented limitation rather than inherit it.

3.4 Delete/recreate and directory churn

Probe Bun chokidar
R1 delete sub/, recreate at the same path, write inside sees recreated dir, sub/deep/b.txt, sub/c.txt same
R2 real git branch churn: a tracked directory removed and restored across three git checkouts, then edited sees the post-churn edit and the new file same
R3 directory swapped by rename() (live→old, staging→live), then write into the swapped-in dir caught live/y.txt MISSED — no events at all
rewatch (exact #42570 shape) watch → close → rm → mkdir → watch again → write; all four combinations of pre-watched × recursive 4/4 caught n/a

R3 is a chokidar defect Bun does not share, and it is a realistic shape
(blue-green output directories, mv dist.new dist).

3.5 Error surfaces (Bun 1.4.2)

Situation Behavior
fs.watch on a missing directory throws synchronously, code === "ENOENT"
unreadable (mode 000) subdirectory inside a recursive watch error event EPERM EACCES: permission denied, watch '…/locked'; not fatal; siblings keep reporting
attribute change on the recursive root itself change with filename === undefined (issue #43066) — callers must null-guard
close() synchronous, releases every watch descriptor, no post-close events

4. Would Bun's fs.watch alone satisfy peruse?

Against the requirement list:

peruse requirement Bun fs.watch
per-project lazy watcher ✅ one call per project; 12 concurrent watchers cost 1 inotify instance and 0 extra fds
~200 ms coalescing ✅ peruse already owns this; Bun's extra chattiness is absorbed
never follow symlinks ✅ free today — no descent, no followSymlinks option needed. Risk: PR #43090 / issue #43065 may add link-entry reporting (still no descent)
.git metadata events (HEAD, refs, index) ✅ all present, with relative paths
ignore gitignored dirs ⚠️ the one real gap — no ignored option. Events can be filtered in the callback (peruse's skipRel already does), but the watches are installed regardless
bounded resource usage ⚠️ per-directory, not per-file (20x better than chokidar), but uncappable — you cannot tell the recursive walk to stop
readiness before first response ✅ empirically unnecessary — 109/109 raced writes caught on a 4,000-dir tree with zero delay. Resolve ready synchronously after the call returns
clean close ✅ no leaks, no post-close events
survive poisoned symlinks (#17) ✅ immune — this is a chokidar bug
skip sockets/FIFOs/devices ✅ inotify watches the directory, not the special file; PR #43090 explicitly skips non-regular link targets

What peruse would reimplement, and how much code

Added (roughly 25–40 lines):

  1. Event-level ignore filtering — skipRel already exists (server/index.js:618);
    it just moves from chokidar's ignored callback into the fs.watch callback.
    Near-zero new code.
  2. Null-filename guard for the recursive-root event (issue #43066). One line.
  3. ready resolves right after the fs.watch call, with the existing
    RuntimeClosedError contract preserved. A few lines.
  4. error handler: same rate-limited logging as today, plus an ENOSPC-specific
    message telling the user to raise fs.inotify.max_user_watches. ~15 lines.

Deleted (roughly 220–280 lines of server/index.js):

  • SURVIVABLE_LINK_ERRORS and findScanBreakingLinks (~lines 434–497).
  • The whole recovery subsystem: fallbackWatchers, fallbackSnapshots,
    reconcileTimers, verifyTimers, snapshotDirectory, dropFallback,
    scheduleVerification, reconcileFallback, scheduleReconcile,
    installFallback, recoverSubtree (~lines 597–612 and 647–762).
  • The stall watchdog and its heartbeat plumbing (~lines 818–845).
  • The watchable / softFd gate and the WATCH_BUDGET admission set, or a
    rewrite of the budget in terms of inotify watches rather than fds.
  • Plausibly bin/peruse.js's ulimit re-exec, on Linux at least.

Net: peruse gets smaller, loses its only runtime dependency, and deletes the
subsystem that has produced the most incident history in the dev log — all of
which is squarely the project's "minimum code, maximum leverage" principle.


5. Recommendation: drop chokidar, with three guards

Confidence is high on Linux, medium on macOS (untestable here).

Evidence for:

  1. The founding rationale is void. Discussion 1571 is resolved; its last
    complaint predates the 1.3.14 rewrite by 19 months; the specific
    event-dropping issues (#36328, #39070, #7919) are closed as completed. Both
    watchers scored 1,940/1,940 and 20,000/20,000 here.
  2. peruse's worst bug is a chokidar bug. Issue Directories containing a dangling symlink silently lose live updates #17 — the ENOTDIR link that
    leaves ready pending forever and silently unwatches a directory — reproduced
    against chokidar on this machine today (E3) and did not reproduce against Bun.
    ~250 lines of peruse exist only to survive it.
  3. 20x cheaper kernel resources. 202 vs 4,203 watches on the synthetic tree;
    606 vs 8,380 on the real repo; 4,041 vs 24,150 on the 4,000-dir tree.
  4. 14x faster startup on the real repo (15 ms vs 356 ms) and no readiness
    race to wait out — the first project request stops blocking on a scan.
  5. Strictly better on two real shapes: the rename-swapped directory (R3,
    chokidar missed it entirely) and the timestamp-preserving in-place rewrite
    that ARCHITECTURE.md documents as a known blind spot.
  6. Failures are now loud, not silent (Bun 1.4's error events for
    unwatchable subtrees).

Risks and the guards for them:

  1. No install-time ignore (the only structural regression). A recursive watch
    registers node_modules and .git/objects whether or not peruse wants them.
    On this repo that is 606 watches instead of 16 — a 38x amplification that is
    still absolutely trivial. On a large JS monorepo (30k+ directories under
    node_modules) against a stock max_user_watches of 8192, it is exhaustion.
    Guard: handle the ENOSPC error event with a message naming
    fs.inotify.max_user_watches. If that proves insufficient, the escape hatch
    is not chokidar — it is peruse's own traversal calling
    fs.watch(dir, { recursive: false }) on admitted directories only
    (~120–180 lines, per-directory cost, full ignore control, still no
    dependency). Do not build that pre-emptively; measure first.
  2. Queue overflow is unproven. IN_Q_OVERFLOW handling could not be tested
    because this kernel's queue is 64x the default, and issue #42936 states the
    inotify backend shares the Windows gap (where a fix landed via PR #42951).
    Guard: a regression test on a stock-configured machine, and treat a
    <null>/undefined filename as "invalidate the whole tree" — which is cheap,
    because peruse's SSE clients already re-fetch the tree on any event.
  3. The area is churning right now. Four fs.watch PRs opened in the week
    before this research, one of which (#43090) changes symlink-entry reporting.
    Guard: declare a Bun floor. package.json currently has no engines field;
    add >=1.3.14 at minimum, preferably >=1.4.0 for the error-event
    behavior, and pin each relied-upon behavior with a test — the project's own
    practice of proving regression tests RED first applies here.
  4. macOS is unverified. Bun's rewrite moved macOS to FSEvents-only, which
    should remove the fd-per-path cost that caused peruse's fd-exhaustion saga.
    But chokidar-on-Bun watches per file, so on macOS it may now be worse than
    the measurements above suggest, not better. Guard: run harness.js and
    edges.js unchanged on a Mac before release; they need no modification.

Not recommended: keep chokidar (pays 20x the kernel watches, keeps the
dependency, keeps ~250 lines of workaround, and still carries the E3 stall and
the R3 rename-swap miss), or a hybrid that runs both (doubles the watch
count and the failure surface for no benefit — the fallback machinery exists to
compensate for chokidar, and without chokidar there is nothing to compensate for).


URLs

Scratchpad artifacts

harness.js, edges.js, stress.js, recreate.js, rewatch.js, errs.js,
out-bun-burst.json, out-chokidar-burst.json, out-edges-bun.json,
out-edges-chokidar.json, out-stress-bun.json, out-stress-chokidar.json,
out-recreate-bun.json, and the matching *.log files, all under
/tmp/claude-1001/-home-talkasab-peruse/e3fafb89-3d10-4a60-b3c0-d1fe62ffe04a/scratchpad/.

Activity

  1. added a commit that references this issue on Oct 4, 2026
  2. talkasab commented on Oct 4, 2026

    @talkasab
    OwnerAuthor

    Landed on dev in 5fff4a0; will ship in the next release (see [Unreleased] in CHANGELOG.md).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions