Skip to content

fs.promises.watch: implement as a real async generator - #34718

Open
robobun wants to merge 7 commits into
mainfrom
claude/e3fb6844/fs-promises-watch-async-generator
Open

robobun wants to merge 7 commits into
mainfrom
claude/e3fb6844/fs-promises-watch-async-generator

Conversation

@robobun

@robobun robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • fs.promises.watch() opens its native watcher inside the call (src/js/node/fs.promises.ts:100 on main). An un-iterated watcher keeps the process alive, and nothing can close it. watch("/missing") throws ENOENT from the call, so a try around the for await misses it.
  • The hand-written iterator has one resolver slot (fs.promises.ts:153). A second pending next() replaces it, so the first next() never settles. throw() and Symbol.asyncDispose are missing.

Fix

  • watch() is an async function*, as in Node. The body runs on the first next(): the watcher opens there, and each error rejects that next().
  • The generator queues concurrent next() calls and supplies return(), throw() and Symbol.asyncDispose. A finally closes the watcher on every exit.
  • Verified: test/js/node/watch/fs.watch.test.ts (five new tests, the released bun fails four), plus test/js/node/watch/.

Background

  • An async generator runs no code before its first next(). It serves one request at a time, oldest first. A return() is a request too.
  • Considered a lazy start and a resolver queue in the hand-written iterator. The generator gives both in 42 fewer lines, with Node's semantics.

Downsides

  • Pending next() calls settle oldest first, as in Node. A loop that abandons next() on a timeout and calls it again saw 5 of 5 events on main. It sees 0 of 5 here and on Node v26.3.0, and its return() waits for the next event (an AbortSignal still ends it).
  • Events between watch() and the first next() are lost, as in Node. Argument errors (watch(12)) reject the first next(), not the call.
  • Instructions (release builds, interleaved A/B): open + next() + abort 37,820 to 39,925 (+5.6%), one delivered event +660 (+4.9%). Cycles and wall time are equal.
Notes

Repro for the resolver slot

import { promises as fsp } from "node:fs";
import * as fs from "node:fs";
const dir = fs.mkdtempSync("/tmp/watch-");
const it = fsp.watch(dir)[Symbol.asyncIterator]();
const p1 = it.next(), p2 = it.next();
fs.writeFileSync(dir + "/a", "1");
fs.writeFileSync(dir + "/b", "2");
await Promise.all([p1, p2]);          // main: p1 never settles
console.log(typeof it.throw);         // main: "undefined"

Repro for the eager start

require("fs/promises").watch("/tmp");                      // main: the process never exits
try { for await (const e of require("fs/promises").watch("/missing")) {} }
catch (e) { console.log("caught", e.code); }               // main: ENOENT escapes the try

Other differences from main that follow from the generator

  • return() returns a promise. On main it returns a plain { value, done } object.
  • The object is its own iterator (it[Symbol.asyncIterator]() === it).
  • await using w = fsp.watch(dir) works. On main it throws TypeError: @@asyncDispose and @@dispose must not be undefined or null and leaks the watcher. See the comment below on this PR.

Tests

  • The new tests are in the fs.promises.watch block: iterator shape (return() and throw()), two concurrent next() calls, abort between two yields, a never-iterated watcher in a child process, and ENOENT on the first iteration.
  • The released bun (1.4.3-canary.1) fails four of the five. The abort test passes there too. It guards the generator: after a yield, the body must check the signal again before it reads the queue, or an abort ends the loop with { done: true } and no AbortError.

Numbers

  • The instruction counts come from a release build of this branch merged onto main 6d504dd, compared with that main, on linux x64. One core, 15 rounds, spread 0.05% to 0.54%. A third row: open + one event + return() is +670 (+2.4%). Runs with N events, 2N events, and the top JIT tier off give +604 to +911 per event, so it is a cost per operation. The load of node:fs/promises and fs.promises.readFile do not change. 30,000 events take 2,384 ms against 2,390 ms.
  • 1,000 watchers that nothing iterates, each on its own directory: main holds 1,000 inotify watches, also after every reference is dropped and a full GC. This branch holds 0. Node v26.3.0 holds 0.
  • The numbers were not taken on macOS or Windows.

