Skip to content

Emit unhandledRejection at the end of the tick that created it - #33354

Open
robobun wants to merge 5 commits into
mainfrom
farm/4fa6f111/unhandled-rejection-tick-ordering
Open

Emit unhandledRejection at the end of the tick that created it#33354
robobun wants to merge 5 commits into
mainfrom
farm/4fa6f111/unhandled-rejection-tick-ordering

Conversation

@robobun

@robobun robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Repro

setImmediate(() => {
  Promise.reject(new Error("x")); // never handled
  setTimeout(() => console.log("later timer"), 0);
});
process.on("unhandledRejection", () => console.log("unhandledRejection"));
node: unhandledRejection, later timer
bun:  later timer, unhandledRejection

Without an unhandledRejection handler, node prints the error and exits before later timer runs; Bun runs the timer first.

Cause

Node reports unhandled rejections from processTicksAndRejections, which its timer and immediate queues run after every callback. So a rejection is always delivered before anything that callback scheduled, and before anything already queued behind it.

Bun only scanned at the tail of autoTick, after the poll and timer phases, so everything the callback scheduled got to run first. The timer case looked correct only by accident: a setTimeout(…, 0) created inside a timer callback is not due within the same drain, so the tail scan beat it. Once a sibling timer is already due, the same reordering shows up.

Fix

Run the scan at the end of each timer and immediate callback, which is where node's runNextTicks() sits. handleRejectedPromises() now reports whether a handler ran, so the caller can re-drain the microtask queue the handler may have pushed onto, mirroring node's do { drain } while (processPromiseRejections()).

Two placements that look right but aren't:

  • EventLoop::exit(), even though that is where the callback's microtasks already drain. exit() is also how native code re-enters JS from inside a JS turn: Interpreter::finish() calls it while ShellPromise.then() is mid-flight, between rej(potentialError) and the super.then() that handles it. Scanning there reports a rejection the caller is about to handle. The enter/exit counter cannot tell the two apart, so only the two batch-drain phases call the new hook.
  • The throw site of a callback that threw. exit_maybe_drain_microtasks(false) deliberately skips the drain there, so the scan would see a promise whose .catch() is merely queued and report it. Node marks the failed callback's scope and skips its checkpoint entirely, running it once the immediate batch drains, which is what keeps the timers-immediate-exception fixture's nextTicks deferred past the next immediate. This does the same.

Verification

Differential against node v26, comparing emission order. All of these were wrong before this change:

rejection created in competing work before after / node
setImmediate setTimeout(…, 0) scheduled by it later timer, unhandledRejection unhandledRejection, later timer
setImmediate sibling setImmediate queued behind it second immediate, unhandledRejection unhandledRejection, second immediate
setImmediate setTimeout(…, 5) already scheduled 5ms timer, unhandledRejection unhandledRejection, 5ms timer
setTimeout sibling timer due in the same drain second timer, unhandledRejection unhandledRejection, second timer
setImmediate process.nextTick queued by the handler nextTick never ran at all unhandledRejection, nextTick from handler, second immediate
a throwing setImmediate setTimeout(…, 0) scheduled by it later timer, unhandledRejection uncaughtException, unhandledRejection, later timer
a throwing setImmediate ending a batch that started clean first immediate, later timer, unhandledRejection first immediate, uncaughtException, unhandledRejection, later timer
a throwing setImmediate that clearImmediates its sibling later timer, unhandledRejection uncaughtException, unhandledRejection, later timer

Plus two cases that already matched node on main and are pinned so the checkpoint placement can't drift:

| a throwing setImmediate whose rejection is .catch()ed by a queued microtask | reports nothing (uncaughtException only) |
| a throwing setImmediate with a sibling immediate behind it | uncaughtException, second immediate, unhandledRejection |

All ten are cases in test/js/node/process/process.test.js; the eight in the first table fail on main.

Also re-checked against node and unchanged by this PR: rejections from process.nextTick, queueMicrotask, module top level, fs.readFile callbacks, a setTimeout scheduling another setTimeout, a setTimeout scheduling a setImmediate, a setImmediate scheduling another setImmediate, and nextTick queued from the rejecting callback (still delivered before the rejection).

