fix(runtime): timeout.refresh() preserves ref state; Immediate has no numeric conversion - #10620
proggeramlug wants to merge 2 commits into
Conversation
… numeric conversion refresh() called set_timer_ref_state(id, true) unconditionally, so refreshing an unrefd Timeout/Interval re-refd it -- hasRef() flipped to true and the process stayed alive for a callback that had been deliberately detached from the event loop. Node refresh() reschedules only and never touches ref state; drop the forced ref-state write and let the existing entry (set at schedule time, updated by any ref()/unref() since, pinned while the timer is queued) stand. js_number_coerce gave setImmediate handles the same numeric-conversion shortcut as Timeout handles, so +setImmediate(...) returned a number instead of NaN. Node only gives Timeout (setTimeout/setInterval) a numeric conversion; Immediate has none. Add is_immediate_timer_id and gate the shortcut on it so an Immediate falls through to the generic toPrimitive/toString path, which already yields NaN. Fixes #10541 Fixes #10542
|
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 (5)
Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review. 📝 WalkthroughWalkthroughThe timer runtime now preserves ref and unref state across ChangesTimer handle behavior
Priority: ➖ Normal Estimated code review effort: 2 (Simple) | ~10 minutes Change: Bug fix · Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to The timer behavior changes preserve the intended ref-state and numeric-coercion semantics with matching regression coverage. No actionable merge 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 8 functions across 4 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 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 |
Summary
Two small timer-semantics defects in
crates/perry-runtime/src/timer.rs, fixed together (same subsystem, same file):timeout.refresh()re-refs anunref()'d timer:hasRef()flips to true and the process stays alive until it fires #10541 —timeout.refresh()re-refs anunref()'dTimeout/interval:hasRef()flips totrueand the process stays alive for a callback that had been deliberately detached from the event loop.+setImmediate(...)is a number in Perry; Node'sImmediatehas no numeric conversion (NaN) #10542 —+setImmediate(...)coerces to a number in Perry; in Node anImmediatehas no numeric conversion (+setImmediate(...)isNaN), unlikeTimeout(+setTimeout(...)/+setInterval(...)is the internal id).Root cause
timeout.refresh()re-refs anunref()'d timer:hasRef()flips to true and the process stays alive until it fires #10541:js_timer_refresh(crates/perry-runtime/src/timer.rs:914) calledset_timer_ref_state(timer_id, true)unconditionally after rescheduling both theCallbackTimer(Timeout) andIntervalTimerbranches. Node'sTimeout.refresh()only resets the start time / reschedules the deadline — it never touches ref state. The forced write meant anyunref()'d timer that was laterrefresh()'d became ref'd again.+setImmediate(...)is a number in Perry; Node'sImmediatehas no numeric conversion (NaN) #10542:js_number_coerce's pointer branch (crates/perry-runtime/src/builtins/numbers.rs:562) has a fast path that converts any known timer handle straight to its numeric id, gated only oncrate::timer::is_known_timer_id(id)— which is true for bothTimeoutandImmediatehandles alike (register_scheduled_timerrecordskind: CallbackTimerKind::{Timeout,Immediate}but the coercion never consulted it).Fix
js_timer_refresh: removed bothset_timer_ref_state(timer_id, true)calls. The id's ref-state entry is left exactly as it was — set atregister_scheduled_timertime and updated by anyref()/unref()call since — and per#10447's pinning, it cannot have been evicted from the bounded ref-state registry while the timer is still queued (_scheduled: ScheduledTimerIdholds the pin).builtins/numbers.rs: addedcrate::timer::is_immediate_timer_id(id)(newpub(crate) fnintimer.rs, thin wrapper over the existingtimer_handle_kind) and gated the numeric shortcut on!is_immediate_timer_id(id). AnImmediatenow falls through to the existing generic (object-shaped)toPrimitive/toStringpath, which already produces"[object Object]"→NaN— no new code needed there, it was simply unreachable for timer handles before.Both fixes are 2–10 line, surgical diffs; no architectural changes.
Tests
Added
test-files/test_gap_10541_10542_timer_refresh_ref_immediate_primitive.ts, comparing byte-for-byte againstnode --experimental-strip-types(pinned Node 26.5.1). Covers:unref()thenrefresh()on aTimeoutand on anInterval: both must keephasRef() === false, and the process must actually exit without running the (BUG-labelled) detached callback — this is measured, not assumed: the unref'd 1.2 s timer is left un-cleared, so if the bug re-refs it, its callback fires and prints beforeexit, which is what diverges the diff.ref()(explicit) thenrefresh(): must stay ref'd (guards a naive alternative fix that unconditionally clears ref state instead of leaving it alone).refresh()'dTimeoutstill reschedules and fires (proves the reschedule itself, not just ref state, still works).+setImmediate(...):typeofis"number",Number.isNaN(...)istrue;Object.prototype.toString.call(immediate)is"[object Object]".+setTimeout(...)/+setInterval(...): verified against real Node 26.5.1 first (not assumed) — both remain numbers,Number.isNaNisfalse.clearTimeout/clearImmediatestill work on all the handles produced above.Proof the test fails on the baseline (
/root/claude-fixbase-10476-main, unpatched, v0.5.1593, same-shape timer.rs as pre-fix): compiling and running the new test there reproduces both bugs exactly —On the fixed build, output is byte-for-byte identical to the Node oracle.
Also added two
perry-runtimeunit tests incrates/perry-runtime/src/timer/tests_inline.rs:refresh_and_immediate_primitive_tests::refresh_preserves_ref_state— exercisesjs_timer_refresh/js_timer_ref/js_timer_unref/js_timer_has_refdirectly for a Timeout, a ref'd Timeout, and an Interval.refresh_and_immediate_primitive_tests::immediate_kind_is_distinguished_from_timeout— exercises the newis_immediate_timer_idagainst Timeout/Interval/Immediate handles.Validation
RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --tests timer: 32/32 passed (includes the two new unit tests plus all pre-existing timer suites, including#10447'slive_timers_keep_ref_state_across_timer_churn, unaffected). No#[no_mangle]signatures or emitted-IR symbols changed, so noperry/codegen test re-run was needed.Lint (
SKIP_COMPILE_GATES=1 ./scripts/run_lint_gates.sh): 76/77 gates passed. The one red isPublic benchmark evidence freshness, pre-existing/red on every PR in this repo (unrelated to this change).Gap suite (targeted,
PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter <name>):test_gap_10541_10542_timer_refresh_ref_immediate_primitivePASS,test_gap_10447_timer_ref_state_evictionPASS,test_gap_6287_timer_batch_orderPASS,test_parity_timersPASS,test_parity_timers_promisesPASS. Did not run the full local gap suite (this change is scoped to two FFI entry points in one file, not a hot lowering/runtime path used by most programs); CI's gap-suite shards are the full gate per the standard process.Performance:
perf stat -e instructions,task-clock, 3 runs each, baseline (/root/claude-fixbase-10476-main, unpatched, sameperry-devprofile) vs this fix, on a refresh()++timeout-coercion churn loop (2,000,000 iterations, exercising both touched functions every iteration):No regression (both deltas favor the fix slightly —
js_timer_refreshnow does one fewer ref-state write per call than before, which outweighs the one addedis_immediate_timer_idcheck injs_number_coerce's already-cold timer branch). Node wall time for the same 2,000,000-iteration loop, for context only (different runtime, not a direct comparison): 0.228 s. Perry's absolute wall time for this specific synthetic micro-loop (~1.0 s) is a pre-existing characteristic of the mutex/Vec-scan-based timer queue (unrelated to and unmoved by this fix, per the A/B above) and out of scope for this PR.Not verified
Fixes #10541
Fixes #10542
Summary by CodeRabbit
Timeout.refresh()and interval refreshes now preserve the timer’s existingref()/unref()state.NaN, matching Node.js behavior.