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.
This commit is contained in:
Mitchell Hashimoto
2026-08-16 12:37:16 -07:00
parent 0ba6250388
commit a8e9b413f1
17 changed files with 481 additions and 243 deletions
+5
View File
@@ -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:
+49 -11
View File
@@ -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();
+38 -24
View File
@@ -106,6 +106,7 @@
<script>
let wasmInstance = null;
let wasmMemory = null;
let typeLayout = null;
async function loadWasm() {
try {
@@ -124,6 +125,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) {
@@ -252,18 +259,22 @@
throw new Error(`ghostty_sgr_new failed with result ${result}`);
}
const parserPtr = new DataView(getBuffer()).getUint32(parserPtrPtr, true);
const parserPtr = wasmInstance.exports.ghostty_wasm_take_opaque(parserPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(parserPtrPtr);
// Allocate and set parameters
const paramsPtr = wasmInstance.exports.ghostty_wasm_alloc_u16_array(params.length);
const paramsByteLength = params.length * Uint16Array.BYTES_PER_ELEMENT;
const paramsPtr = wasmInstance.exports.ghostty_wasm_alloc(paramsByteLength);
const paramsView = new Uint16Array(getBuffer(), paramsPtr, params.length);
params.forEach((p, i) => paramsView[i] = p);
// Allocate and set separators (or use null if empty)
let sepsPtr = 0;
const sepsByteLength = separators.length > 0 ? params.length : 0;
if (separators.length > 0) {
sepsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(separators.length);
const sepsView = new Uint8Array(getBuffer(), sepsPtr, separators.length);
sepsPtr = wasmInstance.exports.ghostty_wasm_alloc(sepsByteLength);
const sepsView = new Uint8Array(getBuffer(), sepsPtr, sepsByteLength);
sepsView.fill(0);
separators.forEach((s, i) => sepsView[i] = s.charCodeAt(0));
}
@@ -289,7 +300,8 @@
output += 'm\n\n';
// Iterate through attributes
const attrPtr = wasmInstance.exports.ghostty_wasm_alloc_sgr_attribute();
const attrSize = typeLayout.types.GhosttySgrAttribute.size;
const attrPtr = wasmInstance.exports.ghostty_wasm_alloc(attrSize);
let count = 0;
while (wasmInstance.exports.ghostty_sgr_next(parserPtr, attrPtr)) {
@@ -313,9 +325,9 @@
case SGR_ATTR_TAGS.DIRECT_COLOR_FG: {
// Use ghostty_color_rgb_get to extract RGB components
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const rPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const gPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const bPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
@@ -325,17 +337,17 @@
output += `Foreground RGB = (${r}, ${g}, ${b})\n`;
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
wasmInstance.exports.ghostty_wasm_free(rPtr, 1);
wasmInstance.exports.ghostty_wasm_free(gPtr, 1);
wasmInstance.exports.ghostty_wasm_free(bPtr, 1);
break;
}
case SGR_ATTR_TAGS.DIRECT_COLOR_BG: {
// Use ghostty_color_rgb_get to extract RGB components
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const rPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const gPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const bPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
@@ -345,17 +357,17 @@
output += `Background RGB = (${r}, ${g}, ${b})\n`;
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
wasmInstance.exports.ghostty_wasm_free(rPtr, 1);
wasmInstance.exports.ghostty_wasm_free(gPtr, 1);
wasmInstance.exports.ghostty_wasm_free(bPtr, 1);
break;
}
case SGR_ATTR_TAGS.UNDERLINE_COLOR: {
// Use ghostty_color_rgb_get to extract RGB components
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const rPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const gPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const bPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
@@ -365,9 +377,9 @@
output += `Underline color RGB = (${r}, ${g}, ${b})\n`;
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
wasmInstance.exports.ghostty_wasm_free(rPtr, 1);
wasmInstance.exports.ghostty_wasm_free(gPtr, 1);
wasmInstance.exports.ghostty_wasm_free(bPtr, 1);
break;
}
@@ -415,7 +427,9 @@
outputDiv.textContent = output;
// Cleanup
wasmInstance.exports.ghostty_wasm_free_sgr_attribute(attrPtr);
wasmInstance.exports.ghostty_wasm_free(attrPtr, attrSize);
wasmInstance.exports.ghostty_wasm_free(paramsPtr, paramsByteLength);
wasmInstance.exports.ghostty_wasm_free(sepsPtr, sepsByteLength);
wasmInstance.exports.ghostty_sgr_free(parserPtr);
} catch (e) {
+20 -10
View File
@@ -183,6 +183,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');
}
}
// Parse escape sequences in the input string (e.g. \x1b, \r, \n)
function parseEscapes(str) {
return str
@@ -204,6 +213,7 @@
const cols = parseInt(document.getElementById('cols').value, 10);
const rows = parseInt(document.getElementById('rows').value, 10);
const vtText = parseEscapes(document.getElementById('vtInput').value);
const usizeSize = typeLayout.abi.usize_size;
// Allocate pointer to receive the terminal handle
const termPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
@@ -219,19 +229,19 @@
throw new Error(`ghostty_terminal_new failed with result ${newResult}`);
}
const termPtr = new DataView(getBuffer()).getUint32(termPtrPtr, true);
const termPtr = wasmInstance.exports.ghostty_wasm_take_opaque(termPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(termPtrPtr);
// Write VT data to the terminal
const vtBytes = new TextEncoder().encode(vtText);
const dataPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(vtBytes.length);
const dataPtr = wasmInstance.exports.ghostty_wasm_alloc(vtBytes.length);
new Uint8Array(getBuffer()).set(vtBytes, dataPtr);
wasmInstance.exports.ghostty_terminal_vt_write(termPtr, dataPtr, vtBytes.length);
wasmInstance.exports.ghostty_wasm_free_u8_array(dataPtr, vtBytes.length);
wasmInstance.exports.ghostty_wasm_free(dataPtr, vtBytes.length);
// Create a plain-text formatter
const FMT_OPTS_SIZE = typeLayout.types.GhosttyFormatterTerminalOptions.size;
const fmtOptsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(FMT_OPTS_SIZE);
const fmtOptsPtr = wasmInstance.exports.ghostty_wasm_alloc(FMT_OPTS_SIZE);
new Uint8Array(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE).fill(0);
const fmtOptsView = new DataView(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE);
setField(fmtOptsView, 'GhosttyFormatterTerminalOptions', 'size', FMT_OPTS_SIZE);
@@ -254,19 +264,19 @@
const fmtResult = wasmInstance.exports.ghostty_formatter_terminal_new(
0, fmtPtrPtr, termPtr, fmtOptsPtr
);
wasmInstance.exports.ghostty_wasm_free_u8_array(fmtOptsPtr, FMT_OPTS_SIZE);
wasmInstance.exports.ghostty_wasm_free(fmtOptsPtr, FMT_OPTS_SIZE);
if (fmtResult !== GHOSTTY_SUCCESS) {
wasmInstance.exports.ghostty_terminal_free(termPtr);
throw new Error(`ghostty_formatter_terminal_new failed with result ${fmtResult}`);
}
const fmtPtr = new DataView(getBuffer()).getUint32(fmtPtrPtr, true);
const fmtPtr = wasmInstance.exports.ghostty_wasm_take_opaque(fmtPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(fmtPtrPtr);
// Format with alloc
const outPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
const outLenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
const outLenPtr = wasmInstance.exports.ghostty_wasm_alloc(usizeSize);
const formatResult = wasmInstance.exports.ghostty_formatter_format_alloc(
fmtPtr, 0, outPtrPtr, outLenPtr
);
@@ -277,8 +287,8 @@
throw new Error(`ghostty_formatter_format_alloc failed with result ${formatResult}`);
}
const outPtr = new DataView(getBuffer()).getUint32(outPtrPtr, true);
const outLen = new DataView(getBuffer()).getUint32(outLenPtr, true);
const outPtr = wasmInstance.exports.ghostty_wasm_take_opaque(outPtrPtr);
const outLen = readUsize(outLenPtr);
const outBytes = new Uint8Array(getBuffer(), outPtr, outLen);
const outText = new TextDecoder().decode(outBytes);
@@ -294,7 +304,7 @@
// Clean up
wasmInstance.exports.ghostty_free(0, outPtr, outLen);
wasmInstance.exports.ghostty_wasm_free_opaque(outPtrPtr);
wasmInstance.exports.ghostty_wasm_free_usize(outLenPtr);
wasmInstance.exports.ghostty_wasm_free(outLenPtr, usizeSize);
wasmInstance.exports.ghostty_formatter_free(fmtPtr);
wasmInstance.exports.ghostty_terminal_free(termPtr);
-25
View File
@@ -319,31 +319,6 @@ GHOSTTY_API GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute
GHOSTTY_API GhosttySgrAttributeValue* ghostty_sgr_attribute_value(
GhosttySgrAttribute* attr);
#ifdef __wasm__
/**
* Allocate memory for an SGR attribute (WebAssembly only).
*
* This is a convenience function for WebAssembly environments to allocate
* memory for an SGR attribute structure that can be passed to ghostty_sgr_next.
*
* @return Pointer to the allocated attribute structure
*
* @ingroup wasm
*/
GHOSTTY_API GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void);
/**
* Free memory for an SGR attribute (WebAssembly only).
*
* Frees memory allocated by ghostty_wasm_alloc_sgr_attribute.
*
* @param attr Pointer to the attribute structure to free
*
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr);
#endif
#ifdef __cplusplus
}
#endif
+2 -1
View File
@@ -358,7 +358,8 @@ typedef struct {
* "schema": 1,
* "abi": {
* "target": "wasm32", "os": "freestanding", "environment": "none",
* "pointer_size": 4, "usize_size": 4, "endian": "little"
* "pointer_size": 4, "usize_size": 4, "max_alignment": 16,
* "endian": "little"
* },
* "types": {
* "GhosttyRenderStateData": {
+87 -87
View File
@@ -10,12 +10,12 @@
#ifdef __wasm__
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/types.h>
/** @defgroup wasm WebAssembly Utilities
*
* Convenience functions for allocating various types in WebAssembly builds.
* Convenience functions for working with the low-level C ABI in WebAssembly
* builds.
* **These are only available the libghostty-vt wasm module.**
*
* Ghostty relies on pointers to various types for ABI compatibility, and
@@ -28,37 +28,64 @@
* your custom allocator. This is a very rare use case in the WebAssembly
* world so these are optimized for simplicity.
*
* Use ghostty_wasm_alloc() and ghostty_wasm_free() for host-owned scratch
* buffers and ABI values. Dynamic-language hosts can use ghostty_type_json()
* to discover pointer and size_t widths, maximum alignment, byte order, and
* the size and alignment of public C structs. Do not mix allocation families:
* buffers returned by libghostty-vt allocating APIs must still be released
* with ghostty_free(), and opaque handles must be released with their
* type-specific destructor.
*
* ## Memory growth
*
* An exported function may grow Wasm linear memory when it allocates. Numeric
* pointers and handles remain valid, but JavaScript ArrayBuffer, DataView, and
* typed-array objects created before the growth may no longer cover the live
* memory. Reacquire `exports.memory.buffer` immediately before every host-side
* memory access. A host that caches views should recreate them whenever either
* the buffer identity or its byte length changes.
*
* ## Example Usage
*
* Here's a simple example of using the Wasm utilities with the key encoder:
* Here's a simple example that creates a terminal, writes bytes, and safely
* handles memory growth:
*
* @code
* const { exports } = wasmInstance;
* const view = new DataView(wasmMemory.buffer);
* const memory = exports.memory;
* let cachedBuffer = null;
* let cachedLength = 0;
* let cachedBytes = null;
*
* // Create key encoder
* const encoderPtr = exports.ghostty_wasm_alloc_opaque();
* exports.ghostty_key_encoder_new(null, encoderPtr);
* const encoder = view.getUint32(encoder, true);
* function bytes() {
* const buffer = memory.buffer;
* if (buffer !== cachedBuffer || buffer.byteLength !== cachedLength) {
* cachedBuffer = buffer;
* cachedLength = buffer.byteLength;
* cachedBytes = new Uint8Array(buffer);
* }
* return cachedBytes;
* }
*
* // Configure encoder with Kitty protocol flags
* const flagsPtr = exports.ghostty_wasm_alloc_u8();
* view.setUint8(flagsPtr, 0x1F);
* exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr);
* function check(result) {
* if (result !== 0) throw new Error(`libghostty-vt error: ${result}`);
* }
*
* // Allocate output buffer and size pointer
* const bufferSize = 32;
* const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize);
* const writtenPtr = exports.ghostty_wasm_alloc_usize();
* // One slot can be reused for every opaque-handle constructor.
* const slot = exports.ghostty_wasm_alloc_opaque();
* if (slot === 0) throw new Error("out of memory");
* check(exports.ghostty_terminal_new(0, slot, 80, 24));
* const terminal = exports.ghostty_wasm_take_opaque(slot);
*
* // Encode the key event
* exports.ghostty_key_encoder_encode(
* encoder, eventPtr, bufPtr, bufferSize, writtenPtr
* );
* const input = new TextEncoder().encode("Hello, world!");
* const inputPtr = exports.ghostty_wasm_alloc(input.length);
* if (inputPtr === 0) throw new Error("out of memory");
* bytes().set(input, inputPtr); // Acquires the current memory after alloc.
* exports.ghostty_terminal_vt_write(terminal, inputPtr, input.length);
*
* // Read encoded output
* const bytesWritten = view.getUint32(writtenPtr, true);
* const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten);
* exports.ghostty_wasm_free(inputPtr, input.length);
* exports.ghostty_terminal_free(terminal);
* exports.ghostty_wasm_free_opaque(slot);
* @endcode
*
* @remark The code above is pretty ugly! This is the lowest level interface
@@ -68,9 +95,36 @@
* @{
*/
/**
* Allocate caller-owned storage for a Wasm ABI value or scratch buffer.
*
* The returned address is aligned to the target's maximum C ABI alignment,
* reported as `abi.max_alignment` by ghostty_type_json(). The memory is
* uninitialized. A zero-length request returns NULL.
*
* The returned pointer must be released with ghostty_wasm_free() using the
* exact same length.
*
* @param len Number of bytes to allocate
* @return Pointer to allocated storage, or NULL if len is zero or allocation
* failed
* @ingroup wasm
*/
GHOSTTY_API void* ghostty_wasm_alloc(size_t len);
/**
* Free storage allocated by ghostty_wasm_alloc().
*
* @param ptr Pointer to free, or NULL (NULL is safely ignored)
* @param len Original allocation length passed to ghostty_wasm_alloc()
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free(void *ptr, size_t len);
/**
* Allocate an opaque pointer. This can be used for any opaque pointer
* types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc.
* types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. The allocated slot
* is initialized to NULL and may be reused across constructors.
*
* @return Pointer to allocated opaque pointer, or NULL if allocation failed
* @ingroup wasm
@@ -86,72 +140,18 @@ GHOSTTY_API void** ghostty_wasm_alloc_opaque(void);
GHOSTTY_API void ghostty_wasm_free_opaque(void **ptr);
/**
* Allocate an array of uint8_t values.
* Take an opaque handle from an out-parameter slot.
*
* @param len Number of uint8_t elements to allocate
* @return Pointer to allocated array, or NULL if allocation failed
* Returns the handle currently stored in @p slot and resets the slot to NULL.
* This function does not allocate, free the returned handle, or free the slot.
* Always check the GhosttyResult returned by the function that populated the
* slot before calling this function.
*
* @param slot Pointer to an opaque out-parameter slot, or NULL
* @return Stored opaque handle, or NULL if slot or its value is NULL
* @ingroup wasm
*/
GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8_array(size_t len);
/**
* Free an array allocated by ghostty_wasm_alloc_u8_array().
*
* @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
* @param len Length of the array (must match the length passed to alloc)
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len);
/**
* Allocate an array of uint16_t values.
*
* @param len Number of uint16_t elements to allocate
* @return Pointer to allocated array, or NULL if allocation failed
* @ingroup wasm
*/
GHOSTTY_API uint16_t* ghostty_wasm_alloc_u16_array(size_t len);
/**
* Free an array allocated by ghostty_wasm_alloc_u16_array().
*
* @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
* @param len Length of the array (must match the length passed to alloc)
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len);
/**
* Allocate a single uint8_t value.
*
* @return Pointer to allocated uint8_t, or NULL if allocation failed
* @ingroup wasm
*/
GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8(void);
/**
* Free a uint8_t allocated by ghostty_wasm_alloc_u8().
*
* @param ptr Pointer to free, or NULL (NULL is safely ignored)
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_u8(uint8_t *ptr);
/**
* Allocate a single size_t value.
*
* @return Pointer to allocated size_t, or NULL if allocation failed
* @ingroup wasm
*/
GHOSTTY_API size_t* ghostty_wasm_alloc_usize(void);
/**
* Free a size_t allocated by ghostty_wasm_alloc_usize().
*
* @param ptr Pointer to free, or NULL (NULL is safely ignored)
* @ingroup wasm
*/
GHOSTTY_API void ghostty_wasm_free_usize(size_t *ptr);
GHOSTTY_API void* ghostty_wasm_take_opaque(void **slot);
/** @} */
+2 -2
View File
@@ -2,8 +2,8 @@ const std = @import("std");
const builtin = @import("builtin");
const testing = std.testing;
/// Convenience functions
pub const convenience = @import("allocator/convenience.zig");
/// Wasm-specific allocation helpers.
pub const wasm = @import("allocator/wasm.zig");
/// Useful alias since they're required to create Zig allocators
pub const ZigVTable = std.mem.Allocator.VTable;
-59
View File
@@ -1,59 +0,0 @@
//! This contains convenience functions for allocating various types.
//!
//! The primary use case for this is Wasm builds. Ghostty relies a lot on
//! pointers to various types for ABI compatibility and creating those pointers
//! in Wasm is tedious. This file contains a purely additive set of functions
//! that can be exposed to the Wasm module without changing the API from the
//! C library.
//!
//! Given these are convenience methods, they always use the default allocator.
//! If a caller is using a custom allocator, they have the expertise to
//! allocate these types manually using their custom allocator.
// Get our default allocator at comptime since it is known.
const default = @import("../allocator.zig").default;
const alloc = default(null);
pub const Opaque = *anyopaque;
pub fn allocOpaque() callconv(.c) ?*Opaque {
return alloc.create(*anyopaque) catch return null;
}
pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void {
if (ptr) |p| alloc.destroy(p);
}
pub fn allocU8Array(len: usize) callconv(.c) ?[*]u8 {
const slice = alloc.alloc(u8, len) catch return null;
return slice.ptr;
}
pub fn freeU8Array(ptr: ?[*]u8, len: usize) callconv(.c) void {
if (ptr) |p| alloc.free(p[0..len]);
}
pub fn allocU16Array(len: usize) callconv(.c) ?[*]u16 {
const slice = alloc.alloc(u16, len) catch return null;
return slice.ptr;
}
pub fn freeU16Array(ptr: ?[*]u16, len: usize) callconv(.c) void {
if (ptr) |p| alloc.free(p[0..len]);
}
pub fn allocU8() callconv(.c) ?*u8 {
return alloc.create(u8) catch return null;
}
pub fn freeU8(ptr: ?*u8) callconv(.c) void {
if (ptr) |p| alloc.destroy(p);
}
pub fn allocUsize() callconv(.c) ?*usize {
return alloc.create(usize) catch return null;
}
pub fn freeUsize(ptr: ?*usize) callconv(.c) void {
if (ptr) |p| alloc.destroy(p);
}
+98
View File
@@ -0,0 +1,98 @@
//! Wasm allocation conveniences for caller-owned storage and opaque out slots.
//!
//! The primary use case for this is Wasm builds. Ghostty relies a lot on
//! pointers to various types for ABI compatibility and creating those pointers
//! in Wasm is tedious. This file contains the small set of functions exposed by
//! the Wasm module without changing the API from the C library.
//!
//! Given these are convenience methods, they always use the default allocator.
//! If a caller is using a custom allocator, they have the expertise to
//! allocate these types manually using their custom allocator.
const std = @import("std");
const c_abi = @import("../c_abi.zig");
// Get our default allocator at comptime since it is known.
const default = @import("../allocator.zig").default;
const alloc = default(null);
const wasm_alignment: std.mem.Alignment = .fromByteUnits(c_abi.max_alignment);
/// A nullable opaque C handle stored in a constructor out-parameter slot.
pub const Opaque = ?*anyopaque;
/// Allocate `len` bytes of uninitialized, caller-owned Wasm ABI storage.
///
/// The returned pointer is aligned for any fundamental C ABI type and must be
/// released with `freeBytes` using the same `len`. Returns null when `len` is
/// zero or allocation fails.
pub fn allocBytes(len: usize) callconv(.c) ?[*]u8 {
if (len == 0) return null;
return alloc.rawAlloc(len, wasm_alignment, @returnAddress());
}
/// Release storage returned by `allocBytes`.
///
/// `len` must exactly match the allocation length. A null pointer is ignored.
pub fn freeBytes(ptr: ?[*]u8, len: usize) callconv(.c) void {
const p = ptr orelse return;
if (len == 0) return;
alloc.rawFree(p[0..len], wasm_alignment, @returnAddress());
}
/// Allocate a null-initialized slot for an opaque constructor out-parameter.
///
/// The slot may be reused after each value is removed with `takeOpaque`. It
/// must eventually be released with `freeOpaque`.
pub fn allocOpaque() callconv(.c) ?*Opaque {
const ptr = allocBytes(@sizeOf(Opaque)) orelse return null;
const result: *Opaque = @ptrCast(@alignCast(ptr));
result.* = null;
return result;
}
/// Release a slot returned by `allocOpaque` without freeing its stored handle.
///
/// Call the handle's type-specific destructor before freeing a populated slot.
/// A null slot pointer is ignored.
pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void {
freeBytes(@ptrCast(ptr), @sizeOf(Opaque));
}
/// Remove and return the handle in an opaque out-parameter slot.
///
/// The slot is reset to null so it can be safely reused for another
/// constructor. This does not free either the handle or the slot. Returns null
/// when the slot pointer is null or the slot is empty.
pub fn takeOpaque(ptr: ?*Opaque) callconv(.c) Opaque {
const p = ptr orelse return null;
const result = p.*;
p.* = null;
return result;
}
test "Wasm allocation" {
const ptr = allocBytes(1) orelse return error.OutOfMemory;
defer freeBytes(ptr, 1);
try std.testing.expectEqual(
@as(usize, 0),
@intFromPtr(ptr) % c_abi.max_alignment,
);
ptr[0] = 42;
try std.testing.expectEqual(@as(u8, 42), ptr[0]);
try std.testing.expect(allocBytes(0) == null);
freeBytes(null, 0);
}
test "opaque slots are initialized and reusable" {
const slot = allocOpaque() orelse return error.OutOfMemory;
defer freeOpaque(slot);
try std.testing.expect(takeOpaque(slot) == null);
var value: u8 = 42;
slot.* = &value;
try std.testing.expectEqual(@as(?*anyopaque, &value), takeOpaque(slot));
try std.testing.expect(takeOpaque(slot) == null);
}
+28
View File
@@ -0,0 +1,28 @@
//! Memory-layout properties shared with C API callers.
//!
//! The C ABI defines how values are represented in memory so code written in
//! different languages can safely exchange them. One part of that contract is
//! alignment: some values must begin at an address divisible by 2, 4, 8, or
//! another power of two. Reading a value from a less-aligned address can be
//! slow on some CPUs and invalid on others.
//!
//! This module derives those properties from Zig's compile-time target data.
//! It does not call or link libc. Keeping the calculation here gives allocators
//! and ABI metadata one source of truth.
const std = @import("std");
const builtin = @import("builtin");
/// Largest address alignment required by a fundamental C ABI type, in bytes.
///
/// For example, a value of 16 means storage intended to hold an arbitrary C
/// value must begin at an address evenly divisible by 16. The Wasm allocator
/// uses this value for caller-owned ABI storage, and `ghostty_type_json`
/// publishes it so hosts know the guarantee made by that allocator.
pub const max_alignment: u16 = max: {
var result: u16 = @alignOf(*anyopaque);
for (std.enums.values(std.Target.CType)) |c_type| {
result = @max(result, builtin.target.cTypeAlignment(c_type));
}
break :max result;
};
+4 -11
View File
@@ -400,19 +400,12 @@ comptime {
// On Wasm we need to export our allocator convenience functions.
if (builtin.target.cpu.arch.isWasm()) {
const alloc = @import("lib/allocator/convenience.zig");
const alloc = @import("lib/allocator/wasm.zig");
@export(&alloc.allocBytes, .{ .name = "ghostty_wasm_alloc" });
@export(&alloc.freeBytes, .{ .name = "ghostty_wasm_free" });
@export(&alloc.allocOpaque, .{ .name = "ghostty_wasm_alloc_opaque" });
@export(&alloc.freeOpaque, .{ .name = "ghostty_wasm_free_opaque" });
@export(&alloc.allocU8Array, .{ .name = "ghostty_wasm_alloc_u8_array" });
@export(&alloc.freeU8Array, .{ .name = "ghostty_wasm_free_u8_array" });
@export(&alloc.allocU16Array, .{ .name = "ghostty_wasm_alloc_u16_array" });
@export(&alloc.freeU16Array, .{ .name = "ghostty_wasm_free_u16_array" });
@export(&alloc.allocU8, .{ .name = "ghostty_wasm_alloc_u8" });
@export(&alloc.freeU8, .{ .name = "ghostty_wasm_free_u8" });
@export(&alloc.allocUsize, .{ .name = "ghostty_wasm_alloc_usize" });
@export(&alloc.freeUsize, .{ .name = "ghostty_wasm_free_usize" });
@export(&c.wasm_alloc_sgr_attribute, .{ .name = "ghostty_wasm_alloc_sgr_attribute" });
@export(&c.wasm_free_sgr_attribute, .{ .name = "ghostty_wasm_free_sgr_attribute" });
@export(&alloc.takeOpaque, .{ .name = "ghostty_wasm_take_opaque" });
}
}
}
-2
View File
@@ -114,8 +114,6 @@ pub const sgr_unknown_full = sgr.unknown_full;
pub const sgr_unknown_partial = sgr.unknown_partial;
pub const sgr_attribute_tag = sgr.attribute_tag;
pub const sgr_attribute_value = sgr.attribute_value;
pub const wasm_alloc_sgr_attribute = sgr.wasm_alloc_attribute;
pub const wasm_free_sgr_attribute = sgr.wasm_free_attribute;
pub const key_event_new = key_event.new;
pub const key_event_free = key_event.free;
-11
View File
@@ -126,17 +126,6 @@ pub fn attribute_value(
return &attr.value;
}
pub fn wasm_alloc_attribute() callconv(lib.calling_conv) *sgr.Attribute.C {
const alloc = std.heap.wasm_allocator;
const ptr = alloc.create(sgr.Attribute.C) catch @panic("out of memory");
return ptr;
}
pub fn wasm_free_attribute(attr: *sgr.Attribute.C) callconv(lib.calling_conv) void {
const alloc = std.heap.wasm_allocator;
alloc.destroy(attr);
}
test "alloc" {
var p: Parser = undefined;
try testing.expectEqual(Result.success, new(
+5
View File
@@ -60,6 +60,7 @@
"environment",
"pointer_size",
"usize_size",
"max_alignment",
"endian"
],
"properties": {
@@ -81,6 +82,10 @@
"usize_size": {
"$ref": "#/$defs/positiveInteger"
},
"max_alignment": {
"description": "Maximum fundamental C ABI alignment in bytes.",
"$ref": "#/$defs/positiveInteger"
},
"endian": {
"enum": ["little", "big"]
}
+7
View File
@@ -8,6 +8,7 @@ const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("terminal_options");
const lib = @import("../lib.zig");
const c_abi = @import("../../lib/c_abi.zig");
const color = @import("../color.zig");
const clipboard = @import("../clipboard.zig");
@@ -377,6 +378,8 @@ const Json = struct {
try jws.write(@sizeOf(*anyopaque));
try jws.objectField("usize_size");
try jws.write(@sizeOf(usize));
try jws.objectField("max_alignment");
try jws.write(c_abi.max_alignment);
try jws.objectField("endian");
try jws.write(@tagName(builtin.target.cpu.arch.endian()));
try jws.endObject();
@@ -843,6 +846,10 @@ test "manifest parses and is versioned" {
const root = parsed.value.object;
try std.testing.expectEqual(@as(i64, 1), root.get("schema").?.integer);
try std.testing.expect(root.contains("abi"));
try std.testing.expectEqual(
@as(i64, c_abi.max_alignment),
root.get("abi").?.object.get("max_alignment").?.integer,
);
try std.testing.expect(root.contains("library_version"));
const manifest_types = root.get("types").?.object;
try std.testing.expectEqual(type_decls.len, manifest_types.count());
+136
View File
@@ -0,0 +1,136 @@
/**
* Smoke-test the low-level allocation helpers exported by libghostty-vt Wasm.
*
* The test instantiates a release artifact, forces linear memory growth,
* verifies generic allocation and reusable pointer slots, and checks that
* public C structs can be allocated from the ABI manifest.
*
* Build and run locally with:
*
* zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall
* node test/wasm-alloc.mjs zig-out/bin/ghostty-vt.wasm
*/
import assert from "node:assert/strict";
import fs from "node:fs/promises";
const path = process.argv[2];
if (path === undefined) {
console.error("usage: node test/wasm-alloc.mjs <ghostty-vt.wasm>");
process.exit(2);
}
const module = await WebAssembly.compile(await fs.readFile(path));
const instance = await WebAssembly.instantiate(module, {});
const { exports } = instance;
const memory = exports.memory;
let cachedBuffer = null;
let cachedLength = 0;
let cachedBytes = null;
/** Return a byte view over the current linear-memory buffer. */
function memoryBytes() {
const buffer = memory.buffer;
if (buffer !== cachedBuffer || buffer.byteLength !== cachedLength) {
cachedBuffer = buffer;
cachedLength = buffer.byteLength;
cachedBytes = new Uint8Array(buffer);
}
return cachedBytes;
}
// Read target-specific sizes, alignment, and result values from the ABI
// manifest so this test does not duplicate properties of the Wasm target.
const typeJsonPtr = exports.ghostty_type_json();
const typeBytes = memoryBytes();
const typeJsonEnd = typeBytes.indexOf(0, typeJsonPtr);
assert.notEqual(typeJsonEnd, -1);
const typeLayout = JSON.parse(
new TextDecoder().decode(typeBytes.subarray(typeJsonPtr, typeJsonEnd)),
);
const allocationAlignment = typeLayout.abi.max_alignment;
const resultValues = typeLayout.types.GhosttyResult.values;
function check(result) {
assert.equal(
result,
resultValues.SUCCESS,
`libghostty-vt call failed with ${result}`,
);
}
// A block larger than the current memory guarantees allocator-driven growth.
// Verify that the documented lazy view-refresh pattern observes the new buffer.
const oldBuffer = memory.buffer;
const allocationLength = oldBuffer.byteLength + 1;
const allocation = exports.ghostty_wasm_alloc(allocationLength);
assert.notEqual(allocation, 0);
assert.equal(allocation % allocationAlignment, 0);
assert.notEqual(memory.buffer, oldBuffer);
// Distinct arbitrary sentinels verify that both ends of the allocation are
// writable after refreshing the linear-memory view.
const firstSentinel = 0x12;
const lastSentinel = 0x34;
const bytes = memoryBytes();
bytes[allocation] = firstSentinel;
bytes[allocation + allocationLength - 1] = lastSentinel;
assert.equal(bytes[allocation], firstSentinel);
assert.equal(bytes[allocation + allocationLength - 1], lastSentinel);
exports.ghostty_wasm_free(allocation, allocationLength);
assert.equal(exports.ghostty_wasm_alloc(0), 0);
// A pointer slot starts cleared, remains cleared after a failed constructor,
// and can be reused across successful constructors without a DataView read.
const slot = exports.ghostty_wasm_alloc_opaque();
assert.notEqual(slot, 0);
assert.equal(exports.ghostty_wasm_take_opaque(slot), 0);
const terminalColumns = 80;
const terminalRows = 24;
assert.equal(
exports.ghostty_terminal_new(0, slot, 0, terminalRows),
resultValues.INVALID_VALUE,
);
assert.equal(exports.ghostty_wasm_take_opaque(slot), 0);
check(exports.ghostty_terminal_new(0, slot, terminalColumns, terminalRows));
const terminal = exports.ghostty_wasm_take_opaque(slot);
assert.notEqual(terminal, 0);
check(exports.ghostty_render_state_new(0, slot));
const renderState = exports.ghostty_wasm_take_opaque(slot);
assert.notEqual(renderState, 0);
check(exports.ghostty_render_state_row_iterator_new(0, slot));
const rowIterator = exports.ghostty_wasm_take_opaque(slot);
assert.notEqual(rowIterator, 0);
check(exports.ghostty_render_state_row_cells_new(0, slot));
const rowCells = exports.ghostty_wasm_take_opaque(slot);
assert.notEqual(rowCells, 0);
assert.equal(exports.ghostty_wasm_take_opaque(slot), 0);
// Public struct storage uses the generic allocator and exported type layout.
assert.equal(
typeLayout.abi.pointer_size,
typeLayout.types.GhosttyBuffer.fields.ptr.size,
);
assert.equal(
typeLayout.abi.usize_size,
typeLayout.types.GhosttyBuffer.fields.len.size,
);
const sgrAttributeSize = typeLayout.types.GhosttySgrAttribute.size;
const sgrAttribute = exports.ghostty_wasm_alloc(sgrAttributeSize);
assert.notEqual(sgrAttribute, 0);
assert.equal(sgrAttribute % allocationAlignment, 0);
exports.ghostty_wasm_free(sgrAttribute, sgrAttributeSize);
exports.ghostty_render_state_row_cells_free(rowCells);
exports.ghostty_render_state_row_iterator_free(rowIterator);
exports.ghostty_render_state_free(renderState);
exports.ghostty_terminal_free(terminal);
exports.ghostty_wasm_free_opaque(slot);
console.log(`Wasm allocator smoke test passed: ${path}`);