LearnNewsExamplesServices
Frontmatter
id13319
titleBase.destroy() skips clearTimeout for Node timer handles
stateClosed
labels
bugaiarchitecture
assigneesneo-gpt
createdAtJun 15, 2026, 10:43 AM
updatedAtJun 21, 2026, 2:17 AM
githubUrlhttps://github.com/neomjs/neo/issues/13319
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 15, 2026, 2:03 PM

Base.destroy() skips clearTimeout for Node timer handles

Closed v13.1.0/archive-v13-1-0-chunk-3 bugaiarchitecture
neo-opus-grace
neo-opus-grace commented on Jun 15, 2026, 10:43 AM

Authored by Claude Opus 4.8 (Claude Code), @neo-opus-grace (Grace).

Context

Surfaced during #13314 (MemoryService graph-projection lifecycle). I needed destroy() to cancel in-flight projection-retry setTimeouts and reached for Base's async-timer registry (this.timeout() / registerAsync) — then found it does not clear timers in Node, so #13314 tracks the timers explicitly instead. This ticket captures the underlying Neo.core.Base gap.

The Problem

Base.destroy() clears registered async timers with a browser-only guard:

me.#asyncRejects.forEach((reject, id) => {
    if (Neo.isNumber(id)) {   // src/core/Base.mjs:512
        clearTimeout(id)
    }
    reject(Neo.isDestroyed)
});

Base.timeout() registers setTimeout's return value as the id (src/core/Base.mjs:1159). In the browser that is a Number (Neo.isNumber true → cleared). In Node (every ai/ service + any Node Neo runtime) setTimeout returns a Timeout object, so Neo.isNumber(id) is falseclearTimeout is never called. The timer is not cleared on destroy: it keeps the event loop alive until it fires naturally (a process-exit delay for one-shot CLI invocations) and then fires against a torn-down instance (benign — the reject already won the promise, so the internal resolve() is a no-op, but the timer leaked).

Base.trap() registers Symbol ids (src/core/Base.mjs:1176, no timer); those are correctly skipped by the guard — and must stay skipped.

The Architectural Reality

  • src/core/Base.mjs:508-546destroy() + the #asyncRejects sweep (the browser-only guard at :512).
  • src/core/Base.mjs:1154-1165timeout() registers a real setTimeout timer id.
  • src/core/Base.mjs:1172-1188trap() registers a Symbol id (no timer).
  • The #asyncRejects map mixes timer ids (Number in browser / Timeout object in Node) and Symbol ids (trap). The guard must clear the former in BOTH runtimes and skip the latter.

The Fix

Replace the browser-only Neo.isNumber(id) guard with a runtime-agnostic one that clears Number and Timeout-object ids while still skipping Symbols, e.g.:

if (typeof id !== 'symbol') {
    clearTimeout(id)
}

(clearTimeout is a safe no-op for an already-settled Number or Timeout object; only Symbol must be excluded — verify clearTimeout(symbol) is excluded rather than thrown.)

Acceptance Criteria

  • A Base instance with a pending this.timeout(...) has that timer clearTimeout'd on destroy() in Node (a clearTimeout spy is called with the timer id; or the timer does not fire post-destroy).
  • trap()'s Symbol ids are still NOT passed to clearTimeout.
  • Browser numeric ids remain cleared (no regression).

Out of Scope

  • #13314's explicit per-timer Set tracking in MemoryService — it can stay, or migrate to Base.timeout() once this lands. (It worked around exactly this gap.)

Avoided Traps

  • clearTimeout(id) unconditionally — drops the Symbol-id exclusion trap() relies on; the guard must still skip Symbols.

Related

  • #13314 (surfaced it; the PR Deltas flag it as a latent Base.mjs gap), #13313.

Decision Record impact: none — a correctness fix within the existing Base async-lifecycle contract (it makes the documented destroy-cancellation actually work in Node).

Release classification: post-release (latent core-lifecycle bug; not v13-blocking). Boardless.

Note: framework-core change (every class's destroy() path). @tobiu flagged the Base.mjs fix as his call during #13314; filed unassigned for visibility/prioritization.

Origin Session ID: 0f5d9f1d-0683-452d-aac1-f467297186ac