Skip to content

Make @effectionx/node once() scope-bound #251

Description

@taras

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 in race() is deregistered when the race settles.
  • The regression covers both EventEmitter-style on/off and EventTarget-style
    addEventListener/removeEventListener sources.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions