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: '/// ' }, });