Skip to content

timer: assign epoch on heap insert so AbortSignal.timeout fires in creation order - #34895

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/00d5441f/abortsignal-timeout-order
Jul 22, 2026
Merged

timer: assign epoch on heap insert so AbortSignal.timeout fires in creation order#34895
Jarred-Sumner merged 2 commits into
mainfrom
farm/00d5441f/abortsignal-timeout-order

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

AbortSignal.timeout(ms) signals created with the same deadline were firing their abort events in (near-)reverse creation order, while setTimeout with the same delay fires in creation order in the same process. Node and browsers fire both in creation order; WPT covers this in dom/abort/timeout.any.js ("AbortSignal timeouts fire in order").

Repro

let a = ""; for (const v of "123456") setTimeout(() => { a += v; }, 5);
let b = ""; for (const v of "123456") { const s = AbortSignal.timeout(5); s.onabort = () => { b += v; }; }
setTimeout(() => console.log({ setTimeout: a, timeoutSignals: b }), 200);
// before: { setTimeout: "123456", timeoutSignals: "654321" }
// after:  { setTimeout: "123456", timeoutSignals: "123456" }

Cause

Equal-deadline JS timers are ordered in the pairing heap by a monotonically increasing epoch stored in the timer's TimerFlags. setTimeout/setInterval schedule via All::update(), which bumped the global epoch and wrote it into the timer before the heap insert. AbortSignal.timeout schedules via All::insert() directly (through the timer_insert runtime hook), which did not bump the epoch, so every signal timer kept the default epoch 0. With all epochs equal, EventLoopTimer::less(a, b) evaluates the tiebreak as (0 - 0) < U25_MAX/2 == true for every pair, so each newly inserted node becomes the heap root: LIFO order on pop.

Fix

Move the epoch bump from All::update() into All::insert(). update() already calls insert() as its final step, so the setTimeout/setInterval path is unchanged (one bump per schedule/refresh, same as before). AbortSignal.timeout now gets a fresh epoch on its direct insert() call and sorts alongside setTimeout in creation order. Non-JS-timer tags (SubprocessTimeout, ValkeyConnectionTimeout, GC timers, etc.) are unaffected because js_timer_flags_ptr returns None for them.

Verification

bun bd test test/js/web/abort/abort.test.ts

New test fails on main with [5, 4, 3, 2, 1, 0, "t"] and passes with this change.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/abort/abort.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 (1080b7c6e)

test/js/web/abort/abort.test.ts:
(pass) AbortSignal > spawn test [381.23ms]
(pass) AbortSignal > AbortSignal.timeout(n) should not freeze the process [537.60ms]
(pass) AbortSignal > AbortSignal.any() should fire abort event [24.05ms]
(pass) AbortSignal > .signal.reason should be a DOMException [10.05ms]
(pass) AbortSignal > .signal.reason should be a DOMException for timeout [19.48ms]
109 |       cmd: [bunExe(), "-e", src],
110 |       env: bunEnv,
111 |       stderr: "pipe",
112 |     });
113 |     const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
114 |     expect(JSON.parse(stdout.trim())).toEqual([0, 1, 2, 3, 4, 5, "t"]);
                                            ^

... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (1080b7c6e)

test/js/web/abort/abort.test.ts:
(pass) AbortSignal > spawn test [11.01ms]
(pass) AbortSignal > AbortSignal.timeout(n) should not freeze the process [25.42ms]
(pass) AbortSignal > AbortSignal.any() should fire abort event [0.38ms]
(pass) AbortSignal > .signal.reason should be a DOMException [0.12ms]
(pass) AbortSignal > .signal.reason should be a DOMException for timeout [10.33ms]
(pass) AbortSignal > AbortSignal.timeout with equal deadlines fire in creation order [16.58ms]

 6 pass
 0 fail
 14 expect() calls
Ran 6 tests across 1 file. [250.00ms]
__F:0:S:0
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/web/abort/abort.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 (1080b7c6e)

test/js/web/abort/abort.test.ts:
(pass) AbortSignal > spawn test [382.61ms]
(pass) AbortSignal > AbortSignal.timeout(n) should not freeze the process [523.39ms]
(pass) AbortSignal > AbortSignal.any() should fire abort event [24.56ms]
(pass) AbortSignal > .signal.reason should be a DOMException [10.27ms]
(pass) AbortSignal > .signal.reason should be a DOMException for timeout [19.24ms]
(pass) AbortSignal > AbortSignal.timeout with equal deadlines fire in creation order [444.72ms]

 6 pass
 0 fail
 14 expect() calls
