@@ -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