Skip to content

timer.unref() is forgotten after 65,536 later timers: the bounded ref-state table evicts still-scheduled timers and the process stays alive #10447

Description

@proggeramlug

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 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions