@zakkster/lite-cleanup
Zero-GC FinalizationRegistry helper. Shared disposal registry with unregister semantics, tag-based collection reporting, and error routing.
Extracted from the shared FR pattern used across @zakkster/lite-observe and @zakkster/lite-floating. Consumed as the primitive layer under @zakkster/lite-leak.
- Single-file ESM, no runtime deps, ASCII-only source
- One
FinalizationRegistryper registry, tag-attributed - Zero-GC steady state: 0 B/call on
unregisterand FR callback - Explicit
unregistercancels the finalizer (idempotent, null-safe) onCollectfires ONLY on the FR path -- signal that a caller missed disposeonErrorroutes cleanup throws with tag attribution- Node 20+; browsers with
FinalizationRegistrysupport
Install
npm i @zakkster/lite-cleanup
Usage
import { createDisposalRegistry } from '@zakkster/lite-cleanup';
const registry = createDisposalRegistry({
name: 'my-module',
onCollect: (tag) => {
// Fires when a target was collected WITHOUT explicit unregister.
// Use as a leak signal.
console.warn('missed dispose:', tag);
},
onError: (err, tag) => {
console.error('cleanup threw for', tag, err);
}
});
// Register a target with a cleanup function and optional tag.
const handle = registry.register(target, cleanup, 'my-tag');
// Explicit disposal: cancels the finalizer, does NOT run cleanup.
// Caller is responsible for running cleanup manually if needed.
registry.unregister(handle);
Held-value contract (LOAD-BEARING)
The
cleanupclosure MUST NOT close overtarget.
Capturing the target inside the cleanup closure keeps the target reachable via the FinalizationRegistry itself, silently defeating finalization. The rule is un-enforceable at runtime; it is caller discipline.
Wrong -- target is captured in the closure:
const target = someObject;
registry.register(target, () => {
target.dispose(); // captures `target`, retains it, defeats FR
});
Right -- capture only the resources to release:
const target = someObject;
const resource = target.resource;
registry.register(target, () => {
resource.release(); // captures `resource`, not `target`
});
If you truly need to access the target from cleanup, wrap it in a WeakRef first -- but for almost every case, capturing the specific resource is what you want.
Lifecycle
stateDiagram-v2
[*] --> Live: register(target, cleanup, tag)
Live --> Disposed: unregister(handle)
Live --> Collected: target GC'd
Collected --> [*]: cleanup() then onCollect(tag)
Disposed --> [*]
- Explicit path (
unregister): setsdisposed = true, unregisters the FR finalizer, decrementssize(). Does not invokecleanup. Idempotent. - FR path (target GC'd): fires
cleanup(), thenonCollect(tag). Decrementssize(). Errors incleanuproute toonError.
Only the FR path invokes cleanup. The explicit path is a cancellation -- the caller has already handled disposal by other means.
API
VERSION: string
Package version constant. Kept in sync with package.json.
createDisposalRegistry(options?) -> registry
Create a new registry backed by a single FinalizationRegistry.
Options:
| Field | Type | Description |
|---|---|---|
name |
string |
Optional identifier for diagnostics. Defaults to 'lite-cleanup'. |
onCollect |
(tag) => void |
Called after the FR path fires. Not called on unregister. |
onError |
(err, tag) => void |
Called when cleanup throws. If omitted, errors are swallowed. |
Returns an object:
registry.register(target, cleanup, tag?) -> handle
registry.unregister(handle) -> void
registry.size() -> number
registry.name : string
registry.register(target, cleanup, tag?)
Register target for finalization. When target becomes unreachable (and unregister was not called first), cleanup runs on the FR path.
Returns an opaque handle. Pass this handle to unregister to cancel.
registry.unregister(handle)
Cancel the finalizer for handle. Idempotent. Accepts null / undefined as no-ops. Does not invoke cleanup.
registry.size()
Count of live (non-disposed) registrations.
Zero-GC profile
Measured via perf_hooks GC observation under node --expose-gc:
| Path | Allocations | Scavenges |
|---|---|---|
register() |
1 record (~24 B) | 0 |
unregister() |
0 B | 0 |
| FR callback | 0 B | 0 |
size() |
0 B | 0 |
register is not a hot path (called on subscription setup). unregister and the FR path are hot and stay at 0 B/call.
Tests
npm test # basic + no-GC-required tests
npm run test:gc # full suite with --expose-gc
Nine test files:
basic.test.js-- API surface and idempotencyheld-value-contract.test.js-- FR fires when target unreachableleak-probe.test.js-- 4096-cycle size return-to-zerorace.test.js-- unregister-after-enqueue does not double-fireonCollect.test.js-- hook semantics (FR path only, tag threading)error-hook.test.js-- cleanup throws routed with tagretained-heap.test.js-- 10K cycles under 1 MB retainedgc-gate.test.js-- 0 GC events onunregisterhot pathversion.test.js--VERSIONconst matchespackage.json
Why this exists
Two libraries in the ecosystem (lite-observe, lite-floating) inlined the same FinalizationRegistry bookkeeping. @zakkster/lite-leak needed the same pattern with additional hooks (onCollect for leak attribution, onError for cleanup error routing). Rather than triplicate the pattern, this package extracts it as the canonical primitive. Both consumers cut over in patch releases with no behavior change.
Non-goals
- Not a garbage collector. FR timing is non-deterministic. This is a safety net for missed dispose, not a replacement for it.
- Not a
WeakRefhelper. Different concern. UseWeakRefdirectly where needed. - Not a resource pool. Registrations are permanent until explicit unregister or GC.
License
MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>