fix(ext/node): implement StatWatcher.ref() / unref()#33408
Conversation
Node's StatWatcher.ref()/unref() toggle whether the watcher's internal handle keeps the event loop alive (see lib/internal/fs/watchers.js). Deno's polyfill threw `notImplemented` from both methods, which broke scripts that legitimately call `watcher.unref()` to let the process exit once other work completes. Deno's StatWatcher polls via an async loop that was using std/async/delay. Inline the delay into the watcher and track the in-flight Timeout so ref()/unref() can call the corresponding methods on it. Apply the current ref state to each new interval timer so the setting sticks across poll iterations. Also drop the now-unused `_util/async.ts` file from the polyfill registry since this was its only caller in-tree. Enables parallel/test-fs-watchfile-ref-unref.js in the node compat suite. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Reviewed — functional and matches the test's observable behavior, with a couple of notes on Node conformance and one minor correctness concern. What's correct
Divergences from Node
Minor correctness concern In Performance Per-poll allocation of a new Promise + timer + abort listener is heavier than the old No blocking issues. The refcount divergence only matters if something in userland (or a future test) depends on transition-triggered behavior — worth a follow-up but fine for landing. |
nathanwhitbot
left a comment
There was a problem hiding this comment.
Review
Verified against Node source and the enabled test.
Correctness — matches Node's intent
Node's StatWatcher.prototype.ref() / unref() at lib/internal/fs/watchers.js:231-246 toggle whether the internal _StatWatcher libuv handle keeps the loop alive. Node uses a KFSStatWatcherRefCount / KFSStatWatcherMaxRefCount pair for idempotency against repeated ref()/unref() calls. Deno's polyfill has no libuv handle — the "handle" is the setTimeout between polls — so toggling #timer.ref()/#timer.unref() is the correct analogue.
The boolean #refed in Deno is a simplification of Node's refcount/maxrefcount pair, but behaviorally equivalent for the externally-observable ref/unref pattern: repeat calls to unref() after the handle is already unref'd are no-ops in both. The test (unref unref ref unref ref ref unref) doesn't differentiate between the two since it only checks that ref/unref don't throw and the process exits.
Walk-through of #sleep
#sleep(ms: number): Promise<void> {
return new Promise((resolve, reject) => {
const signal = this.#abortController.signal;
if (signal.aborted) { reject(signal.reason); return; }
const abort = () => { clearTimeout(timer); this.#timer = null; reject(signal.reason); };
const done = () => { signal.removeEventListener("abort", abort); this.#timer = null; resolve(); };
const timer = setTimeout(done, ms);
if (!this.#refed) { timer.unref(); }
this.#timer = timer;
signal.addEventListener("abort", abort, { once: true });
});
}Verified:
- ✅ Fast-exits if signal already aborted (matches
delay's pre-condition). - ✅ Promise constructor body is synchronous;
setTimeout(done, ms)can't fire before theaddEventListener("abort", …)registers, so no ordering race. - ✅ Both
abortanddonepaths null#timerand release listeners.{ once: true }covers the abort-then-GC case even ifdonenever runs. - ✅ On re-entry, a new timer picks up the current
#refed, so changes made while#timerwas null (during thestatAsyncportion of the loop) take effect immediately on the next poll.
Test trace (parallel/test-fs-watchfile-ref-unref.js)
First watcher, assuming default #refed = true:
| Call | #timer state |
#refed after |
timer.ref/unref |
OK |
|---|---|---|---|---|
unref() |
likely live (mid-sleep) | false | timer.unref() |
✅ |
unref() |
live | false | timer.unref() (idempotent on libuv side) |
✅ |
ref() |
live | true | timer.ref() |
✅ |
unref() |
live | false | timer.unref() |
✅ |
ref() |
live | true | timer.ref() |
✅ |
ref() |
live | true | timer.ref() |
✅ |
unref() |
live | false | timer.unref() |
✅ |
unwatchFile triggers stop() → abortController.abort() → #sleep rejects → outer catch swallows AbortError. Clean shutdown.
Second part (two listeners, 100ms setTimeout): within 100ms the default 5007ms watcher interval hasn't elapsed, so watcher2.#timer is still the live initial setTimeout. watcher2.unref() at the end flips #refed=false + timer.unref(). The test's explicit setTimeout(100ms) is what was keeping the loop alive; once its callback fires and watcher2 is unref'd, the process exits. ✓
Issues
1. Timeout type is not imported (likely TS error under strict checking).
#timer: Timeout | null = null;fs.ts imports only { clearTimeout, setTimeout } from node:timers — neither brings in the Timeout type. The actual Timeout constructor lives at ext:deno_node/internal/timers.mjs:71, and the timers.ts polyfill imports it for its own : Timeout annotations. Two options:
- Add a value + type import:
import { clearTimeout, setTimeout, type Timeout } from "node:timers";(ifnode:timersre-exports the type — checkcli/tsc/dts/node/timers.d.cts). - Or
import type { Timeout } from "ext:deno_node/internal/timers.mjs";.
If the current code passes tsc, it's because polyfill type-checking isn't strict enough to catch unresolved names in type position — but that's fragile. The test passing just means runtime behavior is correct; it doesn't validate the type annotation.
2. _util/async.ts file orphaned but not deleted.
The PR removes the lib.rs polyfill registry entry and the import site in fs.ts, but leaves:
ext/node/polyfills/_util/async.ts(the file itself — 30ish lines of dead code)tools/core_import_map.json:552("ext:deno_node/_util/async.ts": "../ext/node/polyfills/_util/async.ts")
I searched the repo — no other callers. Deleting the file + removing the import-map entry would be a clean follow-up (or part of this PR). Leaving the file stranded invites a future contributor to wire it back in by accident.
3. Pre-existing (not this PR) — kFSStatWatcherAddOrCleanRef is a no-op.
Deno's kFSStatWatcherAddOrCleanRef symbol handler updates #refCount but nothing else reads it, so "clean" / "cleanAll" don't actually unref anything on the handle. Node's equivalent calls this.unref() / this._handle?.unref(). Not this PR's job — but the way this PR wires ref/unref to #timer means a straightforward follow-up is possible: if (#refCount === 0) this.#timer?.unref() in the "clean" / "cleanAll" branches.
Performance / safety
- Inlining
#sleepremoves one await-layer (no moreimport delay from std) — net-zero perf. - No leaks: each timer/listener pair is torn down on either resolve or reject;
{ once: true }guards the GC path. - The private
#refed+#timerfields aren't observable externally — no API surface leak.
Nits
- The constructor of
AbortControlleris allocated unconditionally; doesn't matter for correctness but the pre-PR code also did this. Leaving as-is. watcher.ref()/watcher.unref()nowreturn this— matches Node's return value, which enableswatcher.unref().watchFile(...)chaining. Good.
LGTM modulo the Timeout type import (issue 1) — the async.ts orphan (issue 2) is worth cleaning up either here or in a follow-up.
nathanwhitbot
left a comment
There was a problem hiding this comment.
Clean-up commit removed stray <<<<<<</>>>>>>> markers in fs.ts; merge resolution kept the upstream side (deprecate import) and discarded the unused delay import. Workflow sync trivial. LGTM.
Summary
Node's
StatWatcher.ref()/unref()toggle whether the watcher's internal handle keeps the event loop alive (seelib/internal/fs/watchers.js). Deno's polyfill was throwingnotImplementedfrom both methods, so scripts callingwatcher.unref()to let the process exit once other work completes would crash instead.Deno's
StatWatcherpolls via an async loop that usedstd/async/delay. Inline the delay into the watcher and hang onto the in-flightTimeoutsoref()/unref()can call the corresponding methods on it. Record the current ref state and reapply it on each new interval timer so the setting persists across poll iterations.Drop the now-unused
_util/async.tsfile from the polyfill registry — this was its only in-tree caller.Enables
parallel/test-fs-watchfile-ref-unref.jsin the node compat suite.Closes #23042