@zakkster/lite-leak
Zero-GC leak diagnostic primitive for the @zakkster/lite-* ecosystem.
lite-leak wraps @zakkster/lite-cleanup with owner-tree attribution from @zakkster/lite-signal 1.5.0+. Track a target for GC observation; if it survives past its owner's cleanup, you get a structured leak report with the owner path snapshot at track-time.
Status: v1.3.0 -- stable. Eleven detection kernels shipped (raf-orphan in 1.1.0; worker-orphan, audio-node and socket-orphan in 1.2.0; gl-resource-orphan in 1.3.0). Full M2 audit API (auditByKind, auditByOwner, remediate). Four ecosystem sinks (createTraceSink, createGenericSink, createProfilerSignalSink, createStudioSink). Peer matrix validating owner-frame assumptions against the lite-signal 1.8.0 base and the rebuilt 1.9-1.12 line. Retained-heap budget suite. WHY-1.0.md and REJECTED.md ship in-tree. The lite-leakforge demo/toolkit product builds on top as a separate package.
- Single-file ESM, no bundled deps, ASCII-only source
- Auto-untrack via
lite-signal'sonCleanup: any FR-fired collection is by definition a target that outlived its owner - Owner-path attribution: non-retaining snapshot of
{id, kind}frames at track-time - Opt-in call-site capture via
Error().stack(dev only) - Zero-GC steady state on hot paths
- Node 20+; browsers with
FinalizationRegistry
Install
npm i @zakkster/lite-leak
npm i @zakkster/lite-signal # peer dep, >=1.5.0
Concept
The core insight: with lite-signal's owner tree, "clean disposal" is precisely defined. When an effect disposes, onCleanup callbacks fire in cascade order. lite-leak piggybacks on this: when you track() inside an effect body, it registers an onCleanup that automatically untracks the handle.
That means the operational definition of "a leak" is: a tracked target became unreachable, and its owner's cleanup never ran. The FR safety net catches exactly this case.
import { createLeakTracker } from '@zakkster/lite-leak';
import { effect } from '@zakkster/lite-signal';
const tracker = createLeakTracker({
onLeak: (report) => {
console.warn('LEAK:', report.tag, 'owner path:', report.ownerPath);
}
});
effect(() => {
const resource = acquireExpensive();
tracker.track(resource, () => releaseExpensive(resource.handle), 'expensive-1');
// ...
});
// On effect dispose: auto-untrack fires, no leak. Clean.
// vs.
effect(() => {
const resource = acquireExpensive();
globalCache.set('expensive', resource); // externalized; auto-untrack won't help
tracker.track(resource, () => {}, 'expensive-2');
});
// If globalCache never releases -- FR eventually fires, onLeak reports.
Naming note
untrack collides with lite-signal's untrack(fn) (which suppresses dependency tracking). Different signatures, different concerns. If you import both in the same file, alias one:
import { untrack as untrackLeak } from '@zakkster/lite-leak';
import { untrack as untrackDeps } from '@zakkster/lite-signal';
API
VERSION: string
Package version constant.
createLeakTracker(options?) -> tracker
Options:
| Field | Type | Description |
|---|---|---|
name |
string |
Diagnostics label. Default 'lite-leak'. |
captureStacks |
boolean |
Capture Error().stack at track time. Dev only. Default false. |
onLeak |
(report: LeakReport) => void |
Called on FR path (target GCd without prior untrack, manual or auto). |
onError |
(err: unknown, tag: unknown | null) => void |
Called if the caller's cleanup throws. |
Returns:
tracker.track(target, cleanup, tag?) -> handle
tracker.untrack(handle) -> void
tracker.size() -> number
tracker.name : string
tracker.track(target, cleanup, tag?)
Register target for leak observation.
- When called inside an effect or computed body, automatically wires
onCleanup(() => tracker.untrack(handle))so the handle is released on owner disposal. - When called outside any owner (top-level, inside
createRoot), the caller is responsible for eventually callinguntrack.
Returns an opaque handle.
tracker.untrack(handle)
Explicit untrack. Idempotent, null-safe. Cancels the finalizer without running cleanup (matches lite-cleanup semantics -- see below).
tracker.size()
Live tracked-handle count.
Module-level track / untrack
For one-off usage without a leak-report callback, import module-level track / untrack -- they lazily create a default tracker (name 'lite-leak') with no onLeak:
import { track, untrack } from '@zakkster/lite-leak';
Leak report shape
{
tag: unknown | null, // the tag passed to track()
ownerPath: readonly OwnerFrame[] // { id, kind } frames, root-first
| null, // null if track() called outside any owner
origin: string | null, // Error().stack if captureStacks: true, else null
kind: 'unknown', // reserved for M1 kernels
collectedAt: number, // performance.now() at FR firing
}
type OwnerFrame = { id: number, kind: 'effect' | 'computed' | 'signal' };
Frames contain only primitives -- the id (number) and kind (string enum) do NOT retain owner node references. Safe to hold across async boundaries.
Held-value contract (inherits + extends lite-cleanup)
Two rules for track(target, cleanup, tag):
- The
cleanupclosure MUST NOT close overtarget. (Inherited fromlite-cleanup.) - The
tagvalue MUST NOT close overtarget. Since tags are retained on the internal record until FR fires or explicit untrack, capturingtargetvia the tag defeats finalization the same way as the cleanup rule.
Wrong:
tracker.track(target, () => target.dispose(), target); // BOTH RULES VIOLATED
Right:
const resource = target.resource;
tracker.track(target, () => resource.release(), 'my-tag-string');
Semantics quick reference
| Path | Fires cleanup? | Fires onLeak? |
|---|---|---|
| Owner disposal -> auto-untrack fires | No | No |
Explicit tracker.untrack(handle) |
No | No |
| FR path (target GCd, no prior untrack) | Yes | Yes |
Explicit untrack cancels the FR without running cleanup -- the caller has already handled disposal by other means.
Zero-GC profile
Measured on Node 22 under --expose-gc:
| Path | Allocations |
|---|---|
track() (no owner) |
1 record (~40 B) + 1 lite-cleanup record |
track() (in owner) |
1 record + 1 lite-cleanup record + 1 onCleanup closure |
untrack() |
0 B |
| FR callback | 0 B (reuses record) |
size() |
0 B |
track() in an owner context allocates one small closure for the auto-untrack wiring. This is a one-time cost per track, not a hot-path cost. untrack and the FR path are strictly zero-alloc.
Tests
npm test # basic + non-GC tests
npm run test:gc # full suite with --expose-gc
Ten test files:
basic.test.js-- API surface and idempotencyowner-integration.test.js-- effect/computed/createRoot integrationleak-report.test.js-- report shape and FR-path semanticsauto-untrack.test.js-- onCleanup wiring and cascade behaviorcapture-stacks.test.js-- opt-in Error().stack behaviorheld-value-contract.test.js-- FR fires when target unreachableleak-probe.test.js-- 4096-cycle size return-to-zeroretained-heap.test.js-- 10K cycles under budgetrace.test.js-- untrack after enqueue does not double-fireversion.test.js-- VERSION const matches package.json
Kernels (M1-a)
Kernels detect specific leak classes. Register them on a tracker; they hook into the FR-refine chain (classifying leak reports) and expose an on-demand audit() pass (finding problems pre-FR).
import { createLeakTracker, createOwnerCascadeOrphanKernel } from '@zakkster/lite-leak';
import { effect } from '@zakkster/lite-signal';
const tracker = createLeakTracker({
onLeak: (r) => console.warn('LEAK:', r.kind, r),
onFinding: (f) => console.warn('AUDIT FINDING:', f.kind, f),
onWarning: (w) => console.warn('WARNING:', w.kind, w),
});
const off = tracker.registerKernel(createOwnerCascadeOrphanKernel());
effect(() => {
const resource = acquireExpensive();
// audit: true opts in to owner-handle retention (dev cost) so kernels
// can walk the chain later.
tracker.track(resource, () => releaseExpensive(resource.handle), 'expensive-1', { audit: true });
});
// On demand:
const findings = tracker.audit();
if (findings.length > 0) console.warn(findings);
off(); // unregister kernel
Kernel shape
{
name: 'owner-cascade-orphan', // unique per tracker
patchSurfaces: [], // conflict guard
priority: 0, // ordering weight, default 0
install(ctx) { /* wire hooks, patch globals */ },
uninstall() { /* restore */ },
refine(report, record) { return refinedReport | null }, // FR path
audit() { return findings[] }, // pre-FR scan
}
Kernels are opt-in: install() is where they patch anything (globals, DOM). uninstall() restores. Kernels claiming the same patch surface trigger KernelConflictError at register time.
Priority ordering
Both the FR-time refine chain and the on-demand audit iterate kernels by descending priority (default 0). Ties broken by registration order (stable sort). This exists so a broad, catch-all kernel registered early doesn't mask a specialised kernel registered later:
tracker.registerKernel(createGenericFallbackKernel()); // priority 0
tracker.registerKernel(createOwnerCascadeOrphanKernel()); // priority 0 (default)
// The generic kernel is tried first; owner-cascade never gets a chance to refine.
// Fix: bump the specialised kernel's priority.
const specialised = createOwnerCascadeOrphanKernel();
specialised.priority = 10;
tracker.registerKernel(specialised);
tracker.registerKernel(createGenericFallbackKernel());
// Now owner-cascade wins on cases it can classify; generic catches the rest.
onFinding vs onWarning vs onLeak
onLeak(report)-- terminus of the FR path. Confirmed leak: target GCd without prior untrack. Highest urgency.onWarning(finding)-- kernel-emitted, pre-FR anomaly. "Something suspicious just happened." Live signal, actionable at development time.onFinding(finding)-- kernel-emitted, neutral. Used byaudit()results and by ecosystem sinks (lite-trace,lite-devtools).
createOwnerCascadeOrphanKernel() (shipped in 0.2.0)
Walks each audited handle's owner chain (via ownerOf), compares against the frozen ownerPath snapshot at track-time. Findings when the live chain has diverged.
Finding shape:
{
kind: 'owner-cascade-orphan',
tag, ownerPath, origin,
brokenAt: number, // depth where the check failed
reason: 'stale' | 'diverged' | 'kind-diverged' | 'truncated',
liveFrame: OwnerFrame | null, // what's actually there now
handle: LeakHandle,
}
Zero global state. No patched globals. Pure library code -- safest possible first kernel.
createTimerOrphanKernel({ target?, warnOnNoOwner?, captureStacks?, priority? }) (shipped in 0.3.0)
Patches setTimeout / clearTimeout, setInterval / clearInterval, requestAnimationFrame / cancelAnimationFrame on the target (default globalThis). Only the methods that exist on the target are patched — safe to install against partial mocks.
- Timer set inside effect/computed: auto-wires
onCleanup→ the corresponding clear/cancel runs on owner disposal. Forgotten cleanup no longer leaks the callback closure. - Timer set outside any owner: emits
onWarningat set-time (earliest possible signal). Suppressible viawarnOnNoOwner: false. - FR-refine path: classifies leak reports on tracked callback closures as
'timer-orphan'withtimerKind,timerId, andwasCleared(true if the entry was reaped before the FR fired). - Audit path: enumerates currently-pending timers; findings for module-scope (
no-owner-pending) and disposed-owner (owner-disposed-timer-pending).
import { createLeakTracker, createTimerOrphanKernel } from '@zakkster/lite-leak';
import { effect } from '@zakkster/lite-signal';
const tracker = createLeakTracker({
onWarning: (w) => console.warn('timer no-owner:', w.origin),
onFinding: (f) => console.warn('timer audit:', f.reason),
});
tracker.registerKernel(createTimerOrphanKernel({ captureStacks: true }));
effect(() => {
setTimeout(() => update(), 100); // wired via onCleanup, no warning
});
setTimeout(() => update(), 100); // WARNING: no-owner-set, at set-time
Warning shape (set-time):
{
kind: 'timer-orphan',
reason: 'no-owner-set',
timerKind: 'setTimeout' | 'setInterval' | 'requestAnimationFrame',
timerId: unknown, // the underlying timer id
origin: string | null, // captured stack if captureStacks: true
}
Audit finding shape:
{
kind: 'timer-orphan',
reason: 'no-owner-pending' | 'owner-disposed-timer-pending',
timerKind, timerId, origin,
}
Patch surfaces claimed: ['setTimeout', 'setInterval', 'requestAnimationFrame']. A second timer-orphan kernel on the same tracker triggers KernelConflictError. Pass { handleRaf: false } to drop requestAnimationFrame from the claimed surfaces and hand rAF loops to createRafOrphanKernel() (see below).
For testing: pass a mock target via test/_helpers/clock.js (createMockClock()) and test/_helpers/raf.js (createMockRaf()), then compose them into a null-prototype target.
createListenerOrphanKernel({ EventTarget?, warnOnNoOwner?, captureStacks?, priority? }) (shipped in 0.5.0)
Patches EventTarget.prototype.addEventListener and removeEventListener. Listeners added inside an effect/computed body auto-remove on owner cleanup via onCleanup. Listeners added outside any owner emit onWarning with { kind: 'listener-orphan', reason: 'no-owner-set', type, origin }.
tracker.registerKernel(createListenerOrphanKernel());
effect(() => {
element.addEventListener('click', handler); // auto-removed on dispose
});
element.addEventListener('click', handler); // WARNING: no-owner-set
Patch surface: 'EventTarget.addEventListener'. Refines FR reports on tracked listener records (tag shape { kind: 'listener', type }). No internal enumeration registry — audit() returns empty.
createObserverOrphanKernel({ target?, warnOnNoOwner?, captureStacks?, priority? }) (shipped in 0.5.0)
Replaces MutationObserver / ResizeObserver / IntersectionObserver constructors on the target (default globalThis) with wrappers that instrument disconnect() and wire onCleanup(() => instance.disconnect()) when constructed inside an owner. Only the constructors present on the target are patched.
tracker.registerKernel(createObserverOrphanKernel());
effect(() => {
const mo = new MutationObserver(cb); // disconnect auto-called on dispose
mo.observe(el, { childList: true });
});
Patch surfaces: 'MutationObserver', 'ResizeObserver', 'IntersectionObserver'. audit() enumerates pending observers with no-owner-pending and owner-disposed-observer-pending reasons.
createDetachedDomKernel({ root?, warnOnDetach?, captureStacks?, priority? }) (shipped in 0.5.0)
Detects DOM nodes that have been detached from the tree without a prior explicit untrack. User-facing kernel.watch(node, tag?) opts nodes in. A MutationObserver on the configured root (default document) catches removals live.
const kernel = createDetachedDomKernel({ root: document });
tracker.registerKernel(kernel);
const el = document.getElementById('anchor');
const handle = kernel.watch(el, 'anchor');
// Later, if el is removed without untrack:
someParent.removeChild(el);
// -> onWarning fires: { kind: 'detached-dom', reason: 'detached-without-untrack', tag: 'anchor', origin }
Patch surface: 'detached-dom.root'. audit() walks node.isConnected and emits detached-at-audit for watched nodes not currently in the tree. Refines FR reports on dom-node tagged records.
createAsyncRetentionKernel({ target?, warnOnNoOwner?, captureStacks?, priority? }) (shipped in 0.6.0)
Replaces target.AbortController (default globalThis) with a wrapper. Controllers created inside effect/computed auto-abort() on owner disposal via onCleanup. Controllers created outside any owner emit onWarning at construction time.
tracker.registerKernel(createAsyncRetentionKernel());
effect(() => {
const c = new AbortController();
fetch(url, { signal: c.signal });
// On effect dispose: c.abort() fires automatically via onCleanup
});
Patch surface: 'AbortController'. audit() enumerates pending controllers with no-owner-pending and owner-disposed-controller-pending reasons. Kernel provides advise(finding) used by tracker.remediate().
Interoperates with @zakkster/lite-await's structural cleanup contract -- lite-await's own AbortController usage always wires abort into the settlement path, so the kernel never fires on well-behaved lite-await code. The kernel exists to flag manual new AbortController() usage that doesn't follow the discipline.
createRafOrphanKernel({ target?, warnOnNoOwner?, warnOnRescheduleAfterDispose?, captureStacks?, priority? }) (shipped in 1.1.0)
Loop-aware requestAnimationFrame leak detection -- coverage for the single most common leak-prone resource in the ecosystem (every rendering package runs a rAF loop).
timer-orphan already patches rAF, but it treats each frame as a fire-once timer. A self-rescheduling loop defeats that: the reschedule requestAnimationFrame(loop) runs during the frame callback phase, outside any owner, so timer-orphan warns on every frame and -- worse -- the cleanup wired at the first schedule cancels the frame id that was live then, long since consumed. The loop is now on frame N; the loop leaks forever.
raf-orphan models the loop as a chain. The owner captured at the first schedule is inherited by every continuation scheduled from inside the chain's own callback (detected via an active-chain window, not a fresh getOwner() read, which is undefined mid-callback).
import { createLeakTracker, createRafOrphanKernel } from '@zakkster/lite-leak';
const tracker = createLeakTracker({ onWarning: console.warn });
tracker.registerKernel(createRafOrphanKernel());
function loop() { draw(); requestAnimationFrame(loop); }
effect(() => { requestAnimationFrame(loop); });
// On effect dispose: the CURRENTLY armed frame is cancelled -> loop stops.
- Auto-cancel that actually stops the loop -- cancels the frame armed now, not at schedule-time.
- One warning per loop, not per frame -- a module-scope loop emits a single
no-owner-set. reschedule-after-dispose-- emitted when a chain reschedules after its origin owner disposed (broken cascade, or a callback that disposes its own owner mid-frame then loops on).audit():no-owner-loop-armed,owner-disposed-loop-armed.refine()classifies FR-collected loop callbacks.advise(finding)fortracker.remediate().
Patch surfaces claimed: ['requestAnimationFrame', 'cancelAnimationFrame']. It conflicts with timer-orphan on the rAF surface by design. To run both, cede rAF from timer-orphan:
tracker.registerKernel(createTimerOrphanKernel({ handleRaf: false }));
tracker.registerKernel(createRafOrphanKernel());
createWorkerOrphanKernel({ target?, warnOnNoOwner?, trackObjectURLs?, captureStacks?, priority? }) (shipped in 1.2.0)
Patches Worker and SharedWorker. Dropping the last reference to a Worker does not stop it -- the agent keeps its thread, heap and message queue until the document unloads, which is invisible to any tool that only walks the main-thread graph. Constructed inside an effect, the worker is terminated on owner disposal. Constructed outside any owner, it emits onWarning with { kind: 'worker-orphan', reason: 'no-owner-set', workerKind, origin }.
audit() reports no-owner-worker-live and owner-disposed-worker-live. A SharedWorker is deliberately never auto-terminated: it exposes no terminate(), so the constructing context cannot stop it. Rather than reap it and report clean on an agent that is still running, the registration stays and audit surfaces it.
Object URLs are attributed, not policed. Only a blob: URL actually passed to a worker constructor is recorded; URL.revokeObjectURL is patched for bookkeeping alone and emits nothing on its own. Patching createObjectURL globally would report every image blob in your app as a worker finding. A worker whose URL was never revoked gets blob-url-unrevoked; pass trackObjectURLs: false to leave the surface untouched.
tracker.registerKernel(createWorkerOrphanKernel());
effect(() => {
const w = new Worker('/render.js'); // auto-terminated on dispose
});
const stray = new Worker('/render.js'); // -> onWarning: no-owner-set
Revoking immediately after construction is correct -- the worker script is fetched during construction -- so @zakkster/lite-worker, which revokes on the next line, is clean here by design.
createAudioNodeKernel({ target?, warnOnNoOwner?, trackSources?, captureStacks?, priority? }) (shipped in 1.2.0)
Patches AudioNode.prototype.connect / disconnect and, when trackSources is on, AudioScheduledSourceNode.prototype.start / stop.
A connected AudioNode is referenced by the audio graph, not just by your JavaScript, so dropping the reference to a still-connected node frees nothing and a heap snapshot showing no JS references proves nothing. That is why the hook is connect() rather than the factory methods: a node that is constructed and never connected is inert and collectable, and a node becomes retained the moment it joins the graph.
A full disconnect() reaps. A partial disconnect(destination) does not -- the node is still audible through its other outputs. Owner disposal stops a playing source before disconnecting it.
| Reason | Channel | Meaning |
|---|---|---|
no-owner-connect |
warning | node joined the graph outside any owner |
no-owner-node-connected |
finding | still connected, no owner will disconnect it |
owner-disposed-node-connected |
finding | owner gone, node still in the graph |
source-started-not-stopped |
finding | source is still rendering its buffer |
In-house consumer: @zakkster/lite-audio v1.1.0. Install the kernel around a LiteAudio lifecycle and audit() should be empty after destroy():
tracker.registerKernel(createAudioNodeKernel());
const audio = new LiteAudio();
await audio.init(new AudioContext());
// ... play, crossfade, bus routing ...
audio.destroy();
assert.deepEqual(tracker.audit(), []); // anything left is a real graph leak
createSocketOrphanKernel({ target?, warnOnNoOwner?, captureStacks?, priority? }) (shipped in 1.2.0)
Patches WebSocket and EventSource. An open socket is held by the network stack, not by your JavaScript: dropping the reference leaves the connection open, the server-side session live, and an EventSource reconnecting on a timer forever. This is the leak that presents as "the app gets slower the longer you navigate" rather than as a heap graph anyone can read.
audit() reports by readyState rather than by bookkeeping -- a connection the peer already closed is not a leak, so only CONNECTING or OPEN counts. Reasons: no-owner-open (warning), no-owner-socket-open, owner-disposed-socket-open. In-house consumer: @zakkster/lite-ws.
createGlResourceOrphanKernel({ gl, label?, warnOnNoOwner?, captureStacks?, priority? }) (shipped in 1.3.0)
Patches the resource factories on a WebGL context: createBuffer/deleteBuffer, textures, framebuffers, renderbuffers, shaders, programs, vertex arrays, samplers and queries. Only the kinds a given context exposes are patched.
A WebGLBuffer is a small JS wrapper around memory owned by the driver. Dropping the JS reference frees the wrapper and nothing else -- the allocation survives until delete*() or context loss. This is the one leak class where a clean heap snapshot is actively misleading, because the leaked bytes were never on the JS heap to begin with.
gl is required. There is no global default: an application may hold several contexts, and instrumenting the wrong one would report clean forever.
const kernel = createGlResourceOrphanKernel({ gl, label: 'main' });
tracker.registerKernel(kernel);
effect(() => {
const buf = gl.createBuffer(); // deleted automatically on disposal
});
const stray = gl.createTexture(); // -> onWarning: no-owner-create
| Reason | Channel | Meaning |
|---|---|---|
no-owner-create |
warning | resource allocated outside any owner |
no-owner-resource-live |
finding | still allocated, nothing will delete it |
owner-disposed-resource-live |
finding | owner gone, device memory still held |
Findings carry resourceKind so a texture leak is distinguishable from a buffer leak.
Context loss. audit() returns nothing once gl.isContextLost() is true -- a lost context already destroyed everything it owned, so reporting those resources would be reporting a leak the driver already collected. Owner disposal against a lost context is a no-op, not a throw.
Several contexts at once. The kernel's name and patch surfaces are namespaced by label (auto-unique when omitted), because registerKernel enforces unique names and surfaces per tracker and would otherwise reject a second context as a conflict that does not exist. finding.kind is always 'gl-resource-orphan', so auditByKind() and remediate() are unaffected. A real double-install on the same context is still caught, because that check is keyed by the context object rather than by surface name.
Fail-closed on input (1.2.1)
Every input the tracker does not understand is rejected at the boundary, because the alternative is a detector that reports clean for a reason nobody can see. Green must mean "I looked and found nothing", never "I did not look".
| Input | Behaviour |
|---|---|
Unknown createLeakTracker option ({ onLeek }) |
throws, names the key you meant |
Unknown track() option ({ audti: true }) |
throws, names the key you meant |
Misspelled kernel hook (audti(), instal()) |
throws; _-prefixed and unrelated keys are untouched |
Non-callable handler (onWarning: 42) |
throws at construction, not at report time |
Non-finite priority (NaN, Infinity, '5') |
throws |
Non-array patchSurfaces, or surfaces with no install() |
throws |
track() on a primitive |
throws a lite-leak error naming the argument |
untrack() with a foreign handle |
no-op -- never a decrement |
That last row was the serious one. untrack() used to pass any object to the peer registry, which decremented its counter regardless of whether it had issued that handle, so three foreign untracks against three live handles drove size() to 0 while all three were still tracked. size() is a leak oracle; it now fails closed and can never report a negative count.
Pure classifier kernels with only refine()/audit() and no install() remain legal -- not every kernel patches something.
Patch-lifecycle findings (all patching kernels)
registerKernel's patchSurfaces guard is scoped to one tracker. Two trackers -- an app's and a test harness's, or two bundled copies of the package -- could therefore each wrap the same global and neither would complain; whichever uninstalled first restored the pre-first-patch original, silently disabling the other. A leak detector that stops detecting without saying so is the worst failure this package has.
Patch claims are now a property of the target, held in a module-level WeakMap. timer-orphan, listener-orphan, and async-retention emit two findings via onFinding:
| Reason | When | Payload |
|---|---|---|
patch-double-install |
At install: another kernel instance already patched these surfaces on this target. Both stay active, so events are double-counted. | surfaces: string[], detail |
patch-layered |
At uninstall: a third party layered a wrapper over ours, so theirs was left in place rather than destroyed. | surfaces: string[], detail |
A contested surface is reported, not thrown -- installing a kernel and never uninstalling it is a documented, working pattern, so a hard error would reject correct code. Restore is identity-checked, so an APM agent or test framework that wrapped you after install survives your uninstall(). Claims are released on uninstall, so install/uninstall cycles stay clean.
const finding = { kind: 'timer-orphan', reason: 'patch-double-install',
surfaces: ['setTimeout', 'clearTimeout'], detail: '...' };
Note for CI gates: these are onFinding events, so a gate that treats any finding as a confirmed leak (as lite-leakforge's does) will surface a double-install as a failure. That is intended -- a double-patched global invalidates the run's counts.
Peer matrix
lite-leak reaches into lite-signal owner-tree internals (the {id, kind} frame snapshot, nodeId() liveness, onCleanup cascade order, createRoot detachment). Those are observable but unversioned; a lite-signal release that changes any of them breaks leak attribution silently.
test/peer-assumptions.test.js pins every such assumption and banners the resolved lite-signal version. peers.json lists the versions the matrix expands over -- the baseline (1.8.0 clean base) and rebuilt-latest (the latest rebuilt 1.9-1.12 prerelease). .github/workflows/peer-matrix.yml fans out over peers.json x Node {20, 22} on every push, and via repository_dispatch: lite-signal-release so a breaking owner-tree change fails here before it ships in lite-signal.
npm run test:peers # against the currently-installed peer
npm run peers:matrix # local sweep over every version in peers.json
Audit API (M2)
Three methods on the tracker for querying findings:
// All findings across all registered kernels
const all = tracker.audit();
// Filter by kind
const timers = tracker.auditByKind('timer-orphan');
// Filter by owner (findings whose ownerPath contains the given owner's id)
import { getOwner } from '@zakkster/lite-signal';
let owner;
effect(() => { owner = getOwner(); });
const inThisEffect = tracker.auditByOwner(owner);
// Get a human-readable advisory for a finding
const advice = tracker.remediate(finding);
console.warn(advice);
Kernels can optionally provide advise(finding) returning per-reason advisory text. tracker.remediate() walks registered kernels in priority-then-registration order asking each for advice; first non-null string wins.
Ecosystem sinks (M2.5)
Adapters that route lite-leak events into other packages in the ecosystem.
createTraceSink({ tracer, leakTagPrefix?, warningTagPrefix?, findingTagPrefix?, errorTag? })
Routes leak reports, warnings, findings, and errors into @zakkster/lite-trace as zero-duration spans. Each event becomes tracer.begin(tag); tracer.end() -- a Perfetto-visible instant marker when the trace is exported via toChromeTrace().
import { Tracer } from '@zakkster/lite-trace';
import { createLeakTracker, createTraceSink, createTimerOrphanKernel } from '@zakkster/lite-leak';
const tracer = new Tracer(2048);
const sink = createTraceSink({ tracer });
const tracker = createLeakTracker({
onLeak: sink.onLeak,
onWarning: sink.onWarning,
onFinding: sink.onFinding,
onError: sink.onError,
});
tracker.registerKernel(createTimerOrphanKernel());
// ... run your app ...
// Then export the trace with leak events on the timeline:
const chromeTrace = tracer.toChromeTrace();
createGenericSink({ onLeak?, onWarning?, onFinding?, onError? })
Composable sink adapter for arbitrary destinations (studio panels, observability pipelines). Swallows callback throws. Missing callbacks are no-ops.
const studio = createGenericSink({
onLeak: (r) => studioPanel.pushLeak(r),
onWarning: (w) => studioPanel.pushWarning(w),
});
To combine multiple sinks, construct each and fan out in the tracker's option callback:
const trace = createTraceSink({ tracer });
const studio = createGenericSink({ onLeak: (r) => studioPanel.push(r) });
const tracker = createLeakTracker({
onLeak: (r) => { trace.onLeak(r); studio.onLeak(r); },
onWarning: (w) => trace.onWarning(w),
onFinding: (f) => trace.onFinding(f),
onError: (e, t) => trace.onError(e, t),
});
createProfilerSignalSink() (shipped in 1.0.0)
Lifts leak-event counters into @zakkster/lite-signal signals. Bind them to any HUD, dashboard, or effect.
import { effect } from '@zakkster/lite-signal';
import { createLeakTracker, createProfilerSignalSink } from '@zakkster/lite-leak';
const sink = createProfilerSignalSink();
const tracker = createLeakTracker({
onLeak: sink.onLeak,
onWarning: sink.onWarning,
onFinding: sink.onFinding,
onError: sink.onError,
});
// Bind to HUD; effect re-runs when counters change.
effect(() => {
hudLeakEl.textContent = 'Leaks: ' + sink.leakCount();
hudLastEl.textContent = sink.lastLeakKind() || 'clean';
});
Signals exposed: leakCount, warningCount, findingCount, errorCount, lastLeakKind, lastWarningKind. Related writes are batched so one event = one effect run. Methods: reset(), dispose().
Ghost-safe: creates ~6 signals at construction, never more. Zero per-event graph churn beyond those signal writes. Mirrors the discipline of @zakkster/lite-profiler-signal.
createStudioSink({ mount?, title?, maxLogRows?, zIndex? }) (shipped in 1.0.0)
DOM overlay in the visual style of @zakkster/lite-studio (dark theme, monospace, fixed-position). Rolling log of leak events, warnings, findings, errors, capped at maxLogRows (default 60). Companion to lite-studio's main panel; no dependency on lite-studio itself, just visual affinity.
import { createLeakTracker, createStudioSink } from '@zakkster/lite-leak';
const sink = createStudioSink({ title: 'my-app leaks', maxLogRows: 40 });
const tracker = createLeakTracker({
onLeak: sink.onLeak,
onWarning: sink.onWarning,
onFinding: sink.onFinding,
onError: sink.onError,
});
// ... later, unmount when done
sink.unmount();
Ghost-safe: no signals, imperative DOM updates only. Options: mount: false skips auto-mount at construction (call sink.mount() later). zIndex overrides the default (2147482999, one below lite-studio's own).
Roadmap
| Version | Milestone | Contents |
|---|---|---|
| 0.1.0 | M0 | Primitive: createLeakTracker, track, untrack, onLeak reports. |
| 0.2.0 | M1-a | Kernel infrastructure + owner-cascade-orphan kernel. |
| 0.3.0 | M1-b | + timer-orphan (setTimeout/setInterval/rAF). |
| 0.5.0 | M1-c/d/e | + listener-orphan, observer-orphan, detached-dom. |
| 0.6.0 | M1-f | + async-retention (AbortController). |
| 0.8.0 | M2 | auditByKind, auditByOwner, remediate() + kernel advise(). |
| 0.9.0 | M2.5 | createTraceSink, createGenericSink -- ecosystem sink adapters. |
| 1.0.0 | M3 | createProfilerSignalSink, createStudioSink, retained-heap suite, WHY-1.0.md, REJECTED.md. Stable. |
| later | lite-leakforge |
Priority-tier demo, oscilloscope scenes, docs (separate package). |
Non-goals
- Not a GC. FR timing is non-deterministic; leak reports are a diagnostic signal, not a synchronous check.
- Not automatic instrumentation. Consumers explicitly
track()what they care about. Auto-instrumentation is what the M1 kernels do. - Not a memory profiler. Different concern -- point-in-time budget checks belong in
lite-gc-profilerand friends.
License
MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>