Skip to content

events: make addAbortListener resist stopImmediatePropagation - #33377

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/16291f50/resist-stop-propagation
Jul 22, 2026
Merged

events: make addAbortListener resist stopImmediatePropagation#33377
Jarred-Sumner merged 1 commit into
mainfrom
farm/16291f50/resist-stop-propagation

Conversation

@robobun

@robobun robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Repro

const events = require("node:events");

const controller = new AbortController();
// any library sharing your signal
controller.signal.addEventListener("abort", e => e.stopImmediatePropagation());
// documented to ALWAYS run on abort
events.addAbortListener(controller.signal, () => console.log("cleanup"));
controller.abort();

Node prints cleanup. Bun prints nothing.

addAbortListener is the API Node tells resource owners to use specifically so that another consumer of a shared AbortSignal cannot suppress their teardown. On Bun it degraded into a plain listener, so it was skipped in exactly the composed-signal scenarios it exists for.

Cause

AbortSignal is a native EventTarget, and EventTarget::innerInvokeEventListeners left the dispatch loop the moment stopImmediatePropagation() had been called:

if (event.immediatePropagationStopped())
    break;

Node's EventTarget is JS, so it can record a per-listener resistStopPropagation flag (from its internal kResistStopPropagation symbol) and skip only the listeners that lack it.

internal/abort_listener.ts already passed [kResistStopPropagation]: true, but it was a plain JS symbol that the native dictionary converter never read, so it did nothing. node:events shipped a second copy of addAbortListener that did not even pass it.

Fix

  • AddEventListenerOptions gains resistStopPropagation, parsed in convertDictionary from $kResistStopPropagation, a JSC private symbol only Bun's internal modules can reach. Userland cannot forge it: a string key, an own Symbol(), Symbol.for() and Object.prototype pollution all fail.
  • RegisteredEventListener carries the flag, and innerInvokeEventListeners skips suppressed listeners instead of breaking, so a listener that set it still runs.
  • The duplicate addAbortListener in node:events is gone; it now uses internal/abort_listener.
  • Same flag wired into the other listeners Node marks kResistStopPropagation: events.once(emitter, type, { signal }) (both listeners), util.aborted(), timers/promises setTimeout/setImmediate/setInterval, and stream reduce().

On those last paths, a suppressed signal did not merely skip cleanup, it left the promise pending forever:

with a stopImmediatePropagation() listener on the signal bun 1.4.0 node this PR
events.addAbortListener(signal, fn) fn not called called called
events.once(emitter, type, { signal }) never settles AbortError AbortError
util.aborted(signal, resource) never settles resolves resolves
timers/promises.setTimeout(ms, v, { signal }) never settles AbortError AbortError

stopImmediatePropagation() still suppresses ordinary listeners, and it still leaves a later once: true listener registered.

fs.promises.watch's JS abort listener is deliberately left alone: Bun's native fs.watch registers its own abort algorithm, which runs before event dispatch, so marking the redundant JS listener would change nothing.

Verification

Six new tests across event-emitter.test.ts, timers.promises.test.ts and test-aborted.test.ts. Each fails in under 60ms on a build without the src/ changes and passes with them:

before / after
### test/js/node/events/event-emitter.test.ts            exit=1
(fail) ... > runs after an earlier listener stopped propagation [12.24ms]
(fail) ... > once(emitter, event, { signal }) still rejects on a suppressed signal [14.52ms]
### test/js/node/timers.promises/timers.promises.test.ts  exit=1
(fail) setTimeout > rejects even when another listener stopped propagation [24.65ms]
(fail) setImmediate > rejects even when another listener stopped propagation [17.49ms]
(fail) setInterval > ends the iterator even when another listener stopped propagation [54.02ms]
### test/js/node/util/test-aborted.test.ts                exit=1
(fail) aborted resolves even when another listener stopped propagation [17.41ms]

After: all three files pass, plus test/js/web/events/, test/js/node/events/, test/js/node/stream/, test/js/deno/event/, and the test-eventtarget.js / test-events-once.js / test-events-add-abort-listener.mjs Node suites.

BUN_JSC_validateExceptionChecks=1 reports no violations for the new get() in convertDictionary.


