Skip to content

Clean up EventTarget and AbortSignal implementation - #7053

Open
jasnell wants to merge 18 commits into
mainfrom
jasnell/eventtarget-abortsignal-improvements
Open

Clean up EventTarget and AbortSignal implementation#7053
jasnell wants to merge 18 commits into
mainfrom
jasnell/eventtarget-abortsignal-improvements

Conversation

@jasnell

@jasnell jasnell commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Larger PR split intentionally into smaller incremental commits for easier review. I recommend first looking at the tests and documentation changes to get a big picture view. Then, walk through the commits one at a time to review in batches.

Long standing todo here that has been put off long enough.

AbortSignal

  • An IoContext is not longer required to create. Signals can be created at global
    scope and created/observed/aborted across different requests. new AbortController(),
    AbortSignal.abort(), new WritableStream(), and request.signal no longer throw
    during module evaluation. AbortSignal.timeout() still requires an IoContext to schedule
    the timeout.
  • Per-registration native cancellation. The signal-wide IoOwn<RefcountedCanceler> is
    gone. Each wrap() / newCanceler() / addAbortAction() registers a cell bound to the
    registering request's IoContext. Aborts triggered elsewhere are delivered into the owning
    context on its next turn (or silently dropped if it's gone); registrations are reclaimed on
    completion and at IoContext teardown, and swept thereafter, so long-lived signals don't
    accumulate per-request state.
  • RefcountedCanceler is now ReleasingCanceler. Single-owner, releases rather
    than cancels wrapped promises on drop, unlinks listeners as it fires them, and fires
    late-registered listeners immediately.
  • Spec-shaped internals. addEventListener's {signal} option is an abort algorithm
    (runs before abort listeners); AbortSignal.any() uses the spec's dependent-signals model
    with flattening (fixes the expected-fail WPT ordering test; deletes the followingSignal and
    synthesized-listener hacks); triggerAbort follows the spec's "signal abort" sequence;
    synthetic dispatchEvent('abort') no longer runs internal plumbing; pre-aborted wrap()
    rejects with the reason-derived exception instead of throwing a TypeError.
  • onabort follows HTML event-handler semantics: it occupies the listener-list position of
    its activation instead of always firing first.
  • RPC. Per-clone registrations with owner routing (a signal may be serialized from several
    requests); the pending abort reason arrives in a mutex-guarded box readable from any
    context; arming the abort subscription is centralized in addAbortAction() and gated to
    abort-relevant registrations (no longer blocks actor hibernation for unrelated listeners).

EventTarget

  • NativeHandler machinery deleted (the handler OneOf, 9 hash-callback overloads,
    bidirectional bare-reference lifetimes, custom destructor, and the GC-visitation special
    case). EventHandler is a flat, identity-keyed record; internal consumers use the
    AbortSignal primitives above instead.
  • DispatchExceptionPolicy. The JS-exposed dispatchEvent() and abort-event dispatch
    report listener exceptions (via the global scope's error event) and continue, per spec —
    so abort() can no longer throw from a throwing listener. The runtime's top-level event
    delivery (fetch/scheduled/etc.) keeps the propagate behavior.
  • Protected hooks (managesEventHandlerAttribute, addEventHandlerListener) for subclasses
    implementing positioned event-handler attributes.

New internal API surface: AbortSignal::wrap() (rejects with the abort reason),
newCanceler(), addAbortAction() (context-bound), addAbortAlgorithm() (JS-heap);
documented in docs/reference/detail/abort-signal.md.

Additional edits

  • Default Event's trusted flag to NOEvent's constructor defaulted trusted to
    YES, so user-constructed subclasses (CustomEvent, MessageEvent, ErrorEvent,
    CloseEvent) incorrectly reported isTrusted === true. Untrusted is now the default;
    runtime construction sites (and runtime-only subclass constructors such as
    ExtendableEvent) pass Trusted::YES explicitly.
  • Update legacy on<type> handlers to use proper positions — new
    EventTarget::get/setEventHandlerAttribute() implements HTML's event handler IDL
    attribute semantics once (trampoline listener at first-assignment position, kept across
    reassignment, fresh position after clearing, non-callable objects stored but never
    invoked). AbortSignal.onabort migrates onto it (deleting its bespoke copy);
    MessagePort.onmessage and EventSource.on{open,message,error} no longer always fire
    before addEventListener() listeners.
  • Smaller EventTarget cleanups — the never-invoked EventListenerCallback machinery
    is replaced with a listenerCountChanged() virtual notified on every listener-set
    mutation. Fixes addEventListener('message') never starting a MessagePort (only
    onmessage assignment did), and makes a closed port terminal (clearing onmessage
    could previously reset it to pending).
  • Move internal event dispatches to report policyWebSocket, EventSource, and
    MessagePort runtime-fired events now dispatch with spec semantics: a throwing listener is
    reported (global error event + console) and the remaining listeners still run.
    The historical fail-fast reaction is preserved on top via
    DispatchResult::firstException: the WebSocket still errors out, the EventSource still
    closes (dropping the rest of the batch), and the MessagePort still dispatches
    messageerror. Also fixes reportError() re-entrancy: a throwing global error
    listener now logs to console (HTML's "in error reporting mode" flag) instead of
    propagating out of the dispatch or recursing.
  • Make standard Event subclasses moar standardMessageEventInit supports the full
    spec surface (data now optional defaulting null, plus origin, lastEventId,
    source, ports, and the EventInit members); CloseEventInit/ErrorEventInit gain
    bubbles/cancelable/composed. Runtime-constructed events are unchanged.
  • Update stale comments/docs following the above.

Behavioral changes

  • isTrusted is now false for user-constructed Event subclass instances (spec).
  • on<type> assignment takes a listener-list position instead of always running first;
    registering any 'message' listener starts a MessagePort.
  • A throwing listener no longer skips the remaining listeners for that event, and its
    exception surfaces on the global error event/console in addition to the existing
    per-object failure reaction. A throwing 'message' listener on a MessagePort now
    triggers messageerror (previously a second 'message' event carrying the exception).
  • new MessageEvent(type) works without an init; init members are reflected per spec.

Compat flags?

The behavioral changes here should be unlikely to require compatibility flags, but I could be convinced with some good practical examples.

@jasnell
jasnell requested review from guybedford and npaun August 18, 2026 21:12
@jasnell
jasnell requested review from a team as code owners August 18, 2026 21:12
@ask-bonk

ask-bonk Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Model not found: cloudflare-ai-gateway/anthropic/claude-opus-4-6. Did you mean: anthropic/claude-opus-4.5, anthropic/claude-opus-4.6, anthropic/claude-opus-4.7?

github run

@ask-bonk

ask-bonk Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

@jasnell Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@jasnell
jasnell force-pushed the jasnell/eventtarget-abortsignal-improvements branch 2 times, most recently from 81fda0d to 5e5c1fb Compare August 18, 2026 21:34
@codspeed-hq

codspeed-hq Bot commented Aug 18, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 9.65%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 71 untouched benchmarks
⏩ 129 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
request[RegExpBenchmark] 4.5 ms 4.1 ms +9.65%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing jasnell/eventtarget-abortsignal-improvements (45e2c5c) with main (5ef8a1f)2

Open in CodSpeed

Footnotes

  1. 129 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (6cc5b04) during the generation of this report, so 5ef8a1f was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@jasnell

jasnell commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

@cloudflare/wrangler folks... the failing workers-sdk test... it's unclear if that's caused by this change set or ... something else.

@jasnell

jasnell commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

/bonk give this a good review

Comment thread src/workerd/api/basics.c++ Outdated
Comment thread src/workerd/api/global-scope.c++
@ask-bonk

ask-bonk Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Posted 2 actionable inline findings. No test suite was run.

Time for a pun! This review found a couple of signals that should not be ignored.

github run

@jasnell
jasnell force-pushed the jasnell/eventtarget-abortsignal-improvements branch from 308fe21 to 6a14fce Compare August 19, 2026 05:02
jasnell added a commit to jasnell/cloudflare-docs that referenced this pull request Aug 19, 2026
@jasnell

jasnell commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Compat flag doc and changelog PR: cloudflare/cloudflare-docs#32863

@jasnell
jasnell force-pushed the jasnell/eventtarget-abortsignal-improvements branch from 6a14fce to 45e2c5c Compare August 19, 2026 15:22
@jasnell

jasnell commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

/bonk re-review

@ask-bonk

ask-bonk Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

@irvinebroque

irvinebroque commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

(nvm, DO actor shares one long-lived IOcontext, I was incorrect)

@guybedford guybedford left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The core rework here is solid — the registration-cell model, cross-context delivery, dependent-signal handling, GC tracing, and the dispatch snapshotting all check out, and the earlier bot findings are properly addressed. Main concern is a pair of flag-off regressions where call sites dropped their legacy catch handlers unconditionally while the gating only protects the dispatch semantics (inline comments 1-2); the rest are smaller.

Findings:

  1. [HIGH] EventSource loses its listener-exception failure reaction when spec_compliant_dispatch_exceptions is off (inline).
  2. [MEDIUM] MessagePort listener exceptions become unhandled rejections when the flag is off (inline).
  3. [MEDIUM] WebSocket CloseEvent dispatches rethrow a reported exception with no fail-fast reaction to engage (inline).
  4. [LOW] any() duplicate-link comment describes a skip that doesn't happen (inline).
  5. [LOW] Dead trusted : 1 = 1 initializer contradicting the new default (inline).
  6. [LOW] addAbortAction()'s "safe from any thread" doc claim needs a caveat (inline).
  7. [QUESTION] Every other UA-fired surface moved to REPORT, but the PromiseRejectionEvent dispatch in the unhandledRejections handler (global-scope.c++ ~line 212, outside this diff) keeps the default PROPAGATE, so a throwing 'unhandledrejection'/'rejectionhandled' listener still propagates out of the promise-reject hook. Intentional omission or oversight?

Note: this review was performed with AI assistance under my direction.

Comment thread src/workerd/api/eventsource.c++ Outdated
Comment thread src/workerd/api/messagechannel.c++
Comment thread src/workerd/api/web-socket.c++ Outdated
Comment thread src/workerd/api/basics.c++
Comment thread src/workerd/api/basics.h
Comment thread src/workerd/api/basics.h
Comment thread docs/reference/detail/abort-signal.md
Comment thread src/wpt/dom/abort-test.ts
Comment thread src/workerd/api/tests/eventsource-test.js
@jasnell
jasnell force-pushed the jasnell/eventtarget-abortsignal-improvements branch from 45e2c5c to 00965cc Compare August 20, 2026 19:42
Comment thread src/workerd/io/external-pusher.c++
@jasnell
jasnell requested review from guybedford and npaun August 20, 2026 19:57
@jasnell

jasnell commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

This is ready to land. Just waiting on approvals.

jasnell added 18 commits August 21, 2026 09:22
Improve the implementation. We don't need this to be Refcounted
with the additional changes being made to AbortSignal in subsequent
commits. Also, clean up the implementation a bit and prepare
AbortableImpl for the AbortSignal refactor.
Re-use the same signaling mechanism used by cross-request
promise resolution for cross-request abort signaling.
Allows an AbortSignal/AbortController to be created outside of
an IoContext. The IoContext dependency will be captured by individual
abort algorithms attached when necessary. This makes it possible for
a single AbortSignal to trigger *across* requests safely and soundly.
Make the implementation more spec compliant.

The changes in behavior are not flagged as it is highly
unlikely for anyone to be depending on the odd non-standardized
event ordering in the original. Additional tests are added
to strengthen coverage
Event's constructor defaulted trusted to Trusted::YES, so any subclass
constructor that did not explicitly pass Trusted::NO produced events
reporting isTrusted === true even when constructed from JavaScript.

Flip the default to Trusted::NO so untrusted is the safe default, and
make runtime-constructed events opt in explicitly:

No compat flag: isTrusted was already false for plain Events and
reflects trusted correctly for runtime events. It's exceedingly
unlikely that anyone is depending on the broken, non-standard
behavior.
Ensures that all event handlers for a given event are run,
even if one throws. Rather than throwing synchronously,
the error is dispatched to the `'error'` event on the
global scope.

Because these are runtime dispatches, it is unlikely to
break user code... or at least, it should be extremely
unlikely too. The existing behavior is non-standard and
unexpected by most code.
- `listenerCountChanged` override replacing the dead `addEventListener` shadow
- cross-context arming routing with tests and doc
- `reportError` stack-getter guard + events test
* Restore the legacy exception routing on the flag-off dispatch paths.
  With spec_compliant_dispatch_exceptions disabled, EventSource message
  dispatch routes a throwing listener back through notifyError() (error
  event + close) instead of failing the enclosing read-loop task, and
  MessagePort message dispatch restores the swallow-and-redispatch
  behavior (a second 'message' event carrying the exception) instead of
  producing an unhandled delivery-microtask rejection. The
  'messageerror' event type remains gated behind the flag. Adds
  legacy-dispatch-exceptions-test with the disable flag pinned so every
  variant exercises the flag-off path.

* Dispatch WebSocket CloseEvents report-only. The socket is already
  closed or failed when 'close' fires, so rethrowing the first listener
  exception only re-surfaced an already-reported exception into
  terminal plumbing and skipped the cleanup following the dispatch.
  'open' and 'message' keep the fail-fast rethrow, which feeds a real
  reaction.

* Set the abort state of dependent signals as they are collected in
  triggerAbort() so that a signal linked more than once (e.g.
  any([s, s])) has its abort steps run only once, rather than relying
  on the second run being accidentally inert.

* Flip Event's dead trusted bitfield initializer to match the
  untrusted-by-default invariant, and document that dropping a native
  abort registration handle destroys the callback's captures on the
  dropping thread.
@jasnell
jasnell force-pushed the jasnell/eventtarget-abortsignal-improvements branch from affd4c1 to 24aac1b Compare August 21, 2026 16:23

@guybedford guybedford left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All findings from my previous review are addressed: the flag-off legacy exception routing is restored for EventSource and MessagePort (with the new pinned-off legacy-dispatch-exceptions-test covering both), the WebSocket CloseEvent dispatches are report-only with PROPAGATE fall-through preserving the old flag-off behavior, and the trusted initializer and addAbortAction doc are fixed.

One new finding on the triggerAbort dedup fix (inline): folding setAbortState() into the collection loop opens a JS-reentrancy window that can mutate dependentSignals mid-iteration. Worth fixing, but it requires a poisoned getter on the abort reason to reach, so approving — fine as a fast-follow if preferred.

Note: this review was performed with AI assistance under my direction.

Comment on lines +976 to +982
for (auto& dep: dependentSignals) {
if (dep->maybeAbortException == kj::none) {
dep->setAbortState(js, kj::OneOf<kj::Exception, jsg::JsValue>(reasonHandle));
dependentsToAbort.add(dep.addRef());
}
}
dependentSignals.clear();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The dedup fix trades the accidental inertness for a reentrancy window: setAbortState() now runs inside the range-for over dependentSignals, and abortException()js.exceptionToKj(reason) reads name/stack off the reason object (jsg/util.c++ metadata extraction), so a user getter or Proxy trap can run arbitrary JS on each iteration. That JS can abort another source signal that shares a dependent with this one — its step 6 then calls severSources(), which shift-erases from this signal's dependentSignals mid-iteration, invalidating the range-for's cached end(). The previous shape didn't have this window because the JS-running loop only touched the local snapshot after dependentSignals.clear().

Suggest snapshot-then-set, keeping the dedup via a recheck over the snapshot:

kj::Vector<jsg::Ref<AbortSignal>> collected;
for (auto& dep: dependentSignals) {
  if (dep->maybeAbortException == kj::none) collected.add(dep.addRef());  // may hold dupes
}
dependentSignals.clear();
auto reasonHandle = KJ_ASSERT_NONNULL(reason).getHandle(js);
for (auto& dep: collected) {
  // Rechecking here dedups any([s, s]) and skips deps aborted by reentrant JS above.
  if (dep->maybeAbortException == kj::none) {
    dep->setAbortState(js, kj::OneOf<kj::Exception, jsg::JsValue>(reasonHandle));
    dependentsToAbort.add(kj::mv(dep));
  }
}

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.

4 participants