@zakkster/lite-leakforge
v1.9.1
Published
Leak specimens, CI harness, and dashboard for @zakkster/lite-leak. Zero-GC diagnostic toolkit.
Maintainers
Readme
@zakkster/lite-leakforge
Leak specimens, CI harness, and diagnostic toolkit for @zakkster/lite-leak.
lite-leakforge is the product layer above lite-leak's primitive tracker. lite-leak detects leaks; leakforge proves it, shows it, and gates it.
- Prove it -- deterministic specimens that trigger each kernel's detection path, with a 3-channel verify contract diffing expected vs actual across leak reports, warnings, and findings.
- Show it -- ASCII formatters, a ghost-safe dashboard data model, and a 6-scene oscilloscope demo.
- Gate it -- a CI harness with
assertNoLeaks()andleakSuite(), plus anpx leakforgeCLI, all producing exit 0 (clean), exit 1 (confirmed leak), or exit 3 (inconclusive / recapture).
npm install @zakkster/lite-leakforgePeer dependency: @zakkster/lite-signal >=1.5.0-beta.3 <2.0.0
Quick start
CI gate
// test/leak.test.js
// Run with: node --expose-gc --test test/leak.test.js
import { describe, it } from 'node:test';
import { assertNoLeaks } from '@zakkster/lite-leakforge';
describe('my library', () => {
it('does not leak', async () => {
await assertNoLeaks((tracker) => {
const resource = createMyResource();
const handle = tracker.track(resource, () => {}, 'my-resource');
destroyMyResource(resource);
tracker.untrack(handle);
});
});
});CLI (npx leakforge)
Run a leak-suite file in CI and let the exit code gate the build. A suite
file default-exports { name, checks: [{ name, run(tracker) }], options? };
each check runs under one shared gate.
// app.leak.mjs
export default {
name: 'my-app',
checks: [
{ name: 'mounts and unmounts cleanly', run: () => { mount(); unmount(); } },
{ name: 'detail panel', run: (tracker) => {
const panel = openDetailPanel();
tracker.track(panel, () => {}, 'detail-panel');
closeDetailPanel(panel);
} },
],
};npx leakforge app.leak.mjs # 0 clean, 1 leak, 3 inconclusive
npx leakforge app.leak.mjs --json leaks.json # + machine-readable artifact
npx leakforge app.leak.mjs --junit leaks.xml # + JUnit XML for CI dashboards
npx leakforge app.leak.mjs --baseline .leakforge-baseline.json --update-baseline # capture
npx leakforge app.leak.mjs --baseline .leakforge-baseline.json # gate on NEW leaks only
npx leakforge --specimens # verify every built-in specimen (kernel acceptance)
npx leakforge --specimens raf-orphan # a single specimen
npx leakforge app.leak.mjs --measure # how big is each leak? (per-call bytes)Adopting on a codebase that already leaks
A gate that fails on every existing leak never gets turned on -- fixing them all
first is too big a cliff. --baseline removes the cliff: capture the current
state once, commit it, and CI then fails only on leaks you add.
# once, capturing today's leaks as the accepted baseline
npx leakforge app.leak.mjs --baseline .leakforge-baseline.json --update-baseline
git add .leakforge-baseline.json
# in CI, from then on
npx leakforge app.leak.mjs --baseline .leakforge-baseline.jsonA finding cluster (kind:reason) regresses when it is new, or when its
count grows past the baseline. A cluster that shrank or disappeared is an
improvement -- reported, never a failure -- and refreshing the baseline
(--update-baseline) locks in the win. Keys are origin-free so the baseline
survives ordinary code edits; a missing or malformed baseline fails closed
(exit 3), never a silent pass.
How big is the leak? (--measure)
--measure runs each check repeatedly under a forced GC and reports the per-call
retained bytes -- the number that turns "there's a leak" into "there's a 3.8 KB
leak per mount, fix it".
npx leakforge app.leak.mjs --measure # default 200 iterations
npx leakforge app.leak.mjs --measure -n 500 # more iterations, less noiseThe figure is honest and aggregate, not fake per-object attribution: a
least-squares slope of heapUsed against iteration count, so one-time setup falls
into the intercept and transient garbage is collected before each sample. Three
rules the output holds to: it requires --expose-gc (without it, retention
cannot be measured, so it refuses rather than guess); an off-heap leak
(socket, worker, child process, fd, GL resource) reads as "not measurable on the
JS heap", never as 0 bytes, because heapUsed genuinely cannot see it; and a
slope under the noise floor is "below the measurable threshold", not a
confident zero. --measure reports -- it does not gate.
The gate needs manual GC; the CLI re-execs itself with --expose-gc
automatically, so plain npx leakforge works. Exit codes aggregate across
checks with evidence-wins precedence: any confirmed leak wins the run.
--specimens completes the CLI trilogy with litecap and gc-profiler.
leakSuite (node:test-native)
import { describe, it } from 'node:test';
import { leakSuite } from '@zakkster/lite-leakforge';
leakSuite(describe, it, 'my-module', (measure) => {
measure('create and dispose', (tracker) => {
const r = { x: 1 };
const h = tracker.track(r, () => {}, 'test');
tracker.untrack(h);
});
measure('no-op is clean', (_tracker) => {
// nothing to leak
});
});Specimen verification
import { verify, createTimerOrphanSpecimen } from '@zakkster/lite-leakforge';
const result = await verify(createTimerOrphanSpecimen());
console.log(result.pass); // true
console.log(result.warnings); // { pass: true, actual: [...], ... }
console.log(result.findings); // { pass: true, actual: [...], ... }Formatters
import {
formatReport, formatOwnerPath, summarize, formatSummary
} from '@zakkster/lite-leakforge/formatters';
const path = [{ id: 3, kind: 'effect' }, { id: 1, kind: 'computed' }];
formatOwnerPath(path, 1);
// '[3 effect] -> [1 computed] *BROKEN*'
const groups = summarize(events);
formatSummary(groups);
// '3x timer-orphan (no-owner-set)\n1x listener-orphan (no-owner-set)'Dashboard data model
import { createDashboardModel, createDashboard } from '@zakkster/lite-leakforge/panels';
import { effect } from '@zakkster/lite-signal';
import { createLeakTracker } from '@zakkster/lite-leak';
const model = createDashboardModel({ logCapacity: 128 });
const tracker = createLeakTracker({
name: 'my-app',
onLeak: model.onLeak,
onWarning: model.onWarning,
onFinding: model.onFinding,
onError: model.onError,
});
// Mount the full dashboard DOM (browser only)
const dashboard = createDashboard({
container: document.getElementById('dashboard'),
model: model,
kernels: [timerK, listenerK], // installed kernel objects
maxLogRows: 60, // pre-allocated row pool
});
// Or use the model directly for custom UIs
effect(() => {
const v = model.logVersion(); // triggers on every event
const entries = model.getEntries();
renderLog(entries);
});
// Kind filter
model.filterKind.set('timer-orphan');API
Subpath exports
| Import | Contents |
|---|---|
| @zakkster/lite-leakforge | Everything (barrel) |
| @zakkster/lite-leakforge/harness | settleFinalizers, settleTracker, createLeakGate, assertNoLeaks, leakSuite, EXIT_* |
| @zakkster/lite-leakforge/formatters | formatReport, formatFinding, formatWarning, formatOwnerPath, summarize, formatSummary, formatVerifyResult |
| @zakkster/lite-leakforge/scenarios | verify, composeScenario, all specimen factories |
| @zakkster/lite-leakforge/panels | createDashboardModel, createDashboard, CHANNEL_* constants |
All subpaths ship their own .d.ts (types resolve for deep imports,
not just the barrel).
Specimens
Each specimen factory returns { name, kernels, expectedLeaks,
expectedWarnings, expectedFindings, needsSettle, inject, release }.
| Factory | Kernel required | Detection channels |
|---|---|---|
| createRawFrSpecimen() | none | FR leak kind: 'unknown' |
| createTimerOrphanSpecimen() | timer-orphan | warning no-owner-set, finding no-owner-pending |
| createListenerOrphanSpecimen() | listener-orphan | warning no-owner-set |
| createObserverOrphanSpecimen() | observer-orphan | warning no-owner-set, finding no-owner-pending |
| createDetachedDomSpecimen() | detached-dom | finding detached-at-audit |
| createAsyncRetentionSpecimen() | async-retention | warning no-owner-set, finding no-owner-pending |
| createRafOrphanSpecimen() | raf-orphan | warning no-owner-set, finding no-owner-loop-armed |
| createWorkerOrphanSpecimen() | worker-orphan | warning no-owner-set, findings no-owner-worker-live + blob-url-unrevoked |
| createAudioNodeSpecimen() | audio-node | warning no-owner-connect, findings no-owner-node-connected + source-started-not-stopped |
| createSocketOrphanSpecimen() | socket-orphan | warning no-owner-open, finding no-owner-socket-open |
| createGlResourceOrphanSpecimen() | gl-resource-orphan | 2x warning no-owner-create, 2x finding no-owner-resource-live (distinct resourceKind) |
The 1.2.0 resource specimens (worker, audio, socket) each own a specimen-local mock host for the same reason as raf-orphan -- Node has none of those globals, and a specimen must never patch a global it shares with the test runner.
The raf-orphan specimen needs no DOM: it drives a specimen-local
requestAnimationFrame host, so it runs anywhere and patches no shared
global. Requires @zakkster/lite-leak >= 1.1.0.
Exit codes
| Code | Constant | Meaning |
|---|---|---|
| 0 | EXIT_CLEAN | No leaks detected, FR settled |
| 1 | EXIT_LEAK | Confirmed leak (FR report or audit finding) |
| 3 | EXIT_INCONCLUSIVE | FR did not settle; recapture recommended |
Precedence: confirmed evidence wins. A leak report or audit finding is exit 1 even when FR did not settle -- an unsettled registry never downgrades hard evidence to "recapture". Exit 3 is reserved for evidence-free unsettled runs.
verify() throws (rather than reporting a bogus FAIL) when a
needsSettle specimen runs without --expose-gc; the five pre-FR
specimens run anywhere, including browsers.
Kernel teardown in both createLeakGate().run() and verify() is
exception-safe: kernels patch global surfaces, and the patches are
removed even when user code or inject() throws.
Ghost safety
createDashboardModel() creates exactly 2 signals (logVersion,
filterKind) at construction. Zero signals created per event. Verified
by model.signalCount() and tested in the ghost-safety suite.
Entry formatting is lazy: entry.text, entry.ownerPath, and
entry.label are memoized getters computed on first read (render
time), so a warning storm costs one small entry object per event and
zero formatting work.
createDashboard() renders through a dirty flag gated by a power-of-2
frame mask: model changes flip one boolean, the gated frame does the
work, and the log shows the newest maxLogRows entries as a sliding
window.
Framework integration
leakforge is framework-agnostic. The gate never imports your framework -- a check mounts and unmounts a component, and lite-leak's kernels plus the FR catch whatever the unmount forgot to release (a stray timer, an unremoved listener, a live observer, a detached DOM subtree). The dashboard is plain DOM, so it drops into any component's mount/unmount hooks.
Component leak checks need a DOM (jsdom or happy-dom) and --expose-gc, the same
as any other suite. The shape is always the same: mount, unmount, assert nothing
survived.
The gate (React / Vue / Angular)
// ui.leak.mjs -- run with: npx leakforge ui.leak.mjs (or under node:test)
export default {
name: 'ui',
checks: [
// React
{ name: 'Widget mounts and unmounts cleanly', run: () => {
const root = createRoot(document.createElement('div'));
root.render(h(Widget));
root.unmount(); // must clear timers/listeners it set
} },
// Vue 3
{ name: 'VueWidget mounts and unmounts cleanly', run: () => {
const app = createApp(VueWidget);
app.mount(document.createElement('div'));
app.unmount();
} },
// Angular (TestBed component fixture)
{ name: 'NgWidget destroys cleanly', run: () => {
const fixture = TestBed.createComponent(NgWidget);
fixture.detectChanges();
fixture.destroy(); // ngOnDestroy must tear subscriptions down
} },
],
};A check that forgets to unmount -- or a component whose teardown leaks -- fails
the gate (exit 1). Reach for tracker.track(handle, () => {}, 'tag') only when
you hold a resource lite-leak has no kernel for and want it watched explicitly;
for ordinary timer/listener/observer/DOM leaks the default kernels are enough.
Use --measure to turn "it leaks" into "it retains 3.8 KB per mount", and
--baseline to adopt the gate on a UI that already leaks without fixing every
component first.
The dashboard (in a component)
createDashboard() mounts into a container element and returns a handle with
dispose(). Mount it in the framework's mount hook, dispose in the unmount hook
so the dashboard itself never leaks.
// React
useEffect(() => {
const dash = createDashboard({ container: ref.current, model, kernels });
return () => dash.dispose();
}, []);
// Vue 3
onMounted(() => { dash = createDashboard({ container: el.value, model, kernels }); });
onBeforeUnmount(() => dash.dispose());
// Angular
ngAfterViewInit() { this.dash = createDashboard({ container: this.el.nativeElement, model: this.model, kernels: this.kernels }); }
ngOnDestroy() { this.dash.dispose(); }The model is reactive (@zakkster/lite-signal), so a custom UI can skip
createDashboard() and read model.getEntries() inside an effect() (or your
framework's own reactivity) keyed off model.logVersion() -- see Dashboard data
model above.
Demo
Run from the package root:
npx serve .Open http://localhost:3000/demo/. Six scenes:
- Tracker -- track/untrack/abandon lifecycle, gc pressure, FR leak reports, oscilloscope size() trace
- Kernel gallery -- five kernels installed live (scene-scoped install/uninstall), orphan injections vs an owned-timer clean path, real DOM-detachment detection
- Specimen lab -- verify() per specimen with PASS/FAIL badges and formatVerifyResult output, composeScenario run-all; raw-fr enables itself when the browser exposes gc()
- Audit console -- audit(), auditByKind(), remediate(), summarize() + formatSummary()
- Dashboard -- the packaged createDashboard() component fed by a live tracker, with a 500-event storm to demonstrate dirty-flag rendering (500 events, one render)
- Stress -- 4096 allocation-clean track/untrack cycles, Float64Array scope ring, size() return-to-zero verdict
Tests
node --expose-gc --test test/*.test.js # 142 unit/integration
npm run torture # 106 adversarialThe unit suite covers settle, gate, specimens, formatters, panels, dom, cli,
baseline, measure, junit and version; all GC-dependent tests skip gracefully
without --expose-gc.
The torture/ suite is the adversarial regression net (not shipped in the
package): overlapping gate runs and global-patch corruption, the tag-matcher
false-pass classes, hostile settle/dashboard options, the CLI exit-code
vocabulary end-to-end, and a soak of 300 sequential gate runs and 100k dashboard
events.
Exit codes
0 clean, 1 leak, 2 usage error, 3 inconclusive. A runtime failure -- a
check that throws or rejects, a suite that fails to import, an unwritable
--json path -- maps to 3: no trustworthy verdict was produced, so the honest
result is "recapture". Evidence wins, so a real leak anywhere in the suite
outranks both errored and unsettled checks.
Architecture decisions
See WHY-1.0.md for the rationale behind key design choices. See REJECTED.md for proposals considered and declined.
License
MIT. Copyright (c) 2026 Zahary Shinikchiev.