[review] gate passed · iteration 5 · 15 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/events/event-emitter.test.ts test/js/node/timers.promises/timers.promises.test.ts test/js/node/util/test-aborted.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (4dfe1e876)

test/js/node/timers.promises/timers.promises.test.ts:
(pass) setTimeout > abort() does not emit global error [138.23ms]
(pass) setTimeout > AbortController can be passed as the `options` argument [7.32ms]
(pass) setTimeout > should reject promise when AbortController is aborted [7.12ms]
46 |     abortController.signal.addEventListener("abort", e => e.stopImmediatePropagation());
47 | 
48 |     const promise = setTimeout(1, "not-aborted", { signal: abortController.signal });
49 |     abortController.abort();
50 | 
51 |     await expect(promise).rejects.toThrow(expect.objectContaining({ name: "AbortError" }));
              
... (truncated)

release without fix: 7 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/timers.promises/timers.promises.test.ts:
(pass) setTimeout > abort() does not emit global error [102.85ms]
(pass) setTimeout > AbortController can be passed as the `options` argument [1.37ms]
(pass) setTimeout > should reject promise when AbortController is aborted [0.35ms]
46 |     abortController.signal.addEventListener("abort", e => e.stopImmediatePropagation());
47 | 
48 |     const promise = setTimeout(1, "not-aborted", { signal: abortController.signal });
49 |     abortController.abort();
50 | 
51 |     await expect(promise).rejects.toThrow(expect.objectContaining({ name: "AbortError" }));
                                       ^
error: expect(received).rejects.toThrow(expected)

Expected promise that rejects
Received promise that resolved: Promise { <resolved> }

      at <anonymous> (/workspace/bun/test/js/node/timers.promises/timers.promises.test.ts:51:35)
(fail) setTimeout > rejects even when another listener stopped propagation [1.51ms]
(pass) setImmediate > abort() does not emit global error [101.11ms]
81 |     abortController.signal.addEventListener("abort", e => e.stopImmediatePropagation());
82 | 
83 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/events/event-emitter.test.ts test/js/node/timers.promises/timers.promises.test.ts test/js/node/util/test-aborted.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (4dfe1e876)

test/js/node/timers.promises/timers.promises.test.ts:
(pass) setTimeout > abort() does not emit global error [141.57ms]
(pass) setTimeout > AbortController can be passed as the `options` argument [7.19ms]
(pass) setTimeout > should reject promise when AbortController is aborted [7.63ms]
(pass) setTimeout > rejects even when another listener stopped propagation [7.60ms]
(pass) setImmediate > abort() does not emit global error [124.92ms]
(pass) setImmediate > rejects even when another listener stopped propagation [10.48ms]
(pass) setInterval > ends the iterator even when another listener stopped propagation [36.54ms]

test/j
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     4dfe1e876b
  features     (none)

