Skip to content

Commit d3fe80c

Browse files
committed
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.
1 parent 03ead6e commit d3fe80c

2 files changed

Lines changed: 104 additions & 17 deletions

File tree

lib/ghostty.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,5 +785,12 @@ export class GhosttyTerminal {
785785
this.viewportBufferPtr = 0;
786786
this.viewportBufferSize = 0;
787787
}
788+
// Also invalidate grapheme buffer since WASM memory may have moved during resize.
789+
// Typed array views become detached when the underlying ArrayBuffer is replaced.
790+
if (this.graphemeBufferPtr) {
791+
this.exports.ghostty_wasm_free_u8_array(this.graphemeBufferPtr, 16 * 4);
792+
this.graphemeBufferPtr = 0;
793+
this.graphemeBuffer = null;
794+
}
788795
}
789796
}

lib/terminal.ts

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ export class Terminal implements ITerminalCore {
102102
private isDisposed = false;
103103
private animationFrameId?: number;
104104

105+
// Resize protection: queue writes during resize to prevent race conditions
106+
private _isResizing = false;
107+
private _writeQueue: Array<{ data: string | Uint8Array; callback?: () => void }> = [];
108+
private _resizeFlushFrameId?: number;
109+
105110
// Addons
106111
private addons: ITerminalAddon[] = [];
107112

@@ -541,6 +546,15 @@ export class Terminal implements ITerminalCore {
541546
data = data.replace(/\n/g, '\r\n');
542547
}
543548

549+
// Queue writes during resize to prevent WASM race conditions.
550+
// Writes will be flushed after resize completes.
551+
// Copy Uint8Array data to prevent mutation by caller before flush.
552+
if (this._isResizing) {
553+
const dataCopy = data instanceof Uint8Array ? new Uint8Array(data) : data;
554+
this._writeQueue.push({ data: dataCopy, callback });
555+
return;
556+
}
557+
544558
this.writeInternal(data, callback);
545559
}
546560

@@ -652,6 +666,11 @@ export class Terminal implements ITerminalCore {
652666

653667
/**
654668
* Resize terminal
669+
*
670+
* Note: We pause the render loop and queue writes during resize to prevent
671+
* race conditions. The WASM terminal reallocates internal buffers during
672+
* resize, and if the render loop or writes access those buffers concurrently,
673+
* it can cause a crash.
655674
*/
656675
resize(cols: number, rows: number): void {
657676
this.assertOpen();
@@ -660,28 +679,81 @@ export class Terminal implements ITerminalCore {
660679
return; // No change
661680
}
662681

663-
// Update dimensions
664-
this.cols = cols;
665-
this.rows = rows;
682+
// Cancel any pending resize flush from a previous resize - this resize supersedes it
683+
if (this._resizeFlushFrameId) {
684+
cancelAnimationFrame(this._resizeFlushFrameId);
685+
this._resizeFlushFrameId = undefined;
686+
}
666687

667-
// Resize WASM terminal
668-
this.wasmTerm!.resize(cols, rows);
688+
// Set resizing flag to queue any incoming writes
689+
this._isResizing = true;
669690

670-
// Resize renderer
671-
this.renderer!.resize(cols, rows);
691+
// Pause render loop during resize to prevent race condition.
692+
// The render loop reads from WASM buffers that are reallocated during resize.
693+
// Without this, concurrent access can cause SIGSEGV crashes.
694+
const wasRunning = this.animationFrameId !== undefined;
695+
if (this.animationFrameId) {
696+
cancelAnimationFrame(this.animationFrameId);
697+
this.animationFrameId = undefined;
698+
}
699+
700+
try {
701+
// Resize WASM terminal (this reallocates internal buffers)
702+
this.wasmTerm!.resize(cols, rows);
672703

673-
// Update canvas dimensions
674-
const metrics = this.renderer!.getMetrics();
675-
this.canvas!.width = metrics.width * cols;
676-
this.canvas!.height = metrics.height * rows;
677-
this.canvas!.style.width = `${metrics.width * cols}px`;
678-
this.canvas!.style.height = `${metrics.height * rows}px`;
704+
// Update dimensions after successful WASM resize
705+
this.cols = cols;
706+
this.rows = rows;
679707

680-
// Fire resize event
681-
this.resizeEmitter.fire({ cols, rows });
708+
// Resize renderer
709+
this.renderer!.resize(cols, rows);
682710

683-
// Force full render
684-
this.renderer!.render(this.wasmTerm!, true, this.viewportY, this);
711+
// Update canvas dimensions
712+
const metrics = this.renderer!.getMetrics();
713+
this.canvas!.width = metrics.width * cols;
714+
this.canvas!.height = metrics.height * rows;
715+
this.canvas!.style.width = `${metrics.width * cols}px`;
716+
this.canvas!.style.height = `${metrics.height * rows}px`;
717+
718+
// Fire resize event
719+
this.resizeEmitter.fire({ cols, rows });
720+
721+
// Force full render with new dimensions
722+
this.renderer!.render(this.wasmTerm!, true, this.viewportY, this);
723+
} catch (err) {
724+
console.error('[ghostty-web] Resize error:', err);
725+
// Still clear the flag so future resizes can proceed
726+
}
727+
728+
// Restart render loop if it was running
729+
if (wasRunning) {
730+
this.startRenderLoop();
731+
}
732+
733+
// Clear resizing flag and flush queued writes after a frame
734+
// This ensures WASM state has fully settled before processing writes
735+
// Track the frame ID so it can be canceled on dispose
736+
this._resizeFlushFrameId = requestAnimationFrame(() => {
737+
this._resizeFlushFrameId = undefined;
738+
this._isResizing = false;
739+
this.flushWriteQueue();
740+
});
741+
}
742+
743+
/**
744+
* Flush queued writes that were blocked during resize
745+
*/
746+
private flushWriteQueue(): void {
747+
// Guard against flush after dispose
748+
if (this.isDisposed || !this.isOpen) {
749+
this._writeQueue = [];
750+
return;
751+
}
752+
const queue = this._writeQueue;
753+
this._writeQueue = [];
754+
for (const { data, callback } of queue) {
755+
this.writeInternal(data, callback);
756+
}
685757
}
686758

687759
/**
@@ -1080,6 +1152,14 @@ export class Terminal implements ITerminalCore {
10801152
this.scrollAnimationFrame = undefined;
10811153
}
10821154

1155+
// Cancel pending resize flush and clear write queue
1156+
if (this._resizeFlushFrameId) {
1157+
cancelAnimationFrame(this._resizeFlushFrameId);
1158+
this._resizeFlushFrameId = undefined;
1159+
}
1160+
this._writeQueue = [];
1161+
this._isResizing = false;
1162+
10831163
// Clear mouse move throttle timeout
10841164
if (this.mouseMoveThrottleTimeout) {
10851165
clearTimeout(this.mouseMoveThrottleTimeout);

0 commit comments

Comments
 (0)