Skip to content

Require scope-bound event listeners in Effection code #748

Description

@taras

Story

As a maintainer writing an Effection operation, I want event subscriptions to be
checked against their owning scope, so cancellation and teardown cannot leave a
listener acting after the work that registered it has ended.

Example

After the installed EffectionX package carries the correction tracked by
thefrontside/effectionx#251,
a one-event wait uses its scope-bound operation:

yield* once(socket, "close");

A longer subscription uses one stable handler and removes it from the same
scope that registered it:

const onData = (chunk: Uint8Array) => inbox.send(chunk);
socket.on("data", onData);
try {
  yield* provide(handle);
} finally {
  socket.off("data", onData);
}

If asynchronous teardown must wait for a value resolved by the listener, the
listener remains registered through that wait and is removed in a synchronous
finally inside the same ensure().

Current gap

The lint gate does not distinguish a subscription owned by an Effection scope
from one whose lifetime depends on its event firing. Raw
EventEmitter.prototype.once(), anonymous .on() handlers, and subscriptions
without matching teardown can therefore enter production and test-support code.
When the event never fires or the scope is cancelled first, the callback may
outlive its owner, retain dead state, or resolve a value nobody is waiting for.

An initial syntactic inventory at origin/main
7cff3462b75f6c5daf655acde61434c3f40f564a finds 106 .once(), .on(), and
.addEventListener() call sites across 38 files in the existing lint surface.
That is an inventory to classify, not a violation count: it includes compliant
scope-owned removal, process-lifetime fixtures, and browser listeners outside
an Effection-owned lifecycle.

The currently installed @effectionx/node@0.2.4 does not yet make its exported
once() scope-bound: constructing the operation registers eagerly, and halting
the interpreting scope leaves the listener registered until the event fires.
thefrontside/effectionx#251
owns that upstream correction.

Contract

The repository follows the EffectionX
Scope-Bound Event Registration Policy:

  • a listener's lifetime depends only on its owning scope, never on whether its
    event fires;
  • raw emitter.once(...) and { once: true } are not used in Effection-owned
    code; the once() operation from @effectionx/node/events is valid only once
    the installed package includes
    thefrontside/effectionx#251's
    correction and passes the cancellation regression below;
  • .on() and .addEventListener() receive a stable named handler with the
    matching .off() or .removeEventListener() in the same scope's teardown;
  • synchronous removal may live in finally; removal following asynchronous
    teardown lives in ensure(); and
  • when teardown awaits a listener-resolved value, removal is in a synchronous
    finally around that wait inside the same ensure().

A local Oxlint rule enforces those observable shapes throughout the repository's
existing lint targets. It distinguishes the scope-bound EffectionX operation
from emitter methods and does not report unrelated APIs merely because they
have a method named on or once.

The migration repairs every applicable violation present on the implementation
feedback revision. It uses no grandfathered baseline, directory-wide disable,
or permanent allowlist for repository-owned code. A genuinely non-Effection
listener lifetime remains outside the rule rather than being wrapped in a fake
scope; any unavoidable narrow suppression names the lifetime invariant and is
audited by the rule tests.

Acceptance

  • deno task lint rejects raw emitter .once() and { once: true } inside an
    Effection-owned lifecycle.
  • It rejects an anonymous subscription, a mismatched handler or emitter in
    teardown, and a subscription with no removal on cancellation.
  • It accepts the corrected installed @effectionx/node/events once(), a named
    subscription removed by the same scope, and the required ensure() plus
    synchronous-finally ordering when teardown waits on the event.
  • Constructing that once() operation registers nothing; interpreting it
    registers one listener; event completion, owner cancellation, and losing a
    race() each leave no listener; and a later event cannot act on its resolver
    or state.
  • Negative controls prove that unrelated .on()/.once() methods,
    process-lifetime fixture listeners, browser-lifetime listeners, and generated
    or vendored files outside the lint gate are not misclassified.
  • Every applicable existing violation in production, test support, tests, and
    repository scripts is migrated. Cancelling each affected owner leaves no
    registered listener and a later event cannot act on its resolver or state.
  • The rule is registered in the local plugin, enabled by the authoritative lint
    configuration, included in the lint-policy inventory, and cannot be silently
    disabled by the review configuration.
  • Removing a migrated teardown or replacing the scope-bound EffectionX
    operation with emitter .once() fails focused evidence.

Evidence

Add focused rule fixtures and tests at a recognizable entrypoint such as:

deno task test scripts/tests/scope-bound-event-registration.test.ts
deno task test scripts/tests/oxlint-policy.test.ts
deno task lint

Run the smallest affected lifecycle tests for every migrated production or
test-support owner. Listener-count and post-cancellation signals, rather than
elapsed time or garbage collection, establish that registrations are gone.

Relationship

Feedback and delivery depend on
thefrontside/effectionx#251
and a published corrected @effectionx/node version being installed and locked
in this repository. Planning, rule implementation, call-site classification,
and migrations may proceed in parallel. The implementation must not exempt the
currently pinned 0.2.4 helper or claim acceptance before the corrected
installed version passes the focused lifecycle regression.

This Story is independent of #717 and may proceed in parallel with PR #747.
PR #747 remains responsible for making the listeners introduced by that PR
scope-bound before it can pass architecture review; this Story does not move or
weaken #732's active implementation contract. Once this lint rule lands, every
later branch must rebase onto it and pass without adding a baseline.

Out of scope

  • Changing public product behavior while migrating listener ownership.
  • Editing generated or vendored snapshots that the lint gate deliberately
    excludes.
  • Requiring process- or browser-lifetime listeners outside Effection-owned work
    to pretend they belong to an Effection scope.

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

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions