Skip to content

Commit b5f73b7

Browse files
committed
🤖 perf: reduce terminal input latency via fire-and-forget IPC
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`_
1 parent 22a816f commit b5f73b7

File tree

4 files changed

+42
-6
lines changed

4 files changed

+42
-6
lines changed

‎src/browser/api.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ function parseWorkspaceActivity(value: unknown): WorkspaceActivitySnapshot | nul
6161
};
6262
}
6363

64+
// Fire-and-forget helper for IPC calls that don't need a response
65+
// Uses fetch with keepalive to ensure the request completes even if page unloads
66+
function sendIPCFireAndForget(channel: string, ...args: unknown[]): void {
67+
// Don't await - fire and forget
68+
void fetch(`${API_BASE}/ipc/${encodeURIComponent(channel)}`, {
69+
method: "POST",
70+
headers: {
71+
"Content-Type": "application/json",
72+
},
73+
body: JSON.stringify({ args }),
74+
keepalive: true, // Ensures request completes even on page unload
75+
}).catch((err) => {
76+
console.error(`Fire-and-forget IPC error (${channel}):`, err);
77+
});
78+
}
6479
// WebSocket connection manager
6580
class WebSocketManager {
6681
private ws: WebSocket | null = null;
@@ -337,8 +352,8 @@ const webApi: IPCApi = {
337352
close: (sessionId) => invokeIPC(IPC_CHANNELS.TERMINAL_CLOSE, sessionId),
338353
resize: (params) => invokeIPC(IPC_CHANNELS.TERMINAL_RESIZE, params),
339354
sendInput: (sessionId: string, data: string) => {
340-
// Send via IPC - in browser mode this becomes an HTTP POST
341-
void invokeIPC(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data);
355+
// Fire-and-forget for minimal latency - no need to wait for response
356+
sendIPCFireAndForget(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data);
342357
},
343358
onOutput: (sessionId: string, callback: (data: string) => void) => {
344359
// Subscribe to terminal output events via WebSocket

‎src/cli/server.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,25 @@ class HttpIpcMainAdapter {
8383
on(channel: string, handler: (event: unknown, ...args: unknown[]) => void): void {
8484
if (!this.listeners.has(channel)) {
8585
this.listeners.set(channel, []);
86+
// Register HTTP route for fire-and-forget handlers too
87+
// Unlike handle(), we don't wait for or return any result
88+
this.app.post(`/ipc/${encodeURIComponent(channel)}`, (req, res) => {
89+
try {
90+
const schema = z.object({ args: z.array(z.unknown()).optional() });
91+
const body = schema.parse(req.body);
92+
const args: unknown[] = body.args ?? [];
93+
// Fire-and-forget: call all listeners, respond immediately
94+
const listeners = this.listeners.get(channel);
95+
if (listeners) {
96+
listeners.forEach((listener) => listener(null, ...args));
97+
}
98+
res.json({ success: true });
99+
} catch (error) {
100+
const message = error instanceof Error ? error.message : String(error);
101+
console.error(`Error in fire-and-forget handler ${channel}:`, error);
102+
res.json({ success: false, error: message });
103+
}
104+
});
86105
}
87106
this.listeners.get(channel)!.push(handler);
88107
}

‎src/desktop/preload.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ const api: IPCApi = {
187187
close: (sessionId) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLOSE, sessionId),
188188
resize: (params) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESIZE, params),
189189
sendInput: (sessionId: string, data: string) => {
190-
void ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data);
190+
// Use send() instead of invoke() for fire-and-forget - no need to wait for response
191+
// This reduces input latency significantly for fast typing
192+
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data);
191193
},
192194
onOutput: (sessionId: string, callback: (data: string) => void) => {
193195
const channel = `terminal:output:${sessionId}`;

‎src/node/services/ipcMain.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,13 +1877,13 @@ export class IpcMain {
18771877
});
18781878

18791879
// Handle terminal input (keyboard, etc.)
1880-
// Use handle() for both Electron and browser mode
1881-
ipcMain.handle(IPC_CHANNELS.TERMINAL_INPUT, (_event, sessionId: string, data: string) => {
1880+
// Use on() instead of handle() for fire-and-forget - reduces input latency
1881+
ipcMain.on(IPC_CHANNELS.TERMINAL_INPUT, (_event, sessionId: string, data: string) => {
18821882
try {
18831883
this.ptyService.sendInput(sessionId, data);
18841884
} catch (err) {
18851885
log.error(`Error sending input to terminal ${sessionId}:`, err);
1886-
throw err;
1886+
// No throw - fire-and-forget doesn't return errors to caller
18871887
}
18881888
});
18891889

0 commit comments

Comments
 (0)