You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. Once more than 65,536 timers have been created after an unref()'d timer,
that timer's unref state is evicted from the bounded id→ref-state table, the lookup falls back to "ref'd", and the
process no longer exits until the long timer fires (and runs its callback).
Reproduction
main.js:
// one long unref'd timer, then >65,536 short-lived timers; the process should exit immediatelyconstMANY=Number(process.env.MANY??70000);constt0=Date.now();constkeep=setTimeout(()=>console.log("long timer fired at",Date.now()-t0,"ms"),8000);keep.unref();for(leti=0;i<MANY;i++)clearTimeout(setTimeout(()=>{},1000));console.log("main done");
node main.js
PERRY_NO_AUTO_OPTIMIZE=1 perry compile main.js -o main &&time ./main
MANY=60000 ./main # below the cap: exits immediately
Expected (Node 26.5.1)
main done
(exits in ~0.1 s; the unref'd timer never fires)
Actual (Perry)
main done
long timer fired at 8000 ms
(the process waits 8.0 s and runs the unref'd timer's callback)
Impact
Long-running servers and CLIs that create a timer per request/operation (rate limiters, timeouts, retries,
debounce) routinely exceed 65,536 timers. After that, every earlier unref() — the standard way to keep a
maintenance/heartbeat/cache-sweep timer from holding the process open — is silently undone: processes hang at
exit, and callbacks the program deliberately detached run anyway.
Observed while profiling rate-limiter-flexible 11.2.0 (one timer per consumed key): a 1M-operation run lingered
12–15 s after the work finished.
Notes
Cause (verified by reading the code): [perf] Quadratic async/promise/timer structures + object-keyed Map O(n) + permanent TIMER_REF_STATES leak #6084 bounded the id→ref-state registry
(crates/perry-runtime/src/timer/ref_states.rs, TIMER_REF_STATES_CAP = 65_536) with FIFO eviction by
insertion order (insert_bounded). Eviction does not check whether the id is still scheduled, so a live,
long-delay unref'd timer is evicted as soon as 65,536 newer ids are inserted. timer_has_ref_state
(crates/perry-runtime/src/timer.rs:124) then returns the default true for the missing id, and the loop-liveness
checks (timer.rs:1266, 1447, 1703, 1791) treat the timer as ref'd. The module comment assumes "an evicted
id is never re-queried in practice", which does not hold for timers that are still pending.
record_timer_handle_kind (timer.rs:617) uses the same cap with min()-key eviction (a linear scan per insert
once full), so the Timeout/Immediate constructor lookup has the same eviction-of-live-ids problem.
A fix should keep the ref state with the scheduled timer entry itself (or only evict ids that are cleared/fired),
keeping the bound for post-clear .hasRef() bookkeeping. Related perf note: the same tables make setTimeout
churn cliff past 65,536 ids (60k timers ≈ 22 ms, 100k ≈ 2.5 s in a microbenchmark).
No existing issue found (searched: unref timer 65536, timer unref forgotten).
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. Once more than 65,536 timers have been created after an
unref()'d timer,that timer's unref state is evicted from the bounded id→ref-state table, the lookup falls back to "ref'd", and the
process no longer exits until the long timer fires (and runs its callback).
Reproduction
main.js:Expected (Node 26.5.1)
(exits in ~0.1 s; the unref'd timer never fires)
Actual (Perry)
(the process waits 8.0 s and runs the unref'd timer's callback)
Impact
debounce) routinely exceed 65,536 timers. After that, every earlier
unref()— the standard way to keep amaintenance/heartbeat/cache-sweep timer from holding the process open — is silently undone: processes hang at
exit, and callbacks the program deliberately detached run anyway.
12–15 s after the work finished.
Notes
(
crates/perry-runtime/src/timer/ref_states.rs,TIMER_REF_STATES_CAP = 65_536) with FIFO eviction byinsertion order (
insert_bounded). Eviction does not check whether the id is still scheduled, so a live,long-delay unref'd timer is evicted as soon as 65,536 newer ids are inserted.
timer_has_ref_state(
crates/perry-runtime/src/timer.rs:124) then returns the defaulttruefor the missing id, and the loop-livenesschecks (
timer.rs:1266,1447,1703,1791) treat the timer as ref'd. The module comment assumes "an evictedid is never re-queried in practice", which does not hold for timers that are still pending.
record_timer_handle_kind(timer.rs:617) uses the same cap withmin()-key eviction (a linear scan per insertonce full), so the
Timeout/Immediateconstructor lookup has the same eviction-of-live-ids problem.keeping the bound for post-clear
.hasRef()bookkeeping. Related perf note: the same tables makesetTimeoutchurn cliff past 65,536 ids (60k timers ≈ 22 ms, 100k ≈ 2.5 s in a microbenchmark).