Skip to content

jsc: drain rejected-promise list in O(n) instead of O(n^2) - #32554

Merged
Jarred-Sumner merged 4 commits into
mainfrom
claude/handle-rejected-promises-linear
Jun 26, 2026
Merged

jsc: drain rejected-promise list in O(n) instead of O(n^2)#32554
Jarred-Sumner merged 4 commits into
mainfrom
claude/handle-rejected-promises-linear

Conversation

@sosukesuzuki

Copy link
Copy Markdown
Contributor

What

GlobalObject::handleRejectedPromises() looped on WriteBarrierList::takeFirst(), which does cellLock() + Vector::removeAt(0) (memmove) per element — O(n²) for n queued rejections.

This replaces it with drainTo() (move the whole list into a MarkedArgumentBuffer under one lock) + linear iteration. An outer do-while keeps the original behaviour for rejections raised from inside an unhandledRejection handler. Also adds an isEmpty() early return for the common no-rejections-pending tick. takeFirst() is now dead and removed.

This is the same move-out-then-iterate pattern JSC's VM::didExhaustMicrotaskQueue and WebCore's RejectedPromiseTracker already use.

Benchmark

Release build, aarch64-darwin. for (i<N) Promise.reject(i) then drive one macrotask:

N before after speedup
1,000 0.09 ms 0.06 ms 1.5×
5,000 1.17 ms 0.25 ms 4.7×
10,000 4.35 ms 0.45 ms 9.7×
20,000 19.13 ms 0.88 ms 21.7×

Before scales ~quadratically (×2 → ×4), after scales linearly (×2 → ×2). Normal microtask throughput (await/.then/queueMicrotask) and memory usage are unchanged.

Test

Added a subprocess test in test/js/node/process/process.test.js that rejects 1000 promises in one tick and asserts:

  • all are delivered, in order
  • a promise .catch()ed before the checkpoint is not delivered
  • a rejection raised from inside the handler is also delivered

`GlobalObject::handleRejectedPromises()` looped on
`WriteBarrierList::takeFirst()`, which acquires `cellLock()` and calls
`Vector::removeAt(0)` (memmove of the remaining elements) once per
promise — O(n^2) total work for n queued rejections.

Replace it with a single `drainTo()` that moves the whole list into a
`MarkedArgumentBuffer` under one lock, then iterates linearly. An outer
do-while preserves the original semantics for rejections raised from
inside an `unhandledRejection` handler. Adds an early return for the
common empty case (this runs on every event-loop tick).

`WriteBarrierList::takeFirst()` is now unused and removed.

This matches the move-out-then-iterate pattern JSC's
`VM::didExhaustMicrotaskQueue` and WebCore's `RejectedPromiseTracker`
already use.

| pending rejections | before   | after   |
|-------------------:|---------:|--------:|
|              1,000 |  0.09 ms | 0.06 ms |
|              5,000 |  1.17 ms | 0.25 ms |
|             10,000 |  4.35 ms | 0.45 ms |
|             20,000 | 19.13 ms | 0.88 ms |
@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator
Updated 2:23 PM PT - Jun 25th, 2026

@robobun, your commit 8aec644ae933530f359960a32928a8145972020c passed in Build #64682! 🎉


🧪   To try this PR locally:

bunx bun-pr 32554

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

bun-32554 --bun

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 070aa6fb-bb3d-489d-a1f6-7dfe45b01278

📥 Commits

Reviewing files that changed from the base of the PR and between 1930ad4 and 8aec644.

📒 Files selected for processing (2)
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/node/process/process.test.js

Walkthrough

WriteBarrierList<T> now drains into a MarkedArgumentBuffer with drainTo, GlobalObject tracks in-flight rejected promises while batching handleRejectedPromises, JSBundlerPlugin switches to the new drain API, and a process test checks rejection ordering and suppression behavior.

Changes

Unhandled Rejection Drain Refactor

