fix(runtime): carry a mock timer's registry pin through its own dispatch - #10588
proggeramlug wants to merge 3 commits into
Conversation
A one-shot mock timer (node:test's mock.timers) left the queue via state.callbacks.remove(idx) and was destructured into (id, callback, args, context), dropping the popped entry's ScheduledTimerId pin right there -- before call_timer_callback had run, let alone finished. A callback that then churned more than TIMER_REF_STATES_CAP timers evicted its own handle mid-dispatch, exactly as #10447 evicted long- lived timers before the pin existed. Carry the ScheduledTimerId in the dispatch action tuple instead, and let it drop only after call_timer_callback returns. Interval mock timers are unaffected: their entry stays in the queue across a tick, so their pin was never at risk.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 8 included reviews per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe mock timer dispatch path now retains a one-shot timer’s scheduled ID through callback execution. A regression test verifies that the ID remains known and referenced during timer churn. ChangesMock timer dispatch
Priority: ⚪ Not assessed Estimated code review effort: 2 (Simple) | ~10 minutes Change: Bug fix · Severity of issue fixed: Low Merge Risk: ⚪ Minimal · up to The mock timer dispatch fix preserves timer identity through callback execution, keeps interval behavior unchanged, and adds focused regression coverage. No merge-blocking risk remains. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 2 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
scripts/gc_runtime_root_holders.py flags any new static/thread_local whose type could hold a GC heap pointer that no registered scanner in its own file reaches. crates/perry-runtime/src/timer/tests_inline.rs's new SELF_ID: AtomicI64 (added by the mock-timer dispatch-pin regression test) stores a scheduled timer id, never a heap pointer, and only exists under #[cfg(test)]. Classify it test_only.
|
Landed via merge train #10631 (v0.5.1595). All source commits preserve authorship; merged main matches the validated train exactly. |
Summary
Follow-up to #10538 (landed as part of v0.5.1592, fixing #10447). That PR
gave every scheduled timer a
ScheduledTimerIdpin that keeps itsref-state/kind entry in the shared registry alive until the timer is fired or
cleared, closing the original #10447 eviction bug for
setTimeout,setIntervalandsetImmediate.node:test's mock timers(
MockCallbackTimer, one-shot) have the same pin field, but the mockdispatch loop dropped it one step too early: before the callback ran,
not after. A one-shot mock timer whose own callback then churns more than
the registry's eviction cap (65,536) worth of other timers evicts its own
registry entry while it is still running — the same symptom #10447 fixed for
long-lived real timers, reopened for a narrower case by the mock path.
Root cause
crates/perry-runtime/src/timer.rs,mock_timers_advance_to: for a one-shotmock callback (not an interval), the fire loop did
state.callbacks.remove(idx)takes ownership of the whole queue entry,including its
_scheduled: ScheduledTimerIdpin. The tuple only takesid,callback,argsandcontextout of it —timer(and therefore_scheduled) drops, retiring the id, at the end of that block: beforecall_timer_callbackis even invoked below it, let alone before thecallback itself returns. Once retired, the id is an ordinary eviction
candidate; if the callback churns more timers than the cap before it
finishes, its own id — the oldest entry in the retired queue at that point —
is the first one evicted.
Interval mock timers don't have this bug: their queue entry is mutated in
place and stays in the queue (it needs to re-fire), so its pin is never
dropped by this loop. The real (non-mock)
CallbackTimer/IntervalTimerpaths don't have it either —
js_run_timer_phase's batch-drain keeps thewhole
CallbackTimer(pin included) alive in thefor (index, mut timer) in expired.into_iter()loop, dropping it only at the end of each iteration,i.e. after that timer's callback has already run.
Fix
Carry the pin in the dispatch action instead of leaving it behind on the
popped queue entry:
The interval branch now returns
Nonefor the pin slot (nothing to carry —its entry stays in the queue), so the tuple shape is shared and the two
branches stay symmetric.
Tests
crates/perry-runtime/src/timer/tests_inline.rs,mock_dispatch_own_pin_tests::a_one_shot_mock_timers_own_pin_survives_its_own_dispatch:schedules one mock one-shot timer whose callback churns
TIMER_REF_STATES_CAP + 2_000realsetTimeout+clearTimeoutcycles andthen checks its own id (
is_known_timer_id,js_timer_has_ref) frominside the still-running callback.
Reproduced on clean
mainfirst, then fixed (this repo's package-auditconvention): with the fix reverted (
git show HEAD:crates/perry-runtime/src/timer.rsrestored, keeping only the new test) the test fails —
— and passes with the fix restored. Existing gap coverage re-run and
confirmed unaffected:
test_gap_10447_timer_ref_state_eviction,test_parity_timers,test_parity_timers_promises,test_parity_test(covers
node:test'smock.timerssurface).Validation
All on Linux x64, Node 26.5.1, cgu=16 release builds.
RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --tests:3982 passed, 2 failed, 4 ignored. Both failures are pre-existing and
unrelated (confirmed identical on an independent branch touching disjoint
files — see the companion fix(codegen,runtime): remove the argument-count ceilings on dynamic calls #10532-followup PR for the same two):
gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_buildsand
gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check,both
debug_assert!-gated checks a--releasetest build compiles out.Lint (
SKIP_COMPILE_GATES=1 ./scripts/run_lint_gates.sh; the compiletier is documented as red on this Linux build host independent of any
change, so it was not run — see host notes): 76 of 77 gates passed, 2
CI-only skipped. The one red,
benchmarks/ci_public_baseline_check.py, ispre-existing — confirmed failing identically on an unmodified
c8cf450563checkout.
scripts/gc_runtime_root_holders.pyflagged the new test'sSELF_ID: AtomicI64(a timer id, never a GC pointer,#[cfg(test)]-only)— classified
test_onlyinscripts/gc_runtime_root_holders.json.raw_handle_debt.py: unaffected (906, unchanged).Gap suite (targeted, not a full local sweep — this change is a
narrow, mock-timers-only dispatch fix, not a hot path): all four filters
above PASS.
Performance (
perf stat -e instructions,task-clock, 3 runs/arm,medians below, Linux x64,
PERRY_NO_AUTO_OPTIMIZE=1, cgu=16 releasebinaries; baseline = unmodified
c8cf450563). Output verifiedbyte-identical between baseline and fix binaries before timing.
setTimeout(f,10)scheduled, thentick(10)fires it)Within noise — the fix moves one
ScheduledTimerId(a singlei64) oneextra step through an existing tuple/
Optioninstead of dropping it inplace; there is no additional allocation or lock on the per-firing path.
Not verified
perry/threadinteraction with mock timers was notexercised (mock timers are a single-realm feature; this was true before
this fix too).
setInterval/setImmediatemock-timer churn scenarios were notseparately scaled — the bug is specific to the one-shot removal path, and
the fix's
Nonebranch for intervals is unchanged behavior, covered byexisting interval tests.
Recovery note
This fix (and its regression test) were originally developed and verified on
a per-hour build host that was shut down mid-validation (owner action, not
an incident) before either could be pushed. The recovered material applied
against an older base (
7661bc05fe, before #10538 landed) and duplicatedmost of #10538's now-landed
ScheduledTimerIdmachinery rather thancontaining the actual one-step fix over it. Rather than replay a stale diff,
this PR re-derives the fix directly against current
main's realmock_timers_advance_to/ref_states.rs, from the root cause describedabove, and adds a fresh regression test reproduced against clean
mainfirst. Everything in Validation above ran from scratch on a fresh clone.
Part of #10447 / #10538 (the original ref-state eviction bug for real
timers is already fixed and merged; this closes a narrower gap in the mock
timer dispatch path that #10538 did not cover).
Summary by CodeRabbit
Bug Fixes
Tests