The timeout-retry consumer

const it = fsp.watch(dir)[Symbol.asyncIterator]();
while (true) {
  const r = await Promise.race([it.next(), timeout(100)]);
  if (r === TIMEOUT) continue;        // abandons the pending next(), then calls next() again
  seen.push(r.value.filename);
}
// meanwhile: 5 x fs.mkdirSync(dir + "/dN"), 300 ms apart (one event each)
runtime events the loop saw (3 runs each)
main (1.4.3-canary.1+367d939d9) 5 of 5
this branch merged onto 367d939 (debug build) 0 of 5
Node v26.3.0 0 of 5
  • On main this loop does not deadlock. The newest next() replaces the resolver slot, so the live call gets each event. The abandoned calls never settle.
  • With first-in first-out settlement the oldest pending call gets each event. Those calls are the abandoned ones, so the live call starves. With two pending calls and one event, main settles the newer call, and this branch and Node settle the older call.
  • Every design that settles the first waiter first has this effect, a resolver queue in the hand-written iterator too. It is the other side of the fix, not of the generator.
  • A loop that keeps its pending promise across timeouts (pending ??= it.next(), race pending, clear it when it settles) sees 5 of 5 on main, on this branch and on Node.

Keeping the wake-up from return()

A plain async generator cannot wake a pending next() from return(). An own return property on the generator object can: it closes the watcher, which queues the close event and wakes the body, and then it calls the prototype return. A userland model of this shape settles the pending next() with { done: true } at once, on bun 1.4.3 and on Node v26.3.0, and the object is still an [object AsyncGenerator]. The cost is one closure and one property per watcher. This PR does not do it, because it departs from Node.


no test proof · iteration 6 · 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

@robobun

robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:23 AM PT - Aug 4th, 2026

❌ @robobun, your commit 2b543e8 has 1 failures in Build #88857 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34718

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

bun-34718 --bun

@coderabbitai

coderabbitai Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

fs.promises.watch now uses an async generator that queues filesystem events, supports concurrent consumers, throws on abort, and centralizes watcher cleanup. Tests cover iterator lifecycle, concurrent next() calls, abort behavior, and unconsumed iterator process lifetime.

Changes

Async watcher behavior

Layer / File(s) Summary
Generator event delivery and cleanup
src/js/node/fs.promises.ts
watch uses an async generator with FIFO event queuing, concurrent-consumer wakeups, abort errors, explicit abort-listener removal, and watcher closure in finally.
Iterator lifecycle and concurrency validation
test/js/node/watch/fs.watch.test.ts
Tests cover return() and throw(), concurrent next() calls, abort after an event, and creating an unconsumed iterator without keeping the process alive.

Possibly related PRs

  • oven-sh/bun#34505: Updates underlying fs.watch and FSWatcher lifecycle and native event/error routing used by fs.promises.watch.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely identifies the main change: implementing fs.promises.watch as a real async generator.
Description check ✅ Passed The description explains the problem, fix, behavior changes, verification, performance impact, compatibility differences, and test status. It covers the required information, although it uses custom h…

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: 2

🤖 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/js/node/fs.promises.ts`:
- Around line 117-124: Remove the unreachable "close" and "error" branches from
the event-processing loop consuming watcher events, leaving it to yield each
filesystem change event. Apply the root-cause listener wiring fix on the watcher
separately as requested by the related comment, rather than preserving dead
checks in this loop.

In `@test/js/node/watch/fs.watch.test.ts`:
- Around line 908-918: Extend the async iterator protocol test around it.throw
to invoke it.throw with a representative error and assert the resulting Promise
follows the expected rejection or closed-generator semantics, while preserving
the existing return() and next() assertions.
🪄 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

Run ID: 3aa195cf-2f0a-4ebc-950c-b4f0b9c4aa23

📥 Commits

Reviewing files that changed from the base of the PR and between 98f6649 and 2bac0c6.

📒 Files selected for processing (2)
  • src/js/node/fs.promises.ts
  • test/js/node/watch/fs.watch.test.ts

Comment thread src/js/node/fs.promises.ts Outdated
Comment thread test/js/node/watch/fs.watch.test.ts
Comment thread src/js/node/fs.promises.ts

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

Caution

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

⚠️ Outside diff range comments (2)
test/js/node/watch/fs.watch.test.ts (1)

947-949: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not discard iterator cleanup failures.

Both cleanup paths catch every return() rejection, so tests can pass while iterator shutdown or finally cleanup is broken. Assert the expected closed result, or the specific expected abort error, instead of using catch(() => {}).

As per coding guidelines, tests must not silently weaken a safety net, and assertions should verify the strongest invariant.

Also applies to: 965-967

🤖 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 `@test/js/node/watch/fs.watch.test.ts` around lines 947 - 949, Update the
iterator cleanup in the finally blocks around ac.abort() and it.return() so
return() rejections are not swallowed. Assert the expected closed iterator
result or the specific expected abort error, preserving validation of shutdown
and finally cleanup behavior in both affected cleanup paths.

Source: Coding guidelines

src/js/node/fs.promises.ts (1)

81-87: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make watch() shutdown wake a blocked idle iterator. return()/throw() can stay pending while the generator sits at await promise, so finally never runs and the watcher can keep the native handle alive. Add a regression that calls return()/throw() after priming the iterator with next() and before any event arrives.

🤖 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/js/node/fs.promises.ts` around lines 81 - 87, The watch iterator’s
shutdown path must wake the promise awaited by the idle generator so return() or
throw() can complete and run cleanup. Update the pendingResolve/wake mechanism
in watch() to settle the blocked await during shutdown, while preserving normal
event delivery, and add regression coverage that primes next() before invoking
both return() and throw() without emitting an event.

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.

Outside diff comments:
In `@src/js/node/fs.promises.ts`:
- Around line 81-87: The watch iterator’s shutdown path must wake the promise
awaited by the idle generator so return() or throw() can complete and run
cleanup. Update the pendingResolve/wake mechanism in watch() to settle the
blocked await during shutdown, while preserving normal event delivery, and add
regression coverage that primes next() before invoking both return() and throw()
without emitting an event.

In `@test/js/node/watch/fs.watch.test.ts`:
- Around line 947-949: Update the iterator cleanup in the finally blocks around
ac.abort() and it.return() so return() rejections are not swallowed. Assert the
expected closed iterator result or the specific expected abort error, preserving
validation of shutdown and finally cleanup behavior in both affected cleanup
paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f4ddd6c1-e39e-4f22-955b-9834b09358e2

📥 Commits

Reviewing files that changed from the base of the PR and between 9e99954 and 23e3ec7.

📒 Files selected for processing (2)
  • src/js/node/fs.promises.ts
  • test/js/node/watch/fs.watch.test.ts

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Re: the two out-of-diff review findings:

it.return().catch(() => {}) in test cleanup (test:947-949, 965-967): removed in c688c43. The concurrent-next cleanup now awaits return() directly; the abort-between-yields test asserts return() resolves {value: undefined, done: true} on a completed generator.

return()/throw() pending while parked at await (src:81-87): this matches Node and the async generator spec. AsyncGenerator.prototype.return queues behind an in-flight await; the request is processed only when the await settles. Verified against Node:

const it = fsp.watch(dir)[Symbol.asyncIterator]();
it.next();            // parks at await (no events)
await it.return();    // hangs on Node too

Node's own fsPromises.watch is an async function* with the same shape and exhibits the same behavior. Making return() wake the blocked await would diverge from Node, so leaving as-is. The practical escape hatch is the one Node documents: pass an AbortSignal and abort it, which does wake the await (via onAbort -> wake()).

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

The abort-between-yields regression I flagged earlier is fixed (23e3ec7) and covered by a new test; the current diff looks correct to me. Deferring to a human because this is a full rewrite of the fs.promises.watch iterator with observable Node-compat behavior shifts (native handle now opens on first next(), validation/ENOENT surface async instead of sync, return() no longer wakes a parked next()) that a maintainer should ratify.

