Skip to content

Conversation

@saranganet
Copy link

@saranganet saranganet commented Nov 23, 2025

prevent resource leak

When a shutdown notification is received from the IOCP (nbytes == 0), the Windows watcher was not closing handles immediately, leading to resource leaks. This fix:

  • Adds handle tracking with handles_closed flag
  • Implements idempotent closeHandles() function
  • Closes handles immediately when shutdown notification is received
  • Makes stop() function use the idempotent cleanup
  • Prevents double-close errors by marking handles as INVALID_HANDLE_VALUE

This ensures proper resource cleanup and prevents handle leaks on Windows.

…revent resource leak

When a shutdown notification is received from the IOCP (nbytes == 0),
the Windows watcher was not closing handles immediately, leading to
resource leaks. This fix:

- Adds handle tracking with handles_closed flag
- Implements idempotent closeHandles() function
- Closes handles immediately when shutdown notification is received
- Makes stop() function use the idempotent cleanup
- Prevents double-close errors by marking handles as INVALID_HANDLE_VALUE

This ensures proper resource cleanup and prevents handle leaks on Windows.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 23, 2025

Walkthrough

Adds a handles_closed: bool field and a private closeHandles function to WindowsWatcher to perform idempotent closure of directory and IOCP handles. Shutdown logic now calls closeHandles immediately instead of leaving handles open.

Changes

Cohort / File(s) Summary
Handle closure tracking and centralized cleanup
src/watcher/WindowsWatcher.zig
Adds handles_closed: bool = false to WindowsWatcher. Introduces private fn closeHandles(this: *WindowsWatcher) void to idempotently close directory and IOCP handles, update state, and log actions. Replaces direct handle cleanup in shutdown/stop with a call to closeHandles.

Suggested reviewers

  • RiskyMH
  • cirospaciari

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the problem, the solution approach, and key implementation details. However, it lacks verification steps as required by the template's 'How did you verify your code works?' section. Add a 'How did you verify your code works?' section describing testing methods used to confirm the fix prevents resource leaks.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: closing handles immediately on shutdown notification to prevent resource leaks on Windows, which aligns with the changeset's primary purpose.

📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 8672e51 and ead4527.

📒 Files selected for processing (1)
  • src/watcher/WindowsWatcher.zig (3 hunks)
🔇 Additional comments (1)
src/watcher/WindowsWatcher.zig (1)

224-226: Good centralization of cleanup in stop

Switching stop() to delegate to closeHandles() is a nice simplification and ensures all shutdown paths share the same idempotent cleanup logic.


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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between ddcec61 and 8672e51.

📒 Files selected for processing (1)
  • src/watcher/WindowsWatcher.zig (3 hunks)
🔇 Additional comments (1)
src/watcher/WindowsWatcher.zig (1)

225-225: stop() now correctly reuses the centralized, idempotent cleanup

Switching stop() to delegate to closeHandles() is a nice simplification and ensures all shutdown paths share the same idempotent logic and INVALID_HANDLE_VALUE marking.

Comment on lines +10 to +11
/// Track if handles have been closed to prevent double-close
handles_closed: bool = false,
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Reset handles_closed in init to avoid leaks on reuse

The flag is great for preventing double-closes, but if a WindowsWatcher instance is ever re‑initialized (calling init again after stop/shutdown), handles_closed will still be true, so closeHandles() becomes a no‑op and new handles will leak.

