npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-leak

v1.7.0

Published

Zero-GC leak diagnostic for the @zakkster/lite-* ecosystem. Owner-tree attribution via lite-signal; pluggable kernels for classifying leaks.

Readme

@zakkster/lite-leak

npm version Coverage Status Zero-GC sponsor npm bundle size npm downloads npm total downloads TypeScript License: MIT

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.7.0 -- stable. Thirteen 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; emitter-orphan in 1.7.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's onCleanup: 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 calling untrack.

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

  1. The cleanup closure MUST NOT close over target. (Inherited from lite-cleanup.)
  2. The tag value MUST NOT close over target. Since tags are retained on the internal record until FR fires or explicit untrack, capturing target via 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
npm run coverage      # write coverage/lcov.info (Node native, no c8/nyc)
npm run coverage:check # enforce 95 line / 85 branch / 90 funcs; runs in prepublishOnly

Coverage is measured on own source only -- node_modules (the vendored lite-cleanup peer) and test/ are excluded, so the badge reflects lite-leak's code, not its dependency's. coverage:check gates publishing; the branch floor of 85 matches lite-gc-profiler, because the untaken branch is where a leak detector goes quiet.

Ten test files:

  • basic.test.js -- API surface and idempotency
  • owner-integration.test.js -- effect/computed/createRoot integration
  • leak-report.test.js -- report shape and FR-path semantics
  • auto-untrack.test.js -- onCleanup wiring and cascade behavior
  • capture-stacks.test.js -- opt-in Error().stack behavior
  • held-value-contract.test.js -- FR fires when target unreachable
  • leak-probe.test.js -- 4096-cycle size return-to-zero
  • retained-heap.test.js -- 10K cycles under budget
  • race.test.js -- untrack after enqueue does not double-fire
  • version.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 by audit() 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 onWarning at set-time (earliest possible signal). Suppressible via warnOnNoOwner: false.
  • FR-refine path: classifies leak reports on tracked callback closures as 'timer-orphan' with timerKind, timerId, and wasCleared (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) for tracker.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.

Growth detection (1.6.0)

Every other kernel answers was this released?. Some leaks are never orphaned at all -- a route cache keyed by URL, a memo table, a subscriber list appended to on every mount. Nothing is leaked in the ownership sense and the process still dies.

Growth is a difference between moments, so one audit() can never see it.

const before = tracker.snapshot();
runInteraction();
const diff = diffSnapshots(before, tracker.snapshot());
// { tracked, byKind: { 'timer-orphan': 0 }, unknown: ['listener-orphan'], measured: 1 }

snapshot() emits nothing. null is not zero: a kernel with no count() reports null, and any kind null on either side diffs to null and appears in unknown. A kernel registered between snapshots was not at zero beforehand -- it was unobserved, so it diffs to null rather than +N. A gate asserting "this added nothing" must check unknown too, or it is asserting over a subset it cannot see.

For continuous monitoring, createCollectionGrowthKernel({ collections: { cache } }) samples once per audit() into a sliding window and reports monotonic-growth. That finding is evidence, not proof -- warmup is monotonic too. Because the window slides, a plateau clears it automatically, which is what stops a warmed cache being permanently indicted.

Aggregation (1.5.0)

audit() returns one finding per leaked resource, which is correct and unreadable: four hundred listeners leaked from one component produce four hundred findings.

const groups = tracker.auditGrouped();
// [{ key: 'listener-orphan:no-owner-add:1f2e3d', kind, reason, origin,
//    count: 400, representative }]

groupFindings(findings, options?) is the pure form and takes findings from anywhere -- audit(), an onFinding handler, or a JSON artifact from a previous run. Pass { byOrigin: false } to collapse every call site of one (kind, reason) into a single group.

There is no severity score, deliberately. Ranking owner-disposed-* against no-owner-* would assert that a broken cleanup cascade is worse than a resource that never had an owner, or the reverse, and neither is true in general. Groups are ordered by count descending -- a frequency signal, described as nothing more -- with the cluster key as a deterministic tiebreak so CI diffs stay stable.

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

Cookbook

Task-oriented recipes live in COOKBOOK.md -- nineteen of them across four tiers: getting a first signal, per-kernel recipes, gating in CI, and production/routing/extension. Start there if you know what you want to do and not which API does it.

The quickest possible start:

import { createLeakTracker, createDefaultKernels } from '@zakkster/lite-leak';

const tracker = createLeakTracker({ name: 'app', onFinding: console.error });
const { kernels, skipped } = createDefaultKernels();
for (const k of kernels) tracker.registerKernel(k);
if (skipped.length) console.info('[leak] not watching:', skipped);

createDefaultKernels() composes the set for your runtime: it cedes requestAnimationFrame to raf-orphan (registering timer-orphan and raf-orphan by hand throws KernelConflictError, and resolving that the obvious way keeps the weaker rAF detector), and it omits kernels whose globals are absent instead of letting them register and silently watch nothing. skipped is the honest half -- a kernel listed there is one whose leaks nothing will report.

detached-dom and gl-resource-orphan are never included; both need configuration that cannot be guessed.

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-profiler and friends.

License

MIT (c) Zahary Shinikchiev <[email protected]>