Skip to content

Opt-in destroy() alongside FinalizationRegistry? #239

Open

Description

@dannyball710

I understand that v1 replaced the destroy() methods with FinalizationRegistry on purpose (#177, MIGRATING.md), and automatic cleanup is the right default. I'd like to ask whether an opt-in way to free an object immediately would be welcome alongside it, for workloads where finalization comes too late.

The problem. My use case is an application that shapes text with many user-supplied fonts in one process. FinalizationRegistry callbacks only run on an event loop turn after a garbage collection, so code that creates large objects without yielding keeps all of them alive, and the WASM memory fills up even though only one object is live at a time. Test case: make a Blob + Face + Font from the same 13 MB font data, drop them, and repeat 400 times. Results with upstream main plus #238:

between rounds result GCs (--trace-gc)
nothing (synchronous loop) WASM memory full at round 157 (2 GiB): Failed to allocate 13631488 bytes 21
await Promise.resolve() same, round 157 21
await setImmediate 400 rounds ok, peak RSS 974 MiB 16
await setTimeout(1) 400 rounds ok, peak RSS 856 MiB 17

So the garbage collector does run, but the memory only comes back after a macrotask turn, and even then the dropped 13 MB faces pile up to almost 1 GiB at the peak while only one face is live. The WASM memory never shrinks, so the process keeps that peak. A server that builds a face per request from a font cache, or a batch job that goes through a font collection in one synchronous pass, runs into this.

Without #238 it is worse: the failed allocation is not detected, the font data is copied over the start of the WASM memory, and the next finalizer that runs traps with RuntimeError: memory access out of bounds inside FinalizationRegistry.cleanupSome. The error is uncaught and exits Node.js with code 1. That PR makes the failure a catchable error, but the memory still cannot be released early.

Proposal. A strict addition, nothing changes for code that doesn't use it:

  • destroy() on the classes that own native memory: Blob, Face, Font, FontFuncs, Buffer, DrawFuncs and PaintFuncs. It frees the native object right away and unregisters it from the registry (registry.register(obj, cleanup, obj) + registry.unregister(obj)), so the finalizer never runs a second time. Calling it again does nothing.
  • Using an object after destroy() throws a JS error instead of passing a freed pointer to HarfBuzz.
  • Optionally the same method as [Symbol.dispose](), so that using face = new hb.Face(blob); frees the face at the end of the scope, in keeping with the "nothing to remember" spirit of v1.

Objects that depend on others (a Face on its Blob, a Font on its Face) already hold their own HarfBuzz references, so destroying in any order is safe.

We already carry the core of this (destroy + unregister, idempotent) as a small local patch, tested in our application. If you're open to the idea, I'd be happy to send the full version above as a PR, with tests in the test suite and a README/MIGRATING update. If you'd rather keep it out of the API, that's fine too, and good to know.

Activity

  1. dannyball710 commented on Oct 13, 2026

    @dannyball710
    Author

    One more data point on why this matters for us: we mostly shape CJK text, and CJK fonts are far larger than Latin ones. A single weight of a typical Japanese/Chinese/Korean font is often 15–20 MB, and variable fonts or font collections (.ttc) can go past 50 MB.

    Extrapolating from the test above: with a 50 MB font, the synchronous loop would run out of WASM memory after roughly 40 rounds instead of 157. Even with a macrotask between rounds, the ~70 dropped faces that piled up at the peak with the 13 MB font would come to about 3.5 GB at 50 MB, well past the 2 GiB limit. So for CJK workloads, yielding to the event loop isn't enough on its own, and being able to free each face deterministically is what keeps memory bounded.

  2. lianghai commented on Oct 13, 2026

    @lianghai
    Contributor

    I’ve been very interested in how [Symbol.dispose]() and using can probably simplify this code base (eg, those Module.stackRestore() and StringPtr.free() calls in in #235). Is it also a reasonable direction to just name the method [Symbol.dispose]() instead of aliasing it under both names?

  3. dannyball710 commented on Oct 13, 2026

    @dannyball710
    Author

    [Symbol.dispose]() is a nice suggestion. But Safari, including iOS, doesn't ship Symbol.dispose yet (MDN lists it as Technology Preview only). There, a class member [Symbol.dispose]() gets the string key "undefined". An explicit obj[Symbol.dispose]() only finds it by accident, and a using compiled by TypeScript doesn't find it at all: TypeScript's downlevel helper falls back to Symbol.for("Symbol.dispose") when Symbol.dispose is missing.

    Keying the method as

    const dispose: typeof Symbol.dispose = Symbol.dispose ?? Symbol.for("Symbol.dispose");

    would make it work in every runtime. That covers using compiled by TypeScript, and an explicit obj[dispose]() call. The same applies if the internals adopt using for stackRestore() / StringPtr.free(), since the build targets ES2021.

    If that direction sounds good, I can send a PR: [Symbol.dispose]() on Blob, Face, Font, FontFuncs, Buffer, DrawFuncs and PaintFuncs (free + unregister, idempotent, using a disposed object throws), with tests and a README/MIGRATING note.

  4. lianghai commented on Oct 13, 2026

    @lianghai
    Contributor

    Polyfilling Symbol.dispose is apparently necessary so I didn’t mention that. TS’s announcement post about its support of using suggsted a simple and cleaner solution at the end of this section: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management

    Symbol.dispose ??= Symbol("Symbol.dispose");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions