diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index 412a631a95f..aee9ebba7da 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -75,6 +75,13 @@ const EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES = 1_000_000; */ const EMULATOR_WRITE_QUEUE_LOW_WATERMARK_BYTES = 250_000; +/** + * When no renderer clients are attached, defer headless emulator catch-up into + * coarse idle batches instead of chasing every PTY chunk immediately. Attach, + * shell init and backpressure still force urgent drains. + */ +const EMULATOR_IDLE_DRAIN_INTERVAL_MS = 250; + /** * How long to wait for the shell-ready marker before unblocking writes. * 15s covers heavy setups like Nix-based devenv via direnv. On timeout, @@ -203,6 +210,7 @@ export class Session { private emulatorWriteQueue: string[] = []; private emulatorWriteQueuedBytes = 0; private emulatorWriteScheduled = false; + private emulatorIdleDrainTimer: ReturnType | null = null; private emulatorFlushWaiters: Array<() => void> = []; // Broadcast data coalescing — see BROADCAST_COALESCE_* constants. @@ -622,16 +630,50 @@ export class Session { this.scheduleEmulatorWrite(); } - private scheduleEmulatorWrite(): void { - if (this.emulatorWriteScheduled || this.disposed) return; - this.emulatorWriteScheduled = true; - setImmediate(() => { - this.processEmulatorWriteQueue(); - }); + private shouldUseIdleEmulatorDrain(): boolean { + return ( + this.attachedClients.size === 0 && + this.shellReadyState !== "pending" && + !this.emulatorWriteBackpressured && + this.snapshotBoundaryWaiters.length === 0 && + this.emulatorFlushWaiters.length === 0 && + this.emulatorWriteQueuedBytes < EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES + ); + } + + private clearIdleEmulatorDrainTimer(): void { + if (!this.emulatorIdleDrainTimer) return; + clearTimeout(this.emulatorIdleDrainTimer); + this.emulatorIdleDrainTimer = null; + } + + private scheduleEmulatorWrite(options?: { urgent?: boolean }): void { + if (this.disposed || this.emulatorWriteScheduled) return; + + const urgent = options?.urgent ?? false; + if (urgent || !this.shouldUseIdleEmulatorDrain()) { + this.clearIdleEmulatorDrainTimer(); + this.emulatorWriteScheduled = true; + setImmediate(() => { + this.processEmulatorWriteQueue(); + }); + return; + } + + if (this.emulatorIdleDrainTimer) return; + this.emulatorIdleDrainTimer = setTimeout(() => { + this.emulatorIdleDrainTimer = null; + if (this.disposed || this.emulatorWriteScheduled) return; + this.emulatorWriteScheduled = true; + setImmediate(() => { + this.processEmulatorWriteQueue(); + }); + }, EMULATOR_IDLE_DRAIN_INTERVAL_MS); } private processEmulatorWriteQueue(): void { if (this.disposed) { + this.clearIdleEmulatorDrainTimer(); this.emulatorWriteQueue = []; this.emulatorWriteQueuedBytes = 0; this.emulatorWriteProcessedItems = 0; @@ -716,9 +758,8 @@ export class Session { this.maybeResumeSubprocessStdoutForEmulatorBackpressure(); if (this.emulatorWriteQueue.length > 0) { - setImmediate(() => { - this.processEmulatorWriteQueue(); - }); + this.emulatorWriteScheduled = false; + this.scheduleEmulatorWrite(); return; } @@ -776,7 +817,7 @@ export class Session { resolve(); }, }); - this.scheduleEmulatorWrite(); + this.scheduleEmulatorWrite({ urgent: true }); this.resolveReachedSnapshotBoundaryWaiters(); }); @@ -1029,6 +1070,7 @@ export class Session { dispose(): Promise { if (this.disposed) return Promise.resolve(); this.disposed = true; + this.clearIdleEmulatorDrainTimer(); const pidsToKill = this.collectProcessPids(); @@ -1081,6 +1123,7 @@ export class Session { this.emulatorWriteProcessedItems = 0; this.nextSnapshotBoundaryWaiterId = 1; this.emulatorWriteScheduled = false; + this.clearIdleEmulatorDrainTimer(); this.resolveAllSnapshotBoundaryWaiters(); const waiters = this.emulatorFlushWaiters; this.emulatorFlushWaiters = []; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 4d4cab8744f..757fd104351 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -477,8 +477,8 @@ export const Terminal = memo(function Terminal({ }); // Stream event handler registration — the subscription itself lives in - // v1TerminalCache and stays alive across mount/unmount cycles so data - // keeps flowing to xterm even while the tab is hidden. + // v1TerminalCache and stays alive across mount/unmount cycles. Hidden + // terminals buffer events for replay instead of burning CPU on xterm.write. // Placed after useTerminalLifecycle so the cache entry exists on cold mount. // Gated on xtermInstance so it re-runs once the lifecycle hook creates it. useEffect(() => { @@ -505,8 +505,8 @@ export const Terminal = memo(function Terminal({ }, }); - // Process lifecycle events (exit, error, disconnect) that arrived - // while this component was unmounted. + // Process any buffered events that arrived while this component was + // unmounted. Data events are already coalesced by the cache. for (const event of queuedEvents) { handleStreamData(event); } @@ -516,6 +516,14 @@ export const Terminal = memo(function Terminal({ }; }, [paneId, xtermInstance, handleStreamData, setConnectionError]); + useEffect(() => { + if (!xtermInstance) return; + v1TerminalCache.setFocused(paneId, isFocused); + if (isFocused) { + xtermRef.current?.focus(); + } + }, [paneId, isFocused, xtermInstance]); + useEffect(() => { const xterm = xtermRef.current; if (!xterm) return; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index f842e4ed1a2..41409a5af5d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -101,6 +101,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { wrapper: HTMLDivElement; linkManager: TerminalLinkManager; openOnce: () => void; + setGpuAccelerationEnabled: (enabled: boolean) => void; cleanup: () => void; } { const { @@ -124,6 +125,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { let opened = false; let webglAddon: WebglAddon | null = null; let webglRafId: number | null = null; + let gpuAccelerationEnabled = false; // Create a detached wrapper div. xterm.open() is deferred until the wrapper // is attached to a live DOM container. @@ -146,15 +148,63 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { // Ligatures not supported by current font } - const openOnce = () => { - if (disposed || opened) return; - opened = true; - xterm.open(wrapper); + const disposeWebglAddon = () => { + if (webglRafId !== null) { + cancelAnimationFrame(webglRafId); + webglRafId = null; + } + if (!webglAddon) return; + try { + webglAddon.dispose(); + } catch (error) { + terminalRendererDebug.warn( + "webgl-addon-dispose-failed", + { + errorMessage: error instanceof Error ? error.message : String(error), + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "webgl-dispose-failed"], + }, + ); + } + webglAddon = null; + if (opened && !disposed) { + xterm.refresh(0, Math.max(0, xterm.rows - 1)); + } + }; + + // scheduleWebglEnable / disposeWebglAddon are mutually exclusive: + // disposeWebglAddon cancels any pending RAF and nulls webglRafId before it + // returns, so a rapid attach -> detach -> attach sequence cannot leave two + // WebglAddon instances loaded — the pending callback bails out via the + // disposed / !gpuAccelerationEnabled / webglAddon guards below. + const scheduleWebglEnable = () => { + if ( + disposed || + !gpuAccelerationEnabled || + !opened || + !wrapper.isConnected || + suggestedRendererType === "dom" || + webglAddon || + webglRafId !== null + ) { + return; + } - // Defer WebGL until after open() so renderer initialization sees a live DOM node. + // Defer WebGL until after open()/attach so renderer initialization + // sees a live DOM node. webglRafId = requestAnimationFrame(() => { webglRafId = null; - if (disposed || suggestedRendererType === "dom") return; + if ( + disposed || + !gpuAccelerationEnabled || + !wrapper.isConnected || + suggestedRendererType === "dom" || + webglAddon + ) { + return; + } try { webglAddon = new WebglAddon(); @@ -168,7 +218,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { captureMessage: true, fingerprint: ["terminal.renderer", "webgl-context-lost"], }); - xterm.refresh(0, xterm.rows - 1); + xterm.refresh(0, Math.max(0, xterm.rows - 1)); }); xterm.loadAddon(webglAddon); } catch { @@ -182,6 +232,22 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { }); }; + const setGpuAccelerationEnabled = (enabled: boolean) => { + gpuAccelerationEnabled = enabled; + if (!enabled) { + disposeWebglAddon(); + return; + } + scheduleWebglEnable(); + }; + + const openOnce = () => { + if (disposed || opened) return; + opened = true; + xterm.open(wrapper); + scheduleWebglEnable(); + }; + const cleanupQuerySuppression = suppressQueryResponses(xterm); const linkManager = new TerminalLinkManager(xterm); @@ -244,17 +310,12 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { wrapper, linkManager, openOnce, + setGpuAccelerationEnabled, cleanup: () => { disposed = true; - if (webglRafId !== null) { - cancelAnimationFrame(webglRafId); - } + disposeWebglAddon(); cleanupQuerySuppression(); linkManager.dispose(); - try { - webglAddon?.dispose(); - } catch {} - webglAddon = null; }, }; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index af196eb37c3..2b3177ee8ca 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -292,6 +292,7 @@ export function useTerminalLifecycle({ handleFileLinkClickRef.current(event, link), onUrlClickRef: handleUrlClickRef, }); + v1TerminalCache.setFocused(paneId, isFocusedRef.current); const { xterm, fitAddon, searchAddon } = cached; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts index 5ec1d8e2cf7..126f614b1f7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts @@ -15,8 +15,8 @@ import type { TerminalStreamEvent } from "./types"; * xterm is opened into a persistent wrapper
that can be * moved between DOM containers without disposing the terminal. * - * Also owns the tRPC stream subscription so data continues flowing - * to xterm even while the React component is unmounted (tab hidden). + * Also owns the tRPC stream subscription so hidden terminals can buffer + * output without keeping xterm busy while the React component is unmounted. */ export interface CachedTerminal { xterm: XTerm; @@ -24,11 +24,16 @@ export interface CachedTerminal { searchAddon: SearchAddon; wrapper: HTMLDivElement; openOnce: () => void; + setGpuAccelerationEnabled: (enabled: boolean) => void; /** Disposes renderer RAF, query suppression, GPU renderer, etc. */ cleanupCreation: () => void; /** Last known dimensions — used to skip no-op resize events. */ lastCols: number; lastRows: number; + /** True while the wrapper is attached to a visible DOM container. */ + isAttached: boolean; + /** True when this pane is the focused pane in the tab. */ + isFocused: boolean; // --- Stream management --- @@ -38,14 +43,18 @@ export interface CachedTerminal { streamReady: boolean; /** Events queued before streamReady (first mount only). */ pendingStreamEvents: TerminalStreamEvent[]; - /** Non-data events queued while no component is mounted. */ - pendingLifecycleEvents: TerminalStreamEvent[]; + /** + * Events queued while no component is mounted. Adjacent data events are + * coalesced so hidden terminals preserve ordering without paying xterm.write + * cost for every chunk. + */ + pendingUnmountedEvents: TerminalStreamEvent[]; + pendingUnmountedBytes: number; /** * Handler provided by the mounted Terminal component. * When set, ALL events are forwarded here so the component can * update React state (exit status, connection error, modes, cwd, etc.). - * When null (component unmounted), data events write directly to xterm - * and non-data events are queued. + * When null (component unmounted), events are buffered for replay. */ eventHandler: ((event: TerminalStreamEvent) => void) | null; /** @@ -61,6 +70,8 @@ export interface CachedTerminal { const cache = new Map(); +const MAX_PENDING_UNMOUNTED_BYTES = 10 * 1024 * 1024; // 10MB + export function has(paneId: string): boolean { return cache.has(paneId); } @@ -80,8 +91,15 @@ export function getOrCreate( console.log(`[v1-terminal-cache] Creating new terminal: ${paneId}`); } - const { xterm, fitAddon, searchAddon, wrapper, openOnce, cleanup } = - createTerminalInWrapper(options); + const { + xterm, + fitAddon, + searchAddon, + wrapper, + openOnce, + setGpuAccelerationEnabled, + cleanup, + } = createTerminalInWrapper(options); const entry: CachedTerminal = { xterm, @@ -89,17 +107,21 @@ export function getOrCreate( searchAddon, wrapper, openOnce, + setGpuAccelerationEnabled, cleanupCreation: cleanup, subscription: null, streamReady: false, pendingStreamEvents: [], - pendingLifecycleEvents: [], + pendingUnmountedEvents: [], + pendingUnmountedBytes: 0, eventHandler: null, subscriptionErrorHandler: null, resizeObserver: null, resizeDebounceTimer: null, lastCols: xterm.cols, lastRows: xterm.rows, + isAttached: false, + isFocused: false, }; cache.set(paneId, entry); @@ -116,8 +138,10 @@ export function attachToContainer( const entry = cache.get(paneId); if (!entry) return; + entry.isAttached = true; container.appendChild(entry.wrapper); entry.openOnce(); + entry.setGpuAccelerationEnabled(entry.isFocused); terminalRendererDebug.info("cache-attach-to-container", { paneId, hasSubscription: entry.subscription !== null, @@ -175,11 +199,20 @@ export function detachFromContainer(paneId: string): void { fingerprint: ["terminal.renderer", "cache-detach-from-container"], }, ); + entry.isAttached = false; + entry.setGpuAccelerationEnabled(false); entry.resizeObserver?.disconnect(); entry.resizeObserver = null; entry.wrapper.remove(); } +export function setFocused(paneId: string, isFocused: boolean): void { + const entry = cache.get(paneId); + if (!entry) return; + entry.isFocused = isFocused; + entry.setGpuAccelerationEnabled(entry.isAttached && isFocused); +} + // --- Appearance --- /** @@ -217,24 +250,6 @@ export function updateAppearance( }; } -// --- rAF write buffer --- - -/** - * Thin wrapper around xterm.write so callers share cache lookup and logging. - * Keep the PTY -> xterm path as close to VS Code/xterm's recommended - * integration as possible. - */ -export function scheduleWrite(paneId: string, data: string): void { - const entry = cache.get(paneId); - if (!entry) return; - entry.xterm.write(data); -} - -/** - * Backward-compatible no-op now that writes go directly to xterm. - */ -export function flushWrite(_paneId: string): void {} - // --- Stream subscription --- function routeEvent( @@ -254,7 +269,8 @@ function routeEvent( return; } - // Component unmounted — write data directly to xterm, queue the rest. + // Component unmounted — buffer events for replay instead of burning CPU on + // hidden xterm.write calls. if (event.type === "data") { terminalRendererDebug.increment("hidden-data-events", 1, { data: { paneId, bytes: event.data.length }, @@ -263,11 +279,37 @@ function routeEvent( data: { paneId }, }); logTerminalWrite("hidden-stream-data", event.data.length, { paneId }); - scheduleWrite(paneId, event.data); - } else { - flushWrite(paneId); - entry.pendingLifecycleEvents.push(event); + const lastBufferedEvent = + entry.pendingUnmountedEvents[entry.pendingUnmountedEvents.length - 1]; + if (lastBufferedEvent?.type === "data") { + lastBufferedEvent.data += event.data; + } else { + entry.pendingUnmountedEvents.push(event); + } + entry.pendingUnmountedBytes += event.data.length; + + // Hard-reset the buffer when the cap is exceeded. Slicing a coalesced + // data chunk at a char index could land in the middle of an ANSI escape + // sequence (e.g. `\x1b[31m`) or a UTF-8 multi-byte boundary, and xterm's + // parser would stay broken from there on. Hitting 10MB while hidden is + // already an outlier (long-lived high-output TUI behind an inactive tab), + // so we drop everything and emit RIS (`\x1bc`) instead — TUIs redraw on + // the next output anyway. + if (entry.pendingUnmountedBytes > MAX_PENDING_UNMOUNTED_BYTES) { + terminalRendererDebug.warn( + "hidden-buffer-overflow", + { paneId, droppedBytes: entry.pendingUnmountedBytes }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "hidden-buffer-overflow"], + }, + ); + entry.pendingUnmountedEvents = [{ type: "data", data: "\x1bc" }]; + entry.pendingUnmountedBytes = 2; + } + return; } + entry.pendingUnmountedEvents.push(event); } /** @@ -301,6 +343,12 @@ export function startStream(paneId: string): void { // so the next remount goes through the full create/attach path. entry.subscription = null; entry.streamReady = false; + // Drop the hidden replay buffer too. Keeping it would splice + // pre-error events into whatever the new subscription emits on + // remount, mixing two terminal states and corrupting xterm's + // parser. The user re-runs whatever they were running anyway. + entry.pendingUnmountedEvents = []; + entry.pendingUnmountedBytes = 0; terminalRendererDebug.error( "cache-stream-error", { @@ -371,8 +419,7 @@ export function markSessionReady(paneId: string): void { /** * Register event handlers from the mounted Terminal component. - * Returns any lifecycle events (exit, error, disconnect) that were - * queued while the component was unmounted. + * Returns any events that were buffered while the component was unmounted. */ export function registerHandlers( paneId: string, @@ -387,13 +434,15 @@ export function registerHandlers( entry.eventHandler = handlers.onEvent; entry.subscriptionErrorHandler = handlers.onError; - // Drain and return queued lifecycle events - return entry.pendingLifecycleEvents.splice(0); + // Drain and return queued hidden events in original order. + const events = entry.pendingUnmountedEvents.splice(0); + entry.pendingUnmountedBytes = 0; + return events; } /** * Unregister the component's event handlers (component unmounting). - * The subscription stays alive; data events write directly to xterm. + * The subscription stays alive; events buffer until the component remounts. */ export function unregisterHandlers(paneId: string): void { const entry = cache.get(paneId);