Skip to content

watcher: wake the watcher thread on shutdown instead of leaking it - #36417

Closed
Properrr wants to merge 1 commit into
oven-sh:mainfrom
Properrr:claude/watcher-wake-thread-on-shutdown
Closed

Properrr wants to merge 1 commit into
oven-sh:mainfrom
Properrr:claude/watcher-wake-thread-on-shutdown

Conversation

@Properrr

Copy link
Copy Markdown
Contributor

What does this PR do?

Watcher::shutdown published running = false and returned, but the watcher thread only re-checks that flag between waits and was parked indefinitely waiting for filesystem events. For a stopped DevServer none ever arrive, so the thread — and the Box<Watcher> it owns and is responsible for freeing — survived for the life of the process.

Creating and dropping six dev servers left six live File Watcher threads.

Each backend gets an explicit wakeup:

  • Linux: an eventfd, waited on alongside the inotify fd via ppoll. read returns empty when it fires, so watch_loop re-checks running and exits. The eventfd is never drained, so there is no lost-wakeup window and wake is safe to call repeatedly from any thread. If eventfd(2) fails the fd is left invalid and read waits on the inotify fd alone — uninterruptible but still correct, so watcher startup cannot fail on this. wake also releases the watch_count futex, which is where the thread parks when nothing is watched yet.
  • kqueue: EVFILT_USER with NOTE_TRIGGER, registered and fired in one kevent call. The ident cannot collide with a real registration; those use EVFILT_VNODE and key on file descriptors.
  • Windows: PostQueuedCompletionStatus with a null OVERLAPPED, which the existing wait loop already treats as a stop signal. Adds the missing extern declaration to bun_sys::windows::kernel32.

The load-bearing ordering detail: wake is called inside Watcher.mutex, and thread_main acquires the same lock before deallocating. Waking after unlocking would read platform.wake_fd out of freed memory — running = false is already published at that point, so the thread is free to exit and drop the box — and write eight bytes into whatever descriptor number that garbage happened to name. I hit exactly that while developing this.

Note this is deliberately not an epoll change. For a single inotify fd, epoll measures slower than a blocking read (+6.7% draining 8000 events); the reason to wait rather than read is interruptibility, not throughput, so ppoll on two fds is the right primitive.

Why the Fd::INVALID guards are in this diff

Making the thread actually exit reached two close() loops that were previously unreachable. Both closed every watchlist fd unconditionally, but platforms that do not need a descriptor per watch store Fd::INVALID, which bun_sys::close asserts against — this surfaced as panic: assertion failed: fd != Fd::INVALID in thread_main. Both now skip invalid descriptors, matching what flush_evictions already did. They are required by this change rather than a drive-by.

How did you verify your code works?

test/bake/fixtures/deinitialization now asserts no File Watcher thread outlives its dev server, by counting threads by name in /proc/self/task (Linux-only; the check no-ops elsewhere).

Verified it catches the regression: with this change stashed and the binary rebuilt, the test fails — six threads leaked. With it, zero.

Linux (x86_64): test/bake/deinitialization, test/cli/watch, test/cli/hot — 23 pass, 0 fail.

macOS (arm64, 26.5.2): the EVFILT_USER mechanism was confirmed with a standalone probe — a thread blocked in kevent() with no timeout is woken by EVFILT_USER + NOTE_TRIGGER. The thread-count assertion itself is Linux-only, so the kqueue wakeup has no end-to-end test here; that is the weakest part of this PR.

Windows is compile-checked only (cargo check --target x86_64-pc-windows-msvc); I have no Windows host, so CI is the gate for that hunk.

`Watcher::shutdown` published `running = false` and returned, but the watcher
thread only re-checks that flag *between* waits and was parked indefinitely
waiting for filesystem events. For a stopped DevServer none ever arrive, so the
thread — and the `Box<Watcher>` it owns and is responsible for freeing —
survived for the life of the process. Creating and dropping six dev servers left
six live `File Watcher` threads.

Each backend gets an explicit wakeup:

- Linux: an eventfd, waited on alongside the inotify fd via `ppoll`. `read`
  returns empty when it fires, so `watch_loop` re-checks `running` and exits. The
  eventfd is never drained, so there is no lost-wakeup window and `wake` is safe
  to call repeatedly from any thread. If `eventfd(2)` fails the fd is left
  invalid and `read` waits on the inotify fd alone — uninterruptible, but still
  correct, so watcher startup cannot fail on this. `wake` also releases the
  `watch_count` futex, which is where the thread parks when nothing is watched
  yet.
- kqueue: `EVFILT_USER` with `NOTE_TRIGGER`, registered and fired in one `kevent`
  call. The ident cannot collide with a real registration; those use
  `EVFILT_VNODE` and key on file descriptors.
- Windows: `PostQueuedCompletionStatus` with a null `OVERLAPPED`, which the
  existing wait loop already treats as a stop signal. Adds the missing extern
  declaration to `bun_sys::windows::kernel32`.