What was reviewed:

  • Traced the flattened loop: signal.aborted is now re-checked after every yield and every await, so abort-during-yield throws AbortError instead of resolving {done:true} via the close sentinel.
  • finally runs on return/throw/error and idempotently closes the watcher + detaches the abort listener; concurrent next() is serialized by the async-generator queue so a single pendingResolve slot is sufficient.
  • Confirmed fs.promises.watch has no other test-suite consumers that depend on the old synchronous-throw timing.
Extended reasoning...

Overview

Rewrites watch() in src/js/node/fs.promises.ts from a hand-rolled {[Symbol.asyncIterator]() { return {next, return} }} object into a real async function*, matching Node's implementation. Adds four tests to test/js/node/watch/fs.watch.test.ts covering iterator shape (return()/throw() return Promises), concurrent next(), abort-while-suspended-at-yield, and never-iterated process exit.

Security risks

None. Pure JS iterator-protocol reshaping over the existing native watcher binding; no new I/O, parsing, or trust boundaries.

Level of scrutiny

Medium. Built-in node:fs/promises module code with subtle async-generator control flow. The first revision had a real regression (my earlier inline finding: aborting between yields resolved {done:true} instead of throwing AbortError because the close sentinel was drained before the abort re-check). That was fixed by flattening the loop to one queue item per iteration so the abort check sits between every yield/await, with a regression test added. I re-traced the fixed flow and it holds.

Other factors

  • All CodeRabbit threads and my prior thread are resolved; the bug-hunting pass on the latest commits found nothing.
  • Grep confirms test/js/node/watch/fs.watch.test.ts is the only test file exercising fs.promises.watch, so the sync→async error-timing shift shouldn't silently break other suites.
  • The intentional behavior changes (lazy native-handle open, ENOENT/validation on first next(), return() queuing behind an in-flight internal await per spec/Node) are defensible Node-compat moves but are user-visible enough that a maintainer should sign off rather than auto-approving.
  • Test cleanup paths look sound: after Promise.all([p1,p2]) the generator is at suspendedYield, so ac.abort(); await it.return() completes without swallowed rejections.

@robobun

robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI on the rebased head (build 88857 at 2b543e8, re-rolled once): test/js/node/watch/fs.watch.test.ts passed on all lanes across both runs. Remaining failures are unrelated to this JS-only change:

  • run-crash-handler.test.ts (Windows PowerShell crash-upload path test, native CLI code)
  • bun-install-patch, fastutf8stream-reopen, zlib-estimated-size-gc, web-crypto, napi (all flaky, passed on retry or alone)

The previous run (88755) had worker-transfer-terminate-stress instead, a documented-intermittent x64-asan JSC assertion. None of these touch fs.promises. Ready for review.

Update: run-crash-handler.test.ts passes on main now (build 119920 at 6d504dd is green). The branch merges onto that main with no conflict. The PR body now lists the behaviour changes against main under Downsides.

@robobun
robobun force-pushed the claude/e3fb6844/fs-promises-watch-async-generator branch from c688c43 to 7e37e67 Compare July 21, 2026 08:55

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

No issues found — the abort-between-yields regression I flagged earlier is fixed (abort re-checked at the top of every loop iteration) and covered by a test. Deferring to a maintainer since this shifts observable Node-compat timing (validation/ENOENT now surface on first next() instead of synchronously).

What was reviewed:

  • Generator control flow: finally closes the watcher and detaches the abort listener on every exit path (return/throw/return()/throw()); closeWatcher() is idempotent.
  • Single pendingResolve slot is safe because async-generator machinery serializes body execution — only one await can be pending.
  • return()-while-parked-at-await hanging matches Node/spec (author verified); close/error sentinel branches are reachable via the internal binding's multiplexed callback.
  • New tests: concurrent next(), return()/throw() shape, abort-at-yield, never-iterated exit, ENOENT-on-first-iteration — all wire failures to reject and clean up in finally.
Extended reasoning...

Overview