Layer / File(s) Summary
WriteBarrierList drainTo API
src/jsc/bindings/WriteBarrierList.h
Adds drainTo(JSCell*, MarkedArgumentBuffer&) that acquires cellLock once, pre-expands buffer capacity, appends all non-null cells, and clears m_list. Removes takeFirst(JSCell*), leaving removeFirstMatching(...) as the next removal API after isEmpty().
GlobalObject rejection drain refactor
src/jsc/bindings/ZigGlobalObject.h, src/jsc/bindings/ZigGlobalObject.cpp
Adds InFlightRejections and m_rejectedPromisesBeingProcessed to track drained rejection batches. Refactors handleRejectedPromises to drain the pending list into a MarkedArgumentBuffer, iterate the drained promises with in-flight indexing, report exceptions, and repeat until empty. Adds a Handle-path scan that suppresses rejectionHandled while the promise is still in a re-entrant drain.
JSBundlerPlugin drainTo adoption
src/jsc/bindings/JSBundlerPlugin.cpp
Updates JSBundlerPlugin__drainDeferred to call deferredPromises.drainTo instead of moveTo, preserving the resolve/reject and exception-handling logic.
Unhandled rejection ordering test
test/js/node/process/process.test.js
Adds a subprocess test that queues 1000 rejections plus a handled one and a nested rejection from inside the handler; asserts all 1000 arrive in strict order, the handled one is absent, the nested one is delivered, and the process exits cleanly.

Possibly related PRs

  • oven-sh/bun#31796: Both PRs touch src/jsc/bindings/JSBundlerPlugin.cpp inside JSBundlerPlugin__drainDeferred, changing deferred promise handling.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title concisely and accurately summarizes the main change: switching rejected-promise handling to a linear drain.
Description check ✅ Passed The description covers the change and verification with benchmark and test details, though it doesn't use the template headings verbatim.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

Comment thread src/jsc/bindings/ZigGlobalObject.cpp
Comment thread src/jsc/bindings/WriteBarrierList.h
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
- promiseRejectionTracker(Handle) now also scans the in-flight tail of
  the drained snapshot, so a handler that .catch()es a later still-pending
  rejection no longer fires a spurious 'rejectionHandled' (it never got
  'unhandledRejection'). Test extended to cover this.
- WriteBarrierList::moveTo() folded into drainTo() (only caller switched).
- Trim comments that referenced the removed takeFirst() implementation.
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
handleRejectedPromises() can re-enter (a handler that ticks the event
loop). The previous flat pointer/index pair was overwritten by the inner
call and unconditionally nulled on its return, leaving the outer loop
without its in-flight state.

Link the in-flight snapshots as a stack-allocated singly-linked list and
restore via SetForScope, and have promiseRejectionTracker(Handle) walk
every level so a .catch() from an inner handler on a promise still
pending in an outer frame is also recognised.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/ZigGlobalObject.cpp (1)

3302-3304: ⚠️ Potential issue | 🟠 Major

Replace RELEASE_ASSERT with graceful error handling for promise rejection queue overflow.