The load-bearing ordering detail: `wake` is called *inside* `Watcher.mutex` and
`thread_main` acquires the same lock before deallocating. Waking after unlocking
would read `platform.wake_fd` out of freed memory — `running = false` is already
published at that point, so the thread is free to exit and drop the box — and
write eight bytes into whatever descriptor number that garbage happened to name.

Note this is deliberately not an epoll change. For a single inotify fd, epoll
measures *slower* than a blocking read (+6.7% draining 8000 events); the reason
to wait rather than read is interruptibility, not throughput, so `ppoll` on two
fds is the right primitive.

Making the thread actually exit reached two `close()` loops that were previously
unreachable. Both closed every watchlist fd unconditionally, but platforms that
do not need a descriptor per watch store `Fd::INVALID`, which `bun_sys::close`
asserts against — this surfaced as `panic: assertion failed: fd != Fd::INVALID`
in `thread_main`. Both now skip invalid descriptors, matching what
`flush_evictions` already did.

The deinitialization fixture asserts no `File Watcher` thread outlives its dev
server. Verified by stashing this change, rebuilding, and re-running: the test
fails (six threads leaked) without it and passes with it. Linux:
test/bake/deinitialization, test/cli/watch, test/cli/hot — 23 pass, 0 fail.

@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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The watcher shutdown path now wakes blocking platform waits on Linux, BSD/macOS, and Windows, coordinates cleanup under a mutex, closes only valid descriptors, and verifies Linux watcher threads terminate during deinitialization.

Watcher shutdown

Layer / File(s) Summary
Platform wake paths
src/watcher/INotifyWatcher.rs, src/watcher/KEventWatcher.rs, src/watcher/WindowsWatcher.rs, src/sys/windows/mod.rs
Platform-specific wake mechanisms interrupt inotify, kqueue, and IOCP waits.
Shutdown coordination and cleanup
src/watcher/Watcher.rs
Shutdown wakes the platform watcher while holding the mutex, filters invalid descriptors, and synchronizes thread cleanup before deallocation.
Deinitialization thread validation
test/bake/fixtures/deinitialization/test.ts
Linux teardown counts File Watcher threads and waits for all watcher threads to exit.

Possibly related PRs

  • oven-sh/bun#36253: Implements related platform-specific watcher wakeups and shutdown coordination.

Suggested reviewers: robobun

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: waking the watcher thread during shutdown to prevent leaks.
Description check ✅ Passed The description follows the required template with both PR purpose and verification details filled in.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/watcher/WindowsWatcher.rs (1)

382-402: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reset self.iocp and self.watcher.dir_handle in stop().

wake() depends on these fields being INVALID_HANDLE_VALUE once the handles are closed, but stop() only closes them. Since shutdown() can call platform.wake() after thread_main enters the Err branch and calls platform.stop(), this needs the same ownership cleanup pattern as INotifyWatcher::stop() and KEventWatcher::stop(): reset both fields to w::INVALID_HANDLE_VALUE after closing so later wake paths do not use stale closes/handles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/watcher/WindowsWatcher.rs` around lines 382 - 402, Update
WindowsWatcher::stop to set both self.watcher.dir_handle and self.iocp to
w::INVALID_HANDLE_VALUE immediately after closing them, matching the cleanup
pattern used by the other watcher implementations so later wake calls cannot use
stale handles.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/watcher/Watcher.rs`:
- Around line 283-292: Update thread_main so me.platform.stop() executes once
for every non-error lifecycle exit, including graceful wakeups on Linux, macOS,
and Windows. Move the stop call out of the Err-specific match branch and place
it before descriptor cleanup or deallocation, while preserving the existing
error handling and synchronization behavior.

---

Outside diff comments:
In `@src/watcher/WindowsWatcher.rs`:
- Around line 382-402: Update WindowsWatcher::stop to set both
self.watcher.dir_handle and self.iocp to w::INVALID_HANDLE_VALUE immediately
after closing them, matching the cleanup pattern used by the other watcher
implementations so later wake calls cannot use stale handles.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c2bf0670-e299-4027-a0f6-0587e3f723fb

📥 Commits

Reviewing files that changed from the base of the PR and between e61c15e and c033f12.

📒 Files selected for processing (6)
  • src/sys/windows/mod.rs
  • src/watcher/INotifyWatcher.rs
  • src/watcher/KEventWatcher.rs
  • src/watcher/Watcher.rs
  • src/watcher/WindowsWatcher.rs
  • test/bake/fixtures/deinitialization/test.ts

