From be7332db17b1106ecbadbd268bdf3034bb44a3d8 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 14 Sep 2026 16:59:57 +0800 Subject: [PATCH] perf: route PTY stream through a single shared IPC listener Every pane registered its own global onPtyData/onPtyExit listener, so each output chunk woke N listeners and N-1 discarded it after an id compare. With many terminals under heavy output that fan-out is pure overhead. Now one IPC listener dispatches through a Map keyed by sessionId: one lookup per chunk, only the owning pane runs. --- src/renderer/src/terminal/TerminalView.tsx | 17 ++++---- src/renderer/src/terminal/ptyDispatcher.ts | 48 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/terminal/ptyDispatcher.ts diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 3f40e44..557e838 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -9,11 +9,11 @@ import '@xterm/xterm/css/xterm.css' import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' import { getThemeById } from '@shared/theme' import type { CommandItem } from '@shared/commands' -import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc' import type { TerminalSettings } from '@shared/settings' import { useSettingsStore } from '@renderer/settings/store' import { writeBroadcast } from '@renderer/workspace/broadcastStore' import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine' +import { subscribePtyData, subscribePtyExit } from './ptyDispatcher' import { cdArgument, conemuCwd } from './cwdTracker' import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore' import './terminal.css' @@ -792,18 +792,19 @@ export const TerminalView: ForwardRefExoticComponent void)[] = [ - window.api.onPtyData((e: PtyDataEvent) => { - if (e.id !== sessionId || deadRef.current) return + // Routed via the shared dispatcher (one IPC listener for all panes) + // instead of a per-pane global listener. + subscribePtyData(sessionId, (data: string) => { + if (deadRef.current) return if (!replayDone) { - pending.push(e.data) + pending.push(data) return } - writeHighlighted(e.data) + writeHighlighted(data) }), - window.api.onPtyExit((e: PtyExitEvent) => { - if (e.id !== sessionId) return + subscribePtyExit(sessionId, (code: number) => { deadRef.current = true - setExitCode(e.exitCode) + setExitCode(code) setDead(true) }) ] diff --git a/src/renderer/src/terminal/ptyDispatcher.ts b/src/renderer/src/terminal/ptyDispatcher.ts new file mode 100644 index 0000000..d2d61fe --- /dev/null +++ b/src/renderer/src/terminal/ptyDispatcher.ts @@ -0,0 +1,48 @@ +import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc' + +/** + * Single shared PTY stream listener. Every pane used to register its own + * window.api.onPtyData/onPtyExit listener, so each output chunk woke N + * listeners (N = pane count) and N-1 of them discarded it after an id + * compare. With many terminals under heavy output that per-chunk fan-out is + * pure overhead. Here one IPC listener dispatches through a Map keyed by + * sessionId: one lookup per chunk, and only the owning pane runs. + */ + +type DataHandler = (data: string) => void +type ExitHandler = (exitCode: number) => void + +const dataHandlers = new Map() +const exitHandlers = new Map() +let bound = false + +function ensureBound(): void { + if (bound) return + bound = true + window.api.onPtyData((e: PtyDataEvent) => { + dataHandlers.get(e.id)?.(e.data) + }) + window.api.onPtyExit((e: PtyExitEvent) => { + exitHandlers.get(e.id)?.(e.exitCode) + }) +} + +/** Subscribe to one session's output. Returns the unsubscribe function. */ +export function subscribePtyData(id: string, cb: DataHandler): () => void { + ensureBound() + dataHandlers.set(id, cb) + return () => { + // A rebind (template apply) may have replaced the handler already; only + // delete when this subscription is still the registered one. + if (dataHandlers.get(id) === cb) dataHandlers.delete(id) + } +} + +/** Subscribe to one session's exit. Returns the unsubscribe function. */ +export function subscribePtyExit(id: string, cb: ExitHandler): () => void { + ensureBound() + exitHandlers.set(id, cb) + return () => { + if (exitHandlers.get(id) === cb) exitHandlers.delete(id) + } +}