Repository navigation
timer.unref() is forgotten after 65,536 later timers: the bounded ref-state table evicts still-scheduled timers and the process stays alive #10447
Copy link
Copy link
Closed
Labels
package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
Description
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 immediately
const MANY = Number(process.env.MANY ?? 70000);
const t0 = Date.now();
const keep = setTimeout(() => console.log("long timer fired at", Date.now() - t0, "ms"), 8000);
keep.unref();
for (let i = 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 immediatelyExpected (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 earlierunref()— 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 defaulttruefor 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 withmin()-key eviction (a linear scan per insert
once full), so theTimeout/Immediateconstructor 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 makesetTimeout
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).
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
Activity
fix(runtime): never evict a scheduled timer's ref state (#10447)
proggeramlug commentedon Sep 17, 2026
Fixed on
mainby PR #10538, landed in merge train #10559 (v0.5.1592) — the train closes its source PRs instead of merging them, so theFixes #10447keyword in #10538 never evaluated. Changelog fragmentchangelog.d/10538-*.mdis onmain.fix(runtime): carry a mock timer's registry pin through its own dispatch
Merge origin/main into turnloop/integration