Ran 6 tests across 1 file. [3.48s]
__F:0:S:0

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) in 691ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
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: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m   Compiling�[0m bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
�[1
... (truncated)
diff hotspot
src/runtime/timer/mod.rs        | 24 +++++++++++++-----------
 test/js/web/abort/abort.test.ts | 27 +++++++++++++++++++++++++++
 2 files changed, 40 insertions(+), 11 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                             reads  edits  tests
src/runtime/timer/mod.rs             5      4      0
test/js/web/abort/abort.test.ts      2      3      0

…eation order

AbortSignal.timeout(ms) signals created with the same deadline were
firing their abort events in reverse creation order, while
setTimeout with the same delay fires in creation order.

AbortSignal.timeout schedules via All::insert() directly, which did not
bump the per-timer epoch, so every signal timer had epoch 0. With all
epochs equal, the pairing-heap meld makes each new insert the root,
giving LIFO order on pop. setTimeout goes through All::update() which
did bump the epoch, so it was unaffected.

Move the epoch bump from update() into insert() so every JS-timer heap
insertion (setTimeout/setInterval reschedule and AbortSignal.timeout
creation) gets a fresh epoch. update() calls insert(), so
setTimeout/setInterval behavior is unchanged. Non-JS-timer tags are
untouched since js_timer_flags_ptr returns None for them.
@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with the reported snippet; the new test in test/js/web/abort/abort.test.ts fails on main with reverse order [5, 4, 3, 2, 1, 0, "t"] and passes with this change.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:18 AM PT - Jul 21st, 2026

@robobun, your commit 1080b7c6e9413ed8f32cc653e512eb2bada2243f passed in Build #76731! 🎉


🧪   To try this PR locally:

bunx bun-pr 34895

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

bun-34895 --bun

@coderabbitai

coderabbitai Bot commented Jul 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: 3da17f04-4d25-4cfe-bed5-d8a15bf4335c

📥 Commits

Reviewing files that changed from the base of the PR and between 91e2402 and 1080b7c.

📒 Files selected for processing (1)
  • test/js/web/abort/abort.test.ts

Walkthrough

Changes

JS timer ordering

Layer / File(s) Summary
Centralize JS timer epoch updates
src/runtime/timer/mod.rs
All::insert updates JS timer epochs before heap placement, while All::update relies on reinsertion for refreshed timers.
Validate equal-deadline abort ordering
test/js/web/abort/abort.test.ts
Adds a test confirming equal-deadline AbortSignal.timeout callbacks run in creation order alongside a same-delay timer.

Possibly related issues

  • oven-sh/bun#33334: Both changes address deterministic ordering for AbortSignal.timeout() timers and their abort events.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: assigning timer epoch on heap insert to restore AbortSignal.timeout creation order.
Description check ✅ Passed The description matches the template and includes both the change summary and a concrete verification command/result.
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.

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

LGTM — clean fix that moves the epoch bump one call level down so AbortSignal.timeout gets a fresh tiebreak epoch on heap insert. One minor test nit inline.

What was reviewed:

  • Enumerated every direct All::insert() caller (DNS, DateHeaderTimer, EventLoopDelayMonitor, Subprocess, GC, Valkey, SQL hooks) — all use non-JS-timer tags, so js_timer_flags_ptr returns None and no new epoch bump occurs for them.
  • TimerObjectInternals reschedule paths (setInterval re-arm, refresh) all go through update()insert(): still exactly one bump per schedule, so setTimeout/setInterval ordering is unchanged.
  • Epoch bump now runs before the heap insert in insert(), so EventLoopTimer::less sees the fresh epoch during meld — same as before.
  • No &mut *timer is live across the new js_timer_flags_ptr call in insert() (SB-clean, mirroring the pattern removed from update()).
Extended reasoning...

Overview

Two files: src/runtime/timer/mod.rs moves the ~6-line epoch-bump block from All::update() into All::insert() (which update() already calls as its final step), and test/js/web/abort/abort.test.ts adds a subprocess test asserting six AbortSignal.timeout(5) handlers plus one setTimeout(_, 5) fire in creation order. The PR description correctly diagnoses the root cause: AbortSignal.timeout schedules via the timer_insert runtime hook → All::insert() directly, bypassing the epoch bump that lived only in update(), so all signal timers had epoch 0 and the pairing-heap tiebreak produced LIFO order.

Security risks

None. This is a scheduling-order fix inside the per-thread timer heap; no untrusted input parsing, no allocation sizing, no cross-thread state. The moved block is byte-identical to what was already running on every setTimeout/setInterval schedule.

Level of scrutiny

Medium — timer heap ordering is core runtime infrastructure, but the change is mechanical: a block moves from a caller into the callee it unconditionally invokes. I verified behavior-preservation by checking every other insert() call site: DateHeaderTimer (tag DateHeaderTimer), EventLoopDelayMonitor (tag EventLoopDelayMonitor), DNS resolver, Subprocess timeout, GC controller, Valkey connection timeout, and the SQL timer_insert hook all use tags for which js_timer_flags_ptr returns None, so the new block is a no-op for them. The only new epoch consumer is AbortSignalTimeout — exactly the fix target. TimerObjectInternals (setTimeout/setInterval init, setInterval re-arm, refresh()) all go through update(), so they still get exactly one bump per schedule.

Other factors

The test follows harness conventions (spawn with -e, bunEnv, await using, concurrent pipe drain, awaits done.promise rather than sleeping) and cites the WPT case it mirrors. robobun confirmed it fails on main with [5,4,3,2,1,0,"t"]. The one nit — expect(stderr).toBe("") can flake on ASAN/debug lanes per REVIEW.md — is already flagged inline and is trivially fixable by dropping the line; the JSON.parse + exitCode assertions already prove correctness. Not blocking on it.

Comment thread test/js/web/abort/abort.test.ts

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

LGTM — pure code motion of the epoch bump from All::update() into its callee All::insert(), so setTimeout/setInterval are provably unchanged and AbortSignal.timeout now gets the epoch it was missing.

What was reviewed:

  • Enumerated every All::insert() caller (dns, valkey, subprocess, GC, sql, DateHeaderTimer, EventLoopDelayMonitor, timer_insert hook) — all non-JS-timer tags return None from js_timer_flags_ptr, so no new epoch bumps outside the intended AbortSignalTimeout.
  • Confirmed TimeoutObject/ImmediateObject reach insert() only via update() (reschedule()update()), so still exactly one epoch bump per schedule/refresh — no double-bump.
  • Stacked-Borrows ordering in update() still holds: timer_ref's last use precedes the raw-ptr reads inside insert().
  • Test: subprocess pipes drained concurrently, awaits the actual completion event, prior stderr-empty nit addressed in 1080b7c.
Extended reasoning...

Overview

Moves the global-epoch bump + per-timer set_epoch write from All::update() into All::insert() in src/runtime/timer/mod.rs. Since update()'s final step is already self.insert(timer), the setTimeout/setInterval path is byte-for-byte behavior-preserving (one bump per schedule/refresh). The only semantic change is that direct insert() callers whose tag matches js_timer_flags_ptr — which is exclusively AbortSignalTimeout among the direct-insert paths — now receive a fresh epoch and therefore sort in creation order among equal-deadline timers, matching WPT and Node. Adds a subprocess regression test in test/js/web/abort/abort.test.ts.

Security risks

None. No untrusted-input parsing, no allocation sizing, no auth/crypto surface. The change is a 6-line block moved between two functions in the same file.

Level of scrutiny

Timer heap ordering is core runtime code, so I traced every insert() call site: update() (behavior-preserving), DateHeaderTimer::run/enable, EventLoopDelayMonitor::enable/on_fire, and via the timer_insert hook AbortSignal::Timeout, GarbageCollectionController, JSValkeyClient, Subprocess, DNS, and SQL. js_timer_flags_ptr returns Some only for TimeoutObject / ImmediateObject / AbortSignalTimeout; every other caller falls through to None and is unaffected. TimeoutObject/ImmediateObject never call insert() directly (only via update() in reschedule()), so there is no double-bump path. The epoch write happens before the heap insert in both fake- and real-timer branches, matching where EventLoopTimer::less reads it.

Other factors

The Stacked-Borrows note that was on the deleted block ("timer_ref's last use is above so the raw (*timer).tag read inside is SB-clean") still holds after the move — timer_ref.next.nsec = ... remains the last use before self.insert(timer) performs the same raw reads. The prior review comment about expect(stderr).toBe("") was addressed in 1080b7c; stderr is still drained concurrently but no longer asserted empty. The test awaits an actual completion signal (Promise.withResolvers counted down by each callback) rather than sleeping, and drains stdout/stderr/exited concurrently per REVIEW.md.

@Jarred-Sumner
Jarred-Sumner merged commit 3a4369b into main Jul 22, 2026
80 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/00d5441f/abortsignal-timeout-order branch July 22, 2026 06:20
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