From e13e9e9af6ff0b0c480756847faa4316319b527f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:04:03 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20buffer=20terminal=20outpu?= =?UTF-8?q?t=20until=20client=20subscribes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In server mode, there was a race condition where the shell prompt would be lost because the PTY started emitting output before the HTTP response (with session ID) returned and before the client registered its WebSocket handler. The fix adds output buffering on the server side: - PTY output is buffered in the session until client subscribes - New TERMINAL_SUBSCRIBE IPC channel to signal readiness - Client calls subscribe after registering its output handler - Server flushes buffered output when subscribe is called This ensures the initial shell prompt is always delivered to the terminal. _Generated with `mux`_ --- src/browser/api.ts | 6 +- src/common/constants/ipc-constants.ts | 1 + src/desktop/preload.ts | 2 + src/node/services/ipcMain.ts | 12 +++ src/node/services/ptyService.ts | 137 +++++++++++++++++--------- 5 files changed, 113 insertions(+), 45 deletions(-) diff --git a/src/browser/api.ts b/src/browser/api.ts index 33b9ad37ab..1347a4a4dd 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -343,7 +343,11 @@ const webApi: IPCApi = { onOutput: (sessionId: string, callback: (data: string) => void) => { // Subscribe to terminal output events via WebSocket const channel = `terminal:output:${sessionId}`; - return wsManager.on(channel, callback as (data: unknown) => void); + const unsubscribe = wsManager.on(channel, callback as (data: unknown) => void); + // Tell server we're ready to receive output - this flushes any buffered output + // that arrived before we registered our handler (fixes first prompt issue) + void invokeIPC(IPC_CHANNELS.TERMINAL_SUBSCRIBE, sessionId); + return unsubscribe; }, onExit: (sessionId: string, callback: (exitCode: number) => void) => { // Subscribe to terminal exit events via WebSocket diff --git a/src/common/constants/ipc-constants.ts b/src/common/constants/ipc-constants.ts index 828797a311..45cd82c746 100644 --- a/src/common/constants/ipc-constants.ts +++ b/src/common/constants/ipc-constants.ts @@ -44,6 +44,7 @@ export const IPC_CHANNELS = { TERMINAL_CLOSE: "terminal:close", TERMINAL_RESIZE: "terminal:resize", TERMINAL_INPUT: "terminal:input", + TERMINAL_SUBSCRIBE: "terminal:subscribe", TERMINAL_WINDOW_OPEN: "terminal:window:open", TERMINAL_WINDOW_CLOSE: "terminal:window:close", diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index 8b5cd86e3e..3508b26248 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -193,6 +193,8 @@ const api: IPCApi = { const channel = `terminal:output:${sessionId}`; const handler = (_event: unknown, data: string) => callback(data); ipcRenderer.on(channel, handler); + // Tell server we're ready to receive output - flushes any buffered output + void ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_SUBSCRIBE, sessionId); return () => ipcRenderer.removeListener(channel, handler); }, onExit: (sessionId: string, callback: (exitCode: number) => void) => { diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 3932a94230..1c7131a855 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1905,6 +1905,18 @@ export class IpcMain { } }); + // Subscribe to terminal output (flushes buffered output) + // In browser mode, output is buffered until the client subscribes to avoid + // losing initial output (like the shell prompt) during the HTTP round-trip. + ipcMain.handle(IPC_CHANNELS.TERMINAL_SUBSCRIBE, (_event, sessionId: string) => { + try { + this.ptyService.subscribeOutput(sessionId); + } catch (err) { + log.error("Error subscribing to terminal:", err); + throw err; + } + }); + ipcMain.handle(IPC_CHANNELS.TERMINAL_WINDOW_OPEN, async (_event, workspaceId: string) => { console.log(`[BACKEND] TERMINAL_WINDOW_OPEN handler called with: ${workspaceId}`); try { diff --git a/src/node/services/ptyService.ts b/src/node/services/ptyService.ts index 23b327fd6b..3b5e538077 100644 --- a/src/node/services/ptyService.ts +++ b/src/node/services/ptyService.ts @@ -27,6 +27,10 @@ interface SessionData { runtime: Runtime; onData: (data: string) => void; onExit: (exitCode: number) => void; + /** Buffer for output received before client subscribes */ + outputBuffer: string[]; + /** Whether the client has subscribed to receive output */ + clientSubscribed: boolean; } /** @@ -161,37 +165,55 @@ export class PTYService { ); } + // Create session data with buffer for pre-subscription output + const sessionData: SessionData = { + pty: ptyProcess, + workspaceId: params.workspaceId, + workspacePath, + runtime, + onData, + onExit, + outputBuffer: [], + clientSubscribed: false, + }; + this.sessions.set(sessionId, sessionData); + // Forward PTY data via callback // Buffer to handle escape sequences split across chunks - let buffer = ""; + let escapeBuffer = ""; ptyProcess.onData((data) => { - // Append new data to buffer - buffer += data; + // Append new data to escape sequence buffer + escapeBuffer += data; // Check if buffer ends with an incomplete escape sequence // Look for ESC at the end without its complete sequence - let sendUpTo = buffer.length; + let sendUpTo = escapeBuffer.length; // If buffer ends with ESC or ESC[, hold it back for next chunk - if (buffer.endsWith("\x1b")) { - sendUpTo = buffer.length - 1; - } else if (buffer.endsWith("\x1b[")) { - sendUpTo = buffer.length - 2; + if (escapeBuffer.endsWith("\x1b")) { + sendUpTo = escapeBuffer.length - 1; + } else if (escapeBuffer.endsWith("\x1b[")) { + sendUpTo = escapeBuffer.length - 2; } else { // Check if it ends with ESC[ followed by incomplete CSI sequence // eslint-disable-next-line no-control-regex, @typescript-eslint/prefer-regexp-exec - const match = buffer.match(/\x1b\[[0-9;]*$/); + const match = escapeBuffer.match(/\x1b\[[0-9;]*$/); if (match) { - sendUpTo = buffer.length - match[0].length; + sendUpTo = escapeBuffer.length - match[0].length; } } // Send complete data if (sendUpTo > 0) { - const toSend = buffer.substring(0, sendUpTo); - onData(toSend); - buffer = buffer.substring(sendUpTo); + const toSend = escapeBuffer.substring(0, sendUpTo); + // Buffer output until client subscribes (fixes race in browser mode) + if (sessionData.clientSubscribed) { + onData(toSend); + } else { + sessionData.outputBuffer.push(toSend); + } + escapeBuffer = escapeBuffer.substring(sendUpTo); } }); @@ -201,15 +223,6 @@ export class PTYService { this.sessions.delete(sessionId); onExit(exitCode); }); - - this.sessions.set(sessionId, { - pty: ptyProcess, - workspaceId: params.workspaceId, - workspacePath, - runtime, - onData, - onExit, - }); } else if (runtime instanceof SSHRuntime) { // SSH: Use node-pty to spawn SSH with local PTY (enables resize support) const sshConfig = runtime.getConfig(); @@ -263,29 +276,47 @@ export class PTYService { ); } + // Create session data with buffer for pre-subscription output + const sessionData: SessionData = { + pty: ptyProcess, + workspaceId: params.workspaceId, + workspacePath, + runtime, + onData, + onExit, + outputBuffer: [], + clientSubscribed: false, + }; + this.sessions.set(sessionId, sessionData); + // Handle data (same as local - buffer incomplete escape sequences) - let buffer = ""; + let escapeBuffer = ""; ptyProcess.onData((data) => { - buffer += data; - let sendUpTo = buffer.length; + escapeBuffer += data; + let sendUpTo = escapeBuffer.length; // Hold back incomplete escape sequences - if (buffer.endsWith("\x1b")) { - sendUpTo = buffer.length - 1; - } else if (buffer.endsWith("\x1b[")) { - sendUpTo = buffer.length - 2; + if (escapeBuffer.endsWith("\x1b")) { + sendUpTo = escapeBuffer.length - 1; + } else if (escapeBuffer.endsWith("\x1b[")) { + sendUpTo = escapeBuffer.length - 2; } else { // eslint-disable-next-line no-control-regex, @typescript-eslint/prefer-regexp-exec - const match = buffer.match(/\x1b\[[0-9;]*$/); + const match = escapeBuffer.match(/\x1b\[[0-9;]*$/); if (match) { - sendUpTo = buffer.length - match[0].length; + sendUpTo = escapeBuffer.length - match[0].length; } } if (sendUpTo > 0) { - const toSend = buffer.substring(0, sendUpTo); - onData(toSend); - buffer = buffer.substring(sendUpTo); + const toSend = escapeBuffer.substring(0, sendUpTo); + // Buffer output until client subscribes (fixes race in browser mode) + if (sessionData.clientSubscribed) { + onData(toSend); + } else { + sessionData.outputBuffer.push(toSend); + } + escapeBuffer = escapeBuffer.substring(sendUpTo); } }); @@ -295,16 +326,6 @@ export class PTYService { this.sessions.delete(sessionId); onExit(exitCode); }); - - // Store PTY (same interface as local) - this.sessions.set(sessionId, { - pty: ptyProcess, - workspaceId: params.workspaceId, - workspacePath, - runtime, - onData, - onExit, - }); } else { throw new Error(`Unsupported runtime type: ${runtime.constructor.name}`); } @@ -317,6 +338,34 @@ export class PTYService { }; } + /** + * Subscribe to terminal output (flushes buffered output and starts streaming). + * In browser mode, output is buffered until the client subscribes to avoid + * losing initial output (like the shell prompt) during the HTTP round-trip. + */ + subscribeOutput(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session) { + log.info(`Cannot subscribe to session ${sessionId}: not found`); + return; + } + + if (session.clientSubscribed) { + log.debug(`Session ${sessionId} already subscribed`); + return; + } + + // Mark as subscribed and flush buffered output + session.clientSubscribed = true; + if (session.outputBuffer.length > 0) { + log.debug(`Flushing ${session.outputBuffer.length} buffered chunks for ${sessionId}`); + for (const chunk of session.outputBuffer) { + session.onData(chunk); + } + session.outputBuffer = []; + } + } + /** * Send input to a terminal session */