From 25ea66c30d045d45701e8fe041e57ce4a7e9e59e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 01:53:56 +0000 Subject: [PATCH] Make terminal write/resize tolerant of exited processes Commands like exit that cause the shell to close should not produce errors on subsequent write or resize calls. Instead of throwing when the terminal is not running, silently drop the operation. Also wrap the raw PTY write/resize calls in try/catch to handle the race where the process exits between the status check and the call. Co-authored-by: Julius Marminge --- .../src/terminal/Layers/Manager.test.ts | 32 +++++++++++++++++++ apps/server/src/terminal/Layers/Manager.ts | 20 +++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 304b49d42b79..21ab1e7d5cc7 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -639,6 +639,38 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("silently drops write to exited terminal instead of throwing", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitExit({ exitCode: 0, signal: 0 }); + + await expect( + manager.write({ threadId: "thread-1", data: "hello\n" }), + ).resolves.toBeUndefined(); + + manager.dispose(); + }); + + it("silently drops resize to exited terminal instead of throwing", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitExit({ exitCode: 0, signal: 0 }); + + await expect( + manager.resize({ threadId: "thread-1", cols: 200, rows: 50 }), + ).resolves.toBeUndefined(); + + manager.dispose(); + }); + it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { if (process.platform === "win32") return; const { manager, ptyAdapter } = makeManager(5, { diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index fa766da570f1..13d88cedbba5 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -438,25 +438,29 @@ export class TerminalManagerRuntime extends EventEmitter const input = decodeTerminalWriteInput(raw); const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { - throw new Error( - `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, - ); + return; + } + try { + session.process.write(input.data); + } catch { + // Process may have exited between the status check and the write call. } - session.process.write(input.data); } async resize(raw: TerminalResizeInput): Promise { const input = decodeTerminalResizeInput(raw); const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { - throw new Error( - `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, - ); + return; } session.cols = input.cols; session.rows = input.rows; session.updatedAt = new Date().toISOString(); - session.process.resize(input.cols, input.rows); + try { + session.process.resize(input.cols, input.rows); + } catch { + // Process may have exited between the status check and the resize call. + } } async clear(raw: TerminalClearInput): Promise {