From a884a3d9aa243c6bdcb0c6a94de74d8709334a44 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 01:15:40 +0000 Subject: [PATCH 1/2] fix: prevent terminal crash on resize during high-output programs Cancel the render loop before WASM resize to prevent accessing detached TypedArray views when buffers are reallocated. Restart render loop and flush any queued writes synchronously after resize completes. This avoids the background-tab regression from PR #114's approach of using an isResizing flag cleared via requestAnimationFrame (rAF is throttled/paused in background tabs, causing writes to queue indefinitely). Changes: - terminal.ts: Add cancelRenderLoop()/flushWriteQueue(), wrap resize() in try/catch with render loop pause/restart, add defensive writeQueue - ghostty.ts: Invalidate grapheme buffers in invalidateBuffers() alongside viewport buffers to prevent stale TypedArray access Co-authored-by: 0xBigBoss <95193764+0xBigBoss@users.noreply.github.com> --- lib/ghostty.ts | 5 ++++ lib/terminal.ts | 77 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index ef18a14..f079885 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -874,5 +874,10 @@ export class GhosttyTerminal { this.viewportBufferPtr = 0; this.viewportBufferSize = 0; } + 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..f05bb1d 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -101,6 +101,7 @@ export class Terminal implements ITerminalCore { private isOpen = false; private isDisposed = false; private animationFrameId?: number; + private writeQueue: Uint8Array[] = []; // Addons private addons: ITerminalAddon[] = []; @@ -660,28 +661,42 @@ export class Terminal implements ITerminalCore { return; // No change } - // Update dimensions - this.cols = cols; - this.rows = rows; + // Cancel render loop before resize to prevent accessing detached TypedArray + // views while WASM reallocates buffers. We restart it after resize completes. + // This avoids the background-tab regression of using an isResizing flag + // cleared via requestAnimationFrame (rAF is throttled/paused in background tabs). + this.cancelRenderLoop(); - // Resize WASM terminal - this.wasmTerm!.resize(cols, rows); + try { + // Update dimensions + this.cols = cols; + this.rows = rows; + + // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views) + this.wasmTerm!.resize(cols, rows); + + // Resize renderer + this.renderer!.resize(cols, rows); - // Resize renderer - this.renderer!.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 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 }); - // Fire resize event - this.resizeEmitter.fire({ cols, rows }); + // Force full render + this.renderer!.render(this.wasmTerm!, true, this.viewportY, this); + } catch (e) { + console.error('Terminal resize failed:', e); + } - // Force full render - this.renderer!.render(this.wasmTerm!, true, this.viewportY, this); + // Flush any writes that were queued during resize, then restart render loop + this.flushWriteQueue(); + this.startRenderLoop(); } /** @@ -1068,11 +1083,9 @@ export class Terminal implements ITerminalCore { this.isDisposed = true; this.isOpen = false; - // Stop render loop - if (this.animationFrameId) { - cancelAnimationFrame(this.animationFrameId); - this.animationFrameId = undefined; - } + // Stop render loop and clear write queue + this.cancelRenderLoop(); + this.writeQueue.length = 0; // Stop smooth scroll animation if (this.scrollAnimationFrame) { @@ -1112,6 +1125,26 @@ export class Terminal implements ITerminalCore { // Private Methods // ========================================================================== + /** + * Cancel the render loop + */ + private cancelRenderLoop(): void { + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = undefined; + } + } + + /** + * Flush any writes that were queued during resize + */ + private flushWriteQueue(): void { + while (this.writeQueue.length > 0) { + const data = this.writeQueue.shift()!; + this.wasmTerm!.write(data); + } + } + /** * Start the render loop */ From e8edc530d732b7b258cb569e8348789b3923fca8 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 01:29:31 +0000 Subject: [PATCH 2/2] Guard startRenderLoop() against duplicate animation frame loops Add early return if animationFrameId is already set, preventing multiple concurrent RAF loops when resize() is called re-entrantly from an onResize listener. --- lib/terminal.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/terminal.ts b/lib/terminal.ts index f05bb1d..8c050fc 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -1149,6 +1149,7 @@ export class Terminal implements ITerminalCore { * Start the render loop */ private startRenderLoop(): void { + if (this.animationFrameId) return; // already running const loop = () => { if (!this.isDisposed && this.isOpen) { // Render using WASM's native dirty tracking