From 65063e510fad2cb83b0b9bd46c3c96797a6745ec Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Thu, 23 Apr 2026 15:51:29 +0900 Subject: [PATCH 1/4] =?UTF-8?q?perf(desktop):=20v1=E3=82=BF=E3=83=BC?= =?UTF-8?q?=E3=83=9F=E3=83=8A=E3=83=AB=E3=81=AE=E5=A4=9A=E9=87=8DTUI?= =?UTF-8?q?=E8=B2=A0=E8=8D=B7=E3=82=92=E8=BB=BD=E6=B8=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/main/terminal-host/session.ts | 62 +++++++++++--- .../TabsContent/Terminal/Terminal.tsx | 16 +++- .../TabsContent/Terminal/helpers.ts | 73 ++++++++++++---- .../Terminal/hooks/useTerminalLifecycle.ts | 1 + .../TabsContent/Terminal/v1-terminal-cache.ts | 83 +++++++++++-------- 5 files changed, 174 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index 4359a8248ec..b4a282bdd64 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, @@ -202,6 +209,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. @@ -621,16 +629,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; @@ -715,9 +757,8 @@ export class Session { this.maybeResumeSubprocessStdoutForEmulatorBackpressure(); if (this.emulatorWriteQueue.length > 0) { - setImmediate(() => { - this.processEmulatorWriteQueue(); - }); + this.emulatorWriteScheduled = false; + this.scheduleEmulatorWrite(); return; } @@ -775,7 +816,7 @@ export class Session { resolve(); }, }); - this.scheduleEmulatorWrite(); + this.scheduleEmulatorWrite({ urgent: true }); this.resolveReachedSnapshotBoundaryWaiters(); }); @@ -1034,6 +1075,7 @@ export class Session { dispose(): Promise { if (this.disposed) return Promise.resolve(); this.disposed = true; + this.clearIdleEmulatorDrainTimer(); const pidsToKill = this.collectProcessPids(); 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 b457ff992a0..30d2866645f 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 @@ -481,8 +481,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(() => { @@ -509,8 +509,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); } @@ -520,6 +520,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 72a20cfe225..b2cfc78afef 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,47 @@ 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 {} + webglAddon = null; + if (opened) { + xterm.refresh(0, Math.max(0, xterm.rows - 1)); + } + }; - // Defer WebGL until after open() so renderer initialization sees a live DOM node. + const scheduleWebglEnable = () => { + if ( + disposed || + !gpuAccelerationEnabled || + !opened || + !wrapper.isConnected || + suggestedRendererType === "dom" || + webglAddon || + webglRafId !== null + ) { + return; + } + + // 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(); @@ -173,7 +207,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 { @@ -187,6 +221,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); @@ -247,17 +297,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 62ac4df05d9..d0e02d5bf53 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 @@ -299,6 +299,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 93210cf4fe0..27e3fd80d4e 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 @@ -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,17 @@ 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[]; /** * 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; /** @@ -80,7 +88,15 @@ export function getOrCreate( console.log(`[v1-terminal-cache] Creating new terminal: ${paneId}`); } - const { xterm, fitAddon, searchAddon, wrapper, openOnce, cleanup } = + const { + xterm, + fitAddon, + searchAddon, + wrapper, + openOnce, + setGpuAccelerationEnabled, + cleanup, + } = createTerminalInWrapper(options); const entry: CachedTerminal = { @@ -89,17 +105,20 @@ export function getOrCreate( searchAddon, wrapper, openOnce, + setGpuAccelerationEnabled, cleanupCreation: cleanup, subscription: null, streamReady: false, pendingStreamEvents: [], - pendingLifecycleEvents: [], + pendingUnmountedEvents: [], eventHandler: null, subscriptionErrorHandler: null, resizeObserver: null, resizeDebounceTimer: null, lastCols: xterm.cols, lastRows: xterm.rows, + isAttached: false, + isFocused: false, }; cache.set(paneId, entry); @@ -116,8 +135,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", { @@ -182,11 +203,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 --- /** @@ -224,24 +254,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( @@ -261,7 +273,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 }, @@ -270,10 +283,15 @@ function routeEvent( data: { paneId }, }); logTerminalWrite("hidden-stream-data", event.data.length, { paneId }); - scheduleWrite(paneId, event.data); + const lastBufferedEvent = + entry.pendingUnmountedEvents[entry.pendingUnmountedEvents.length - 1]; + if (lastBufferedEvent?.type === "data") { + lastBufferedEvent.data += event.data; + return; + } + entry.pendingUnmountedEvents.push(event); } else { - flushWrite(paneId); - entry.pendingLifecycleEvents.push(event); + entry.pendingUnmountedEvents.push(event); } } @@ -378,8 +396,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, @@ -394,13 +411,13 @@ 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. + return entry.pendingUnmountedEvents.splice(0); } /** * 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); From 36507bcf584f4b9eb632a25c4b6eebdaeb746efe Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 25 Apr 2026 05:46:46 +0900 Subject: [PATCH 2/4] style(desktop): format v1 terminal cache --- .../ContentView/TabsContent/Terminal/v1-terminal-cache.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 91f2892e552..e1fcd1ca73e 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 @@ -96,8 +96,7 @@ export function getOrCreate( openOnce, setGpuAccelerationEnabled, cleanup, - } = - createTerminalInWrapper(options); + } = createTerminalInWrapper(options); const entry: CachedTerminal = { xterm, From b1520f20b17c9fcdd1222fc6fbbe9b4c942db371 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sun, 26 Apr 2026 06:13:30 +0900 Subject: [PATCH 3/4] fix(desktop): address CodeRabbit review nitpicks on terminal perf PR - resetProcessState: clear idle drain timer symmetrically with dispose() - disposeWebglAddon: skip xterm.refresh when disposed, surface GPU errors to debug logger - v1-terminal-cache: cap hidden buffer at 10MB, trim oldest data to preserve tail --- .../desktop/src/main/terminal-host/session.ts | 1 + .../TabsContent/Terminal/helpers.ts | 9 +++-- .../TabsContent/Terminal/v1-terminal-cache.ts | 36 ++++++++++++++++--- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index 2df72701056..aee9ebba7da 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -1123,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/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 8ab41025cec..1535be1c6d2 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 @@ -156,9 +156,14 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { if (!webglAddon) return; try { webglAddon.dispose(); - } catch {} + } catch (error) { + terminalRendererDebug.warn("webgl-addon-dispose-failed", undefined, { + captureMessage: true, + fingerprint: ["terminal.renderer", "webgl-dispose-failed"], + }); + } webglAddon = null; - if (opened) { + if (opened && !disposed) { xterm.refresh(0, Math.max(0, xterm.rows - 1)); } }; 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 e1fcd1ca73e..491273a94a5 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 @@ -49,6 +49,7 @@ export interface CachedTerminal { * 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 @@ -69,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); } @@ -110,6 +113,7 @@ export function getOrCreate( streamReady: false, pendingStreamEvents: [], pendingUnmountedEvents: [], + pendingUnmountedBytes: 0, eventHandler: null, subscriptionErrorHandler: null, resizeObserver: null, @@ -279,12 +283,32 @@ function routeEvent( entry.pendingUnmountedEvents[entry.pendingUnmountedEvents.length - 1]; if (lastBufferedEvent?.type === "data") { lastBufferedEvent.data += event.data; - return; + } else { + entry.pendingUnmountedEvents.push(event); } - entry.pendingUnmountedEvents.push(event); - } else { - entry.pendingUnmountedEvents.push(event); + entry.pendingUnmountedBytes += event.data.length; + + // Trim oldest data when the cap is exceeded, preserving the tail. + while (entry.pendingUnmountedBytes > MAX_PENDING_UNMOUNTED_BYTES) { + const first = entry.pendingUnmountedEvents[0]; + if (!first) break; + if (first.type !== "data") { + entry.pendingUnmountedEvents.shift(); + break; + } + const excess = entry.pendingUnmountedBytes - MAX_PENDING_UNMOUNTED_BYTES; + if (first.data.length <= excess) { + entry.pendingUnmountedBytes -= first.data.length; + entry.pendingUnmountedEvents.shift(); + } else { + first.data = first.data.slice(excess); + entry.pendingUnmountedBytes -= excess; + break; + } + } + return; } + entry.pendingUnmountedEvents.push(event); } /** @@ -404,7 +428,9 @@ export function registerHandlers( entry.subscriptionErrorHandler = handlers.onError; // Drain and return queued hidden events in original order. - return entry.pendingUnmountedEvents.splice(0); + const events = entry.pendingUnmountedEvents.splice(0); + entry.pendingUnmountedBytes = 0; + return events; } /** From ad9112cae4fa2d4d42f36c141ecb3a1d3f48d484 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sun, 26 Apr 2026 12:37:24 +0900 Subject: [PATCH 4/4] fix(desktop): harden v1 terminal hidden-buffer overflow / onError / WebGL teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 事前レビューで指摘された HIGH 3 件と関連 nit を修正。 - v1-terminal-cache: 10MB 上限到達時の `slice(excess)` を廃止し、バッファ全捨て + RIS (`\x1bc`) 注入に切替。コアレス済み文字列を char index で切ると ANSI escape sequence や UTF-8 multi-byte 境界の中で分断され、xterm parser が壊れたまま replay されるため。10MB は long-lived hidden TUI の outlier ケースで、TUI は次の出力で全画面再描画する。 - v1-terminal-cache: subscription onError で `pendingUnmountedEvents` / `pendingUnmountedBytes` もクリア。これがないと remount 時に「エラー前」と「新 subscription」のイベントが混ざって xterm 状態が壊れる。 - helpers: scheduleWebglEnable と disposeWebglAddon の相互排他をコメントで明記。fast attach/detach toggle で二重 WebglAddon インスタンスが乗らないことの根拠 (rafId キャンセル + コールバック内ガード) を読者に伝える。 - helpers: webgl-addon-dispose-failed の warn payload に error.message を載せる。バインドした error 変数を捨てていたためテレメトリで原因が追えなかった。 --- .../TabsContent/Terminal/helpers.ts | 19 +++++++-- .../TabsContent/Terminal/v1-terminal-cache.ts | 41 +++++++++++-------- 2 files changed, 39 insertions(+), 21 deletions(-) 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 1535be1c6d2..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 @@ -157,10 +157,16 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { try { webglAddon.dispose(); } catch (error) { - terminalRendererDebug.warn("webgl-addon-dispose-failed", undefined, { - captureMessage: true, - fingerprint: ["terminal.renderer", "webgl-dispose-failed"], - }); + 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) { @@ -168,6 +174,11 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { } }; + // 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 || 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 491273a94a5..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 @@ -288,23 +288,24 @@ function routeEvent( } entry.pendingUnmountedBytes += event.data.length; - // Trim oldest data when the cap is exceeded, preserving the tail. - while (entry.pendingUnmountedBytes > MAX_PENDING_UNMOUNTED_BYTES) { - const first = entry.pendingUnmountedEvents[0]; - if (!first) break; - if (first.type !== "data") { - entry.pendingUnmountedEvents.shift(); - break; - } - const excess = entry.pendingUnmountedBytes - MAX_PENDING_UNMOUNTED_BYTES; - if (first.data.length <= excess) { - entry.pendingUnmountedBytes -= first.data.length; - entry.pendingUnmountedEvents.shift(); - } else { - first.data = first.data.slice(excess); - entry.pendingUnmountedBytes -= excess; - break; - } + // 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; } @@ -342,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", {