npm.io
1.0.0 • Published 2 weeks ago

@zakkster/lite-cleanup

Licence
MIT
Version
1.0.0
Deps
0
Size
18 kB
Vulns
0
Weekly
0

@zakkster/lite-cleanup

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

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 FinalizationRegistry per registry, tag-attributed
  • Zero-GC steady state: 0 B/call on unregister and FR callback
  • Explicit unregister cancels the finalizer (idempotent, null-safe)
  • onCollect fires ONLY on the FR path -- signal that a caller missed dispose
  • onError routes cleanup throws with tag attribution
  • Node 20+; browsers with FinalizationRegistry support

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 cleanup closure MUST NOT close over target.

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): sets disposed = true, unregisters the FR finalizer, decrements size(). Does not invoke cleanup. Idempotent.
  • FR path (target GC'd): fires cleanup(), then onCollect(tag). Decrements size(). Errors in cleanup route to onError.

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 idempotency
  • held-value-contract.test.js -- FR fires when target unreachable
  • leak-probe.test.js -- 4096-cycle size return-to-zero
  • race.test.js -- unregister-after-enqueue does not double-fire
  • onCollect.test.js -- hook semantics (FR path only, tag threading)
  • error-hook.test.js -- cleanup throws routed with tag
  • retained-heap.test.js -- 10K cycles under 1 MB retained
  • gc-gate.test.js -- 0 GC events on unregister hot path
  • version.test.js -- VERSION const matches package.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 WeakRef helper. Different concern. Use WeakRef directly where needed.
  • Not a resource pool. Registrations are permanent until explicit unregister or GC.

License

MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>

Keywords