From 182a5398075bf802d1c73dc7154dc502e7b6e7d8 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Wed, 14 Jan 2026 22:55:44 -0800 Subject: [PATCH] fix crash on input too big --- .../src/main/lib/terminal/input-writer.ts | 133 ++++++++++++++++++ apps/desktop/src/main/lib/terminal/manager.ts | 6 +- apps/desktop/src/main/lib/terminal/session.ts | 5 + apps/desktop/src/main/lib/terminal/types.ts | 2 + 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/lib/terminal/input-writer.ts diff --git a/apps/desktop/src/main/lib/terminal/input-writer.ts b/apps/desktop/src/main/lib/terminal/input-writer.ts new file mode 100644 index 00000000000..2c9bbe524cc --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/input-writer.ts @@ -0,0 +1,133 @@ +import type * as pty from "node-pty"; + +/** + * Non-blocking input writer for PTY. + * + * Prevents the main thread from blocking when writing large amounts of data + * (e.g., pasting Unicode text) by: + * 1. Chunking large writes into smaller pieces + * 2. Using setImmediate between chunks to yield the event loop + * + * This fixes crashes caused by synchronous PTY writes blocking when the + * PTY buffer fills up faster than the shell can consume data. + */ + +/** + * Maximum chunk size for PTY writes (4KB). + * This is small enough to not block noticeably, but large enough + * to be efficient for normal input. + */ +const CHUNK_SIZE = 4 * 1024; + +/** + * Threshold for using chunked writes. + * Data smaller than this is written directly (no overhead). + */ +const CHUNK_THRESHOLD = CHUNK_SIZE; + +export class InputWriter { + private pty: pty.IPty; + private writeQueue: string[] = []; + private isWriting = false; + private isDisposed = false; + + constructor(ptyProcess: pty.IPty) { + this.pty = ptyProcess; + } + + /** + * Write data to the PTY without blocking the main thread. + * + * Small writes are sent directly. Large writes are chunked and + * processed asynchronously to prevent blocking. + */ + write(data: string): void { + if (this.isDisposed) { + return; + } + + // Small data: write directly (most common case - single keystrokes) + if (data.length < CHUNK_THRESHOLD) { + try { + this.pty.write(data); + } catch (error) { + console.error("[InputWriter] Write failed:", error); + } + return; + } + + // Large data: queue and process in chunks + this.writeQueue.push(data); + this.processQueue(); + } + + /** + * Process queued writes in chunks, yielding the event loop between chunks. + */ + private processQueue(): void { + if (this.isWriting || this.writeQueue.length === 0 || this.isDisposed) { + return; + } + + this.isWriting = true; + this.writeNextChunk(); + } + + private writeNextChunk(): void { + if (this.isDisposed) { + this.isWriting = false; + this.writeQueue = []; + return; + } + + // Get next chunk from front of queue + const data = this.writeQueue[0]; + if (!data) { + this.isWriting = false; + return; + } + + // Write one chunk + const chunk = data.slice(0, CHUNK_SIZE); + const remaining = data.slice(CHUNK_SIZE); + + try { + this.pty.write(chunk); + } catch (error) { + console.error("[InputWriter] Chunk write failed:", error); + // Remove the problematic data and continue with next + this.writeQueue.shift(); + if (this.writeQueue.length > 0) { + setImmediate(() => this.writeNextChunk()); + } else { + this.isWriting = false; + } + return; + } + + // Update queue + if (remaining.length > 0) { + // More chunks to write from this data + this.writeQueue[0] = remaining; + } else { + // This data is complete, remove from queue + this.writeQueue.shift(); + } + + // Schedule next chunk (yields event loop) + if (this.writeQueue.length > 0) { + setImmediate(() => this.writeNextChunk()); + } else { + this.isWriting = false; + } + } + + /** + * Dispose of the writer, canceling any pending writes. + */ + dispose(): void { + this.isDisposed = true; + this.writeQueue = []; + this.isWriting = false; + } +} diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index 5603ec2fcbc..bc6aedcb911 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -164,7 +164,9 @@ export class TerminalManager extends EventEmitter { throw new Error(`Terminal session ${paneId} not found or not alive`); } - session.pty.write(data); + // Use InputWriter for non-blocking writes (prevents main thread blocking + // when pasting large amounts of text, especially Unicode) + session.inputWriter.write(data); session.lastActive = Date.now(); } @@ -386,7 +388,7 @@ export class TerminalManager extends EventEmitter { for (const [paneId, session] of this.sessions.entries()) { if (session.workspaceId === workspaceId && session.isAlive) { try { - session.pty.write("\n"); + session.inputWriter.write("\n"); } catch (error) { console.warn( `[TerminalManager] Failed to refresh prompt for pane ${paneId}:`, diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 00929a81e56..c9e5bea6c28 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -9,6 +9,7 @@ import { extractContentAfterClear, } from "../terminal-escape-filter"; import { buildTerminalEnv, FALLBACK_SHELL, getDefaultShell } from "./env"; +import { InputWriter } from "./input-writer"; import type { InternalCreateSessionParams, TerminalSession } from "./types"; const DEFAULT_COLS = 80; @@ -130,6 +131,8 @@ export async function createSession( onData(paneId, batchedData); }); + const inputWriter = new InputWriter(ptyProcess); + return { pty: ptyProcess, paneId, @@ -143,6 +146,7 @@ export async function createSession( isAlive: true, wasRecovered, dataBatcher, + inputWriter, shell, startTime: Date.now(), usedFallback: useFallbackShell, @@ -206,6 +210,7 @@ export function setupDataHandler( } export function flushSession(session: TerminalSession): void { + session.inputWriter.dispose(); session.dataBatcher.dispose(); session.headless.dispose(); } diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index 1fdcf4169ca..b0a8596818a 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -2,6 +2,7 @@ import type { SerializeAddon } from "@xterm/addon-serialize"; import type { Terminal as HeadlessTerminal } from "@xterm/headless"; import type * as pty from "node-pty"; import type { DataBatcher } from "../data-batcher"; +import type { InputWriter } from "./input-writer"; export interface TerminalSession { pty: pty.IPty; @@ -16,6 +17,7 @@ export interface TerminalSession { isAlive: boolean; wasRecovered: boolean; dataBatcher: DataBatcher; + inputWriter: InputWriter; shell: string; startTime: number; usedFallback: boolean;