Rewrites fs.promises.watch in src/js/node/fs.promises.ts from a hand-rolled {next, return} iterator into a real async function*, matching Node's implementation shape. The generator body opens the native watcher lazily on the first next(), queues events via $createFIFO, re-checks signal?.aborted between every yield/await, and closes the watcher + detaches the abort listener in finally. Adds five tests to test/js/node/watch/fs.watch.test.ts.

Security risks

None. This is iterator-protocol plumbing over an existing native watcher binding; no new input parsing, no auth/crypto, no untrusted data handling beyond what already existed.

Level of scrutiny

Moderate. It's a self-contained rewrite of one function, but it's user-facing Node-compat surface with intentional observable-behavior changes: argument validation and ENOENT now reject the first iteration instead of throwing synchronously from watch(), and an un-iterated watch() no longer pins the event loop. Both match Node, but a maintainer should confirm they want that timing shift (it could break code that relied on the old synchronous throw, even though that reliance was itself a Node-incompat).

Other factors

  • My prior inline finding (abort while suspended at yield completing {done:true} instead of throwing AbortError) was fixed by flattening to one queue item per outer iteration and adding a regression test.
  • CodeRabbit's two comments (dead-branch false positive, it.throw() coverage) were both resolved; robobun's rebuttal on the internal-binding callback contract is correct — I checked src/js/internal/fs/watch.ts uses the same "close"/"error" eventType dispatch.
  • The author verified return() pending behind an in-flight await matches Node and the async-generator spec, so that's not a regression.
  • Test coverage is thorough for the specific protocol fixes; existing fs.promises.watch tests in the file continue to exercise the for-await path. Prior CI run had this test file green on all lanes.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase

robobun added 5 commits August 4, 2026 07:41
The hand-rolled {next, return} iterator had a single nextEventResolve
slot, so a second pending next() overwrote the first's resolver and the
first waiter hung forever. return() returned a plain object instead of a
Promise, and throw() was missing entirely.

Replace it with an async function* like Node does. The generator
machinery queues concurrent next() calls by construction and provides
spec-compliant return()/throw() from %AsyncGeneratorPrototype%.

The native handle is now opened on the first next() (inside the
generator body, like Node), so an fsp.watch() that is never iterated no
longer leaks a watcher that keeps the process alive, and ENOENT is
surfaced on the first next() rather than synchronously.
Resuming from yield landed inside the inner drain loop, skipping the
signal?.aborted check. An abort that fired while suspended at yield would
drain buffered events (including the close sentinel pushed by the native
close) and resolve done:true instead of throwing AbortError.

Flatten to one queue item per outer iteration so the abort check runs
between every yield/await.
@robobun
robobun force-pushed the claude/e3fb6844/fs-promises-watch-async-generator branch from 7e37e67 to bafe978 Compare August 4, 2026 07:46
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (83c7055). One conflict in src/js/node/fs.promises.ts: main had added a watcher.close() call in the old iterator's error branch, which is subsumed by the finally { closeWatcher() } here. All 48 fs.watch.test.ts tests pass locally.

Comment thread src/js/node/fs.promises.ts Outdated
Comment thread src/js/node/fs.promises.ts Outdated
Comment thread src/js/node/fs.promises.ts Outdated
Comment thread src/js/node/fs.promises.ts Outdated

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

LGTM — both earlier findings (abort-between-yields regression, comment-cop flags) are addressed, and the current revision has no new issues.

What was reviewed

  • Generator control flow: abort re-checked between every yield/await; finally closes the watcher and detaches the abort listener on all exits (return/throw/close/error/ENOENT).
  • Pre-aborted signal and ENOENT paths throw before the native handle exists, so nothing leaks; never-iterated watch never opens a handle.
  • Test cleanup paths: it.return() after abort on a suspended generator, and the abort-between-yields test's watcher lifetime on assertion failure — ruled out as a leak concern.
  • CI: fs.watch.test.ts green on all lanes; the one failure (worker-transfer-terminate-stress) is a documented unrelated flake.
Extended reasoning...

Overview