Comment thread src/watcher/Watcher.rs
Comment on lines +283 to +292
// Must be inside the lock. `running = false` is published above, so
// the watcher thread may now exit and free the `Box<Watcher>`;
// `thread_main` takes this same lock before freeing, so holding it
// here is what keeps `me` alive across the wake.
me.platform.wake();
me.mutex.unlock();
} else {
if close_descriptors && me.running.load() {
let fds = me.watchlist.items_fd();
for &fd in fds {
for &fd in fds.iter().filter(|fd| fd.is_valid()) {

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "fn watch_loop_cycle" -A 40 src/watcher/
rg -n "impl Drop for (INotifyWatcher|KEventWatcher|WindowsWatcher|Watcher)" -A 15 src/watcher/
rg -n "platform\.stop\(\)|platform::stop" -n src/watcher/

Repository: oven-sh/bun

Length of output: 9570


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Watcher.rs relevant sections =="
sed -n '240,370p' src/watcher/Watcher.rs | cat -n | sed 's/^/Watcher.rs:/'

echo
echo "== platform trait definitions/usages =="
rg -n "trait .*Platform|struct .*Watcher|enum .*Platform|fn stop|fn wake|fn next|fn read" src/watcher -g '*.rs' | sed -n '1,220p'

echo
echo "== watch_loop cycle dispatch =="
rg -n "watch_loop_cycle|watchloop_handle|is_empty|read\\?|next\\?|Inotify|KEvent|Windows" src/watcher/Watcher.rs src/watcher/INotifyWatcher.rs src/watcher/KEventWatcher.rs src/watcher/WindowsWatcher.rs src/watcher/platform.rs 2>/dev/null || true

Repository: oven-sh/bun

Length of output: 12308


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== KEventWatcher stop/wake =="
sed -n '1,100p' src/watcher/KEventWatcher.rs | cat -n

echo
echo "== INotifyWatcher relevant stop/wake/read/watch_loop =="
sed -n '200,280p' src/watcher/INotifyWatcher.rs | cat -n
sed -n '280,305p' src/watcher/INotifyWatcher.rs | cat -n
sed -n '440,515p' src/watcher/INotifyWatcher.rs | cat -n

echo
echo "== WindowsWatcher stop/wake next relevant =="
sed -n '300,420p' src/watcher/WindowsWatcher.rs | cat -n

Repository: oven-sh/bun

Length of output: 18496


Close platform resources on the graceful shutdown path.

thread_main calls me.platform.stop() only in the Err arm, but the graceful shutdown wake returns Ok(()) on Linux and macOS, so the inotify/eventfd or kqueue fd are never closed by stop(). They only close later via close_descriptors, and the Windows null-OVERLAPPED path reaches stop() only by accident.

Move me.platform.stop() out of the match so it runs once for every non-error lifecycle exit, before descriptor cleanup/deallocation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/watcher/Watcher.rs` around lines 283 - 292, Update thread_main so
me.platform.stop() executes once for every non-error lifecycle exit, including
graceful wakeups on Linux, macOS, and Windows. Move the stop call out of the
Err-specific match branch and place it before descriptor cleanup or
deallocation, while preserving the existing error handling and synchronization
behavior.

Source: Coding guidelines

@Properrr

Copy link
Copy Markdown
Contributor Author

Closing as a duplicate of #36253, which predates this by a day and is a strict superset.

I opened this without checking for existing open PRs on the same subsystem — my mistake. For anyone comparing the two, #36253 is the better change and this adds nothing on top of it:

  • Same eventfd + ppoll approach on Linux, but it also removes the watch_count futex rather than waking it as I did — with nothing watched the inotify fd never becomes readable, so ppoll already blocks correctly and the futex is redundant.
  • On macOS it registers a mach port via the existing io_darwin_create_machport helper, matching the established bun_io::waker::KEventWaker pattern, and keeps EVFILT_USER only for FreeBSD, which has no mach ports. I used EVFILT_USER everywhere.
  • It filters watch_loop_cycle with if event.filter != libc::EVFILT_VNODE. Mine does not, and that is an actual defect here: my wake() posts an EVFILT_USER event with udata == 0, so the wakeup gets passed to watch_event_from_kevent and dispatched as a spurious WatchEvent against watchlist index 0. It is benign in practice — the op is empty and running is already false, so dispatch_file_updates returns early — but it is wrong, and watcher: wake the watcher thread on shutdown so a stopped dev server releases it #36253 handles it properly.
  • It ties the leak to a user-visible symptom (EMFILE while initializing file watcher for development server once fs.inotify.max_user_instances is exhausted) with a repro, which is a much better framing than "a thread lingers".

The Fd::INVALID guards on the two close() loops in Watcher::thread_main / shutdown are also already in #36253, so that fix is not lost either. Worth calling out for reviewers of that PR, since it is easy to miss why they are needed: those loops were unreachable before the thread could exit, and reaching them panics with assertion failed: fd != Fd::INVALID on inotify/Windows, where watchlist entries legitimately store an invalid descriptor.

The one thing here that is not in #36253 is the thread-count assertion in test/bake/fixtures/deinitialization (counts File Watcher threads via /proc/self/task after each dev server is dropped). #36253 has its own test/bake/dev-server-watcher-release.test.ts covering the same property via fd accounting, which is the better signal, so I am not proposing to port it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant