From 2abe522d50dd3980b2f58013ff127591051bf917 Mon Sep 17 00:00:00 2001 From: Danny Chyou Date: Wed, 14 Oct 2026 07:12:48 +0800 Subject: [PATCH] Add [Symbol.dispose]() to free objects without waiting for GC FinalizationRegistry callbacks only run on a later task, so a synchronous loop that keeps creating large blobs and faces can fill the 2 GiB WASM memory before any of them is freed. Give every class that owns a native object (Blob, Face, Font, FontFuncs, Buffer, DrawFuncs, PaintFuncs) a [Symbol.dispose]() method, so it can be freed right away, or with a `using` declaration. The classes declare that they implement the built-in Disposable interface, and the generated typings reference the esnext.disposable lib, so consumers that target older libs still get its declarations. The method frees the native object now and unregisters it from the registry, so the finalizer does not free it again. A second call does nothing, and any later use of the object's `ptr` throws. This lives in a new untrack() helper next to track(); DrawFuncs and PaintFuncs now register through track() like the other classes. Runtimes without Symbol.dispose (Safari at the time of writing) need a polyfill such as `Symbol.dispose ??= Symbol("Symbol.dispose");` from the TypeScript 5.2 release notes, and it must run before harfbuzzjs is imported, since the method key is evaluated when the module is loaded. Co-Authored-By: Claude Opus 5.5 --- MIGRATING.md | 2 +- src/blob.ts | 14 +++- src/buffer.ts | 12 +++- src/draw-funcs.ts | 17 +++-- src/face.ts | 13 +++- src/font-funcs.ts | 13 +++- src/font.ts | 12 +++- src/helpers.ts | 25 +++++++- src/paint-funcs.ts | 17 +++-- test/dispose-polyfill.test.js | 76 ++++++++++++++++++++++ test/index.test.js | 116 +++++++++++++++++++++++++++++++++- tsdown.config.ts | 3 + 12 files changed, 301 insertions(+), 19 deletions(-) create mode 100644 test/dispose-polyfill.test.js diff --git a/MIGRATING.md b/MIGRATING.md index ac4c756..89cd15e 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -24,7 +24,7 @@ Instead of using factory functions, we now export classes with constructors. ## Automatic memory cleanup -Instead of manually calling `.destroy()`, WASM objects are now reclaimed automatically using `FinalizationRegistry`. There are no longer any `.destroy()` methods. +Instead of manually calling `.destroy()`, WASM objects are now reclaimed automatically using `FinalizationRegistry`. There are no longer any `.destroy()` methods. Objects can be freed early with `[Symbol.dispose]()` or `using`. Runtimes without `Symbol.dispose` need `Symbol.dispose ??= Symbol("Symbol.dispose");` run before importing harfbuzzjs. ## String/number arguments replaced with enums diff --git a/src/blob.ts b/src/blob.ts index 58e56ed..8d5a4c8 100644 --- a/src/blob.ts +++ b/src/blob.ts @@ -1,10 +1,10 @@ -import { Module, exports, freeFuncPtr, track } from "./helpers"; +import { Module, exports, freeFuncPtr, track, untrack } from "./helpers"; /** * An object representing a {@link https://harfbuzz.github.io/harfbuzz-hb-blob.html | HarfBuzz blob}. * A blob wraps a chunk of binary data, typically the contents of a font file. */ -export class Blob { +export class Blob implements Disposable { readonly ptr: number; /** @@ -23,4 +23,14 @@ export class Blob { ); track(this, exports.hb_blob_destroy); } + + /** + * Free the blob now instead of when it is garbage collected, as at the end + * of a `using` declaration's scope. Idempotent; using the blob afterwards + * throws. A {@link Face} created from the blob keeps the font data alive + * until it is freed too. + */ + [Symbol.dispose](): void { + untrack(this); + } } diff --git a/src/buffer.ts b/src/buffer.ts index 442925e..d15a53e 100644 --- a/src/buffer.ts +++ b/src/buffer.ts @@ -2,6 +2,7 @@ import { Module, exports, track, + untrack, hb_tag, utf8_ptr_to_string, string_to_ascii_ptr, @@ -74,7 +75,7 @@ export type BufferSerializeFormat = ValueOf; * A buffer holds the input text and its properties before shaping, and the * output glyphs and their information after shaping. */ -export class Buffer { +export class Buffer implements Disposable { readonly ptr: number; /** @@ -89,6 +90,15 @@ export class Buffer { track(this, exports.hb_buffer_destroy); } + /** + * Free the buffer now instead of when it is garbage collected, as at the + * end of a `using` declaration's scope. Idempotent; using the buffer + * afterwards throws. + */ + [Symbol.dispose](): void { + untrack(this); + } + /** * Appends a character with the Unicode value of `codePoint` to the buffer, * and gives it the initial cluster value of `cluster`. Clusters can be any diff --git a/src/draw-funcs.ts b/src/draw-funcs.ts index 84378e7..40d0aaf 100644 --- a/src/draw-funcs.ts +++ b/src/draw-funcs.ts @@ -1,7 +1,8 @@ import { Module, exports, - registry, + track, + untrack, register_callback_data_pointer, get_callback_data, remove_callback_data_pointer, @@ -104,23 +105,31 @@ export type DrawClosePathFunc = (drawData: unknown, userData: unknown) => void; * defined, but we translate {@link DrawFuncs.setQuadraticToFunc | quadratic-to} * calls to cubic-to if the callback isn't defined. */ -export class DrawFuncs { +export class DrawFuncs implements Disposable { readonly ptr: number; private funcPtrs: number[] = []; private userDataPtrs: number[] = []; constructor() { this.ptr = exports.hb_draw_funcs_create(); - const ptr = this.ptr; const funcPtrs = this.funcPtrs; const userDataPtrs = this.userDataPtrs; - registry.register(this, () => { + track(this, (ptr) => { exports.hb_draw_funcs_destroy(ptr); for (const ptr of funcPtrs) Module.removeFunction(ptr); for (const ptr of userDataPtrs) remove_callback_data_pointer(ptr); }); } + /** + * Free the draw functions now instead of when they are garbage collected, + * as at the end of a `using` declaration's scope. Idempotent; using them + * afterwards throws. + */ + [Symbol.dispose](): void { + untrack(this); + } + /** * Sets move-to callback to the draw functions object. * @param func The move-to callback. diff --git a/src/face.ts b/src/face.ts index 203aa35..2041b73 100644 --- a/src/face.ts +++ b/src/face.ts @@ -2,6 +2,7 @@ import { Module, exports, track, + untrack, STATIC_ARRAY_SIZE, hb_tag, hb_untag, @@ -38,7 +39,7 @@ export type GlyphClass = ValueOf; * A face represents a single face in a binary font file and is the * foundation for creating Font objects used in text shaping. */ -export class Face { +export class Face implements Disposable { readonly ptr: number; readonly upem: number; @@ -60,6 +61,16 @@ export class Face { track(this, exports.hb_face_destroy); } + /** + * Free the face now instead of when it is garbage collected, as at the end + * of a `using` declaration's scope. Idempotent; using the face afterwards + * throws. A {@link Font} created from the face keeps it alive until it is + * freed too. + */ + [Symbol.dispose](): void { + untrack(this); + } + /** * Return the binary contents of an OpenType table. * @param table Table name diff --git a/src/font-funcs.ts b/src/font-funcs.ts index 2306c0a..ac3f233 100644 --- a/src/font-funcs.ts +++ b/src/font-funcs.ts @@ -1,4 +1,4 @@ -import { Module, exports, track, utf8_ptr_to_string } from "./helpers"; +import { Module, exports, track, untrack, utf8_ptr_to_string } from "./helpers"; import type { FontExtents, GlyphExtents } from "./types"; import { Font } from "./font"; @@ -8,7 +8,7 @@ import { Font } from "./font"; * like glyph advances and extents. HarfBuzz provides built-in default * implementations which can be selectively overridden. */ -export class FontFuncs { +export class FontFuncs implements Disposable { readonly ptr: number; constructor() { @@ -16,6 +16,15 @@ export class FontFuncs { track(this, exports.hb_font_funcs_destroy); } + /** + * Free the font functions now instead of when they are garbage collected, + * as at the end of a `using` declaration's scope. Idempotent; using them + * afterwards throws. A {@link Font} they were set on keeps them alive. + */ + [Symbol.dispose](): void { + untrack(this); + } + /** * Set the font's glyph extents function. * @param func The callback receives a Font and glyph ID. It should return diff --git a/src/font.ts b/src/font.ts index e9c0f79..f4a4d0e 100644 --- a/src/font.ts +++ b/src/font.ts @@ -2,6 +2,7 @@ import { Module, exports, track, + untrack, STATIC_ARRAY_SIZE, hb_tag, utf8_ptr_to_string, @@ -141,7 +142,7 @@ function getJsonDrawFuncs(): DrawFuncs { * parameters (pixels-per-em, variation settings) specified. Fonts are the * primary input to the shaping process. */ -export class Font { +export class Font implements Disposable { readonly ptr: number; private _face?: Face; @@ -161,6 +162,15 @@ export class Font { track(this, exports.hb_font_destroy); } + /** + * Free the font now instead of when it is garbage collected, as at the end + * of a `using` declaration's scope. Idempotent; using the font afterwards + * throws. Its {@link Face} is not freed. + */ + [Symbol.dispose](): void { + untrack(this); + } + /** The {@link Face} associated with this font. */ get face(): Face { if (!this._face) { diff --git a/src/helpers.ts b/src/helpers.ts index 02db097..206258d 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -18,16 +18,37 @@ const utf8Encoder = new TextEncoder(); export const STATIC_ARRAY_SIZE = 128; -export const registry = new FinalizationRegistry<() => void>((cleanup) => { +const registry = new FinalizationRegistry<() => void>((cleanup) => { cleanup(); }); +const cleanups = new WeakMap void>(); + export function track( obj: { ptr: number }, destroy: (ptr: number) => void, ): void { const ptr = obj.ptr; - registry.register(obj, () => destroy(ptr)); + const cleanup = () => destroy(ptr); + registry.register(obj, cleanup, obj); + cleanups.set(obj, cleanup); +} + +/** + * Free a tracked object now instead of when it is garbage collected. + * Idempotent; any use of the object's `ptr` afterwards throws. + */ +export function untrack(obj: { ptr: number }): void { + const cleanup = cleanups.get(obj); + if (!cleanup) return; + cleanups.delete(obj); + registry.unregister(obj); + Object.defineProperty(obj, "ptr", { + get() { + throw new Error(`${obj.constructor.name} has been disposed`); + }, + }); + cleanup(); } /** diff --git a/src/paint-funcs.ts b/src/paint-funcs.ts index a48f8f2..c875f44 100644 --- a/src/paint-funcs.ts +++ b/src/paint-funcs.ts @@ -1,7 +1,8 @@ import { Module, exports, - registry, + track, + untrack, STATIC_ARRAY_SIZE, hb_untag, register_callback_data_pointer, @@ -392,23 +393,31 @@ export type PaintCustomPaletteColorFunc = ( * The custom-palette-color callback is only necessary if you want to override * colors from the font palette with custom colors. */ -export class PaintFuncs { +export class PaintFuncs implements Disposable { readonly ptr: number; private funcPtrs: number[] = []; private userDataPtrs: number[] = []; constructor() { this.ptr = exports.hb_paint_funcs_create(); - const ptr = this.ptr; const funcPtrs = this.funcPtrs; const userDataPtrs = this.userDataPtrs; - registry.register(this, () => { + track(this, (ptr) => { exports.hb_paint_funcs_destroy(ptr); for (const ptr of funcPtrs) Module.removeFunction(ptr); for (const ptr of userDataPtrs) remove_callback_data_pointer(ptr); }); } + /** + * Free the paint functions now instead of when they are garbage collected, + * as at the end of a `using` declaration's scope. Idempotent; using them + * afterwards throws. + */ + [Symbol.dispose](): void { + untrack(this); + } + /** * Sets the push-transform callback on the paint functions object. * @param func The push-transform callback. diff --git a/test/dispose-polyfill.test.js b/test/dispose-polyfill.test.js new file mode 100644 index 0000000..e9ff127 --- /dev/null +++ b/test/dispose-polyfill.test.js @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { expect, it } from "vitest"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Run `fn` with a global Symbol that lacks Symbol.dispose until it is +// polyfilled, as in runtimes without it. +async function withoutSymbolDispose(fn) { + const OriginalSymbol = globalThis.Symbol; + globalThis.Symbol = new Proxy(function () {}, { + apply: (target, thisArg, args) => OriginalSymbol(...args), + get: (target, key) => + key === "dispose" ? target.dispose : Reflect.get(OriginalSymbol, key), + }); + try { + await fn(); + } finally { + globalThis.Symbol = OriginalSymbol; + } +} + +// Compile `using` declarations the way TypeScript does for runtimes without +// them; its helper reads Symbol.dispose when the declaration runs. +function transpileUsing(body) { + const { outputText } = ts.transpileModule( + `export function run(hb, data) { ${body} }`, + { + compilerOptions: { + target: ts.ScriptTarget.ES2021, + module: ts.ModuleKind.CommonJS, + }, + }, + ); + const exports = {}; + new Function("exports", outputText)(exports); + return exports.run; +} + +const data = fs.readFileSync( + path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf"), +); + +it("works with Symbol.dispose polyfilled before the import", async function () { + await withoutSymbolDispose(async () => { + Symbol.dispose ??= Symbol("Symbol.dispose"); + // A query string gives a fresh copy of the module. + const hb = await import("../dist/index.mjs?polyfilled-before-import"); + + const blob = new hb.Blob(data); + blob[Symbol.dispose](); + expect(() => blob.ptr).to.throw("Blob has been disposed"); + + const usingDeclarations = transpileUsing( + `using blob = new hb.Blob(data); + using face = new hb.Face(blob); + return { blob, face, unicodes: [...face.collectUnicodes()] };`, + ); + const result = usingDeclarations(hb, data); + expect(result.unicodes).to.include("a".codePointAt(0)); + expect(() => result.blob.ptr).to.throw("Blob has been disposed"); + expect(() => result.face.ptr).to.throw("Face has been disposed"); + }); +}); + +it("does not work with Symbol.dispose polyfilled after the import", async function () { + await withoutSymbolDispose(async () => { + const hb = await import("../dist/index.mjs?polyfilled-after-import"); + Symbol.dispose ??= Symbol("Symbol.dispose"); + + const blob = new hb.Blob(data); + expect(blob[Symbol.dispose]).to.equal(undefined); + }); +}); diff --git a/test/index.test.js b/test/index.test.js index 6ed2abe..ef81367 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { expect, describe, it } from "vitest"; +import { expect, describe, it, vi } from "vitest"; import * as hb from "../dist/index.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -2139,3 +2139,117 @@ describe("misc", function () { } }); }); + +describe("dispose", function () { + function createObjects() { + const blob = new hb.Blob( + fs.readFileSync(path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf")), + ); + const face = new hb.Face(blob); + const font = new hb.Font(face); + return { + blob, + face, + font, + fontFuncs: new hb.FontFuncs(), + buffer: new hb.Buffer(), + drawFuncs: new hb.DrawFuncs(), + paintFuncs: new hb.PaintFuncs(), + }; + } + + it("[Symbol.dispose]() unregisters the object from the finalization registry", function () { + const unregister = vi.spyOn(FinalizationRegistry.prototype, "unregister"); + try { + for (const [name, object] of Object.entries(createObjects())) { + unregister.mockClear(); + object[Symbol.dispose](); + expect(unregister.mock.calls, name).to.have.lengthOf(1); + expect(unregister.mock.calls[0][0] === object, name).to.equal(true); + expect(unregister.mock.results[0].value, name).to.equal(true); + object[Symbol.dispose](); + expect(unregister.mock.calls, name).to.have.lengthOf(1); + } + } finally { + unregister.mockRestore(); + } + }); + + it("using a disposed object throws", function () { + const objects = createObjects(); + for (const [name, object] of Object.entries(objects)) { + object[Symbol.dispose](); + expect(() => object.ptr, name).to.throw( + `${object.constructor.name} has been disposed`, + ); + } + const { blob, face, font, fontFuncs, buffer, drawFuncs, paintFuncs } = + objects; + expect(() => new hb.Face(blob)).to.throw("Blob has been disposed"); + expect(() => face.collectUnicodes()).to.throw("Face has been disposed"); + expect(() => font.glyphFromName("a")).to.throw("Font has been disposed"); + expect(() => buffer.addText("a")).to.throw("Buffer has been disposed"); + expect(() => drawFuncs.setMoveToFunc(() => {})).to.throw( + "DrawFuncs has been disposed", + ); + expect(() => paintFuncs.setPopTransformFunc(() => {})).to.throw( + "PaintFuncs has been disposed", + ); + const { font: liveFont } = createObjects(); + expect(() => liveFont.setFuncs(fontFuncs)).to.throw( + "FontFuncs has been disposed", + ); + expect(() => hb.shape(liveFont, buffer)).to.throw( + "Buffer has been disposed", + ); + }); + + it("disposing an object keeps the objects created from it working", function () { + const { blob, face, font, buffer } = createObjects(); + blob[Symbol.dispose](); + face[Symbol.dispose](); + buffer.addText("abc"); + buffer.guessSegmentProperties(); + hb.shape(font, buffer); + expect(buffer.getGlyphInfos().map((info) => info.codepoint)).to.deep.equal([ + 68, 69, 70, + ]); + }); + + it("disposing frees memory without waiting for garbage collection", function () { + // 40 blobs of 64 MiB exceed the 2 GiB WASM memory unless each is freed. + const data = new Uint8Array(64 * 1024 * 1024); + for (let i = 0; i < 40; i++) { + const blob = new hb.Blob(data); + const face = new hb.Face(blob); + const font = new hb.Font(face); + font[Symbol.dispose](); + face[Symbol.dispose](); + blob[Symbol.dispose](); + } + expect(hb.versionString()).to.match(/^\d+\.\d+\.\d+$/); + }); + + // Built from a string, since this file must also parse where `using` is not + // supported (Node.js before 24). + let usingDeclarations; + try { + usingDeclarations = new Function( + "hb", + "data", + `using blob = new hb.Blob(data); + using face = new hb.Face(blob); + return { blob, face, unicodes: [...face.collectUnicodes()] };`, + ); + } catch {} + + it.runIf(usingDeclarations)("works with using declarations", function () { + const { blob, face, unicodes } = usingDeclarations( + hb, + fs.readFileSync(path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf")), + ); + expect(unicodes).to.include("a".codePointAt(0)); + expect(() => blob.ptr).to.throw("Blob has been disposed"); + expect(() => face.ptr).to.throw("Face has been disposed"); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 95fffe8..0a53773 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,4 +8,7 @@ export default defineConfig({ outDir: "dist", deps: { neverBundle: ["./harfbuzz.js"] }, copy: ["src/harfbuzz.d.ts"], + // The classes implement Disposable, so consumers need its declarations even + // without "esnext" in their lib. + banner: { dts: '/// ' }, });