From b5f73b790308301df611d89ed518aa1a59a1f6dc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:10:55 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20perf:=20reduce=20terminal=20inpu?= =?UTF-8?q?t=20latency=20via=20fire-and-forget=20IPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal input was using ipcRenderer.invoke() which waits for a response before processing the next keystroke. This caused keystroke reordering under fast typing (e.g., 'ls↵' becoming 'l↵s'). Fix: - Electron: Use ipcRenderer.send() instead of invoke() - Browser: Add sendIPCFireAndForget() helper that doesn't await response - Main process: Use ipcMain.on() instead of handle() for terminal input The fire-and-forget pattern is appropriate since: 1. Terminal input doesn't return meaningful data 2. Errors are logged server-side but don't need to propagate 3. Order preservation is guaranteed by IPC channel ordering Fixes: #795 _Generated with `mux`_ --- src/browser/api.ts | 19 +++++++++++++++++-- src/cli/server.ts | 19 +++++++++++++++++++ src/desktop/preload.ts | 4 +++- src/node/services/ipcMain.ts | 6 +++--- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/browser/api.ts b/src/browser/api.ts index 33b9ad37a..0e4c0238d 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -61,6 +61,21 @@ function parseWorkspaceActivity(value: unknown): WorkspaceActivitySnapshot | nul }; } +// Fire-and-forget helper for IPC calls that don't need a response +// Uses fetch with keepalive to ensure the request completes even if page unloads +function sendIPCFireAndForget(channel: string, ...args: unknown[]): void { + // Don't await - fire and forget + void fetch(`${API_BASE}/ipc/${encodeURIComponent(channel)}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ args }), + keepalive: true, // Ensures request completes even on page unload + }).catch((err) => { + console.error(`Fire-and-forget IPC error (${channel}):`, err); + }); +} // WebSocket connection manager class WebSocketManager { private ws: WebSocket | null = null; @@ -337,8 +352,8 @@ const webApi: IPCApi = { close: (sessionId) => invokeIPC(IPC_CHANNELS.TERMINAL_CLOSE, sessionId), resize: (params) => invokeIPC(IPC_CHANNELS.TERMINAL_RESIZE, params), sendInput: (sessionId: string, data: string) => { - // Send via IPC - in browser mode this becomes an HTTP POST - void invokeIPC(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data); + // Fire-and-forget for minimal latency - no need to wait for response + sendIPCFireAndForget(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data); }, onOutput: (sessionId: string, callback: (data: string) => void) => { // Subscribe to terminal output events via WebSocket diff --git a/src/cli/server.ts b/src/cli/server.ts index e6e94cae5..0beac28ab 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -83,6 +83,25 @@ class HttpIpcMainAdapter { on(channel: string, handler: (event: unknown, ...args: unknown[]) => void): void { if (!this.listeners.has(channel)) { this.listeners.set(channel, []); + // Register HTTP route for fire-and-forget handlers too + // Unlike handle(), we don't wait for or return any result + this.app.post(`/ipc/${encodeURIComponent(channel)}`, (req, res) => { + try { + const schema = z.object({ args: z.array(z.unknown()).optional() }); + const body = schema.parse(req.body); + const args: unknown[] = body.args ?? []; + // Fire-and-forget: call all listeners, respond immediately + const listeners = this.listeners.get(channel); + if (listeners) { + listeners.forEach((listener) => listener(null, ...args)); + } + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error in fire-and-forget handler ${channel}:`, error); + res.json({ success: false, error: message }); + } + }); } this.listeners.get(channel)!.push(handler); } diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index 8a9ea1c71..6ca2bc3f5 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -187,7 +187,9 @@ const api: IPCApi = { close: (sessionId) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLOSE, sessionId), resize: (params) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESIZE, params), sendInput: (sessionId: string, data: string) => { - void ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data); + // Use send() instead of invoke() for fire-and-forget - no need to wait for response + // This reduces input latency significantly for fast typing + ipcRenderer.send(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data); }, onOutput: (sessionId: string, callback: (data: string) => void) => { const channel = `terminal:output:${sessionId}`; diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 3932a9423..030b05206 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1877,13 +1877,13 @@ export class IpcMain { }); // Handle terminal input (keyboard, etc.) - // Use handle() for both Electron and browser mode - ipcMain.handle(IPC_CHANNELS.TERMINAL_INPUT, (_event, sessionId: string, data: string) => { + // Use on() instead of handle() for fire-and-forget - reduces input latency + ipcMain.on(IPC_CHANNELS.TERMINAL_INPUT, (_event, sessionId: string, data: string) => { try { this.ptyService.sendInput(sessionId, data); } catch (err) { log.error(`Error sending input to terminal ${sessionId}:`, err); - throw err; + // No throw - fire-and-forget doesn't return errors to caller } });