@zakkster/lite-cleanup
v1.1.0
Published
Zero-GC FinalizationRegistry helper. Shared disposal registry with unregister semantics, tag-based collection reporting, and error routing.
Maintainers
Readme
@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-cleanupUsage
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.
The contract covers tag, not just cleanup
tag is stored on the same record, and that record is the FinalizationRegistry held value. A tag that reaches the target pins it exactly as a capturing closure does:
registry.register(target, cleanup, target); // rejected since 1.1.0
registry.register(target, cleanup, { ref: target }); // NOT detectable -- still your problemThe identity case throws. Deeper reachability cannot be detected without walking the object graph, so it stays caller discipline like the closure rule above.
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 : stringregistry.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.
Rejected at the boundary (since 1.1.0), because each one fails silently otherwise:
| Input | Result |
| ----- | ------ |
| target that is null or a primitive | TypeError naming the argument, instead of a raw FinalizationRegistry message |
| tag === target | TypeError -- the record is the FR held value, so a tag referencing its own target pins it forever and the finalizer can never fire |
| cleanup that is neither callable nor null/undefined | TypeError -- a string or number here is a typo, never an intent |
Objects, functions and unregistered symbols are all valid targets; symbols became legal FR targets in ES2023.
cleanup may be null or undefined, meaning observe only, nothing to run. onCollect still fires.
registry.unregister(handle)
Cancel the finalizer for handle. Idempotent. Does not invoke cleanup.
Handles are scoped to the registry that issued them (since 1.1.0). Anything else -- a foreign object, another registry's handle, a primitive, null -- is a no-op: it is never counted, never mutated, and never throws. Before 1.1.0 any object fell through, so three foreign calls against three live handles drove size() to 0 while all three were still registered, and the object you passed had its disposed, cleanup and tag fields overwritten.
Ownership lives in a WeakSet of issued records, so it never pins a handle and a caller writing handle.disposed = true cannot defeat it.
registry.size()
Count of live registrations. Consumers gate on this (size() === 0 meaning "nothing pending"), so it fails closed: an unrecognised handle can never decrement it, and it can never report a negative count.
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-gcTen 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.jsontorture.test.js-- adversarial black-box suite (24 tests): handle provenance, forged handles (lookalike, prototype-chain, frozen, hostile setter, revoked proxy, cross-realm),register()atomicity, retention, duplicate registration, throw containment, reentrancy, and a 100,000-operation seeded fuzz assertingsize()is exact after every operation
The torture suite fails 17 of 24 against 1.0.0. It is black-box by design -- it only touches the public API, so it stays honest if the internals are rewritten.
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 <[email protected]>
