Skip to content

Commit daf771d

Browse files
authored
🤖 fix: buffer terminal output until client subscribes (#818)
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`_
1 parent 7571937 commit daf771d

File tree

5 files changed

+113
-45
lines changed

5 files changed

+113
-45
lines changed

src/browser/api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,11 @@ const webApi: IPCApi = {
343343
onOutput: (sessionId: string, callback: (data: string) => void) => {
344344
// Subscribe to terminal output events via WebSocket
345345
const channel = `terminal:output:${sessionId}`;
346-
return wsManager.on(channel, callback as (data: unknown) => void);
346+
const unsubscribe = wsManager.on(channel, callback as (data: unknown) => void);
347+
// Tell server we're ready to receive output - this flushes any buffered output
348+
// that arrived before we registered our handler (fixes first prompt issue)
349+
void invokeIPC(IPC_CHANNELS.TERMINAL_SUBSCRIBE, sessionId);
350+
return unsubscribe;
347351
},
348352
onExit: (sessionId: string, callback: (exitCode: number) => void) => {
349353
// Subscribe to terminal exit events via WebSocket

src/common/constants/ipc-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const IPC_CHANNELS = {
4444
TERMINAL_CLOSE: "terminal:close",
4545
TERMINAL_RESIZE: "terminal:resize",
4646
TERMINAL_INPUT: "terminal:input",
47+
TERMINAL_SUBSCRIBE: "terminal:subscribe",
4748
TERMINAL_WINDOW_OPEN: "terminal:window:open",
4849
TERMINAL_WINDOW_CLOSE: "terminal:window:close",
4950

src/desktop/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ const api: IPCApi = {
193193
const channel = `terminal:output:${sessionId}`;
194194
const handler = (_event: unknown, data: string) => callback(data);
195195
ipcRenderer.on(channel, handler);
196+
// Tell server we're ready to receive output - flushes any buffered output
197+
void ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_SUBSCRIBE, sessionId);
196198
return () => ipcRenderer.removeListener(channel, handler);
197199
},
198200
onExit: (sessionId: string, callback: (exitCode: number) => void) => {

src/node/services/ipcMain.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1905,6 +1905,18 @@ export class IpcMain {
19051905
}
19061906
});
19071907

1908+
// Subscribe to terminal output (flushes buffered output)
1909+
// In browser mode, output is buffered until the client subscribes to avoid
1910+
// losing initial output (like the shell prompt) during the HTTP round-trip.
1911+
ipcMain.handle(IPC_CHANNELS.TERMINAL_SUBSCRIBE, (_event, sessionId: string) => {
1912+
try {
1913+
this.ptyService.subscribeOutput(sessionId);
1914+
} catch (err) {
1915+
log.error("Error subscribing to terminal:", err);
1916+
throw err;
1917+
}
1918+
});
1919+
19081920
ipcMain.handle(IPC_CHANNELS.TERMINAL_WINDOW_OPEN, async (_event, workspaceId: string) => {
19091921
console.log(`[BACKEND] TERMINAL_WINDOW_OPEN handler called with: ${workspaceId}`);
19101922
try {

src/node/services/ptyService.ts

Lines changed: 93 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ interface SessionData {
2727
runtime: Runtime;
2828
onData: (data: string) => void;
2929
onExit: (exitCode: number) => void;
30+
/** Buffer for output received before client subscribes */
31+
outputBuffer: string[];
32+
/** Whether the client has subscribed to receive output */
33+
clientSubscribed: boolean;
3034
}
3135

3236
/**
@@ -161,37 +165,55 @@ export class PTYService {
161165
);
162166
}
163167

168+
// Create session data with buffer for pre-subscription output
169+
const sessionData: SessionData = {
170+
pty: ptyProcess,
171+
workspaceId: params.workspaceId,
172+
workspacePath,
173+
runtime,
174+
onData,
175+
onExit,
176+
outputBuffer: [],
177+
clientSubscribed: false,
178+
};
179+
this.sessions.set(sessionId, sessionData);
180+
164181
// Forward PTY data via callback
165182
// Buffer to handle escape sequences split across chunks
166-
let buffer = "";
183+
let escapeBuffer = "";
167184

168185
ptyProcess.onData((data) => {
169-
// Append new data to buffer
170-
buffer += data;
186+
// Append new data to escape sequence buffer
187+
escapeBuffer += data;
171188

172189
// Check if buffer ends with an incomplete escape sequence
173190
// Look for ESC at the end without its complete sequence
174-
let sendUpTo = buffer.length;
191+
let sendUpTo = escapeBuffer.length;
175192

176193
// If buffer ends with ESC or ESC[, hold it back for next chunk
177-
if (buffer.endsWith("\x1b")) {
178-
sendUpTo = buffer.length - 1;
179-
} else if (buffer.endsWith("\x1b[")) {
180-
sendUpTo = buffer.length - 2;
194+
if (escapeBuffer.endsWith("\x1b")) {
195+
sendUpTo = escapeBuffer.length - 1;
196+
} else if (escapeBuffer.endsWith("\x1b[")) {
197+
sendUpTo = escapeBuffer.length - 2;
181198
} else {
182199
// Check if it ends with ESC[ followed by incomplete CSI sequence
183200
// eslint-disable-next-line no-control-regex, @typescript-eslint/prefer-regexp-exec
184-
const match = buffer.match(/\x1b\[[0-9;]*$/);
201+
const match = escapeBuffer.match(/\x1b\[[0-9;]*$/);
185202
if (match) {
186-
sendUpTo = buffer.length - match[0].length;
203+
sendUpTo = escapeBuffer.length - match[0].length;
187204
}
188205
}
189206

190207
// Send complete data
191208
if (sendUpTo > 0) {
192-
const toSend = buffer.substring(0, sendUpTo);
193-
onData(toSend);
194-
buffer = buffer.substring(sendUpTo);
209+
const toSend = escapeBuffer.substring(0, sendUpTo);
210+
// Buffer output until client subscribes (fixes race in browser mode)
211+
if (sessionData.clientSubscribed) {
212+
onData(toSend);
213+
} else {
214+
sessionData.outputBuffer.push(toSend);
215+
}
216+
escapeBuffer = escapeBuffer.substring(sendUpTo);
195217
}
196218
});
197219

@@ -201,15 +223,6 @@ export class PTYService {
201223
this.sessions.delete(sessionId);
202224
onExit(exitCode);
203225
});
204-
205-
this.sessions.set(sessionId, {
206-
pty: ptyProcess,
207-
workspaceId: params.workspaceId,
208-
workspacePath,
209-
runtime,
210-
onData,
211-
onExit,
212-
});
213226
} else if (runtime instanceof SSHRuntime) {
214227
// SSH: Use node-pty to spawn SSH with local PTY (enables resize support)
215228
const sshConfig = runtime.getConfig();
@@ -263,29 +276,47 @@ export class PTYService {
263276
);
264277
}
265278

279+
// Create session data with buffer for pre-subscription output
280+
const sessionData: SessionData = {
281+
pty: ptyProcess,
282+
workspaceId: params.workspaceId,
283+
workspacePath,
284+
runtime,
285+
onData,
286+
onExit,
287+
outputBuffer: [],
288+
clientSubscribed: false,
289+
};
290+
this.sessions.set(sessionId, sessionData);
291+
266292
// Handle data (same as local - buffer incomplete escape sequences)
267-
let buffer = "";
293+
let escapeBuffer = "";
268294
ptyProcess.onData((data) => {
269-
buffer += data;
270-
let sendUpTo = buffer.length;
295+
escapeBuffer += data;
296+
let sendUpTo = escapeBuffer.length;
271297

272298
// Hold back incomplete escape sequences
273-
if (buffer.endsWith("\x1b")) {
274-
sendUpTo = buffer.length - 1;
275-
} else if (buffer.endsWith("\x1b[")) {
276-
sendUpTo = buffer.length - 2;
299+
if (escapeBuffer.endsWith("\x1b")) {
300+
sendUpTo = escapeBuffer.length - 1;
301+
} else if (escapeBuffer.endsWith("\x1b[")) {
302+
sendUpTo = escapeBuffer.length - 2;
277303
} else {
278304
// eslint-disable-next-line no-control-regex, @typescript-eslint/prefer-regexp-exec
279-
const match = buffer.match(/\x1b\[[0-9;]*$/);
305+
const match = escapeBuffer.match(/\x1b\[[0-9;]*$/);
280306
if (match) {
281-
sendUpTo = buffer.length - match[0].length;
307+
sendUpTo = escapeBuffer.length - match[0].length;
282308
}
283309
}
284310

285311
if (sendUpTo > 0) {
286-
const toSend = buffer.substring(0, sendUpTo);
287-
onData(toSend);
288-
buffer = buffer.substring(sendUpTo);
312+
const toSend = escapeBuffer.substring(0, sendUpTo);
313+
// Buffer output until client subscribes (fixes race in browser mode)
314+
if (sessionData.clientSubscribed) {
315+
onData(toSend);
316+
} else {
317+
sessionData.outputBuffer.push(toSend);
318+
}
319+
escapeBuffer = escapeBuffer.substring(sendUpTo);
289320
}
290321
});
291322

@@ -295,16 +326,6 @@ export class PTYService {
295326
this.sessions.delete(sessionId);
296327
onExit(exitCode);
297328
});
298-
299-
// Store PTY (same interface as local)
300-
this.sessions.set(sessionId, {
301-
pty: ptyProcess,
302-
workspaceId: params.workspaceId,
303-
workspacePath,
304-
runtime,
305-
onData,
306-
onExit,
307-
});
308329
} else {
309330
throw new Error(`Unsupported runtime type: ${runtime.constructor.name}`);
310331
}
@@ -317,6 +338,34 @@ export class PTYService {
317338
};
318339
}
319340

341+
/**
342+
* Subscribe to terminal output (flushes buffered output and starts streaming).
343+
* In browser mode, output is buffered until the client subscribes to avoid
344+
* losing initial output (like the shell prompt) during the HTTP round-trip.
345+
*/
346+
subscribeOutput(sessionId: string): void {
347+
const session = this.sessions.get(sessionId);
348+
if (!session) {
349+
log.info(`Cannot subscribe to session ${sessionId}: not found`);
350+
return;
351+
}
352+
353+
if (session.clientSubscribed) {
354+
log.debug(`Session ${sessionId} already subscribed`);
355+
return;
356+
}
357+
358+
// Mark as subscribed and flush buffered output
359+
session.clientSubscribed = true;
360+
if (session.outputBuffer.length > 0) {
361+
log.debug(`Flushing ${session.outputBuffer.length} buffered chunks for ${sessionId}`);
362+
for (const chunk of session.outputBuffer) {
363+
session.onData(chunk);
364+
}
365+
session.outputBuffer = [];
366+
}
367+
}
368+
320369
/**
321370
* Send input to a terminal session
322371
*/

0 commit comments

Comments
 (0)