fix: stop terminal open/switch from rewriting state per output chunk (#2536)

Opening a terminal rebuilt the WASM terminal twice and rewrote the
persisted session snapshot on every streamed output chunk, so the cost
grew with each open terminal and could crash under load.

- Move PTY scrollback to a standalone buffers map keyed by directory and
  tab id; output no longer touches sessions, so the tab strip, the
  project-action monitor and the persist projection stay referentially
  stable while chunks stream.
- Memoize partialize and add a dedup storage adapter that skips writes
  when the persisted projection is unchanged.
- Reuse the idle terminal WebSocket across tab switches (15s grace)
  instead of re-authenticating and replaying the snapshot on every attach.
- Key the viewport by directory + tab only so createSession no longer
  tears down and rebuilds the Ghostty terminal.
- Reset the terminal in place on replay discontinuities instead of
  bumping the renderer generation.
- Scan the chunk array from the end (O(1) per write) instead of findIndex.

Adds regression tests for the buffer map, socket reuse and viewport key,
and documents the store invariants.
This commit is contained in:
Serhii Dziupin
2026-07-30 13:48:24 +03:00
committed by GitHub
parent 0d6ecbfc12
commit f1e52b0cbc
9 changed files with 460 additions and 102 deletions
@@ -308,8 +308,9 @@ export const ProjectActionsButton = ({
React.useEffect(() => {
const monitorRuns = () => {
const terminalSessions = useTerminalStore.getState().sessions;
const currentRuns = useTerminalStore.getState().projectActionRuns;
const terminalStore = useTerminalStore.getState();
const terminalSessions = terminalStore.sessions;
const currentRuns = terminalStore.projectActionRuns;
for (const [runKey, entry] of Object.entries(currentRuns)) {
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
@@ -321,9 +322,10 @@ export const ProjectActionsButton = ({
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = displayActions.find((item) => item.id === entry.actionId);
if (!action || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) continue;
const bufferChunks = terminalStore.getBuffer(entry.directory, entry.tabId).chunks;
if (!action || bufferChunks.length === 0) continue;
const nextChunks = tab.bufferChunks.filter((chunk) => watch.lastSeenChunkId === null || chunk.id > watch.lastSeenChunkId);
const nextChunks = bufferChunks.filter((chunk) => watch.lastSeenChunkId === null || chunk.id > watch.lastSeenChunkId);
if (nextChunks.length === 0) continue;
const combined = nextChunks.map((chunk) => chunk.data).join('');
@@ -364,7 +366,7 @@ export const ProjectActionsButton = ({
monitorRuns();
return useTerminalStore.subscribe((state, previousState) => {
if (state.sessions !== previousState.sessions) monitorRuns();
if (state.sessions !== previousState.sessions || state.buffers !== previousState.buffers) monitorRuns();
});
}, [displayActions, openContextPreview, openExternal, projectActionRuns, removeProjectActionRun, setTabPreviewUrl, t, updateProjectActionRunStatus]);
@@ -53,6 +53,9 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
const outputRewriteCarryRef = React.useRef('');
const safeResetRef = React.useRef(getGhosttySafeResetSequence(theme.background));
const writingRef = React.useRef(false);
// Incremented whenever the replay stream restarts, so a write completing from
// before the restart cannot clear the in-flight flag of a newer write.
const writeEpochRef = React.useRef(0);
const visibleRef = React.useRef(isVisible);
const rendererReadyRef = React.useRef(false);
const [ready, setReady] = React.useState(0);
@@ -98,19 +101,39 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
return;
}
writingRef.current = true;
const epoch = writeEpochRef.current;
terminal.write(rewritten.data, () => {
if (terminalRef.current !== terminal) return;
if (terminalRef.current !== terminal || writeEpochRef.current !== epoch) return;
writingRef.current = false;
if (writeQueueRef.current) flush();
});
}, []);
/**
* Replay discontinuities (restart, reconnect, buffer reset) only need the VT
* state cleared. `Terminal.reset()` frees and rebuilds the WASM terminal while
* keeping the canvas, renderer and font atlas, so prefer it over remounting the
* whole terminal; the generation bump remains the fallback before the terminal
* exists.
*/
const recreateRenderer = React.useCallback(() => {
lastChunkRef.current = null;
writeQueueRef.current = '';
outputRewriteCarryRef.current = '';
writingRef.current = false;
setRendererGeneration((value) => value + 1);
writeEpochRef.current += 1;
const terminal = terminalRef.current;
if (!terminal) {
setRendererGeneration((value) => value + 1);
return;
}
try {
terminal.reset();
const safeReset = safeResetRef.current;
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
} catch {
setRendererGeneration((value) => value + 1);
}
}, []);
React.useEffect(() => {
@@ -195,6 +218,7 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
writeQueueRef.current = '';
outputRewriteCarryRef.current = '';
writingRef.current = false;
writeEpochRef.current += 1;
rendererReadyRef.current = false;
};
}, [fit, fontFamily, fontSize, rendererGeneration, theme]);
@@ -214,10 +238,20 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
return;
}
const previous = lastChunkRef.current;
const previousIndex = previous === null ? -1 : chunks.findIndex((chunk) => chunk.id === previous);
if (previous !== null && previousIndex < 0) {
recreateRenderer();
return;
// Chunk ids are monotonic and the store appends, so the already-written chunk
// is normally the last one. Scanning from the end keeps this O(1) per chunk
// instead of O(chunks) on every streamed write.
let previousIndex = -1;
if (previous !== null) {
for (let index = chunks.length - 1; index >= 0; index -= 1) {
const id = chunks[index].id;
if (id === previous) { previousIndex = index; break; }
if (id < previous) break;
}
if (previousIndex < 0) {
recreateRenderer();
return;
}
}
const isReplay = previousIndex < 0;
const pending = previousIndex >= 0 ? chunks.slice(previousIndex + 1) : chunks;
@@ -1,7 +1,7 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { EMPTY_TERMINAL_BUFFER, useTerminalStore } from '@/stores/useTerminalStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { type TerminalStreamEvent } from '@/lib/api/types';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -97,7 +97,10 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const terminalSessionId = activeTab?.terminalSessionId ?? null;
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
const bufferChunks = activeTab?.bufferChunks ?? [];
// Scrollback is a leaf subscription: streaming output must not rerender the tab strip.
const bufferChunks = useTerminalStore((s) => (
effectiveDirectory && activeTabId ? s.getBuffer(effectiveDirectory, activeTabId).chunks : EMPTY_TERMINAL_BUFFER.chunks
));
const isConnecting = activeTab?.isConnecting ?? false;
const previewUrl = activeTab?.previewUrl ?? null;
@@ -424,7 +427,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
let terminalId = tab?.terminalSessionId ?? null;
const terminalLifecycle = tab?.lifecycle ?? 'idle';
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
const hasBufferedOutput = (tab?.bufferLength ?? 0) > 0 || (tab?.bufferChunks?.length ?? 0) > 0;
const buffer = useTerminalStore.getState().getBuffer(directory, tabId);
const hasBufferedOutput = buffer.byteLength > 0 || buffer.chunks.length > 0;
if (!terminalId) {
if (terminalLifecycle === 'exited') {
@@ -793,12 +797,15 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const xtermTheme = React.useMemo(() => convertThemeToXterm(currentTheme), [currentTheme]);
// Viewport identity is the tab, not the PTY session. Including the session id
// here tore down and rebuilt the Ghostty terminal (WASM VT + canvas + font
// atlas) a second time the moment `createSession` resolved, doubling the cost
// of every terminal open. Session changes are handled by the chunk replay path.
const terminalViewportKey = React.useMemo(() => {
const directoryPart = effectiveDirectory ?? 'no-dir';
const tabPart = activeTabId ?? 'no-tab';
const terminalPart = terminalSessionId ?? 'no-terminal';
return `${directoryPart}::${tabPart}::${terminalPart}`;
}, [effectiveDirectory, activeTabId, terminalSessionId]);
return `${directoryPart}::${tabPart}`;
}, [effectiveDirectory, activeTabId]);
React.useEffect(() => {
if (!isTerminalVisible || useTouchTerminalInput) {
@@ -0,0 +1,64 @@
/**
* Regression guard for slow terminal opening on Linux.
*
* `TerminalViewport` is keyed by `terminalViewportKey`. That key used to include
* the PTY session id, which is null until `createSession` resolves. Because the
* viewport must mount first to report its size before a session can be created,
* every terminal open built a Ghostty terminal (WASM VT + 2D canvas renderer +
* font atlas), threw it away when the session id arrived, and built a second one.
* The same churn repeated on reconnect and on every incidental session-id change,
* and the repeated WASM terminal allocate/free cycles are the suspected source of
* the reported crashes.
*
* Viewport identity must therefore be directory + tab only. Session changes are
* handled by the chunk replay path, which resets the existing terminal in place.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const terminalViewSource = readFileSync(join(__dirname, '..', 'TerminalView.tsx'), 'utf-8');
const terminalViewportSource = readFileSync(
join(__dirname, '..', '..', 'terminal', 'TerminalViewport.tsx'),
'utf-8',
);
const viewportKeyBlock = (() => {
const start = terminalViewSource.indexOf('const terminalViewportKey = React.useMemo(');
expect(start).toBeGreaterThan(-1);
const end = terminalViewSource.indexOf('}, [', start);
expect(end).toBeGreaterThan(start);
return terminalViewSource.slice(start, terminalViewSource.indexOf(');', end));
})();
describe('terminal viewport remount guard', () => {
test('viewport identity excludes the PTY session id', () => {
expect(viewportKeyBlock).toContain('effectiveDirectory');
expect(viewportKeyBlock).toContain('activeTabId');
expect(viewportKeyBlock).not.toContain('terminalSessionId');
});
test('viewport key memo does not depend on the PTY session id', () => {
const dependencyStart = terminalViewSource.indexOf('}, [', terminalViewSource.indexOf('const terminalViewportKey'));
const dependencies = terminalViewSource.slice(dependencyStart, terminalViewSource.indexOf(']', dependencyStart));
expect(dependencies).toContain('effectiveDirectory');
expect(dependencies).toContain('activeTabId');
expect(dependencies).not.toContain('terminalSessionId');
});
test('replay discontinuities reset the terminal in place instead of remounting it', () => {
const start = terminalViewportSource.indexOf('const recreateRenderer = React.useCallback(');
expect(start).toBeGreaterThan(-1);
const body = terminalViewportSource.slice(start, terminalViewportSource.indexOf('}, []);', start));
expect(body).toContain('terminal.reset()');
// The generation bump stays only as the fallback when no terminal exists yet.
expect(body.indexOf('if (!terminal)')).toBeLessThan(body.indexOf('terminal.reset()'));
});
test('scrollback is read from the buffer slice, not from the tab', () => {
expect(terminalViewSource).toContain('getBuffer(');
expect(terminalViewSource).not.toContain('activeTab?.bufferChunks');
});
});