Repository navigation
Opt-in destroy() alongside FinalizationRegistry? #239
Description
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 thatusing 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
dannyball710 commentedon Oct 13, 2026
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.
lianghai commentedon Oct 13, 2026
I’ve been very interested in how
[Symbol.dispose]()andusingcan probably simplify this code base (eg, thoseModule.stackRestore()andStringPtr.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?dannyball710 commentedon Oct 13, 2026
[Symbol.dispose]()is a nice suggestion. But Safari, including iOS, doesn't shipSymbol.disposeyet (MDN lists it as Technology Preview only). There, a class member[Symbol.dispose]()gets the string key"undefined". An explicitobj[Symbol.dispose]()only finds it by accident, and ausingcompiled by TypeScript doesn't find it at all: TypeScript's downlevel helper falls back toSymbol.for("Symbol.dispose")whenSymbol.disposeis missing.Keying the method as
would make it work in every runtime. That covers
usingcompiled by TypeScript, and an explicitobj[dispose]()call. The same applies if the internals adoptusingforstackRestore()/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.lianghai commentedon Oct 13, 2026
Polyfilling
Symbol.disposeis apparently necessary so I didn’t mention that. TS’s announcement post about its support ofusingsuggsted 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