Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions src/Watcher.zig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ cwd: string,
thread: std.Thread = undefined,
running: bool = true,
close_descriptors: bool = false,
/// When true, threadMain skips its cleanup (watchlist.deinit + destroy).
/// Used when the owner (e.g. PathWatcherManager) takes responsibility for
/// destroying the Watcher after the thread exits.
skip_thread_cleanup: bool = false,

evict_list: [max_eviction_count]WatchItemIndex = undefined,
evict_list_i: WatchItemIndex = 0,
Expand Down Expand Up @@ -125,6 +129,7 @@ pub fn deinit(this: *Watcher, close_descriptors: bool) void {
fd.close();
}
}
WatcherTrace.deinit();
this.watchlist.deinit(this.allocator);
const allocator = this.allocator;
allocator.destroy(this);
Expand Down Expand Up @@ -241,20 +246,25 @@ fn threadMain(this: *Watcher) !void {
.result => {},
}

// deinit and close descriptors if needed
if (this.close_descriptors) {
const fds = this.watchlist.items(.fd);
for (fds) |fd| {
fd.close();
// If the owner has taken responsibility for cleanup (e.g.
// PathWatcherManager after an onError), skip freeing here to
// avoid UAF — the Watcher is still referenced by the manager.
if (!this.skip_thread_cleanup) {
// deinit and close descriptors if needed
if (this.close_descriptors) {
const fds = this.watchlist.items(.fd);
for (fds) |fd| {
fd.close();
}
}
}
this.watchlist.deinit(this.allocator);
this.watchlist.deinit(this.allocator);

// Close trace file if open
WatcherTrace.deinit();
// Close trace file if open
WatcherTrace.deinit();

const allocator = this.allocator;
allocator.destroy(this);
const allocator = this.allocator;
allocator.destroy(this);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

pub fn flushEvictions(this: *Watcher) void {
Expand Down
118 changes: 90 additions & 28 deletions src/bun.js/node/path_watcher.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,17 @@ pub const PathWatcherManager = struct {
}

fn unrefPendingTask(this: *PathWatcherManager) void {
// deinit() may destroy(this). Defer it until after unlock so we don't
// unlock() a freed mutex.
var should_deinit = false;
defer if (should_deinit) this.deinit();

this.mutex.lock();
defer this.mutex.unlock();
this.pending_tasks -= 1;
if (this.deinit_on_last_task and this.pending_tasks == 0) {
if (this.pending_tasks == 0) {
this.has_pending_tasks.store(false, .release);
this.deinit();
if (this.deinit_on_last_task) should_deinit = true;
}
}

Expand Down Expand Up @@ -299,6 +304,7 @@ pub const PathWatcherManager = struct {
this: *PathWatcherManager,
err: bun.sys.Error,
) void {
var needs_deinit = false;
{
this.mutex.lock();
defer this.mutex.unlock();
Expand All @@ -314,14 +320,39 @@ pub const PathWatcherManager = struct {
}
}

// we need a new manager at this point
// Tell the Watcher thread to skip its own cleanup (lines 244-257
// in threadMain). PathWatcherManager still holds main_watcher and
// uses it until deinit(), so threadMain must not free it.
this.main_watcher.skip_thread_cleanup = true;

// Schedule deferred cleanup: watchers will unregister after
// receiving their error events, triggering deinit when the
// last one closes. If no watchers exist, we must deinit now.
if (this.watcher_count == 0) {
needs_deinit = true;
} else {
this.deinit_on_last_watcher = true;
}
}

// Acquire default_manager_mutex AFTER releasing this.mutex to maintain
// consistent lock ordering (default_manager_mutex before this.mutex).
// deinit() acquires default_manager_mutex first; holding this.mutex
// while acquiring default_manager_mutex would invert that order.
{
default_manager_mutex.lock();
defer default_manager_mutex.unlock();
default_manager = null;
if (default_manager == this) {
default_manager = null;
}
}

// deinit manager when all watchers are closed
this.deinit();
// If no watchers were registered, the deferred cleanup won't
// trigger. Clean up manager resources directly, but skip
// main_watcher.deinit() since threadMain owns that.
if (needs_deinit) {
this.deinit();
}
Comment thread
robobun marked this conversation as resolved.
}

pub const DirectoryRegisterTask = struct {
Expand Down Expand Up @@ -449,8 +480,13 @@ pub const PathWatcherManager = struct {

{
watcher.mutex.lock();
defer watcher.mutex.unlock();
watcher.file_paths.append(bun.default_allocator, child_path.path) catch |err| {
const append_result = watcher.file_paths.append(bun.default_allocator, child_path.path);
watcher.mutex.unlock();
// On error, drop the ref we took in _fdFromAbsolutePathZ. Must do
// this AFTER releasing watcher.mutex: _decrementPathRef acquires
// manager.mutex, and unregisterWatcher acquires manager.mutex before
// watcher.mutex — inverting here would AB/BA deadlock.
append_result catch |err| {
manager._decrementPathRef(entry_path_z);
return switch (err) {
error.OutOfMemory => .{ .err = .{
Expand Down Expand Up @@ -604,17 +640,22 @@ pub const PathWatcherManager = struct {
this._decrementPathRefNoLock(file_path);
}

// unregister is always called form main thread
// unregister is always called from main thread
fn unregisterWatcher(this: *PathWatcherManager, watcher: *PathWatcher) void {
// Must defer deinit() to AFTER releasing this.mutex, for two reasons:
// 1. deinit() re-acquires this.mutex when hasPendingTasks() is true.
// The mutex is non-recursive, so calling deinit() while holding
// the lock self-deadlocks.
// 2. deinit() may destroy(this). Unlocking a freed mutex is UAF.
// Zig defers fire LIFO, so registering this defer before the lock/unlock
// pair makes it fire last (after unlock).
var should_deinit = false;
defer if (should_deinit) this.deinit();

this.mutex.lock();
defer this.mutex.unlock();

var watchers = this.watchers.slice();
defer {
if (this.deinit_on_last_watcher and this.watcher_count == 0) {
this.deinit();
}
}

for (watchers, 0..) |w, i| {
if (w) |item| {
Expand Down Expand Up @@ -644,6 +685,8 @@ pub const PathWatcherManager = struct {
}
}
Comment thread
robobun marked this conversation as resolved.
}

should_deinit = this.deinit_on_last_watcher and this.watcher_count == 0;
}

fn deinit(this: *PathWatcherManager) void {
Expand All @@ -654,21 +697,29 @@ pub const PathWatcherManager = struct {
default_manager = null;
}

// only deinit if no watchers are registered
if (this.watcher_count > 0) {
// wait last watcher to close
this.deinit_on_last_watcher = true;
return;
}

if (this.hasPendingTasks()) {
// Check watcher_count and pending_tasks under one lock to avoid TOCTOU
{
this.mutex.lock();
defer this.mutex.unlock();
// deinit when all tasks are done
this.deinit_on_last_task = true;
return;
if (this.watcher_count > 0) {
this.deinit_on_last_watcher = true;
this.mutex.unlock();
return;
}
if (this.pending_tasks > 0) {
// deinit when all tasks are done
this.deinit_on_last_task = true;
this.mutex.unlock();
return;
}
this.mutex.unlock();
Comment thread
robobun marked this conversation as resolved.
}

// When skip_thread_cleanup is true (error path), threadMain is still
// running but will skip its own cleanup. Join to ensure it has exited
// before we free the Watcher.
if (this.main_watcher.skip_thread_cleanup) {
this.main_watcher.thread.join();
}
Comment thread
robobun marked this conversation as resolved.
this.main_watcher.deinit(false);
Comment on lines +723 to 734

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.

🔴 The self-join guard in deinit() (lines 729-730) is broken: threadMain sets watchloop_handle = null (Watcher.zig:240) BEFORE calling onError (line 243), so when onError calls deinit() with watcher_count == 0, the guard reads null, watcher_tid == null short-circuits to true, and thread.join() executes from the watcher thread itself — a deterministic self-join (POSIX EDEADLK / Zig unreachable panic). Furthermore, even if the self-join guard were fixed to skip the join, line 734 unconditionally calls main_watcher.deinit(false) which frees the Watcher struct (watchloop_handle is null → else branch at Watcher.zig:125 → allocator.destroy), and control returns to threadMain line 252 which reads this.skip_thread_cleanup from freed memory — UAF. Fix: save the thread ID before clearing watchloop_handle, and when deinit detects it is on the watcher thread, skip both thread.join() AND main_watcher.deinit(false), letting threadMain handle its own cleanup.

Extended reasoning...

The Self-Join Deadlock

The self-join guard at lines 728-732 attempts to detect whether deinit() is being called from the watcher thread by reading this.main_watcher.watchloop_handle. However, threadMain (Watcher.zig:240) sets this.watchloop_handle = null before calling this.onError() at line 243. When onError calls deinit() (which happens when watcher_count == 0 at line 331), watchloop_handle has already been cleared.

The guard condition at line 730 is: if (watcher_tid == null or watcher_tid.? != std.Thread.getCurrentId()). Since watcher_tid is null, the or short-circuits to true, and the code enters the if-block and calls this.main_watcher.thread.join() at line 731 — from the watcher thread itself. POSIX pthread_join on the calling thread returns EDEADLK; Zig std.Thread.join() treats this as unreachable, causing a panic in safe builds or undefined behavior in release builds.

Step-by-step proof of the self-join

  1. threadMain (Watcher.zig:240): this.watchloop_handle = null
  2. threadMain (Watcher.zig:243): calls this.onError(this.ctx, err)
  3. onError (path_watcher.zig:326): sets this.main_watcher.skip_thread_cleanup = true
  4. onError (line 331): watcher_count == 0needs_deinit = true
  5. onError (line 354): calls this.deinit()
  6. deinit (line 728): skip_thread_cleanup is true → enters block
  7. deinit (line 729): watcher_tid = watchloop_handle = null
  8. deinit (line 730): null == null evaluates to true via short-circuit → enters if-body
  9. deinit (line 731): this.main_watcher.thread.join()SELF-JOIN DEADLOCK

This is deterministic, not a race condition — it triggers whenever all JS fs.watch() handles are already closed when the OS watcher reports a system error.

The UAF (even if the self-join were fixed)

Even if the self-join guard were correctly fixed to skip the join when called from the watcher thread, line 734 unconditionally calls this.main_watcher.deinit(false). Since watchloop_handle was set to null at Watcher.zig:240, Watcher.deinit(false) takes the else branch (Watcher.zig:125-136): it calls this.watchlist.deinit(this.allocator) at line 133 freeing the watchlist, then allocator.destroy(this) at line 135 freeing the entire Watcher struct.

Control then returns through deinit()onError()threadMain(). At Watcher.zig:252, threadMain reads this.skip_thread_cleanup from the freed Watcher struct — a use-after-free. In the current code this UAF is masked by the self-join panic/deadlock occurring first at line 731, but fixing the self-join without fixing this would expose the UAF.

Impact

The self-join is a deterministic crash/deadlock on the error path. The UAF is a memory safety violation that would be exposed by any correct fix of the self-join. Both must be fixed together.

Suggested Fix

Save the thread ID in a separate field before clearing watchloop_handle, or use std.Thread.getCurrentId() compared against thread.getHandle() instead of relying on watchloop_handle. When deinit detects it is on the watcher thread, skip both thread.join() and main_watcher.deinit(false), letting threadMain handle its own Watcher cleanup after onError returns (the if (!this.skip_thread_cleanup) block at Watcher.zig:252 would need to be inverted to perform cleanup when on the error path).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified: threadMain sets watchloop_handle = null (line 241) before calling onError (line 244). When watcher_count == 0, onError calls deinit() where the null watchloop_handle short-circuits the self-join guard → thread.join() from the watcher thread itself = EDEADLK. Briefing fixer.


if (this.watcher_count > 0) {
Expand Down Expand Up @@ -714,6 +765,9 @@ pub const PathWatcher = struct {
resolved_path: ?string = null,
has_pending_directories: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
closed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// Guards against double-deinit from the TOCTOU race between
/// deinit() (main thread) and unrefPendingDirectory() (work pool).
deinit_started: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
pub const ChangeEvent = struct {
hash: Watcher.HashType = 0,
event_type: EventType = .change,
Expand Down Expand Up @@ -824,12 +878,18 @@ pub const PathWatcher = struct {
}

pub fn unrefPendingDirectory(this: *PathWatcher) void {
// deinit() calls setClosed() which re-locks this.mutex, and may then
// proceed to destroy(this). Defer it until after unlock so we don't
// self-deadlock or unlock() a freed mutex.
var should_deinit = false;
defer if (should_deinit) this.deinit();

this.mutex.lock();
defer this.mutex.unlock();
this.pending_directories -= 1;
if (this.isClosed() and this.pending_directories == 0) {
if (this.pending_directories == 0) {
this.has_pending_directories.store(false, .release);
this.deinit();
if (this.isClosed()) should_deinit = true;
}
Comment thread
robobun marked this conversation as resolved.
}

Expand Down Expand Up @@ -874,9 +934,11 @@ pub const PathWatcher = struct {
}

pub fn deinit(this: *PathWatcher) void {
if (this.deinit_started.swap(true, .acq_rel)) return;
this.setClosed();
if (this.hasPendingDirectories()) {
// will be freed on last directory
this.deinit_started.store(false, .release);
return;
Comment on lines +948 to 953

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.

🟡 The deinit_started.store(false) reset at line 952 creates a narrow TOCTOU window where neither thread performs cleanup, causing a permanent PathWatcher leak. If Thread A reads hasPendingDirectories() as true (line 950) and Thread B completes unrefPendingDirectory (decrementing to 0, seeing closed=true) and enters deferred deinit() before Thread A reaches line 952, Thread B's swap returns true and bails, then Thread A resets deinit_started to false and returns — no future trigger exists to free the PathWatcher. Fix: keep deinit_started true in the early-return path and have unrefPendingDirectory check deinit_started directly instead of calling deinit().

Extended reasoning...

What the bug is

The deinit_started atomic guard in PathWatcher.deinit() (lines 948-953) has a residual TOCTOU race between the atomic swap and the reset that causes a permanent PathWatcher resource leak. The deinit_started.store(false, .release) at line 952 is intended to allow unrefPendingDirectory to later trigger cleanup when the last pending directory completes, but there is a window where both threads bail without cleaning up.

The specific race condition

The race occurs between hasPendingDirectories() returning true (line 950) and deinit_started.store(false) (line 952):

  1. Thread A (main thread calling PathWatcher.deinit()): deinit_started.swap(true) returns false at line 948 → proceeds. Calls setClosed() (line 949). Reads hasPendingDirectories() → returns true (line 950).
  2. Thread B (work pool in unrefPendingDirectory): Acquires this.mutex (line 898). Decrements pending_directories to 0 (line 900). Stores has_pending_directories = false (line 902). Reads isClosed() → returns true (from step 1). Sets should_deinit = true. Releases mutex. Deferred this.deinit() fires.
  3. Thread B in deinit(): deinit_started.swap(true) returns true (set by Thread A in step 1) → returns immediately at line 948.
  4. Thread A continues: Executes deinit_started.store(false) (line 952) → resets the flag. Returns (line 953).

Why neither thread performs cleanup

After this interleaving:

  • Thread A gave up because hasPendingDirectories() was true (step 1)
  • Thread B gave up because deinit_started was true (step 3)
  • Thread A reset deinit_started to false (step 4)

The final state is: pending_directories == 0, closed == true, deinit_started == false. No future trigger exists — unrefPendingDirectory will never be called again (pending_directories is already 0), and no other code path calls deinit() on this PathWatcher. The PathWatcher struct, its file_paths, and associated allocations are permanently leaked.

Timing analysis

The window is narrow: Thread B must execute steps 2-3 (the entire unrefPendingDirectory critical section plus the deferred deinit() entry including the atomic swap) between Thread A's lines 950 and 952, which are essentially consecutive instructions (branch + store). On a multi-core system, Thread B runs concurrently on another core and only needs to complete ~15 instructions (mutex lock/decrement/store/check/unlock/function call/swap) during Thread A's ~3 instructions (branch evaluation + store + return). While unlikely per invocation, it is theoretically triggerable under load, especially on ARM with weaker memory ordering.

Impact

The consequence is a resource leak only — the PathWatcher struct plus associated allocations (file_paths, etc.) are never freed. This is not a crash, UAF, or security issue. The trigger scenario (main thread calling deinit() racing with the very last unrefPendingDirectory completing on the work pool) is itself uncommon, and the timing window within that scenario is extremely tight.

How to fix

Instead of resetting deinit_started to false in the hasPendingDirectories() early-return path, keep it true. Then modify unrefPendingDirectory to check deinit_started directly (similar to how deinit_on_last_task/deinit_on_last_watcher work elsewhere in this file). When unrefPendingDirectory sees pending_directories == 0 and deinit_started == true, it performs the cleanup directly rather than calling deinit() which would bail on the swap. Alternatively, perform the hasPendingDirectories() check under the mutex to coordinate with unrefPendingDirectory atomically.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid finding — narrow TOCTOU window where neither thread frees the PathWatcher. Low severity (leak only, not crash), but should be fixed. Briefing fixer.

}

Expand Down
72 changes: 72 additions & 0 deletions test/js/node/watch/fs.watch.deadlock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";

describe("fs.watch deadlock", () => {
test("rapid create/close of recursive watchers should not hang", async () => {
Comment thread
robobun marked this conversation as resolved.
// Create a deep directory tree to ensure DirectoryRegisterTask runs on
// the work pool, increasing the chance of the close() racing with it.
using dir = tempDir("watch-deadlock", {
"a/b/c/d/e/f1.txt": "x",
"a/b/c/d/e/f2.txt": "x",
"a/b/c/d/f3.txt": "x",
"a/b/c/f4.txt": "x",
"a/b/f5.txt": "x",
"g/h/i/j/f6.txt": "x",
"g/h/i/f7.txt": "x",
"g/h/f8.txt": "x",
"k/l/m/f9.txt": "x",
"k/l/f10.txt": "x",
});

// Spawn a subprocess that rapidly creates and closes recursive watchers.
// Before the fix, this could deadlock when:
// 1. unregisterWatcher() called deinit() while holding this.mutex
// (deinit re-acquires the same non-recursive mutex → self-deadlock)
// 2. unrefPendingTask() called deinit() while holding mutex → UAF
// 3. processWatcher held watcher.mutex while calling _decrementPathRef
// which acquires manager.mutex → AB/BA with unregisterWatcher
//
// The subprocess has a 10s timeout: if it hangs, it's a deadlock.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const fs = require("fs");
const dir = process.argv[1];

// Timeout: if we hang for 10s, it's a deadlock
const timer = setTimeout(() => {
process.stderr.write("DEADLOCK: hung for 10 seconds\\n");
process.exit(1);
}, 10000);
timer.unref();

let done = 0;
const total = 50;

for (let i = 0; i < total; i++) {
// Stagger slightly to increase thread interleaving
const w = fs.watch(dir, { recursive: true }, () => {});
// Close immediately — racing with directory scanning on worker thread
w.close();
done++;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// If we reach here without deadlocking, success
console.log("OK " + done);
`,
String(dir),
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stderr.replace(/^WARNING: ASAN.*\n?/gm, "")).toBe("");
expect(stdout).toStartWith("OK");
expect(exitCode).toBe(0);
});
});
Loading