Conversation
count() and find_max() in src/io/heap.rs recursed once per node. A heap built from timers with decreasing deadlines is one chain of child links, so jest.getTimerCount() and jest.runOnlyPendingTimers() overflowed the native stack at a few hundred thousand fake timers and died with SIGSEGV. Both now use one iterative depth-first walk that climbs back up through the prev links in constant stack space.
|
Warning Review limit reached
On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file. Or wait 2 minutes for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 10:33 AM PT - Sep 6th, 2026
❌ @robobun, your commit 2809034 has 2 failures in
The baseline build contains instructions not available on Static scan violations
|
|
Status Reproduced on bun 1.4.3-canary (f42e980): 1e6 fake timers with decreasing deadlines, then Fix: one iterative walk shared by Reviewed: this PR should stay open as the small crash fix. It overlaps #40187 on the same CI: the diff is green. The new test passes on every lane in builds 111095, 111372, and 111382. The remaining red lanes are unrelated to this change and also fail on main: |
There was a problem hiding this comment.
I reviewed this and didn't find any bugs. The iterative walk relies on the same prev invariant that remove() already depends on (line 188's (*prev).heap().child == v check), and I traced it against single-node, sibling-chain, child-chain, and mixed shapes — each node is visited exactly once and the null-root early return is preserved. Because this is an unsafe-Rust rewrite of a traversal that also backs the real event-loop timer heap, a human look at the prev-climb reasoning would still be worthwhile.
What was reviewed
for_eachcorrectness: verified theprev.child == nodeparent-detection matches the invariantmeld/remove/combine_siblingsmaintain, and thatroot.prev == null/root.next == nullhold on every mutation path so the climb terminates without missing nodes.find_maxsemantics: null root still returns null (for_each early-returns before the closure runs); the redundantless(root, root)on the first visit matches the old code's first call.- Dead code:
count_internal/find_max_internalhave no remaining references. - Test:
sh -c 'ulimit -s … && exec "$0" -e "$1"'avoids shell-escaping the script, pipes are drained concurrently, stdout is asserted before exitCode, and the Windows skip is scoped to the POSIX-onlyulimit.
Extended reasoning...
Overview
The PR replaces two recursive helpers in the intrusive pairing heap (src/io/heap.rs) — count_internal and find_max_internal — with a single iterative for_each that walks the tree in constant stack space by descending through child, moving across next, and climbing back via prev until prev.child == node identifies the parent. count() and find_max() now both delegate to it. A regression test in fake-timers.test.ts spawns a child under ulimit -s 1024, inserts 60k decreasing-deadline fake timers, and asserts getTimerCount() and runOnlyPendingTimers() complete with correct counts.
Security risks
None. This is an internal data-structure traversal with no user-controlled input beyond timer count; the change removes an unbounded-recursion DoS vector rather than adding attack surface. No parsing, no allocation-size arithmetic, no boundary crossings.
Level of scrutiny
High. This is unsafe Rust in a shared data structure used not only by fake timers but by the real event-loop timer heap (src/runtime/timer/mod.rs). A traversal that skips or double-visits nodes would corrupt getTimerCount and runOnlyPendingTimers semantics for every consumer. I walked the algorithm against the invariants the existing meld/combine_siblings/remove code maintains: every non-root node's prev points either to its left sibling (where prev.child != node) or to its parent (where prev.child == node) — the exact discriminant remove() already uses at line 188 — and the root's prev and next are always null across insert/delete_min/remove. Under those invariants the walk visits each node exactly once and terminates at the root. The find_max null-root path and the redundant less(root, root) first comparison both match the old code's behavior.
Other factors
The test follows harness conventions (bunExe/bunEnv, Promise.all drain, stdout-before-exitCode assertion order, narrow test.skipIf(isWindows) for the POSIX-only ulimit), and the "$0" -e "$1" argv-passing pattern sidesteps shell-escaping the inline script. The 60s per-test timeout is a generous ceiling for an ~8s debug-ASAN run per the PR notes. The old recursive helpers are fully deleted with no stragglers. The PR author reports out-of-tree brute-force verification across insert/remove/delete_min sequences and a 2M-node chain, which the in-tree test cannot host because bun_io doesn't link under cargo test. Given the unsafe rewrite sits on the real timer path, a maintainer sign-off on the prev-chain reasoning is the right bar even though I found no defects.
Problem
jest.getTimerCount()andjest.runOnlyPendingTimers()die with a silentSegmentation fault(exit 139, no crash banner) once a few hundred thousand fake timers are pending. An ASAN build reportsAddressSanitizer: stack-overflowinIntrusive::count_internal(src/io/heap.rs:94) andIntrusive::find_max_internal(src/io/heap.rs:124).childlinks in the pairing heap, so the recursion depth equals the timer count. With increasing deadlines the chain runs alongnextlinks instead. The release build overflows the 8 MB main stack at about 240k timers.Fix
count()andfind_max()now share one iterative depth-first walk,Intrusive::for_each. It descends throughchildandnext, and climbs back throughprevlinks. The walk uses constant stack space.prevpoints at its left sibling, or at its parent when it is the leftmost child, andmeld,combine_siblings, andremovekeep that invariant. Soprev.child == nodetells the walk that it reached the parent.test/js/bun/test/fake-timers/fake-timers.test.ts(new test: 60k decreasing timers under a 1 MB stack, stock bun segfaults). Alsotest/js/bun/test/test-timers.test.ts,test/js/node/timers, and the sinonjs fake-timers port.heap.rsregion with aVec-stackfor_each. Either walk fixes the crash. This PR is the small step: if timer: remove unsafe from the timer module #40187 lands first, this one reduces to its test. If this lands first, timer: remove unsafe from the timer module #40187 takes its ownfor_eachon rebase and keeps the test.Background
src/io/heap.rsis an intrusive pairing heap. Each node embeds anIntrusiveFieldwithchild,next, andprevpointers.childis the leftmost child,nextthe right sibling,prevthe left sibling or (for the leftmost child) the parent.src/runtime/test_runner/timers/FakeTimers.rskeeps pending timers in this heap.getTimerCountcallscount().runOnlyPendingTimerscallsfind_max()to find the deadline it must run up to.src/runtime/timer/mod.rs) is the same type, so it gets the same walk.Notes
getTimerCount()→ SIGSEGV.runOnlyPendingTimers()with 300k decreasing timers → SIGSEGV. Increasing order survives on the release build because thenextleg of the old recursion is a tail position that the optimizer turned into a loop.ulimit -s: at 512 KB between 14k and 16k timers (about 35 bytes per frame). The debug ASAN build overflows between 100k and 200k timers at 8 MB.sh -c 'ulimit -s 1024 && exec ...'so that 60k timers are enough. 60k timers take about 8 s on the debug ASAN build, almost all of it insetTimeoutcreation and firing. A 256 KB stack is too small for the debug build to boot the VM.insert,meld,delete_min,remove, andcombine_siblingsare already loop based. Nothing else in the file recurses.for_eachpublic and retargeting the two teardown walks insrc/runtime/timer/mod.rsonto it (those walks belong to timer: remove unsafe from the timer module #40187's refactor, and this PR stays a crash fix).remove, and tendelete_mincalls, in decreasing, increasing, and scrambled insert orders, plus a 2M-node chain. That test cannot live inbun_iobecause the crate does not link standalone undercargo test.no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/test/fake-timers/fake-timers.test.ts