fix(ext/node): emit StatWatcher 'stop' event asynchronously#33448
Conversation
Node's StatWatcher emits 'stop' on a process.nextTick after the
underlying handle is closed (see StatWatcher.prototype.stop in
lib/internal/fs/watchers.js). That defers the event so listeners
removed synchronously after `stop()` are not called.
Deno's polyfill emitted 'stop' synchronously inside `stop()`, so:
watch.once('stop', listener);
watch.stop();
watch.removeListener('stop', listener);
would fire `listener` instead of removing it. Defer the emission
through `process.nextTick`.
Enables parallel/test-fs-watch-stop-sync.js and
parallel/test-fs-watch-stop-async.js in the node compat suite.
nathanwhitbot
left a comment
There was a problem hiding this comment.
Review
Verified against Node source and both enabled tests.
Correctness — defers emission via process.nextTick
Node at lib/internal/fs/watchers.js:201-211:
StatWatcher.prototype.stop = function() {
if (this._handle === null) return;
defaultTriggerAsyncIdScope(this._handle.getAsyncId(),
process.nextTick,
emitStop,
this);
this._handle.close();
this._handle = null;
};The defaultTriggerAsyncIdScope(..., process.nextTick, emitStop, this) resolves at runtime to process.nextTick(emitStop, this) within an async-id scope. PR's process.nextTick(() => this.emit("stop")) is the same observable behavior — the user-visible deferral is identical. The async-id scope only matters for AsyncLocalStorage context tracking, which Deno's polyfill doesn't currently propagate through the StatWatcher handle anyway, so there's no regression.
Test trace
test-fs-watch-stop-sync.js (L1-21):
const listener = common.mustNotCall(...);
const watch = fs.watchFile(__filename, common.mustNotCall());
watch.once('stop', listener);
watch.stop();
watch.removeListener('stop', listener);Pre-PR: emit("stop") runs synchronously inside stop() → listener fires → mustNotCall fails ❌
Post-PR: process.nextTick(() => this.emit("stop")) defers → removeListener runs first → emission fires with no listener → ✅
test-fs-watch-stop-async.js (L1-19):
let triggered = false;
watch.once('stop', () => { triggered = true; });
watch.stop();
assert.strictEqual(triggered, false); // immediately after stop()
setImmediate(() => assert.strictEqual(triggered, true));Pre-PR: triggered flips to true synchronously inside stop() → first assertion fails ❌
Post-PR: triggered stays false until next tick → first assertion passes; setImmediate runs after next-tick queue drains, so triggered is true by then → ✅
Idempotency / double-stop safety
stop()'s early return is preserved:
if (this.#abortController.signal.aborted) {
return;
}
this.#abortController.abort();
process.nextTick(() => this.emit("stop"));Calling stop() twice still emits "stop" exactly once. ✓
this binding in the arrow
process.nextTick(() => this.emit("stop")) — arrow captures this from StatWatcher.prototype.stop's lexical scope, which is the StatWatcher instance. Correct.
Minor: Node would also pass this as a nextTick argument
Node uses process.nextTick(emitStop, this) (function + arg form) rather than an arrow closure. Functionally equivalent — the arrow form allocates one closure per stop, the function-arg form passes this as a param. Closure-allocation overhead is negligible since stop() is called once per watcher lifetime. Leave as-is.
Safety / performance
- One additional microtask per stop. Negligible.
- No leak:
process.nextTickcallback runs once and the closure is released after. - No interaction with the async poll loop's
try/catch— that loop catches its own AbortError separately from this emission path.
Config
- Added
parallel/test-fs-watch-stop-async.jsandparallel/test-fs-watch-stop-sync.js, alphabetically placed between-utimes.jsand-watch.js. Clean.
LGTM.
|
Matches Node |
Summary
Node's
StatWatcheremits'stop'on aprocess.nextTickafter the underlying handle is closed (seeStatWatcher.prototype.stopinlib/internal/fs/watchers.js). That defers the event so listeners removed synchronously afterstop()are not called.Deno's polyfill emitted
'stop'synchronously insidestop(), so this pattern broke:Defer the emission through
process.nextTick.Enables
parallel/test-fs-watch-stop-sync.jsandparallel/test-fs-watch-stop-async.jsin the node compat suite.Test plan
cargo test --test node_compat -- test-fs-watch-stop— both passtest-fs-watchfile.js(already in config) still passestest-fs-watchfile-bigint.jsandtest-fs-watchfile-ref-unref.jsstill fail as before — both are pre-existing, not inconfig.jsonc