Known gap, not addressed here

A rejection created in the poll phase (e.g. a socket data handler) is still reported after an already-due timer. Pre-existing; the natural hook is exit(), which is unusable for the reason above.

Suites run against the debug build (no new failures vs. main)
  • test/js/node/process/process.test.js
  • test/js/node/timers/ (including node's timers-immediate-exception fixture, which pins the deferred-checkpoint behaviour)
  • test/js/bun/shell/commands/ (the suite an earlier revision of this PR broke)
  • test/js/web/timers/, test/js/node/timers.promises/
  • test/js/web/streams/streams.test.js, test/js/bun/http/serve.test.ts
  • test/js/bun/test/ (bun's own test-runner suite)
  • node's test/parallel/test-promise*-unhandled-*.js, test-promises-unhandled-rejections.js, test-microtask-queue-run-immediate.js, test-timers-immediate.js

The pre-existing failures in those suites (bunshell ls > permission denied directory — the container runs as root, server.requestIP > v6, expect.assertions DOES fail the test, …, …) reproduce identically with src/ reverted to main.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

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: b668de7b-f615-4b09-8693-cc9a308da72a

📥 Commits

Reviewing files that changed from the base of the PR and between 1f6e5e3 and 4c2a3b6.

📒 Files selected for processing (2)
  • src/jsc/event_loop.rs
  • test/js/node/process/process.test.js

Walkthrough

The PR changes rejected-promise handling to return a boolean across the C++/FFI/Rust boundary, adds post-tick event-loop draining for rejected promises and microtasks, wires that into timer execution paths, and adds process tests covering ordering with timers, immediates, and nextTick.

Changes

Unhandled rejection bool return and post-tick draining

Layer / File(s) Summary
C++ handleRejectedPromises returns bool
src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/bindings/ZigGlobalObject.h
GlobalObject::handleRejectedPromises changes from void to bool, returns false when nothing is queued, tracks whether any handler ran, and handles termination versus reportable exceptions differently while draining.
FFI and Rust binding signature updates
src/jsc/bindings/bindings.cpp, src/jsc/bindings/headers.h, src/jsc/JSGlobalObject.rs
The exported JSC__JSGlobalObject__handleRejectedPromises declaration and Rust wrapper now return bool, with the Rust host-call path defaulting to false on failure.
Post-tick microtask draining and timer wiring
src/jsc/event_loop.rs, src/runtime/timer/timer_object_internals.rs
A new event-loop helper runs rejected-promise handling after ticks and drains microtasks, and it is called from immediate and timer callback paths.
Process unhandledRejection ordering tests
test/js/node/process/process.test.js
Adds subprocess tests that check unhandledRejection output ordering relative to later timers/immediates and process.nextTick work scheduled inside the handler.

Possibly related PRs

  • oven-sh/bun#32554: Also changes rejected-promise handling and its draining loop, overlapping with the core handleRejectedPromises update here.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main behavior change to unhandledRejection timing.
Description check ✅ Passed The description is detailed and includes purpose and verification, even though it does not use the exact template headings.
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.

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:05 AM PT - Jul 5th, 2026

@robobun, your commit 4c2a3b6 has some failures in Build #68550 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33354

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

bun-33354 --bun

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Top level await causes promise rejections inside global.setTimeout to be ignored, allowing execution to continue #22546 - Top-level await causes promise rejections inside setTimeout to be ignored; moving the rejection scan to end-of-tick aligns with Node's processTicksAndRejections and should fix the ordering

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #22546

🤖 Generated with Claude Code

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

This doesn't fix #22546. That issue is a different bug, and this PR leaves it unchanged.

In that repro the rejection is already reported at the right moment, before the loop's next iteration:

$ bun issue22546.mjs
2
3
error: oops     <- reported promptly, both before and after this PR
oops
4
5
...runs forever

What's broken there is that Bun doesn't abort afterwards, and only when the entry point uses top-level await. Dropping the await makes the same program terminate on the rejection:

reports the rejection terminates
await main() (top-level await) yes no
main() yes yes

Same on both sides of this diff, so the ordering change has no bearing on it. Node aborts in both cases because its default is --unhandled-rejections=throw; Bun's default reporter prints and records a non-zero exit code without stopping the loop, and something about the pending top-level-await module promise is swallowing the termination that otherwise happens. That's worth fixing, but it lives in the reporter/exit path, not in when the scan runs.

Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Both reviews landed on the exit() placement, which turned out to be wrong for a reason neither of us had: CI went red on every platform with test/js/bun/shell/commands/{exit,false}.test.ts.

EventLoop::exit() is not only the end-of-tick hook. Native code also uses it to re-enter JS from inside a JS turn. Interpreter::finish() does exactly that, and for a synchronous builtin like false the whole thing runs inside ShellPromise.then():

then(onfulfilled, onrejected) {
  this.#run();                                 // interp.run() -> finish() -> rej(potentialError)
  return super.then(onfulfilled, onrejected);  // ...handler attached only here
}

finish()'s loop_.entered() guard drops at depth 0, so the scan ran in the gap and reported a rejection that was about to be handled three statements later. The enter/exit counter can't distinguish "macrotask callback finished" from "native re-entered JS mid-turn", so exit() is off the table. I moved the hook to the two batch-drain phases (run_immediate_task and fire), where nothing sits above us on the JS stack. Shell suite is green again, and the four ordering cases still pass.

Review on the microtask re-drain (event_loop.rs:277) — confirmed and fixed. Worth noting it's worse than the reviewer states: on main the handler's nextTick doesn't just run late, it never runs at all.

node:        unhandledRejection | nextTick from handler | second immediate
bun (main):  second immediate   | unhandledRejection                        <- nextTick dropped
bun (now):   unhandledRejection | nextTick from handler | second immediate

handleRejectedPromises() now returns whether a handler ran, so the new hook can loop report -> drain until quiescent, which is the shape of processTicksAndRejections. That's covered by a new test.

Review on allow_drain_microtask (event_loop.rs:304) — confirmed, deferring. Reproduced exactly as described (uncaughtException, later timer, unhandledRejection; node emits the rejection second), and it is unchanged by this PR. Both suggested fixes mean draining microtasks and scanning on a path that deliberately skips the drain because an exception is live, so it belongs with the uncaught-exception path rather than here. Noted in the PR description along with the poll-phase gap, which has the same root cause as the exit() problem above.

robobun added 2 commits July 5, 2026 07:26
EventLoop::exit() is where a macrotask callback's microtasks drain, but
the unhandled-rejection scan only ran at the tail of autoTick, after the
poll and timer phases. A rejection created in a setImmediate callback was
therefore reported after any macrotask that callback scheduled, after a
sibling immediate queued behind it, and after an already-due timer.

Run the scan from exit() once the enter/exit counter returns to 0, which
is what Node does from processTicksAndRejections.
EventLoop::exit() is not the end-of-tick hook it looks like: native code
also uses it to re-enter JS from inside a JS turn. Interpreter::finish()
does exactly that, so scanning there reported a ShellPromise rejection that
then() was about to handle three statements later, failing every Bun Shell
test that asserts a non-zero exit code.

Call the scan from the two batch-drain phases instead, where no JS frame
sits above us. Report-then-redrain in a loop so a nextTick queued by the
unhandledRejection handler also runs before the next macrotask, matching
processTicksAndRejections; handleRejectedPromises() now returns whether a
handler ran so the loop terminates.
@robobun
robobun force-pushed the farm/4fa6f111/unhandled-rejection-tick-ordering branch from 62c8204 to c938dc0 Compare July 5, 2026 07:31
Comment thread src/runtime/timer/timer_object_internals.rs Outdated
Comment thread src/jsc/event_loop.rs

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

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@test/js/node/process/process.test.js`:
- Around line 964-1043: The subprocess cases in the current process tests are
independent and should run concurrently instead of sequentially. Update the
`it.each` block and the trailing `it("drains process.nextTick...")` test to use
the concurrent test style recommended by the suite conventions (for example,
`test.concurrent`/`describe.concurrent` or the equivalent in this file), since
each case uses its own `Bun.spawn` and has no shared state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 88ec2feb-5173-4462-bd37-5ff5e071b937

📥 Commits

Reviewing files that changed from the base of the PR and between 62c8204 and c938dc0.

📒 Files selected for processing (8)
  • src/jsc/JSGlobalObject.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/headers.h
  • src/jsc/event_loop.rs
  • src/runtime/timer/timer_object_internals.rs
  • test/js/node/process/process.test.js

Comment thread test/js/node/process/process.test.js
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

The 🔴 on the throw path was right, and the bug was real. But the suggested fix (drain before the first scan) turns out to break a different node behaviour, so the resolution went a different way. Pushed as 404b0f4.

Confirmed

setImmediate(() => {
  const p = Promise.reject(new Error("x"));
  queueMicrotask(() => p.catch(() => {}));
  throw new Error("boom");
});
node      : uncaught
bun main  : uncaught
bun (old) : uncaught, unhandledRejection, rejectionHandled   <- the regression

Why draining there is wrong

test/js/node/timers/timers-immediate-exception-fixture.js pins the opposite: when an immediate throws, node defers its nextTick queue past the next immediate. Draining at the throw site makes those ticks observe a stale counter and the fixture fails. That's what exit_maybe_drain_microtasks(false) exists to preserve.

So node isn't draining-then-scanning at the throw site. It marks the failed callback's scope and skips the checkpoint entirely, then runs it once the immediate batch drains. Four probes against node v26 pin the contract:

node
throwing immediate queues a nextTick, sibling immediate behind it uncaught, second immediate, tick
throwing immediate rejects + schedules a setTimeout(…, 0) uncaught, unhandledRejection, later timer
throwing immediate rejects, .catch() in a queued microtask uncaught only
throwing immediate rejects, sibling immediate behind it uncaught, second immediate, unhandledRejection

Deferred to the end of the batch, not to the throw site (rows 1 and 4) and not to the next loop turn (row 2, where it still beats the timer phase).

What landed

No scan at the throw site; one scan after tick_immediate_tasks' existing post-batch maybe_drain_microtasks(). All four rows above now match node, the fixture passes, and row 2 closes the allow_drain_microtask gap from the earlier review on this PR, which I'd previously deferred. Rows 1, 3 and 4 already matched on main and are now pinned as tests so the placement can't drift.

On the other two comments

Stale PR description (socket row / "five"): already fixed before that review rendered. The description now has six fail-on-main rows plus two regression guards, and calls out the poll-phase gap explicitly.

it.concurrent.each: no change needed. These tests sit inside the describe.concurrent(() => { … }) block at process.test.js:476, and bun's runner resolves ConcurrentMode::Inherit to the parent's setting (bun_test.rs:1694), so plain it() already runs concurrently there. Every sibling test in that block relies on the same inheritance.

@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/event_loop.rs (1)

860-880: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Verify batch ordering when a non-last immediate throws.

exception_thrown in the to_run_now loop (line 865) is overwritten on every iteration, so it reflects only the last task's outcome. This PR relies on that value to decide whether handle_rejected_promises_after_tick fires per-task (inside run_immediate_task, when the task didn't throw) or once at the end of the batch (here, when the last task threw).

I traced the "throw-first, succeed-second" case (covered by the new "defers a rejection..." test) and it resolves correctly, because the succeeding second task's own per-task hook ends up scanning the still-global rejection table left by the first (throwing) task. However, the symmetric case — an earlier immediate in the same batch succeeds while a later one throws — isn't exercised by any test here, and depends on this same last-task-wins nuance to route through the post-loop branch correctly.

Since this mechanism is pre-existing (only the new handle_rejected_promises_after_tick call was added to the exception_thrown branch), it's likely fine, but a Node-parity test with the throw at the end of a multi-immediate batch would pin down this coupling for future refactors.

🤖 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/event_loop.rs` around lines 860 - 880, Add a regression test around
`run_immediate_task`/`handle_rejected_promises_after_tick` for a multi-immediate
batch where an earlier immediate succeeds and a later one throws, so
`exception_thrown` being overwritten by the last task is covered explicitly.
Verify the batch still routes the rejection handling through the post-loop
`maybe_drain_microtasks` + `handle_rejected_promises_after_tick` path in
`src/jsc/event_loop.rs`, matching Node’s ordering for a throw-at-end case. Keep
the new test symmetric with the existing throw-first/succeed-second coverage to
pin down the last-task-wins behavior.
🤖 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/event_loop.rs`:
- Around line 860-880: Add a regression test around
`run_immediate_task`/`handle_rejected_promises_after_tick` for a multi-immediate
batch where an earlier immediate succeeds and a later one throws, so
`exception_thrown` being overwritten by the last task is covered explicitly.
Verify the batch still routes the rejection handling through the post-loop
`maybe_drain_microtasks` + `handle_rejected_promises_after_tick` path in
`src/jsc/event_loop.rs`, matching Node’s ordering for a throw-at-end case. Keep
the new test symmetric with the existing throw-first/succeed-second coverage to
pin down the last-task-wins behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3596de60-9c56-444b-8910-d5d7c3d374fa

📥 Commits

Reviewing files that changed from the base of the PR and between c938dc0 and 404b0f4.

📒 Files selected for processing (3)
  • src/jsc/event_loop.rs
  • src/runtime/timer/timer_object_internals.rs
  • test/js/node/process/process.test.js

exit_maybe_drain_microtasks(false) skips the drain when a setImmediate
callback throws, so scanning at the throw site saw un-drained state: a promise
whose .catch() was merely queued looked unhandled, producing a spurious
unhandledRejection and rejectionHandled pair.

Node marks the failed callback's scope and skips its checkpoint entirely,
running it once the immediate batch drains, which is what keeps the
timers-immediate-exception fixture's nextTicks deferred past the next
immediate. Do the same: no scan at the throw site, one scan after
tick_immediate_tasks' existing post-batch drain. That still reports ahead of
the timer phase, matching Node.
@robobun
robobun force-pushed the farm/4fa6f111/unhandled-rejection-tick-ordering branch from 404b0f4 to 1f6e5e3 Compare July 5, 2026 09:05
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on the exception_thrown coupling, and worth pinning. Added in 1f6e5e3.

You're right that tick_immediate_tasks overwrites exception_thrown each iteration, so the routing between the per-task hook and the post-batch one turns on the last task's outcome. I probed all three combinations against node v26:

batch shape node main this PR
succeed, then throw (ends the batch) first immediate, uncaughtException, unhandledRejection, later timer … later timer, unhandledRejection matches node
throw in the middle, succeed last first immediate, uncaughtException, third immediate, unhandledRejection matches matches
succeed, then throw with a queued .catch() first immediate, uncaughtException matches matches

All three route correctly, for the reason you traced: when the last task didn't throw, its own per-task hook picks up whatever an earlier throwing task left in the rejection table; when it did throw, the post-batch branch does. The first row is the interesting one and it's a fix (main is wrong there).

So rather than add a fourth near-identical block, I folded the throwing-immediate cases into one it.each and added that row. Seven of the nine ordering cases now fail on main; the other two are regression guards for behaviour that already matched.

Comment thread src/jsc/event_loop.rs
Comment thread src/jsc/event_loop.rs
… one

tick_immediate_tasks assigned exception_thrown rather than OR-ing it, so a task
that returned early without running (cleared via clearImmediate, unref'd with
nothing else alive, stale test-isolation generation) erased the fact that an
earlier task threw. The batch tail then skipped both the microtask drain and the
rejection scan, and the rejection landed after the timer phase.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

The exception_thrown finding is real and is fixed in ea7c6e6. Good catch — it defeats the exact ordering one of this PR's own tests asserts.

Confirmed

setImmediate(() => {
  Promise.reject(new Error("x"));
  setTimeout(() => console.log("later timer"), 0);
  clearImmediate(im2);
  throw new Error("boom");
});
var im2 = setImmediate(() => console.log("never"));
node     : uncaughtException, unhandledRejection, later timer
bun main : uncaughtException, later timer, unhandledRejection
bun (before ea7c6e6) : uncaughtException, later timer, unhandledRejection

The cleared sibling takes run_immediate_task's early return, which reports false, and the plain assignment erased the fact that the first task threw. The batch tail then skipped both the drain and the scan.

exception_thrown |= … as suggested, plus a test for the case. It also widens the pre-existing maybe_drain_microtasks() gate from last-task-threw to any-task-threw, which I checked doesn't shift anything: the timers-immediate-exception fixture still passes, and all four throwing-immediate probes plus the 16-case ordering matrix still match node.

On the stale-description comment

Already fixed. Known gap has been singular (poll-phase only) since 404b0f4; the reject-and-throw bullet went away in the same commit that closed it. The description now lists eight fail-on-main rows and two regression guards.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green, one lane is blocked on CI infra

The only red lane on https://buildkite.com/bun/bun/builds/68519 is :darwin: 26 aarch64 - test-bun, and it never ran a test:

Error: buildkite-agent artifact download timed out after 120s for step
'darwin-aarch64-build-bun'. Refusing to continue with a partial download

That is the artifact transfer, not this diff. It is failing identically on every concurrent build in the pipeline, on unrelated branches:

build branch failed job
68508 farm/4fa6f111/unhandled-rejection-tick-ordering :darwin: 26 aarch64 - test-bun
68512 farm/49b9d0bd/memory-usage-heap-invariant :darwin: 26 aarch64 - test-bun
68513 farm/b70d7484/tls-cipher-config :darwin: 26 aarch64 - test-bun (×2)
68514 farm/f28e8720/ffi-bytelength-range-error :darwin: 26 aarch64 - test-bun
68519 this PR :darwin: 26 aarch64 - test-bun

Everything else that has reported is green, including every shard of the lanes that would catch this change:

  • :debian: 13 x64-asan - test-bun — all shards pass
  • :windows: 2019 x64 - test-bun, :windows: 2019 x64-baseline - test-bun, :windows: 11 aarch64 - test-bun — all shards pass
  • cargo clippy, Format, Lint JavaScript, and every build lane — pass

Those are the same lanes that caught the two real regressions earlier in this PR (the Bun Shell exit-code suite, and the spurious unhandledRejection on a throwing immediate), so they are load-bearing here, not incidental.

Final: I re-rolled once (4c2a3b6f, an empty commit) and build 68550 hit the identical failure on the same shard:

Error: buildkite-agent artifact download timed out after 120s for step
'darwin-aarch64-build-bun'. Refusing to continue with a partial download

That is the only re-roll I'll spend, so no further retriggers from me.

Build 68550 also lost a second darwin shard, to a different network failure, which makes the diagnosis clearer: it isn't one flaky artifact step, the darwin agents can't reach the network.

shard failure got as far as
:darwin: 26 aarch64 buildkite-agent artifact download timed out after 120s no tests
:darwin: 14 x64 fatal: unable to access 'https://github.com/oven-sh/bun.git/': Could not resolve host: github.com no checkout

282 of 284 lanes passed, and there are no error-level annotations on the build, i.e. not a single test failed anywhere.

The diff itself is green and that is not an inference: the retrigger commit is empty, so build 68519 above ran the byte-identical tree and finished 283 passed / 1 failed, the one failure being this same artifact download, with no error-level annotations anywhere. Every x64-asan and Windows test-bun shard passed, which are the lanes that caught the two real regressions earlier in this PR.

Ready for a maintainer as-is. A re-run once the darwin artifact fetch is healthy should go green without any change to the code.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference: #33356 lands in the same area and will conflict with this on merge. Details and a suggested reconciliation are in #33356 (comment).

Short version: that PR fixes a different bug (work a handler defers behind a microtask hop is dropped, rather than emitted in the wrong order), and I verified this branch does not fix its repro, so both are needed. But both add the same report-then-drain loop, plumbed through handleRejectedPromises() -> bool here versus a new has_pending_rejected_promises() extern there, across the same five files. Whichever merges first, the other should rebase and reuse the loop that's already present.

The darwin aarch64 test-bun shard failed on 'buildkite-agent artifact download
timed out after 120s for step darwin-aarch64-build-bun' before running a test.
Every other lane passed (283/284).
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.

1 participant