This PR rewrites fs.promises.watch() in src/js/node/fs.promises.ts from a hand-rolled {next, return} iterator into a real async function*, matching Node's implementation shape. Net ~50 lines removed. Five new tests in test/js/node/watch/fs.watch.test.ts cover concurrent next(), return()/throw() shape, abort-while-suspended-at-yield, never-iterated process exit, and ENOENT surfacing on first iteration.

Security risks

None. This is a JS-side async iterator wrapper around the existing native watcher binding. No new syscalls, no path handling changes, no untrusted input parsing beyond what the old code already did (validateAbortSignal, filename coercion). The __proto__: null on yielded events is preserved.

Level of scrutiny

Medium. It's a Node-compat behavioral change (errors now surface on first next() instead of synchronously), but that's the documented intent and matches Node exactly. The function is self-contained; no other callers in the tree depend on the old synchronous-throw shape. The generator's finally block correctly handles every exit path I traced: normal return on 'close', throw on 'error', it.return()/it.throw() from the consumer, ENOENT before the try, and pre-aborted signal before the watcher exists.

Other factors

  • I reviewed this PR twice previously. The first pass found a real regression (abort while suspended at yield returned {done:true} instead of throwing AbortError) — fixed with a flat one-item-per-iteration loop and a dedicated regression test. The second pass flagged comment-cop violations — trimmed. Both threads resolved.
  • CodeRabbit's two findings (dead-code branches, throw() not exercised) were both addressed/refuted; the 'close'/'error' branches are reachable via the internal binding's multiplexed callback contract.
  • The bug-hunting system found nothing this run; one candidate (test cleanup leaking a watcher on assertion failure) was verified and ruled out.
  • Promise.withResolvers() (non-primordial) matches the pre-existing pattern in this file and the removed code.
  • CI build 88755: fs.watch.test.ts passes on all lanes; the sole failure is the known-intermittent worker-transfer-terminate-stress SIGABRT on x64-asan, unrelated to fs.

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

No issues found — the implementation looks correct and all prior review threads are resolved. Deferring only because this is a public-API rewrite with an observable timing change (ENOENT / arg-validation errors now surface on the first next() instead of synchronously from watch()), which a maintainer should sign off on.

What was reviewed:

  • Generator control flow: abort re-checked between every yield/await; finally closes the watcher and detaches the abort listener on all exits (return, throw, error, close sentinel).
  • Single pendingResolve slot is safe because the async-generator queue serializes body execution — only one await promise is ever live.
  • eventType === "close"/"error" branches are reachable via the internal binding's multiplexed callback (same contract as internal/fs/watch.ts #onEvent).
  • Test cleanup paths: ac.abort() before await it.return() wakes any parked await so return() can't hang behind a queued next().
Extended reasoning...

Overview

Rewrites fs.promises.watch (src/js/node/fs.promises.ts) from a hand-rolled {next, return} iterator to a real async function*, matching Node's implementation shape. The generator body opens the native watcher on first next(), drains a FIFO of {eventType, filename} events, re-checks signal.aborted between every yield, and closes the watcher / detaches the abort listener in a finally. Five new tests in test/js/node/watch/fs.watch.test.ts cover concurrent next(), return()/throw() shape, abort-while-suspended-at-yield, never-iterated process exit, and ENOENT-on-first-iteration.

Security risks

None. This is a JS-side control-flow refactor of an existing Node-compat API; no new attack surface, no untrusted-input parsing, no privilege boundaries touched.

Level of scrutiny

Medium-high. The function itself is small (~50 lines), but async-generator + abort + native-close-sentinel interaction is subtle — my earlier review of this PR caught a real regression (abort between yields resolving {done:true} instead of throwing AbortError), which was fixed with a regression test. The current revision has been through CodeRabbit, comment-cop, and two rounds of my inline findings; all threads are resolved and the bug hunter found nothing this run.

