Clean up EventTarget and AbortSignal implementation - #7053
Conversation
|
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? |
|
@jasnell Bonk workflow failed. Check the logs for details. View workflow run · To retry, trigger Bonk again. |
81fda0d to
5e5c1fb
Compare
Merging this PR will improve performance by 9.65%
|
| 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
Footnotes
-
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. ↩
-
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. ↩
|
@cloudflare/wrangler folks... the failing workers-sdk test... it's unclear if that's caused by this change set or ... something else. |
|
/bonk give this a good review |
|
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. |
308fe21 to
6a14fce
Compare
|
Compat flag doc and changelog PR: cloudflare/cloudflare-docs#32863 |
6a14fce to
45e2c5c
Compare
|
/bonk re-review |
|
LGTM! |
|
(nvm, DO actor shares one long-lived IOcontext, I was incorrect) |
guybedford
left a comment
There was a problem hiding this comment.
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:
- [HIGH] EventSource loses its listener-exception failure reaction when
spec_compliant_dispatch_exceptionsis off (inline). - [MEDIUM] MessagePort listener exceptions become unhandled rejections when the flag is off (inline).
- [MEDIUM] WebSocket CloseEvent dispatches rethrow a reported exception with no fail-fast reaction to engage (inline).
- [LOW]
any()duplicate-link comment describes a skip that doesn't happen (inline). - [LOW] Dead
trusted : 1 = 1initializer contradicting the new default (inline). - [LOW]
addAbortAction()'s "safe from any thread" doc claim needs a caveat (inline). - [QUESTION] Every other UA-fired surface moved to REPORT, but the
PromiseRejectionEventdispatch in theunhandledRejectionshandler (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.
45e2c5c to
00965cc
Compare
|
This is ready to land. Just waiting on approvals. |
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.
affd4c1 to
24aac1b
Compare
guybedford
left a comment
There was a problem hiding this comment.
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.
| 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(); |
There was a problem hiding this comment.
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));
}
}
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
scope and created/observed/aborted across different requests.
new AbortController(),AbortSignal.abort(),new WritableStream(), andrequest.signalno longer throwduring module evaluation.
AbortSignal.timeout()still requires an IoContext to schedulethe timeout.
IoOwn<RefcountedCanceler>isgone. Each
wrap()/newCanceler()/addAbortAction()registers a cell bound to theregistering request's
IoContext. Aborts triggered elsewhere are delivered into the owningcontext 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.
RefcountedCanceleris nowReleasingCanceler. Single-owner, releases ratherthan cancels wrapped promises on drop, unlinks listeners as it fires them, and fires
late-registered listeners immediately.
addEventListener's{signal}option is an abort algorithm(runs before
abortlisteners);AbortSignal.any()uses the spec's dependent-signals modelwith flattening (fixes the expected-fail WPT ordering test; deletes the
followingSignalandsynthesized-listener hacks);
triggerAbortfollows the spec's "signal abort" sequence;synthetic
dispatchEvent('abort')no longer runs internal plumbing; pre-abortedwrap()rejects with the reason-derived exception instead of throwing a TypeError.
onabortfollows HTML event-handler semantics: it occupies the listener-list position ofits activation instead of always firing first.
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 toabort-relevant registrations (no longer blocks actor hibernation for unrelated listeners).EventTarget
NativeHandlermachinery deleted (the handlerOneOf, 9 hash-callback overloads,bidirectional bare-reference lifetimes, custom destructor, and the GC-visitation special
case).
EventHandleris a flat, identity-keyed record; internal consumers use theAbortSignalprimitives above instead.DispatchExceptionPolicy. The JS-exposeddispatchEvent()and abort-event dispatchreport listener exceptions (via the global scope's
errorevent) and continue, per spec —so
abort()can no longer throw from a throwing listener. The runtime's top-level eventdelivery (fetch/scheduled/etc.) keeps the propagate behavior.
managesEventHandlerAttribute,addEventHandlerListener) for subclassesimplementing 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
Event's trusted flag to NO —Event's constructor defaultedtrustedtoYES, so user-constructed subclasses (
CustomEvent,MessageEvent,ErrorEvent,CloseEvent) incorrectly reportedisTrusted === true. Untrusted is now the default;runtime construction sites (and runtime-only subclass constructors such as
ExtendableEvent) passTrusted::YESexplicitly.on<type>handlers to use proper positions — newEventTarget::get/setEventHandlerAttribute()implements HTML's event handler IDLattribute semantics once (trampoline listener at first-assignment position, kept across
reassignment, fresh position after clearing, non-callable objects stored but never
invoked).
AbortSignal.onabortmigrates onto it (deleting its bespoke copy);MessagePort.onmessageandEventSource.on{open,message,error}no longer always firebefore
addEventListener()listeners.EventTargetcleanups — the never-invokedEventListenerCallbackmachineryis replaced with a
listenerCountChanged()virtual notified on every listener-setmutation. Fixes
addEventListener('message')never starting aMessagePort(onlyonmessageassignment did), and makes a closed port terminal (clearingonmessagecould previously reset it to pending).
WebSocket,EventSource, andMessagePortruntime-fired events now dispatch with spec semantics: a throwing listener isreported (global
errorevent + console) and the remaining listeners still run.The historical fail-fast reaction is preserved on top via
DispatchResult::firstException: theWebSocketstill errors out, theEventSourcestillcloses (dropping the rest of the batch), and the
MessagePortstill dispatchesmessageerror. Also fixesreportError()re-entrancy: a throwing globalerrorlistener now logs to console (HTML's "in error reporting mode" flag) instead of
propagating out of the dispatch or recursing.
Eventsubclasses moar standard —MessageEventInitsupports the fullspec surface (
datanow optional defaultingnull, plusorigin,lastEventId,source,ports, and theEventInitmembers);CloseEventInit/ErrorEventInitgainbubbles/cancelable/composed. Runtime-constructed events are unchanged.Behavioral changes
isTrustedis nowfalsefor user-constructed Event subclass instances (spec).on<type>assignment takes a listener-list position instead of always running first;registering any
'message'listener starts aMessagePort.exception surfaces on the global
errorevent/console in addition to the existingper-object failure reaction. A throwing
'message'listener on aMessagePortnowtriggers
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.