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)) {
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 false → clearTimeout 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-546 — destroy() + the #asyncRejects sweep (the browser-only guard at :512).
src/core/Base.mjs:1154-1165 — timeout() registers a real setTimeout timer id.
src/core/Base.mjs:1172-1188 — trap() 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
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
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-retrysetTimeouts and reached forBase'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 underlyingNeo.core.Basegap.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()registerssetTimeout's return value as the id (src/core/Base.mjs:1159). In the browser that is aNumber(Neo.isNumbertrue → cleared). In Node (everyai/service + any Node Neo runtime)setTimeoutreturns aTimeoutobject, soNeo.isNumber(id)is false →clearTimeoutis 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 — therejectalready won the promise, so the internalresolve()is a no-op, but the timer leaked).Base.trap()registersSymbolids (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-546—destroy()+ the#asyncRejectssweep (the browser-only guard at:512).src/core/Base.mjs:1154-1165—timeout()registers a realsetTimeouttimer id.src/core/Base.mjs:1172-1188—trap()registers aSymbolid (no timer).#asyncRejectsmap mixes timer ids (Numberin browser /Timeoutobject in Node) andSymbolids (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 clearsNumberandTimeout-object ids while still skippingSymbols, e.g.:if (typeof id !== 'symbol') { clearTimeout(id) }(
clearTimeoutis a safe no-op for an already-settledNumberorTimeoutobject; onlySymbolmust be excluded — verifyclearTimeout(symbol)is excluded rather than thrown.)Acceptance Criteria
Baseinstance with a pendingthis.timeout(...)has that timerclearTimeout'd ondestroy()in Node (aclearTimeoutspy is called with the timer id; or the timer does not fire post-destroy).trap()'sSymbolids are still NOT passed toclearTimeout.Out of Scope
Settracking inMemoryService— it can stay, or migrate toBase.timeout()once this lands. (It worked around exactly this gap.)Avoided Traps
clearTimeout(id)unconditionally — drops theSymbol-id exclusiontrap()relies on; the guard must still skipSymbols.Related
Base.mjsgap), #13313.Decision Record impact:
none— a correctness fix within the existingBaseasync-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 theBase.mjsfix as his call during #13314; filed unassigned for visibility/prioritization.Origin Session ID: 0f5d9f1d-0683-452d-aac1-f467297186ac