diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index 8a05dfdba08f..2956a87b3ec1 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -665,6 +665,17 @@ impl All { // SAFETY: caller guarantees `timer` is a valid live EventLoopTimer. let tag = unsafe { (*timer).tag }; debug_assert!(tag != EventLoopTimerTag::WTFTimer, "use wtf_arm"); + + // Bump the global epoch into the per-timer flags so equal-deadline JS + // timers (setTimeout/setInterval/AbortSignal.timeout) fire in insertion + // order. Before heap insert: `EventLoopTimer::less` reads epoch as tiebreak. + // SAFETY: `timer` is live (caller contract). + if let Some(flags) = unsafe { js_timer_flags_ptr(timer) } { + self.epoch = self.epoch.wrapping_add(1) & ((1u32 << 25) - 1); + // SAFETY: `flags` points into the live container recovered above. + unsafe { (*flags.as_ptr()).set_epoch(self.epoch) }; + } + if self.fake_timers.is_active() && tag.allow_fake_timers() { // SAFETY: see fn contract unsafe { @@ -829,17 +840,8 @@ impl All { timer_ref.next.sec = time.sec; timer_ref.next.nsec = time.nsec; - // Bump the global epoch and write it back - // into the per-timer flags so equal-deadline JS timers fire in - // refresh order. - // SAFETY: `timer` is live (caller contract); `timer_ref`'s last use - // is above so the raw `(*timer).tag` read inside is SB-clean. - if let Some(flags) = unsafe { js_timer_flags_ptr(timer) } { - self.epoch = self.epoch.wrapping_add(1) & ((1u32 << 25) - 1); - // SAFETY: `flags` points into the live container recovered above. - unsafe { (*flags.as_ptr()).set_epoch(self.epoch) }; - } - + // `insert` bumps the global epoch and writes it into the per-timer + // flags so equal-deadline JS timers fire in refresh order. self.insert(timer); } diff --git a/test/js/web/abort/abort.test.ts b/test/js/web/abort/abort.test.ts index 48c19fd1c347..1a61e509dd7f 100644 --- a/test/js/web/abort/abort.test.ts +++ b/test/js/web/abort/abort.test.ts @@ -87,4 +87,31 @@ describe("AbortSignal", () => { expect(fmt(ac.reason)).toEqual(fmt(new DOMException("The operation timed out.", "TimeoutError"))); expect(ac.reason.code).toBe(23); }); + + // https://wpt.fyi/results/dom/abort/timeout.any.html "AbortSignal timeouts fire in order" + test("AbortSignal.timeout with equal deadlines fire in creation order", async () => { + const src = ` + const order = []; + const done = Promise.withResolvers(); + let remaining = 7; + const tick = v => { order.push(v); if (--remaining === 0) done.resolve(); }; + for (let i = 0; i < 6; i++) { + const s = AbortSignal.timeout(5); + s.onabort = () => tick(i); + } + // setTimeout with the same delay is a reference: it already fires in + // creation order, and these signals should sort alongside it. + setTimeout(() => tick("t"), 5); + await done.promise; + console.log(JSON.stringify(order)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout.trim())).toEqual([0, 1, 2, 3, 4, 5, "t"]); + expect(exitCode).toBe(0); + }); });