Other factors

  • Observable behavior change: because the generator body runs on first next(), ENOENT, ERR_INVALID_ARG_TYPE, and pre-aborted-signal errors now reject the first iteration rather than throwing synchronously from watch(). This matches Node exactly and is documented in the PR description, but it's a user-visible semantics shift for anyone wrapping fs.promises.watch() in a synchronous try/catch. That's the one thing I'd want a maintainer to explicitly acknowledge before merge.
  • Node parity on return() while parked at await: the author verified (and documented in a PR comment) that return() queuing behind an in-flight await matches both the async-generator spec and Node's own fsPromises.watch; the escape hatch is AbortSignal, which does wake the await via onAbort → wake().
  • CI: build 88755 (pre-rebase head) passed fs.watch.test.ts on all lanes; the post-rebase build 88857 was retriggered at the current head.
  • Test quality: new tests follow harness conventions (bunEnv/bunExe, await using for spawns, drain stdout/stderr/exited concurrently, repeat() polling instead of sleeps, abort-signal cleanup in finally).

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

This change also fixes await using on the value that fs.promises.watch() returns.

On main, watch() returns a plain object with only [Symbol.asyncIterator]. The object has no Symbol.asyncDispose. So await using w = fsp.watch(dir) throws TypeError: @@asyncDispose and @@dispose must not be undefined or null. Because watch() opens the native watcher before the first next(), the watcher from the initializer also leaks and keeps the process alive. Node returns an async generator object. It inherits Symbol.asyncDispose from %AsyncIteratorPrototype%, so the same code works there.

Repro:

import fsp from "node:fs/promises";
const w = fsp.watch(".");
console.log("asyncDispose:", typeof w[Symbol.asyncDispose]);
try { { await using x = w; } console.log("ok"); } catch (e) { console.log("THREW", e.message); }
const t = setTimeout(() => { console.log("loop still alive after 1s (watcher leaked)"); process.exit(0); }, 1000); t.unref();

Bun 1.4.0 and main print:

asyncDispose: undefined
THREW @@asyncDispose and @@dispose must not be undefined or null
loop still alive after 1s (watcher leaked)

Node v26.3.0 prints asyncDispose: function, ok, and exits at once. A debug build of main with the src/js/node/fs.promises.ts change from this branch prints the same as Node. I also checked a started generator: after one next() resolves with an event, the end of the await using block closes the watcher and the process exits. Node v26.3.0 does the same.

A test for this shape would lock it in. For example: typeof fsp.watch(dir)[Symbol.asyncDispose] is "function", and a child process that leaves an await using block after one event exits on its own.

@robobun

robobun commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

I ran the consumer that the original report names, and the result changes the merge decision for this PR.

The report says that a Promise.race([it.next(), timeout]) retry loop deadlocks on main. It does not. On main the newest next() replaces the resolver slot, so the live call gets each event. Only the abandoned calls never settle.

With this PR, pending calls settle oldest first, as in Node. Each event goes to an abandoned call, and the live call starves.

runtime events the loop saw (5 events, 3 runs each)
main (1.4.3-canary.1+367d939d9) 5 of 5
this branch merged onto 367d939 (debug build) 0 of 5
Node v26.3.0 0 of 5
const it = fsp.watch(dir)[Symbol.asyncIterator]();
while (true) {
  const r = await Promise.race([it.next(), timeout(100)]);
  if (r === TIMEOUT) continue; // abandons the pending next(), then calls next() again
  seen.push(r.value.filename);
}
// meanwhile: 5 x fs.mkdirSync(dir + "/dN"), 300 ms apart (one event each)

This is the other side of the fix, not of the generator. Every design that settles the first waiter first has it, a resolver queue in the hand-written iterator too. A loop that keeps its pending promise across timeouts (pending ??= it.next(), race pending, clear it when it settles) sees 5 of 5 on main, on this branch, and on Node.

The PR still fixes Promise.all([it.next(), it.next()]), the return() and throw() shape, Symbol.asyncDispose, the watcher that nothing iterates, and the ENOENT timing. But it makes a loop that works on main today lose every event, with no error. That is Node's behavior. Whether Node parity is worth that here is a maintainer's decision. The Downsides section and the Notes in the PR body now have these numbers. I did not change the code.

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