22 deps, 106 codegen, 1169 objects in 871ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1232] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [30.00ms]
[2/1232] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [2.00ms]
[3/1232] gen ErrorCode+*.h
[4/1232] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [10.00ms]
[5/1232] fetch picohttpparser
[picohttpparser] up to date
[6/1232] fetch zlib
[zlib] up to date
[7/1232] fetch libjpeg-turbo
[libjpeg-tur
... (truncated)
diff hotspot
src/js/builtins.d.ts                               |  9 +++
 src/js/builtins/BunBuiltinNames.h                  |  1 +
 src/js/internal/abort_listener.ts                  |  7 +-
 src/js/internal/shared.ts                          | 11 ++-
 src/js/internal/streams/operators.ts               |  4 +-
 src/js/node/events.ts                              | 35 ++--------
 src/js/node/timers.promises.ts                     |  7 +-
 src/js/node/util.ts                                |  3 +-
 src/jsc/bindings/webcore/AddEventListenerOptions.h |  5 ++
 src/jsc/bindings/webcore/EventTarget.cpp           | 11 +--
 .../bindings/webcore/JSAddEventListenerOptions.cpp | 11 +++
 src/jsc/bindings/webcore/RegisteredEventListener.h |  7 +-
 test/js/node/events/event-emitter.test.ts          | 79 ++++++++++++++++++++++
 .../node/timers.promises/timers.promises.test.ts   | 43 +++++++++++-
 test/js/node/util/test-aborted.test.ts             | 12 ++++
 15 files changed, 198 insertions(+), 47 deletions(-)

gate history · 3 passed · 0 rejected · iteration 5

evidence per changed file
file                                                    reads  edits  tests
src/js/builtins.d.ts                                        1      1      0
src/js/builtins/BunBuiltinNames.h                           1      1      0
src/js/internal/abort_listener.ts                           2      3      0
src/js/internal/shared.ts                                   3      4      0
src/js/internal/streams/operators.ts                        3      5      0
src/js/node/events.ts                                       4      7      0
src/js/node/timers.promises.ts                              2      5      0
src/js/node/util.ts                                         1      2      0
src/jsc/bindings/webcore/AddEventListenerOptions.h          1      2      0
src/jsc/bindings/webcore/EventTarget.cpp                    2      2      0
src/jsc/bindings/webcore/JSAddEventListenerOptions.cpp      1      2      0
src/jsc/bindings/webcore/RegisteredEventListener.h          1      4      0
test/js/node/events/event-emitter.test.ts                   2      2      0
test/js/node/timers.promises/timers.promises.test.ts        4      8      0
test/js/node/util/test-aborted.test.ts                      1      2      0

@robobun
robobun requested a review from alii as a code owner July 5, 2026 13:26
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR introduces a resistStopPropagation mechanism spanning WebCore's native EventTarget implementation and Bun's JS internals. A new private symbol flag marks specific abort event listeners to continue executing even after stopImmediatePropagation() is called, and this is applied across abort-listener usages in events, timers.promises, util, and stream operators.

Changes

ResistStopPropagation feature

Layer / File(s) Summary
Native EventTarget support
src/jsc/bindings/webcore/AddEventListenerOptions.h, RegisteredEventListener.h, EventTarget.cpp, JSAddEventListenerOptions.cpp
Adds a resistStopPropagation boolean to AddEventListenerOptions and RegisteredEventListener, converts the JS private symbol into this flag, and changes innerInvokeEventListeners to skip only listeners not marked to resist stop propagation.
Shared TS helper and builtin identifier
src/js/builtins.d.ts, src/js/builtins/BunBuiltinNames.h, src/js/internal/shared.ts
Declares the AddEventListenerOptions TS interface with $kResistStopPropagation, registers kResistStopPropagation as a builtin private identifier, and replaces the previously exported symbol with a resistStopPropagation(options) helper function.
Adopt across Node compat modules
src/js/internal/abort_listener.ts, src/js/node/events.ts, src/js/node/timers.promises.ts, src/js/node/util.ts, src/js/internal/streams/operators.ts
Updates abort listener registrations in addAbortListener, once, setTimeout/setImmediate/setInterval, aborted, and reduce to use resistStopPropagation(...) instead of the removed symbol; removes duplicate local addAbortListener from events.ts.
Tests
test/js/node/events/event-emitter.test.ts, test/js/node/timers.promises/timers.promises.test.ts, test/js/node/util/test-aborted.test.ts
Adds tests verifying abort listeners and cleanup handlers still execute despite another listener calling stopImmediatePropagation(), across EventEmitter.addAbortListener, once, timers.promises APIs, and util.aborted.

Possibly related PRs

  • oven-sh/bun#29540: Both PRs modify BUN_COMMON_PRIVATE_IDENTIFIERS_EACH_PROPERTY_NAME and related BunBuiltinNames generation logic.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: making addAbortListener resist stopImmediatePropagation.
Description check ✅ Passed The description covers the problem, cause, fix, and verification, though it doesn’t use the template’s exact section headings.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:12 AM PT - Jul 18th, 2026

@robobun, your commit 4dfe1e8 has 1 failures in Build #75420 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33377

That installs a local version of the PR into your bun-33377 executable, so you can run:

bun-33377 --bun

@claude claude Bot 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.

I didn't find any bugs, but this changes native EventTarget dispatch semantics (the breakcontinue in innerInvokeEventListeners) and threads a new private-symbol option through the C++ dictionary converter, so it's worth a human look before merging.

Extended reasoning...

Overview

This PR makes events.addAbortListener (and the other Node APIs that use kResistStopPropagation internally — events.once, util.aborted, timers/promises, stream reduce) resistant to stopImmediatePropagation() on shared AbortSignals, matching Node.js. It does so by adding a resistStopPropagation flag to the native AddEventListenerOptions/RegisteredEventListener, keyed off a new JSC private symbol $kResistStopPropagation, and changing EventTarget::innerInvokeEventListeners to continue past suppressed listeners rather than break. On the JS side it consolidates the duplicate addAbortListener into internal/abort_listener, replaces the plain-Symbol kResistStopPropagation export with a resistStopPropagation() helper that sets the private symbol, and wires it through six internal modules. 15 files touched across C++ bindings, builtin names, internal JS, and tests.

Security risks

Low. The new option is gated behind a JSC private name (builtinNames(vm).kResistStopPropagationPrivateName()), so userland cannot set it via string keys, own Symbols, Symbol.for, or prototype pollution. The extra object->get() in convertDictionary follows the same exception-checked pattern as the existing option reads. No auth/crypto/permission code is involved.

Level of scrutiny

Medium-high. The breakcontinue change in innerInvokeEventListeners is on the hot path for every event dispatch in the runtime, and it subtly changes iteration behavior (the loop now walks the remaining listeners after stopImmediatePropagation instead of exiting early). The tests confirm ordinary listeners are still suppressed and once: true semantics are preserved, but this is core WebCore-derived dispatch logic and deserves a maintainer's eyes. The dictionary converter also now performs an extra property get on every addEventListener options object.

Other factors

  • The removed kResistStopPropagation Symbol export from internal/shared has no remaining consumers (grep-verified).
  • Test coverage is solid: six new tests exercising the positive case, ordering, disposal, and the negative contract (ordinary listeners still suppressed), plus the PR description reports the broader event/stream suites still pass.
  • No prior human reviews or outstanding comments; CI build was just kicked off.
  • No CODEOWNERS entries cover these paths.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Both of the things flagged for a human look are fair, so here is the evidence I gathered on each.

1. breakcontinue in innerInvokeEventListeners

Differential against node v26, seven dispatch edge cases, all identical before and after:

case node this PR
capture-phase stopper, bubble-phase resist listener capture-stopper,resist capture-stopper,resist
resist listener is once: true, runs after a stop consumed (1 listener left) consumed (1 listener left)
removeEventListener called from a listener after a stop a a
plain once: true listener skipped by a stop stays registered stays registered
passive listener after a stop does not run does not run
stopPropagation() (not Immediate) a,b a,b
resist listener itself calls stopImmediatePropagation() resist resist

On the cost: the loop only walks past listeners once stopImmediatePropagation() has actually been called, and for each one it skips, the body is wasRemoved() plus the two phase checks plus the stopped check. No allocation, no JS call, no side effect, so nothing in it is observable. Node does the same walk (its #dispatch keeps iterating when root.resistStopPropagation is set and skips per-listener on event[kStop] && !handler.resistStopPropagation); it only short-circuits when the list has no resisting listener, which is an optimization on a path that is already rare.

2. Extra object->get() in convertDictionary<AddEventListenerOptions>

It only runs when the third argument is an object. addEventListener(type, fn) and addEventListener(type, fn, true) never reach convertDictionary at all.

When it does run, it is the cheapest of the five reads: it uses the Identifier cached in BunBuiltinNames, so unlike the four existing reads it skips the Identifier::fromString(vm, "capture"_s) AtomString lookup.

A/B of addEventListener(type, fn, { once: true }) + removeEventListener, same debug+ASAN build, best of 3 rounds x 100k iterations:

main       99.2 us/op, 101.5 us/op
this PR   101.3 us/op, 101.6 us/op

The difference is inside the run-to-run spread of an unchanged binary (main alone varies by 2.3 us). Absolute numbers are debug+ASAN, not meaningful on their own, but the delta is what matters here.

3. The private symbol

Userland cannot set it. A string key "$kResistStopPropagation", an own Symbol("kResistStopPropagation"), Symbol.for("kResistStopPropagation") and Object.prototype pollution all fail to make a listener resist.

It also cannot be harvested. Even when an internal options object is handed straight to userland (by patching AbortSignal.prototype.addEventListener), JSC filters private names out of enumeration:

ownKeys:                 once
getOwnPropertySymbols:   0
JSON:                    {"once":true}
proto:                   null

@claude claude Bot 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.

I didn't find any issues, but this changes the core EventTarget::innerInvokeEventListeners dispatch loop (breakcontinue) and introduces a private-symbol channel from internal JS into the native dictionary converter — both worth a human sanity-check on the approach.

Extended reasoning...

Overview

This PR makes events.addAbortListener (and the other Node APIs that use kResistStopPropagation internally — events.once, util.aborted, timers/promises, stream reduce) resist stopImmediatePropagation() on shared AbortSignals, matching Node.js semantics. It touches 15 files: the native WebCore EventTarget dispatch loop and options plumbing (EventTarget.cpp, AddEventListenerOptions.h, RegisteredEventListener.h, JSAddEventListenerOptions.cpp), a new private builtin name (BunBuiltinNames.h, builtins.d.ts), six internal JS modules, and three test files. It also deduplicates addAbortListener (removing the copy in node:events in favor of internal/abort_listener) and removes the now-dead kResistStopPropagation plain-Symbol export from internal/shared.

Security risks

The main security-relevant surface is the new private-symbol option: a listener carrying $kResistStopPropagation bypasses stopImmediatePropagation(). The PR keys it off a JSC private name (builtinNames(vm).kResistStopPropagationPrivateName()) so userland cannot set it via string key, own Symbol, Symbol.for, or prototype pollution; the follow-up comment also verifies it isn't enumerable when the internal options object is handed to a patched addEventListener. That reasoning looks sound to me, but since this is effectively a new privileged capability gated on a private symbol, it deserves a human eye.

Level of scrutiny

Medium-high. The breakcontinue change in innerInvokeEventListeners affects every EventTarget dispatch in the runtime, not just AbortSignal. The PR argues (with a 7-case differential vs. Node) that the only observable difference is the intended one, and a regression test confirms ordinary listeners are still suppressed. The extra object->get() in convertDictionary<AddEventListenerOptions> only runs when an options object is passed and uses a cached Identifier; the author's A/B shows it's within noise. Still, this is a deliberate divergence from the DOM spec's dispatch algorithm and a design decision (private-symbol channel from builtin JS into a native IDL dictionary) that a maintainer should sign off on.

Other factors

  • No bugs surfaced from the multi-agent review.
  • I confirmed the removed kResistStopPropagation Symbol export has no remaining consumers in src/.
  • Test coverage is good: six new targeted tests plus a negative test that stopImmediatePropagation still works for ordinary listeners; the description shows they fail on the old build.
  • events.once now passes the same opts object (with the private symbol) as the flags argument through eventTargetAgnosticAddListener even when emitter is an EventEmitter — harmless since only flags.once is read on that path, but worth noting.
  • No prior human or bot review comments to address; CI build is in progress.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Two notes on the points raised, since both are design questions worth settling on the record.

once() passing the same opts through eventTargetAgnosticAddListener. That is Node's shape, not an accident. lib/events.js builds one opts and hands it to the emitter listener whether the emitter is an EventEmitter or an EventTarget:

// node lib/events.js, inside once()
const opts = { __proto__: null, once: true, [kResistStopPropagation]: true };
eventTargetAgnosticAddListener(emitter, name, resolver, opts);

The only difference here is that I reuse that one object for the signal listener, where Node allocates a second identical literal. convertDictionary reads the values out synchronously and nothing retains the object, so the reuse is safe.

The divergence from the DOM spec's dispatch algorithm. This is narrower than it looks, and I think that is the thing to check rather than the continue itself.

innerInvokeEventListeners only takes a different path when some listener on that target has resistStopPropagation set, and that bit can only be set through a JSC private name that userland cannot reach. Concretely:

  • No addEventListener call from user code can produce a resisting listener, so no web-facing dispatch changes behavior.
  • For a dispatch with no resisting listener, the loop skips every remaining listener and invokes nothing, which is observably identical to the old break. Each skipped listener costs a wasRemoved() check plus the two phase checks, with no allocation, no JS call and no side effect.
  • No user listener can start running where it previously would not have. Only a resisting listener runs past a stop, and a resisting listener that itself calls stopImmediatePropagation() still suppresses everything after it.

So the spec divergence is confined to exactly the internal Node APIs where Node's own EventTarget already diverges, for the same reason: addAbortListener has to be un-suppressible or it is pointless.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green, the two red lanes are a break on main and a tart-host capacity error

Rebased onto a227ad991b. #34519 touched src/js/node/events.ts and test/js/node/events/event-emitter.test.ts; both auto-merged. All three new test files pass; fail-before/pass-after still holds (6 tests fail on main's src/ in under 72ms each, all pass with the fix).

Build #75420 finished: 284 passed, 2 failed.

lane cause evidence it is not this change
debian 13 x64-asan test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js JSC exception-scope SIGABRT red on 7/29 other recent unrelated PRs (#75417, #75414, #75412, …); being handled separately
darwin 14 aarch64 guest never up after retry / The number of VMs exceeds the system limit on the tart host (job log is 46 lines total) no test ran

The test-net-connect-memleak.js flake that appeared on #75117 and #75203 did not recur on this build; both alpine lanes passed. The earlier analysis (A/B'd 50/50 pass with and without this diff; same diff ran 40/40 alpine-green on #68585) is in the edit history of this comment.

Ready for review.

@robobun
robobun force-pushed the farm/16291f50/resist-stop-propagation branch from 3d6bc70 to 34b1c52 Compare July 18, 2026 04:06

@claude claude Bot 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.

I didn't find any bugs, but this touches native EventTarget dispatch (innerInvokeEventListeners breakcontinue), adds a JSC private name, and threads a new flag through RegisteredEventListener / AddEventListenerOptions — a deliberate DOM-spec divergence gated behind an internal-only symbol. That's a cross-layer design decision worth a maintainer's eyes rather than a bot approval.

What was reviewed

  • breakcontinue in innerInvokeEventListeners: skipped listeners hit only wasRemoved() + phase checks, no side effects; once: true listeners past a stop stay registered as before.
  • kResistStopPropagation symbol removal from internal/shared: no remaining consumers in src/js.
  • Private-name read in convertDictionary: exception-checked, only reached when the third arg is an object; userland cannot forge the key.
Extended reasoning...

Overview

Implements Node's kResistStopPropagation semantics on Bun's native EventTarget so events.addAbortListener, events.once({signal}), util.aborted, timers/promises, and stream reduce still fire their abort teardown when another listener on the shared AbortSignal calls stopImmediatePropagation(). Spans four WebCore C++ files (dispatch loop, listener struct, options dict, dictionary converter), BunBuiltinNames.h, one shared TS helper, five internal JS modules, and three test files. The duplicate addAbortListener in node:events is deleted in favor of internal/abort_listener.

Security risks

The new option is keyed on a JSC private name, so userland cannot set it via string key, own Symbol, Symbol.for, or prototype pollution (author verified; private names are filtered from ownKeys/getOwnPropertySymbols). The extra object->get() in convertDictionary uses the cached private Identifier and is RETURN_IF_EXCEPTION-guarded. No auth/crypto/permission surface touched.

Level of scrutiny

High. EventTarget::innerInvokeEventListeners is the core event dispatch path shared by every native EventTarget (AbortSignal, WebSocket, MessagePort, etc.), and this change intentionally diverges from the DOM spec's "stop immediate → break" step. The divergence is scoped (only listeners carrying the internal flag ever run past a stop, and no user code can set that flag), but confirming that scoping — and that the continue walk has no observable side effect on skipped listeners — is the kind of architectural call the repo guidance reserves for a maintainer.

Other factors

CI is fully green (286/286), the author provided differential testing against Node v26 across seven dispatch edge cases, and BUN_JSC_validateExceptionChecks=1 is clean. No remaining consumers of the removed kResistStopPropagation symbol export. The bug-hunting system's only candidate findings were comment-length style nits, both refuted. Deferring solely on scope/criticality, not on any identified defect.

@robobun
robobun force-pushed the farm/16291f50/resist-stop-propagation branch from 34b1c52 to 2af6361 Compare July 18, 2026 08:01

@claude claude Bot 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.

No issues found, but this touches native EventTarget::innerInvokeEventListeners dispatch and introduces a new private-symbol → C++ flag mechanism, so it's worth a human look at the design.

What was reviewed:

  • breakcontinue in the dispatch loop: skipped listeners hit no side-effecting code (once/passive/callback all come after the guard), so ordinary stopImmediatePropagation() semantics are preserved.
  • Removal of the kResistStopPropagation symbol export from internal/shared: grepped src/js — every prior consumer was migrated to the new helper; no orphans.
  • convertDictionary addition follows the neighboring reads' exception-check pattern; the private name is looked up via builtinNames(vm) so userland can't set it.
Extended reasoning...

Overview

The PR wires Node's internal kResistStopPropagation semantic through Bun's native EventTarget. It adds a resistStopPropagation bit to AddEventListenerOptions/RegisteredEventListener, reads it from a JSC private name in convertDictionary<AddEventListenerOptions>, and changes innerInvokeEventListeners to skip (rather than break on) suppressed listeners so those with the bit still run. On the JS side it replaces the dead kResistStopPropagation Symbol() export with a resistStopPropagation(opts) helper, deletes the duplicate addAbortListener in node:events in favor of internal/abort_listener, and threads the flag through events.once, util.aborted, timers/promises setTimeout/setImmediate/setInterval, and stream reduce. Six new tests cover the fixed paths plus a regression guard that ordinary stopImmediatePropagation() still suppresses.

Security risks

None identified. The new flag is gated behind a JSC private name that only bundled internal modules can reference; userland cannot forge it via string key, own Symbol, Symbol.for, or prototype pollution (the PR author verified enumeration filters it out). The dispatch-loop change only alters control flow when a listener already carries the internal-only bit, so no user listener can start running where it previously would not.

Level of scrutiny

High. EventTarget::innerInvokeEventListeners is the spec-defined dispatch path for every native EventTarget (AbortSignal, MessagePort, Worker, WebSocket, etc.), and the change is a deliberate divergence from the DOM spec's inner-invoke algorithm. The divergence is narrow and well-argued (mirrors Node's own EventTarget), but it plus the new cross-layer private-symbol mechanism are the kind of architectural choices a maintainer should sign off on rather than land on bot approval alone.

Other factors

The implementation looks correct end-to-end. I confirmed the removed kResistStopPropagation symbol has no remaining consumers in src/js; the continue placement means skipped listeners never reach the isOnce() removal or handleEvent call, so a suppressed once: true listener stays registered as before; the new C++ get() follows the same RETURN_IF_EXCEPTION pattern as the four existing reads and the author ran BUN_JSC_validateExceptionChecks=1 clean. Tests are placed in the existing per-module files, assert exact ordering/codes, and the gate evidence shows fail-before/pass-after on both debug+ASAN and release. Deferring solely on scope, not on any correctness concern.

AbortSignal is a native EventTarget, and innerInvokeEventListeners broke
out of its dispatch loop as soon as a listener called
stopImmediatePropagation(). Listeners that Node marks with its internal
kResistStopPropagation symbol were therefore silently skipped: any code
sharing the signal could suppress another consumer's teardown.

Add resistStopPropagation to AddEventListenerOptions, parsed from a JSC
private symbol that only internal modules can reach, carry it on
RegisteredEventListener, and skip suppressed listeners during dispatch
instead of breaking.

Wire it into the listeners Node marks the same way: addAbortListener,
events.once(emitter, type, { signal }), util.aborted(), timers/promises
setTimeout/setImmediate/setInterval, and stream reduce(). On those paths
a suppressed signal left the promise pending forever rather than merely
skipping cleanup.

Also drops the duplicate addAbortListener in node:events in favor of
internal/abort_listener.
@robobun
robobun force-pushed the farm/16291f50/resist-stop-propagation branch from 2af6361 to 4dfe1e8 Compare July 18, 2026 12:05

@claude claude Bot 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.

No bugs found, but this is a cross-layer change to native EventTarget dispatch semantics (the breakcontinue in innerInvokeEventListeners is a deliberate DOM-spec divergence gated on a new private symbol), so it's worth a human sign-off on the design.

What was reviewed:

  • Checked that the removed kResistStopPropagation export from internal/shared has no remaining consumers in src/.
  • Verified node:eventsinternal/abort_listener require does not introduce a cycle.
  • The breakcontinue loop is observably identical for user-registered listeners (flag only reachable via a JSC private name); a skipped once: true listener stays registered because the check runs before the once-removal, matching the old break.
  • Exception handling in the new convertDictionary get() matches the surrounding pattern with RETURN_IF_EXCEPTION.
Extended reasoning...

Overview

The PR wires Node's kResistStopPropagation semantics into Bun's native EventTarget. It adds a bool resistStopPropagation to AddEventListenerOptions / RegisteredEventListener, reads it from a JSC private symbol in convertDictionary<AddEventListenerOptions>, and changes innerInvokeEventListeners to continue past suppressed listeners instead of breaking when a resisting listener is present. On the JS side it registers a new builtin private name, replaces the exported kResistStopPropagation symbol with a resistStopPropagation(opts) helper, deduplicates addAbortListener (events.ts now imports the internal one), and applies the flag in events.once, util.aborted, timers/promises and stream reduce(). Six new tests cover each surface plus a negative test that ordinary stopImmediatePropagation() still suppresses.

Security risks

The flag is keyed on a JSC private name (BunBuiltinNames), not a user-reachable string or well-known symbol; the author verified prototype pollution, Symbol.for, string keys, and enumeration harvesting all fail to reach it. Userland cannot register a listener that survives stopImmediatePropagation(), so there is no new privilege surface. The extra object->get() in convertDictionary can invoke user getters/Proxy traps, but that was already true of the four preceding reads and it is guarded by RETURN_IF_EXCEPTION. I don't see security concerns here.

Level of scrutiny

This warrants a human look: EventTarget::innerInvokeEventListeners is the dispatch core for every web API in Bun, and changing it from break to continue is a deliberate divergence from the DOM "inner invoke" algorithm. The author's evidence (7 differential edge cases against Node v26, perf A/B, non-forgeability of the symbol) is thorough, and the divergence is scoped to internal-only listeners — but confirming that this is the right layer (native dispatch vs. e.g. addAbortAlgorithmToSignal, which fires before dispatch and is already used elsewhere) is a design call a maintainer should make.

Other factors

  • Removed kResistStopPropagation from internal/shared exports: grep confirms no remaining importers.
  • The 4-line comment in shared.ts was flagged and ruled out as a nit.
  • The RegisteredEventListener bitfield addition (5th bool : 1) fits within existing padding and doesn't change layout meaningfully.
  • Tests use synchronous abort() so there is no timing race; the setTimeout(1) is only a fallback settlement path for a broken build, as the comment explains.
  • No outstanding human reviewer comments; the robobun follow-ups are the author's own evidence posts.

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

One new point worth answering on the record: why the dispatch loop rather than addAbortAlgorithmToSignal.

addAlgorithm only exists on AbortSignal, but Node's kResistStopPropagation is used on listeners on arbitrary EventTargets. The case that makes this concrete:

const events = require("node:events");
const target = new EventTarget();              // not an AbortSignal
target.addEventListener("foo", e => e.stopImmediatePropagation());
const p = events.once(target, "foo");          // resolver registered with kResistStopPropagation
target.dispatchEvent(new Event("foo"));
// node:   resolves
// bun 1.3.14 / 1.4.0: never settles (resolver left registered, 3 listeners)

events.once(emitter, type) marks its resolver kResistStopPropagation regardless of what emitter is (node's lib/events.js line ~1016). target here has no abort-algorithm list to hook, so an addAlgorithm-based fix could not cover it.

Two smaller reasons it is also the wrong layer for the AbortSignal case specifically:

  • Listener shape. addAbortListener callers get an Event with e.target === signal; an abort algorithm receives the abort reason, not an Event. Matching Node means the listener runs as an event listener.
  • Ordering. AbortSignal::signalAbort() runs algorithms first, then dispatches the 'abort' event. A resist listener registered between two ordinary listeners should run in its registered position among them (the tests cover this), which only the dispatch loop can provide.

So addAlgorithm would cover addAbortListener narrowly but would leave events.once(eventTarget, type) broken and diverge on the listener argument and ordering. The dispatch-loop flag is what Node uses and is the only layer that covers every site.

@Jarred-Sumner
Jarred-Sumner merged commit 1ebff2f into main Jul 22, 2026
77 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/16291f50/resist-stop-propagation branch July 22, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants