From a8e9b413f13cfeec77efd95c3ba9b4750fcbfada Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 16 Aug 2026 12:37:16 -0700 Subject: [PATCH] libghostty: simplify Wasm allocation API Replace a bunch of type-specific Wasm allocation functions with a generic byte allocator and reusable opaque out-parameters for pointers. This makes it a lot more ergonomic (relatively) to use the Wasm interface and removes a dozen or so exports. This also updates the `ghostty_type_json` `abi` field with a maximum alignment value that host sides can use to keep every allocation aligned properly, easily, without hardcoding numbers. This adds a test to verify this all works as intended and runs in CI. --- .github/workflows/test.yml | 5 + example/wasm-key-encode/index.html | 60 ++++++++-- example/wasm-sgr/index.html | 62 ++++++---- example/wasm-vt/index.html | 30 +++-- include/ghostty/vt/sgr.h | 25 ----- include/ghostty/vt/types.h | 3 +- include/ghostty/vt/wasm.h | 174 ++++++++++++++--------------- src/lib/allocator.zig | 4 +- src/lib/allocator/convenience.zig | 59 ---------- src/lib/allocator/wasm.zig | 98 ++++++++++++++++ src/lib/c_abi.zig | 28 +++++ src/lib_vt.zig | 15 +-- src/terminal/c/main.zig | 2 - src/terminal/c/sgr.zig | 11 -- src/terminal/c/types.schema.json | 5 + src/terminal/c/types.zig | 7 ++ test/wasm-alloc.mjs | 136 ++++++++++++++++++++++ 17 files changed, 481 insertions(+), 243 deletions(-) delete mode 100644 src/lib/allocator/convenience.zig create mode 100644 src/lib/allocator/wasm.zig create mode 100644 src/lib/c_abi.zig create mode 100644 test/wasm-alloc.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a5216072c..3470a8c33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -733,6 +733,11 @@ jobs: echo "Verified ${artifact} requires simd128" done + - name: Test WASM allocator + run: | + nix develop -c node test/wasm-alloc.mjs ghostty-vt.wasm + nix develop -c node test/wasm-alloc.mjs ghostty-vt-small.wasm + # Compile-only checks for the -Dvt-features flags so that future changes # don't regress any feature combination. build-libghostty-vt-features: diff --git a/example/wasm-key-encode/index.html b/example/wasm-key-encode/index.html index 9f4d8bebb..2578bdad7 100644 --- a/example/wasm-key-encode/index.html +++ b/example/wasm-key-encode/index.html @@ -163,6 +163,7 @@ let wasmMemory = null; let encoderPtr = null; let lastKeyEvent = null; + let typeLayout = null; async function loadWasm() { try { @@ -184,6 +185,12 @@ wasmInstance = wasmModule.instance; wasmMemory = wasmInstance.exports.memory; + + const jsonPtr = wasmInstance.exports.ghostty_type_json(); + const jsonStr = new TextDecoder().decode( + new Uint8Array(wasmMemory.buffer, jsonPtr, wasmMemory.buffer.byteLength - jsonPtr) + ).split('\0')[0]; + typeLayout = JSON.parse(jsonStr); return true; } catch (e) { @@ -199,6 +206,15 @@ return wasmMemory.buffer; } + function readUsize(ptr) { + const view = new DataView(getBuffer()); + switch (typeLayout.abi.usize_size) { + case 4: return view.getUint32(ptr, true); + case 8: return Number(view.getBigUint64(ptr, true)); + default: throw new Error('unsupported size_t width'); + } + } + function formatHex(bytes) { return Array.from(bytes) .map(b => b.toString(16).padStart(2, '0')) @@ -415,16 +431,28 @@ function encodeKeyEvent(event) { if (!encoderPtr) return null; + const usizeSize = typeLayout.abi.usize_size; + let eventPtrPtr = 0; + let eventPtr = 0; + let utf8Ptr = 0; + let utf8Length = 0; + let requiredPtr = 0; + let required = 0; + let bufPtr = 0; + let writtenPtr = 0; + try { // Create key event - const eventPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque(); + eventPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque(); const result = wasmInstance.exports.ghostty_key_event_new(0, eventPtrPtr); if (result !== 0) { throw new Error(`ghostty_key_event_new failed with result ${result}`); } - const eventPtr = new DataView(getBuffer()).getUint32(eventPtrPtr, true); + eventPtr = wasmInstance.exports.ghostty_wasm_take_opaque(eventPtrPtr); + wasmInstance.exports.ghostty_wasm_free_opaque(eventPtrPtr); + eventPtrPtr = 0; // Get action from radio buttons const actionRadio = document.querySelector('input[name="action"]:checked'); @@ -458,9 +486,10 @@ // Set UTF-8 text from the key event (the actual character produced) if (event.key.length === 1) { const utf8Bytes = new TextEncoder().encode(event.key); - const utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(utf8Bytes.length); + utf8Length = utf8Bytes.length; + utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc(utf8Length); new Uint8Array(getBuffer()).set(utf8Bytes, utf8Ptr); - wasmInstance.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Bytes.length); + wasmInstance.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Length); } // Set unshifted codepoint @@ -470,15 +499,15 @@ } // Encode the key event - const requiredPtr = wasmInstance.exports.ghostty_wasm_alloc_usize(); + requiredPtr = wasmInstance.exports.ghostty_wasm_alloc(usizeSize); wasmInstance.exports.ghostty_key_encoder_encode( encoderPtr, eventPtr, 0, 0, requiredPtr ); - const required = new DataView(getBuffer()).getUint32(requiredPtr, true); + required = readUsize(requiredPtr); - const bufPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(required); - const writtenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize(); + bufPtr = wasmInstance.exports.ghostty_wasm_alloc(required); + writtenPtr = wasmInstance.exports.ghostty_wasm_alloc(usizeSize); const encodeResult = wasmInstance.exports.ghostty_key_encoder_encode( encoderPtr, eventPtr, bufPtr, required, writtenPtr ); @@ -487,7 +516,7 @@ return null; // No encoding for this key } - const written = new DataView(getBuffer()).getUint32(writtenPtr, true); + const written = readUsize(writtenPtr); const encoded = new Uint8Array(getBuffer()).slice(bufPtr, bufPtr + written); return { @@ -498,6 +527,13 @@ } catch (e) { console.error('Encoding error:', e); return null; + } finally { + wasmInstance.exports.ghostty_wasm_free(writtenPtr, usizeSize); + wasmInstance.exports.ghostty_wasm_free(bufPtr, required); + wasmInstance.exports.ghostty_wasm_free(requiredPtr, usizeSize); + wasmInstance.exports.ghostty_wasm_free(utf8Ptr, utf8Length); + wasmInstance.exports.ghostty_key_event_free(eventPtr); + wasmInstance.exports.ghostty_wasm_free_opaque(eventPtrPtr); } } @@ -555,13 +591,14 @@ if (!encoderPtr) return; const flags = getKittyFlags(); - const flagsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8(); + const flagsPtr = wasmInstance.exports.ghostty_wasm_alloc(1); new DataView(getBuffer()).setUint8(flagsPtr, flags); wasmInstance.exports.ghostty_key_encoder_setopt( encoderPtr, 5, // GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS flagsPtr ); + wasmInstance.exports.ghostty_wasm_free(flagsPtr, 1); // Re-encode last key with new flags reencodeLastKey(); @@ -649,7 +686,8 @@ throw new Error(`ghostty_key_encoder_new failed with result ${result}`); } - encoderPtr = new DataView(getBuffer()).getUint32(encoderPtrPtr, true); + encoderPtr = wasmInstance.exports.ghostty_wasm_take_opaque(encoderPtrPtr); + wasmInstance.exports.ghostty_wasm_free_opaque(encoderPtrPtr); // Set kitty flags based on checkboxes updateEncoderFlags(); diff --git a/example/wasm-sgr/index.html b/example/wasm-sgr/index.html index e62b26c7e..24f318b9b 100644 --- a/example/wasm-sgr/index.html +++ b/example/wasm-sgr/index.html @@ -106,6 +106,7 @@