Repository navigation
Make @effectionx/node once() scope-bound #251
Description
Problem
once() from @effectionx/node/events leaves its listener registered when the
Effection scope interpreting it is halted. It also registers eagerly when the
operation is constructed rather than when it is interpreted.
This contradicts the repository's
Scope-Bound Event Registration Policy,
which identifies this operation as the scope-bound alternative to
EventEmitter.prototype.once().
Minimal reproduction
Reproduced with @effectionx/node@0.2.4 and effection@4.1.0:
import { EventEmitter } from "node:events";
import { once } from "@effectionx/node/events";
import { run } from "effection";
const emitter = new EventEmitter();
const operation = once(emitter, "done");
console.log(`after-construction=${emitter.listenerCount("done")}`);
const task = run(function* () {
yield* operation;
});
await Promise.resolve();
console.log(`while-running=${emitter.listenerCount("done")}`);
await task.halt();
console.log(`after-halt=${emitter.listenerCount("done")}`);
emitter.emit("done");
console.log(`after-event=${emitter.listenerCount("done")}`);Observed:
after-construction=1
while-running=1
after-halt=1
after-event=0
The listener survives its owning scope and is removed only when the event later
fires. A losing once() operation in race() has the same problem: the race
halts the losing operation, but its listener remains registered.
The current implementation creates its resolver and registers the listener in
the ordinary function call before returning result.operation. The returned
operation therefore owns the wait but not the registration's lifetime.
Expected behavior
once(target, eventName) is a lazy Effection operation. Constructing it performs
no registration. Interpreting it registers one listener, and that listener is
removed whenever the interpreting scope ends—after the event, failure, or
cancellation. An event emitted after cancellation cannot act on the completed
operation's resolver or retained state.
Acceptance
- Constructing
once()leaves both an EventEmitter and an EventTarget with no
new listener. - Interpreting it registers one listener and returns the next event's arguments
with the existing public shape. - Event completion removes the listener before the operation's owner continues.
- Halting the interpreting task before the event removes the listener; emitting
afterwards cannot invoke it. - A losing
once()branch inrace()is deregistered when the race settles. - The regression covers both EventEmitter-style
on/offand EventTarget-style
addEventListener/removeEventListenersources.
Related
- 🤖 Add Scope-Bound Event Registration policy #250 added the scope-bound event-registration policy but did not change
node/events.ts. - ✨ useNativeProcess: a native-listener child-process resource #247 applies explicit scope-owned listener teardown inside the process adapter;
it does not change the exportedonce()operation.
Activity