Skip to content

fix(ext/node): emit StatWatcher 'stop' event asynchronously#33448

Merged
bartlomieju merged 1 commit into
denoland:mainfrom
nathanwhitbot:fix/node-compat-iter25
Apr 25, 2026
Merged

fix(ext/node): emit StatWatcher 'stop' event asynchronously#33448
bartlomieju merged 1 commit into
denoland:mainfrom
nathanwhitbot:fix/node-compat-iter25

Conversation

@nathanwhitbot

Copy link
Copy Markdown
Contributor

Summary

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 this pattern broke:

watch.once('stop', listener);
watch.stop();
watch.removeListener('stop', listener);  // Too late — listener already fired

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.

Test plan

  • cargo test --test node_compat -- test-fs-watch-stop — both pass
  • test-fs-watchfile.js (already in config) still passes
  • test-fs-watchfile-bigint.js and test-fs-watchfile-ref-unref.js still fail as before — both are pre-existing, not in config.jsonc

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 nathanwhitbot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.nextTick callback 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.js and parallel/test-fs-watch-stop-sync.js, alphabetically placed between -utimes.js and -watch.js. Clean.

LGTM.

@nathanwhitbot

Copy link
Copy Markdown
Contributor Author

Matches Node lib/internal/fs/watchers.js:201-211 — deferred emit('stop') via process.nextTick so synchronously-removed listeners don't fire. Direct port.

@bartlomieju
bartlomieju merged commit d904f26 into denoland:main Apr 25, 2026
112 checks passed
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.

3 participants