Skip to content

Commit 74aa7f3

Browse files
diegosouzapwsauyon
andauthored
fix(wasm): zero-initialize WASM page buffers to prevent memory corruption (#12)
Ghostty allocates page buffers via the wasm allocator (which calls wasm_alloc -> memory.grow). New memory pages on grow are zero on most runtimes but the cell layout depends on this being EXPLICITLY true: some cell fields are inspected before the first write, and a stray non-zero byte can corrupt the screen state in ways that surface much later (wrong colors, stuck cursor, dropped grapheme clusters). Patches/ghostty-wasm-api.patch is updated so the underlying buffer arrays are explicitly memset-to-zero at construction. Adds two TS-side regression tests that exercise the corrupted-render shape. Inspired-by: coder#142 Co-authored-by: Sauyon Lee <[email protected]>
1 parent 31ed228 commit 74aa7f3

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

lib/terminal.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3116,6 +3116,82 @@ describe('preserveScrollOnWrite option', () => {
31163116
expect(term.viewportY).toBe(Math.max(0, Math.min(savedViewportY + delta, newScrollback)));
31173117

31183118
term.dispose();
3119+
});
3120+
3121+
describe('WASM memory safety', () => {
3122+
let container: HTMLElement | null = null;
3123+
3124+
beforeEach(() => {
3125+
if (typeof document !== 'undefined') {
3126+
container = document.createElement('div');
3127+
document.body.appendChild(container);
3128+
}
3129+
});
3130+
3131+
afterEach(() => {
3132+
if (container && container.parentNode) {
3133+
container.parentNode.removeChild(container);
3134+
container = null;
3135+
}
3136+
});
3137+
3138+
test('new terminal should not contain stale data from freed terminal', async () => {
3139+
if (!container) return;
3140+
3141+
// Create first terminal and write content
3142+
const term1 = await createIsolatedTerminal({ cols: 80, rows: 24 });
3143+
term1.open(container);
3144+
term1.write('Hello stale data');
3145+
3146+
// Access the Ghostty instance to create a second raw terminal
3147+
const ghostty = (term1 as any).ghostty;
3148+
const wasmTerm1 = term1.wasmTerm!;
3149+
3150+
// Free the first WASM terminal and create a new one through the same instance
3151+
wasmTerm1.free();
3152+
const wasmTerm2 = ghostty.createTerminal(80, 24);
3153+
3154+
// New terminal should have clean grid
3155+
const line = wasmTerm2.getLine(0);
3156+
expect(line).not.toBeNull();
3157+
for (const cell of line!) {
3158+
expect(cell.codepoint).toBe(0);
3159+
}
3160+
expect(wasmTerm2.getScrollbackLength()).toBe(0);
3161+
wasmTerm2.free();
3162+
3163+
term1.dispose();
3164+
});
3165+
3166+
// https://github.com/coder/ghostty-web/issues/141
3167+
test('freeing terminal after writing multi-codepoint grapheme clusters should not corrupt WASM memory', async () => {
3168+
if (!container) return;
3169+
3170+
const term1 = await createIsolatedTerminal({ cols: 80, rows: 24 });
3171+
term1.open(container);
3172+
const ghostty = (term1 as any).ghostty;
3173+
const wasmTerm1 = term1.wasmTerm!;
3174+
3175+
// Write multi-codepoint grapheme clusters (flag emoji, skin tone, ZWJ sequence)
3176+
wasmTerm1.write('\u{1F1FA}\u{1F1F8}'); // 🇺🇸 regional indicator pair
3177+
wasmTerm1.write('\u{1F44B}\u{1F3FD}'); // 👋🏽 wave + skin tone modifier
3178+
wasmTerm1.write('\u{1F468}\u200D\u{1F469}\u200D\u{1F467}'); // 👨‍👩‍👧 ZWJ family
3179+
3180+
// Free the terminal that processed grapheme clusters
3181+
wasmTerm1.free();
3182+
3183+
// Creating and writing to a new terminal on the same instance should not crash
3184+
const wasmTerm2 = ghostty.createTerminal(80, 24);
3185+
expect(() => wasmTerm2.write('Hello')).not.toThrow();
3186+
3187+
// Verify the write actually worked
3188+
const line = wasmTerm2.getLine(0);
3189+
expect(line).not.toBeNull();
3190+
expect(line![0].codepoint).toBe('H'.codePointAt(0)!);
3191+
3192+
wasmTerm2.free();
3193+
term1.dispose();
3194+
});
31193195
});
31203196
});
31213197

patches/ghostty-wasm-api.patch

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,48 @@ index 03a883e20..1336676d7 100644
368368

369369
// On Wasm we need to export our allocator convenience functions.
370370
if (builtin.target.cpu.arch.isWasm()) {
371+
diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig
372+
index 29f414e03..6b5ab19ab 100644
373+
--- a/src/terminal/PageList.zig
374+
+++ b/src/terminal/PageList.zig
375+
@@ -5,6 +5,7 @@ const PageList = @This();
376+
377+
const std = @import("std");
378+
const build_options = @import("terminal_options");
379+
+const builtin = @import("builtin");
380+
const Allocator = std.mem.Allocator;
381+
const assert = @import("../quirks.zig").inlineAssert;
382+
const fastmem = @import("../fastmem.zig");
383+
@@ -338,10 +339,10 @@ fn initPages(
384+
const page_buf = try pool.pages.create();
385+
// no errdefer because the pool deinit will clean these up
386+
387+
- // In runtime safety modes we have to memset because the Zig allocator
388+
- // interface will always memset to 0xAA for undefined. In non-safe modes
389+
- // we use a page allocator and the OS guarantees zeroed memory.
390+
- if (comptime std.debug.runtime_safety) @memset(page_buf, 0);
391+
+ // On WASM, the allocator reuses freed memory without zeroing, so we must
392+
+ // always zero page buffers. On other platforms, only required with runtime
393+
+ // safety (allocators init to 0xAA); in release the OS guarantees zeroed memory.
394+
+ if (comptime builtin.target.cpu.arch.isWasm() or std.debug.runtime_safety) @memset(page_buf, 0);
395+
396+
// Initialize the first set of pages to contain our viewport so that
397+
// the top of the first page is always the active area.
398+
@@ -2673,9 +2674,11 @@ inline fn createPageExt(
399+
else
400+
page_alloc.free(page_buf);
401+
402+
- // Required only with runtime safety because allocators initialize
403+
- // to undefined, 0xAA.
404+
- if (comptime std.debug.runtime_safety) @memset(page_buf, 0);
405+
+ // On WASM, the allocator reuses freed memory without zeroing, so we must
406+
+ // always zero page buffers to prevent stale grapheme/style data from
407+
+ // corrupting the terminal state after a free+realloc cycle.
408+
+ // On other platforms, only required with runtime safety (allocators init to 0xAA).
409+
+ if (comptime builtin.target.cpu.arch.isWasm() or std.debug.runtime_safety) @memset(page_buf, 0);
410+
411+
page.* = .{
412+
.data = .initBuf(.init(page_buf), layout),
371413
diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig
372414
index bc92597f5..d0ee49c1b 100644
373415
--- a/src/terminal/c/main.zig

0 commit comments

Comments
 (0)