From d3fe80ca35c49baeeb6d7918695f6415f9bd0c67 Mon Sep 17 00:00:00 2001 From: Big Boss Date: Fri, 9 Jan 2026 13:53:12 -0600 Subject: [PATCH] fix(terminal): prevent crash during resize with high-output programs Fixes a race condition that causes crashes when resizing the terminal while programs with high output (like cmatrix, htop) are running. The issue occurs because the render loop and write operations can access WASM memory buffers while resize() is reallocating them, causing SIGSEGV crashes. This fix implements three protections: 1. **Pause render loop during resize**: Cancel the animation frame before WASM resize and restart after, preventing concurrent buffer access. 2. **Invalidate cached buffer views**: Clear graphemeBuffer and graphemeBufferPtr when invalidating buffers, since TypedArray views become detached when underlying memory is reallocated. 3. **Queue writes during resize**: Buffer incoming PTY data during resize and flush after a frame, preventing writes from hitting WASM while buffers are being reallocated. All three protections are needed to fully prevent the race condition with rapidly-outputting programs. --- lib/ghostty.ts | 7 +++ lib/terminal.ts | 114 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 7449185..7f14f09 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -785,5 +785,12 @@ export class GhosttyTerminal { this.viewportBufferPtr = 0; this.viewportBufferSize = 0; } + // Also invalidate grapheme buffer since WASM memory may have moved during resize. + // Typed array views become detached when the underlying ArrayBuffer is replaced. + if (this.graphemeBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.graphemeBufferPtr, 16 * 4); + this.graphemeBufferPtr = 0; + this.graphemeBuffer = null; + } } } diff --git a/lib/terminal.ts b/lib/terminal.ts index deef77e..8e61556 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -102,6 +102,11 @@ export class Terminal implements ITerminalCore { private isDisposed = false; private animationFrameId?: number; + // Resize protection: queue writes during resize to prevent race conditions + private _isResizing = false; + private _writeQueue: Array<{ data: string | Uint8Array; callback?: () => void }> = []; + private _resizeFlushFrameId?: number; + // Addons private addons: ITerminalAddon[] = []; @@ -541,6 +546,15 @@ export class Terminal implements ITerminalCore { data = data.replace(/\n/g, '\r\n'); } + // Queue writes during resize to prevent WASM race conditions. + // Writes will be flushed after resize completes. + // Copy Uint8Array data to prevent mutation by caller before flush. + if (this._isResizing) { + const dataCopy = data instanceof Uint8Array ? new Uint8Array(data) : data; + this._writeQueue.push({ data: dataCopy, callback }); + return; + } + this.writeInternal(data, callback); } @@ -652,6 +666,11 @@ export class Terminal implements ITerminalCore { /** * Resize terminal + * + * Note: We pause the render loop and queue writes during resize to prevent + * race conditions. The WASM terminal reallocates internal buffers during + * resize, and if the render loop or writes access those buffers concurrently, + * it can cause a crash. */ resize(cols: number, rows: number): void { this.assertOpen(); @@ -660,28 +679,81 @@ export class Terminal implements ITerminalCore { return; // No change } - // Update dimensions - this.cols = cols; - this.rows = rows; + // Cancel any pending resize flush from a previous resize - this resize supersedes it + if (this._resizeFlushFrameId) { + cancelAnimationFrame(this._resizeFlushFrameId); + this._resizeFlushFrameId = undefined; + } - // Resize WASM terminal - this.wasmTerm!.resize(cols, rows); + // Set resizing flag to queue any incoming writes + this._isResizing = true; - // Resize renderer - this.renderer!.resize(cols, rows); + // Pause render loop during resize to prevent race condition. + // The render loop reads from WASM buffers that are reallocated during resize. + // Without this, concurrent access can cause SIGSEGV crashes. + const wasRunning = this.animationFrameId !== undefined; + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = undefined; + } + + try { + // Resize WASM terminal (this reallocates internal buffers) + this.wasmTerm!.resize(cols, rows); - // Update canvas dimensions - const metrics = this.renderer!.getMetrics(); - this.canvas!.width = metrics.width * cols; - this.canvas!.height = metrics.height * rows; - this.canvas!.style.width = `${metrics.width * cols}px`; - this.canvas!.style.height = `${metrics.height * rows}px`; + // Update dimensions after successful WASM resize + this.cols = cols; + this.rows = rows; - // Fire resize event - this.resizeEmitter.fire({ cols, rows }); + // Resize renderer + this.renderer!.resize(cols, rows); - // Force full render - this.renderer!.render(this.wasmTerm!, true, this.viewportY, this); + // Update canvas dimensions + const metrics = this.renderer!.getMetrics(); + this.canvas!.width = metrics.width * cols; + this.canvas!.height = metrics.height * rows; + this.canvas!.style.width = `${metrics.width * cols}px`; + this.canvas!.style.height = `${metrics.height * rows}px`; + + // Fire resize event + this.resizeEmitter.fire({ cols, rows }); + + // Force full render with new dimensions + this.renderer!.render(this.wasmTerm!, true, this.viewportY, this); + } catch (err) { + console.error('[ghostty-web] Resize error:', err); + // Still clear the flag so future resizes can proceed + } + + // Restart render loop if it was running + if (wasRunning) { + this.startRenderLoop(); + } + + // Clear resizing flag and flush queued writes after a frame + // This ensures WASM state has fully settled before processing writes + // Track the frame ID so it can be canceled on dispose + this._resizeFlushFrameId = requestAnimationFrame(() => { + this._resizeFlushFrameId = undefined; + this._isResizing = false; + this.flushWriteQueue(); + }); + } + + /** + * Flush queued writes that were blocked during resize + */ + private flushWriteQueue(): void { + // Guard against flush after dispose + if (this.isDisposed || !this.isOpen) { + this._writeQueue = []; + return; + } + const queue = this._writeQueue; + this._writeQueue = []; + for (const { data, callback } of queue) { + this.writeInternal(data, callback); + } } /** @@ -1080,6 +1152,14 @@ export class Terminal implements ITerminalCore { this.scrollAnimationFrame = undefined; } + // Cancel pending resize flush and clear write queue + if (this._resizeFlushFrameId) { + cancelAnimationFrame(this._resizeFlushFrameId); + this._resizeFlushFrameId = undefined; + } + this._writeQueue = []; + this._isResizing = false; + // Clear mouse move throttle timeout if (this.mouseMoveThrottleTimeout) { clearTimeout(this.mouseMoveThrottleTimeout);