Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/ghostty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
114 changes: 97 additions & 17 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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();
Expand All @@ -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();
Comment on lines +736 to +739

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Flush queued writes without relying on animation frames

isResizing is only cleared inside a requestAnimationFrame callback, so after any resize in a background/hidden tab (where rAF is paused or heavily throttled) every subsequent write() stays queued and never reaches WASM until the tab becomes visible again. In high-output sessions this causes apparent terminal hangs and unbounded writeQueue growth, which is a production-impacting regression for embedded terminals that keep running while not foregrounded.

Useful? React with 👍 / 👎.

});
}

/**
* 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);
}
}

/**
Expand Down Expand Up @@ -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);
Expand Down