Consider explicitly clearing it at the start of init:

 pub fn init(this: *WindowsWatcher, root: []const u8) !void {
+    this.handles_closed = false;
+
     var pathbuf: bun.WPathBuffer = undefined;
     const wpath = bun.strings.toNTPath(&pathbuf, root);

This is a cheap change and makes the lifecycle more robust even if reuse is rare.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/watcher/WindowsWatcher.zig around lines 10-11, the boolean flag
handles_closed is never reset when an instance is re-initialized, so after
stop/shutdown handles_closed remains true and subsequent init calls will skip
closing old handles causing leaks; modify the init function to explicitly set
self.handles_closed = false at the start of initialization (before allocating or
opening any handles) so the lifecycle can be reused safely, and ensure any
concurrent access is still correctly synchronized if applicable.

Comment on lines 177 to 181
if (nbytes == 0) {
// shutdown notification
// TODO close handles?
// shutdown notification - close handles immediately to prevent resource leaks
log("shutdown notification in WindowsWatcher.next", .{});
this.closeHandles();
return .{ .err = .{
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Good: close on shutdown; optionally short‑circuit next after ESHUTDOWN

Closing handles on the nbytes == 0 “shutdown” completion is exactly what was missing and matches the intent.

One small hardening you might consider: if next is accidentally called again after this path, it will call GetQueuedCompletionStatus on an iocp that closeHandles() has already set to w.INVALID_HANDLE_VALUE. Windows will fail that call, but you could avoid making the syscall at all by bailing out early when the watcher is already shut down:

 pub fn next(this: *WindowsWatcher, timeout: Timeout) bun.sys.Maybe(?EventIterator) {
+    if (this.handles_closed) {
+        return .{ .err = .{
+            .errno = @intFromEnum(bun.sys.SystemErrno.ESHUTDOWN),
+            .syscall = .watch,
+        } };
+    }
+
     switch (this.watcher.prepare()) {

Not required for correctness given current callers, but it makes the API more self‑defensive.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/watcher/WindowsWatcher.zig around lines 177 to 181, add a defensive
early-return in next so it does not call GetQueuedCompletionStatus after
closeHandles has invalidated the iocp; detect the shutdown state (e.g. check if
this.iocp == w.INVALID_HANDLE_VALUE or a dedicated closed flag) at the top of
next and immediately return the same shutdown error/result used for the nbytes
== 0 path so subsequent calls short-circuit instead of making a syscall on an
invalid handle.

Comment on lines 197 to 222
/// Close handles if they haven't been closed already.
/// This function is idempotent and safe to call multiple times.
///
/// When a shutdown notification is received from the IOCP, we need to close
/// handles immediately to prevent resource leaks. This function ensures handles
/// are properly closed and marked as invalid to prevent double-close errors.
fn closeHandles(this: *WindowsWatcher) void {
if (this.handles_closed) {
return;
}
this.handles_closed = true;

// Close directory handle if it's still valid
if (this.watcher.dirHandle != w.INVALID_HANDLE_VALUE) {
_ = bun.windows.CloseHandle(this.watcher.dirHandle);
this.watcher.dirHandle = w.INVALID_HANDLE_VALUE;
log("closed directory handle", .{});
}

// Close IOCP handle if it's still valid
if (this.iocp != w.INVALID_HANDLE_VALUE) {
_ = bun.windows.CloseHandle(this.iocp);
this.iocp = w.INVALID_HANDLE_VALUE;
log("closed IOCP handle", .{});
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Clarify concurrency assumptions and make closeHandles safe before full init

Centralizing cleanup here looks good and the INVALID_HANDLE_VALUE marking is a nice touch, but two edge cases are worth tightening up:

  1. Concurrency / double‑close race

    handles_closed is a plain bool. If stop() and next() can ever run on different threads without external synchronization, both could observe handles_closed == false and race to call CloseHandle on the same OS handles. That still results in a double‑close at the Win32 level.

    If the invariant is “all access to a given WindowsWatcher (including stop and next) is single‑threaded or externally serialized”, it’d be good to document that. Otherwise, consider guarding closeHandles() with the mutex or using an atomic CAS on the flag.

  2. Uninitialized handle values if closeHandles is called before successful init

    this.iocp and this.watcher.dirHandle are declared as = undefined and only assigned real values inside init. If stop() (and hence closeHandles()) is ever called on a partially‑initialized or failed instance, these comparisons:

if (this.watcher.dirHandle != w.INVALID_HANDLE_VALUE) { ... }
if (this.iocp != w.INVALID_HANDLE_VALUE) { ... }

read potentially uninitialized memory and might call CloseHandle on garbage.

To make closeHandles() safe in more states, you can initialize both handles to w.INVALID_HANDLE_VALUE as their default:

- iocp: w.HANDLE = undefined,
- watcher: DirWatcher = undefined,
+ iocp: w.HANDLE = w.INVALID_HANDLE_VALUE,
+ watcher: DirWatcher = .{ .dirHandle = w.INVALID_HANDLE_VALUE },

and keep the rest of init as‑is. That way any early stop()/closeHandles() call is guaranteed to be a no‑op at the OS level.


I'll now gather the necessary information to verify the review comment by examining the codebase.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find the WindowsWatcher struct definition to see field declarations
rg -n "struct WindowsWatcher|const WindowsWatcher" src/watcher/WindowsWatcher.zig -A 20 | head -80

Length of output: 527


🏁 Script executed:

#!/bin/bash
# Find the init function to see initialization of iocp and dirHandle
rg -n "fn init\(" src/watcher/WindowsWatcher.zig -A 30 | head -100

Length of output: 1205


🏁 Script executed:

#!/bin/bash
# Find stop and next functions to understand call flow
rg -n "fn stop\(|fn next\(" src/watcher/WindowsWatcher.zig -A 10

Length of output: 1382


🏁 Script executed:

#!/bin/bash
# Search for mutex usage and threading patterns
rg -n "mutex|Mutex|lock|Lock|atomic" src/watcher/WindowsWatcher.zig -B 2 -A 2

Length of output: 228


🏁 Script executed:

#!/bin/bash
# Get the full context around closeHandles declaration and how handles are declared
rg -n "iocp:|dirHandle:|handles_closed" src/watcher/WindowsWatcher.zig -B 3 -A 3 | head -100

Length of output: 1121


I need to gather more details about the initialization and synchronization patterns.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find the full init() function to see where iocp and dirHandle are assigned
rg -n "fn init\(" src/watcher/WindowsWatcher.zig -A 60 | grep -E "this\.(iocp|watcher\.dirHandle)|dirHandle =|iocp =" | head -20

Length of output: 175


🏁 Script executed:

#!/bin/bash
# Find DirWatcher struct definition to see if dirHandle has a default
rg -n "struct DirWatcher|const DirWatcher" src/watcher/WindowsWatcher.zig -A 15 | head -40

Length of output: 1057


🏁 Script executed:

#!/bin/bash
# Check the complete init function for error handling and assignment order
sed -n '85,143p' src/watcher/WindowsWatcher.zig

Length of output: 1888


🏁 Script executed:

#!/bin/bash
# Look for where dirHandle is assigned or initialized in init
rg -n "dirHandle\s*=" src/watcher/WindowsWatcher.zig -B 2 -A 2

Length of output: 413


🏁 Script executed:

#!/bin/bash
# Check if mutex is actually used in stop() or next()
sed -n '145,195p' src/watcher/WindowsWatcher.zig

Length of output: 1954


🏁 Script executed:

#!/bin/bash
# Check if there's any mutex lock/unlock calls around stop or closeHandles
rg -n "lock\(|unlock\(|with_lock|protect" src/watcher/WindowsWatcher.zig -B 3 -A 3

Length of output: 0


Address concurrency race on handle closure and guard against uninitialized handle reads

Both concerns in the review are confirmed:

  1. Concurrency race is real: The mutex field exists but is never used—no lock(), unlock(), or with_lock() calls found anywhere in the file. Both stop() (line 224) and next() (line 177) can call closeHandles() without synchronization. If they run on different threads, both could observe handles_closed == false and race to call CloseHandle.

  2. Uninitialized handles are reachable: The init() function can fail at NtCreateFile() (line 117) and return early, leaving this.iocp and this.watcher as undefined. If stop() is then called on the partially-initialized instance, closeHandles() will read undefined memory when comparing this.watcher.dirHandle against w.INVALID_HANDLE_VALUE.

The suggested fix is sound: initialize both iocp and watcher.dirHandle to w.INVALID_HANDLE_VALUE at declaration, making closeHandles() safe at any point in the object's lifetime.

- iocp: w.HANDLE = undefined,
- watcher: DirWatcher = undefined,
+ iocp: w.HANDLE = w.INVALID_HANDLE_VALUE,
+ watcher: DirWatcher = .{ .dirHandle = w.INVALID_HANDLE_VALUE },

For the concurrency issue, either document the single-threaded invariant or protect closeHandles() with the existing mutex.

Committable suggestion skipped: line range outside the PR's diff.

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.

2 participants