MarkedArgumentBuffer is an argument buffer with overflow semantics; draining an unbounded WriteBarrierList<JSPromise> into it can turn user-triggered resource exhaustion into a crash. Although the event loop normally drains rejections frequently, a pathological script that creates promises faster than the event loop processes them will overflow the buffer. Per coding guidelines, user-reachable failures must be recoverable errors, never panics — drain in bounded chunks or switch to a GC-rooted container without argument-buffer overflow semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/ZigGlobalObject.cpp` around lines 3302 - 3304, The
RELEASE_ASSERT checking for overflow in the MarkedArgumentBuffer after draining
m_aboutToBeNotifiedRejectedPromises converts user-triggered resource exhaustion
into a crash, violating the requirement that user-reachable failures must be
recoverable. Replace the RELEASE_ASSERT(!promises.hasOverflowed()) check with
graceful error handling by either draining m_aboutToBeNotifiedRejectedPromises
in bounded chunks into the MarkedArgumentBuffer instead of all at once, or by
switching to a GC-rooted container without overflow semantics that can hold an
unbounded number of rejected promises without panic-on-overflow behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 3302-3304: The RELEASE_ASSERT checking for overflow in the
MarkedArgumentBuffer after draining m_aboutToBeNotifiedRejectedPromises converts
user-triggered resource exhaustion into a crash, violating the requirement that
user-reachable failures must be recoverable. Replace the
RELEASE_ASSERT(!promises.hasOverflowed()) check with graceful error handling by
either draining m_aboutToBeNotifiedRejectedPromises in bounded chunks into the
MarkedArgumentBuffer instead of all at once, or by switching to a GC-rooted
container without overflow semantics that can hold an unbounded number of
rejected promises without panic-on-overflow behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 216af42c-dd2f-4432-97fe-2b29f1a9a8b3

📥 Commits

Reviewing files that changed from the base of the PR and between 2b9af97 and 1930ad4.

📒 Files selected for processing (2)
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h

Comment thread test/js/node/process/process.test.js Outdated
inflight.index = i + 1;

Bun__handleRejectedPromise(this, promise);
if (auto ex = scope.exception()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What if its a termination exception? tryClearException will return false and what we should do, in that case, is return out of this function.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@robobun can you resume this PR

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Done in 8aec644: checks vm.isTerminationException(ex) before tryClearException() and returns early, matching drainMicrotasks() in this file and VM::didExhaustMicrotaskQueue() upstream. The stack-scoped SetForScope restores m_rejectedPromisesBeingProcessed on the early return.

@Jarred-Sumner
Jarred-Sumner merged commit 1c861db into main Jun 26, 2026
77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/handle-rejected-promises-linear branch June 26, 2026 00:55
robobun added a commit that referenced this pull request Jul 2, 2026
…the installed window, harden tests

- promiseRejectionTracker(Reject) now goes through
  AsyncContextFrame::withAsyncContextIfNeeded instead of open-coding it,
  dropping a redundant tracking-enabled gate and a dead null check.
- unhandled_rejection()'s microtask drains and the auto-GC that follows the
  dispatch no longer run with the rejected promise's async context installed.
  Node drains outside its exchange window, and the propagation machinery
  assumes the ambient context is undefined during a top-level drain.
- Tests: make each restore clause individually load-bearing (a contextless
  rejection drained from inside a context; a drain that must restore the slot
  afterwards), pin the rejection-time semantic against the creation-time one,
  cover the frame-wrapped Handle path in both unwrap sites (same-tick catch,
  plus the #32554 regression test parametrized with AsyncLocalStorage), and
  assert --unhandled-rejections=strict keeps the context for uncaughtException
  but not for the drain that follows.
robobun added a commit that referenced this pull request Jul 14, 2026
…the installed window, harden tests

- promiseRejectionTracker(Reject) now goes through
  AsyncContextFrame::withAsyncContextIfNeeded instead of open-coding it,
  dropping a redundant tracking-enabled gate and a dead null check.
- unhandled_rejection()'s microtask drains and the auto-GC that follows the
  dispatch no longer run with the rejected promise's async context installed.
  Node drains outside its exchange window, and the propagation machinery
  assumes the ambient context is undefined during a top-level drain.
- Tests: make each restore clause individually load-bearing (a contextless
  rejection drained from inside a context; a drain that must restore the slot
  afterwards), pin the rejection-time semantic against the creation-time one,
  cover the frame-wrapped Handle path in both unwrap sites (same-tick catch,
  plus the #32554 regression test parametrized with AsyncLocalStorage), and
  assert --unhandled-rejections=strict keeps the context for uncaughtException
  but not for the drain that follows.
robobun added a commit that referenced this pull request Jul 18, 2026
…the installed window, harden tests

- promiseRejectionTracker(Reject) now goes through
  AsyncContextFrame::withAsyncContextIfNeeded instead of open-coding it,
  dropping a redundant tracking-enabled gate and a dead null check.
- unhandled_rejection()'s microtask drains and the auto-GC that follows the
  dispatch no longer run with the rejected promise's async context installed.
  Node drains outside its exchange window, and the propagation machinery
  assumes the ambient context is undefined during a top-level drain.
- Tests: make each restore clause individually load-bearing (a contextless
  rejection drained from inside a context; a drain that must restore the slot
  afterwards), pin the rejection-time semantic against the creation-time one,
  cover the frame-wrapped Handle path in both unwrap sites (same-tick catch,
  plus the #32554 regression test parametrized with AsyncLocalStorage), and
  assert --unhandled-rejections=strict keeps the context for uncaughtException
  but not for the drain that follows.
robobun added a commit that referenced this pull request Jul 18, 2026
…the installed window, harden tests

- promiseRejectionTracker(Reject) now goes through
  AsyncContextFrame::withAsyncContextIfNeeded instead of open-coding it,
  dropping a redundant tracking-enabled gate and a dead null check.
- unhandled_rejection()'s microtask drains and the auto-GC that follows the
  dispatch no longer run with the rejected promise's async context installed.
  Node drains outside its exchange window, and the propagation machinery
  assumes the ambient context is undefined during a top-level drain.
- Tests: make each restore clause individually load-bearing (a contextless
  rejection drained from inside a context; a drain that must restore the slot
  afterwards), pin the rejection-time semantic against the creation-time one,
  cover the frame-wrapped Handle path in both unwrap sites (same-tick catch,
  plus the #32554 regression test parametrized with AsyncLocalStorage), and
  assert --unhandled-rejections=strict keeps the context for uncaughtException
  but not for the drain that follows.
robobun added a commit that referenced this pull request Jul 18, 2026
…the installed window, harden tests

- promiseRejectionTracker(Reject) now goes through
  AsyncContextFrame::withAsyncContextIfNeeded instead of open-coding it,
  dropping a redundant tracking-enabled gate and a dead null check.
- unhandled_rejection()'s microtask drains and the auto-GC that follows the
  dispatch no longer run with the rejected promise's async context installed.
  Node drains outside its exchange window, and the propagation machinery
  assumes the ambient context is undefined during a top-level drain.
- Tests: make each restore clause individually load-bearing (a contextless
  rejection drained from inside a context; a drain that must restore the slot
  afterwards), pin the rejection-time semantic against the creation-time one,
  cover the frame-wrapped Handle path in both unwrap sites (same-tick catch,
  plus the #32554 regression test parametrized with AsyncLocalStorage), and
  assert --unhandled-rejections=strict keeps the context for uncaughtException
  but not for the drain that follows.
robobun added a commit that referenced this pull request Jul 18, 2026
…the installed window, harden tests

- promiseRejectionTracker(Reject) now goes through
  AsyncContextFrame::withAsyncContextIfNeeded instead of open-coding it,
  dropping a redundant tracking-enabled gate and a dead null check.
- unhandled_rejection()'s microtask drains and the auto-GC that follows the
  dispatch no longer run with the rejected promise's async context installed.
  Node drains outside its exchange window, and the propagation machinery
  assumes the ambient context is undefined during a top-level drain.
- Tests: make each restore clause individually load-bearing (a contextless
  rejection drained from inside a context; a drain that must restore the slot
  afterwards), pin the rejection-time semantic against the creation-time one,
  cover the frame-wrapped Handle path in both unwrap sites (same-tick catch,
  plus the #32554 regression test parametrized with AsyncLocalStorage), and
  assert --unhandled-rejections=strict keeps the context for uncaughtException
  but not for the drain that follows.
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.

3 participants