From 3abc45cb867ba85400e324e09c32b2c805905daa Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 10 Mar 2026 20:38:07 +0000 Subject: [PATCH] Add wasm leak detector & fix leak --- reconcile-js/src/index.test.ts | 12 +++++ reconcile-js/src/index.ts | 18 ++++++-- reconcile-js/src/wasm-leak-detector.ts | 63 ++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 reconcile-js/src/wasm-leak-detector.ts diff --git a/reconcile-js/src/index.test.ts b/reconcile-js/src/index.test.ts index 1a4394f..0de924c 100644 --- a/reconcile-js/src/index.test.ts +++ b/reconcile-js/src/index.test.ts @@ -1,10 +1,22 @@ import { reconcile, reconcileWithHistory, diff, undiff } from './index'; +import { installWasmLeakDetector, checkForWasmLeaks } from './wasm-leak-detector'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +installWasmLeakDetector(); + +afterEach(() => { + const leaks = checkForWasmLeaks(); + if (leaks.length > 0) { + throw new Error( + `WASM memory leak: ${leaks.length} object(s) not freed:\n ${leaks.join('\n ')}` + ); + } +}); + describe('reconcile', () => { it('call reconcile without cursors', () => { expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world'); diff --git a/reconcile-js/src/index.ts b/reconcile-js/src/index.ts index 3fa998c..006e5d1 100644 --- a/reconcile-js/src/index.ts +++ b/reconcile-js/src/index.ts @@ -325,9 +325,15 @@ function toWasmCursorPosition({ id, position }: CursorPosition): wasmCursorPosit } function toTextWithCursors(textWithCursor: wasmTextWithCursors): TextWithCursors { + const wasmCursors = textWithCursor.cursors(); + const cursors = wasmCursors.map(toCursorPosition); + for (const cursor of wasmCursors) { + cursor.free(); + } + return { text: textWithCursor.text(), - cursors: textWithCursor.cursors().map(toCursorPosition), + cursors, }; } @@ -338,9 +344,11 @@ function toCursorPosition(cursor: wasmCursorPosition): CursorPosition { }; } -function toSpanWithHistory(textWithHistory: wasmSpanWithHistory): SpanWithHistory { - return { - text: textWithHistory.text(), - history: textWithHistory.history(), +function toSpanWithHistory(span: wasmSpanWithHistory): SpanWithHistory { + const result = { + text: span.text(), + history: span.history(), }; + span.free(); + return result; } diff --git a/reconcile-js/src/wasm-leak-detector.ts b/reconcile-js/src/wasm-leak-detector.ts new file mode 100644 index 0000000..a8bcec4 --- /dev/null +++ b/reconcile-js/src/wasm-leak-detector.ts @@ -0,0 +1,63 @@ +/** + * Test utility for detecting WASM memory leaks. + * + * wasm-bindgen registers every JS-side object with a `FinalizationRegistry`. + * This detector patches `FinalizationRegistry.prototype.register` to collect + * references to all WASM objects. After each test, {@link checkForWasmLeaks} + * inspects `__wbg_ptr` on every tracked object - a non-zero pointer means + * `.free()` was never called, i.e. a leak. + * + * Install once (before any WASM calls) and call {@link checkForWasmLeaks} + * in an `afterEach` hook. + */ + +let trackedObjects: object[] = []; +let originalRegister: Function | null = null; + +interface WasmBindgenObject { + __wbg_ptr: number; + constructor: { name?: string }; +} + +function isWasmBindgenObject(target: unknown): target is WasmBindgenObject { + return ( + target !== null && + typeof target === 'object' && + '__wbg_ptr' in (target as Record) + ); +} + +/** + * Patches `FinalizationRegistry.prototype.register` to track all wasm-bindgen + * objects. Safe to call multiple times (idempotent). + */ +export function installWasmLeakDetector(): void { + if (originalRegister) return; + + originalRegister = FinalizationRegistry.prototype.register; + + FinalizationRegistry.prototype.register = function ( + target: object, + heldValue: unknown, + unregisterToken?: object + ) { + if (isWasmBindgenObject(target)) { + trackedObjects.push(target); + } + return originalRegister!.call(this, target, heldValue, unregisterToken); + }; +} + +/** + * Returns any tracked WASM objects whose `__wbg_ptr` is still non-zero + * (i.e. `.free()` was never called). Clears the tracked set afterwards. + */ +export function checkForWasmLeaks(): string[] { + const leaks = trackedObjects + .filter(isWasmBindgenObject) + .filter((obj) => obj.__wbg_ptr !== 0) + .map((obj) => `${obj.constructor?.name ?? 'Unknown'} (ptr=${obj.__wbg_ptr})`); + + trackedObjects = []; + return leaks; +}