Merge pull request #2592 from openchamber/terminal-open-debug

fix(terminal): start PTY before viewport mounts without dropping output or replies
This commit is contained in:
Serhii Dziupin
2026-08-03 12:50:19 +03:00
committed by GitHub
17 changed files with 529 additions and 122 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+77
View File
@@ -0,0 +1,77 @@
diff --git a/src/terminal.ts b/src/terminal.ts
index ec248d46a939f8a09cd669e853cefb126922c80a..c0473bc625edda7be2ade987e8aa3bd99160ce67 100644
--- a/src/terminal.ts
+++ b/src/terminal.ts
@@ -11,6 +11,7 @@ export const DEFAULT_COLS = 80;
export const DEFAULT_ROWS = 24;
export const DEFAULT_FILE = "sh";
export const DEFAULT_NAME = "xterm";
+const INITIAL_OUTPUT_BUFFER_LIMIT = 512 * 1024;
/**
* Quote a string for shell-words compatible splitting on the Rust side.
@@ -136,6 +137,8 @@ export class Terminal implements IPty {
private _readLoop = false;
private _closing = false;
+ private _hasDataSubscriber = false;
+ private _initialOutput = "";
// TextDecoder with streaming mode to properly handle UTF-8 across chunk boundaries
// Without this, multi-byte characters (like box-drawing ─) that span chunks become
@@ -191,12 +194,29 @@ export class Terminal implements IPty {
}
get onData() {
- return this._onData.event;
+ return (listener: (data: string) => void) => {
+ const disposable = this._onData.event(listener);
+ if (!this._hasDataSubscriber) {
+ this._hasDataSubscriber = true;
+ const initialOutput = this._initialOutput;
+ this._initialOutput = "";
+ if (initialOutput) listener(initialOutput);
+ }
+ return disposable;
+ };
}
get onExit() {
return this._onExit.event;
}
+ private _emitData(data: string) {
+ if (this._hasDataSubscriber) {
+ this._onData.fire(data);
+ } else {
+ this._initialOutput = `${this._initialOutput}${data}`.slice(-INITIAL_OUTPUT_BUFFER_LIMIT);
+ }
+ }
+
/* ------------- IO methods ------------- */
write(data: string) {
@@ -235,13 +255,13 @@ export class Terminal implements IPty {
// This prevents corruption when multi-byte chars span chunk boundaries
const decoded = this._decoder.decode(buf.subarray(0, n), { stream: true });
if (decoded) {
- this._onData.fire(decoded);
+ this._emitData(decoded);
}
} else if (n === -2) {
// CHILD_EXITED - flush any remaining bytes in the decoder
const remaining = this._decoder.decode();
if (remaining) {
- this._onData.fire(remaining);
+ this._emitData(remaining);
}
const exitCode = lib.symbols.bun_pty_get_exit_code(this.handle);
this._onExit.fire({ exitCode });
@@ -250,7 +270,7 @@ export class Terminal implements IPty {
// error - flush decoder before breaking
const remaining = this._decoder.decode();
if (remaining) {
- this._onData.fire(remaining);
+ this._emitData(remaining);
}
break;
} else {
+5 -4
View File
@@ -95,7 +95,7 @@
},
"packages/electron": {
"name": "@openchamber/electron",
"version": "1.17.1",
"version": "1.17.2",
"dependencies": {
"@openchamber/web": "workspace:*",
"better-sqlite3": "^12.10.0",
@@ -132,7 +132,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
"version": "1.17.1",
"version": "1.17.2",
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0",
@@ -237,7 +237,7 @@
},
"packages/vscode": {
"name": "openchamber",
"version": "1.17.1",
"version": "1.17.2",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.18.11",
@@ -260,7 +260,7 @@
},
"packages/web": {
"name": "@openchamber/web",
"version": "1.17.1",
"version": "1.17.2",
"bin": {
"openchamber": "./bin/cli.js",
},
@@ -352,6 +352,7 @@
],
"patchedDependencies": {
"@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch",
"bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch",
},
"overrides": {
"@codemirror/language": "6.12.2",
+2 -1
View File
@@ -176,6 +176,7 @@
"vite": "^7.1.2"
},
"patchedDependencies": {
"@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch"
"@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch",
"bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch"
}
}
@@ -18,6 +18,42 @@ import type { TerminalChunk } from '@/stores/useTerminalStore';
let ghosttyPromise: Promise<Ghostty> | null = null;
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
type TerminalSize = { cols: number; rows: number };
const getProvisionalTerminalSize = (
container: HTMLDivElement,
fontFamily: string,
fontSize: number,
): TerminalSize | null => {
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
const context = document.createElement('canvas').getContext('2d');
if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null;
context.font = `${fontSize}px ${fontFamily}`;
const metrics = context.measureText('M');
const cellWidth = Math.ceil(metrics.width);
const cellHeight = Math.ceil(
(metrics.actualBoundingBoxAscent || fontSize * 0.8) +
(metrics.actualBoundingBoxDescent || fontSize * 0.2),
) + 2;
if (cellWidth < 1 || cellHeight < 1) return null;
const style = window.getComputedStyle(container);
const horizontalPadding =
(Number.parseInt(style.paddingLeft, 10) || 0) +
(Number.parseInt(style.paddingRight, 10) || 0);
const verticalPadding =
(Number.parseInt(style.paddingTop, 10) || 0) +
(Number.parseInt(style.paddingBottom, 10) || 0);
// Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions.
return {
cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)),
rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)),
};
};
export type TerminalController = {
focus: () => void;
fit: () => void;
@@ -47,7 +83,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
const fitRef = React.useRef<FitAddon | null>(null);
const inputRef = React.useRef(onInput);
const resizeRef = React.useRef(onResize);
const lastSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const lastSizeRef = React.useRef<TerminalSize | null>(null);
const provisionalSizeRef = React.useRef<TerminalSize | null>(null);
const lastChunkRef = React.useRef<number | null>(null);
const writeQueueRef = React.useRef('');
const outputRewriteCarryRef = React.useRef('');
@@ -65,6 +102,14 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
visibleRef.current = isVisible;
safeResetRef.current = getGhosttySafeResetSequence(theme.background);
React.useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const size = getProvisionalTerminalSize(container, fontFamily, fontSize);
provisionalSizeRef.current = size;
if (size) resizeRef.current(size.cols, size.rows);
}, [fontFamily, fontSize]);
const fit = React.useCallback(() => {
const container = containerRef.current;
const terminal = terminalRef.current;
@@ -168,7 +213,10 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
loadGhostty().then((ghostty) => {
if (disposed) return;
terminal = new GhosttyTerminal(getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false));
terminal = new GhosttyTerminal({
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
...(provisionalSizeRef.current ?? {}),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
@@ -26,6 +26,8 @@ type TerminalViewProps = {
visible?: boolean;
};
const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;
export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
@@ -109,7 +111,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
const [isRestarting, setIsRestarting] = React.useState(false);
const [hasViewportSize, setHasViewportSize] = React.useState(false);
const streamCleanupRef = React.useRef<(() => void) | null>(null);
const activeTerminalIdRef = React.useRef<string | null>(null);
@@ -118,7 +119,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const directoryRef = React.useRef<string | null>(effectiveDirectory);
const terminalControllerRef = React.useRef<TerminalController | null>(null);
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const isTerminalVisibleRef = React.useRef(false);
const pendingTerminalCreatesRef = React.useRef(new Set<string>());
const previewScanTailRef = React.useRef('');
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
const previewProbeGenerationRef = React.useRef(0);
@@ -157,10 +158,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
}
}, [isTerminalVisible]);
React.useEffect(() => {
isTerminalVisibleRef.current = isTerminalVisible;
}, [isTerminalVisible]);
React.useEffect(() => {
terminalIdRef.current = terminalSessionId;
}, [terminalSessionId]);
@@ -424,7 +421,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
}
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
let terminalId = tab?.terminalSessionId ?? null;
const terminalId = tab?.terminalSessionId ?? null;
const terminalLifecycle = tab?.lifecycle ?? 'idle';
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
const buffer = useTerminalStore.getState().getBuffer(directory, tabId);
@@ -441,11 +438,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
return;
}
const size = lastViewportSizeRef.current;
if (!size && isTerminalVisibleRef.current) {
const createKey = `${directory}\u0000${tabId}`;
if (pendingTerminalCreatesRef.current.has(createKey)) {
return;
}
// Launch the shell while Ghostty is still loading and fitting.
// The backend accepts 80x24, then receives the measured size as
// soon as the viewport is ready.
const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
pendingTerminalCreatesRef.current.add(createKey);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
@@ -454,8 +457,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const session = await terminal.createSession({
cwd: directory,
sessionId: tabId,
cols: size?.cols,
rows: size?.rows,
cols: initialSize.cols,
rows: initialSize.rows,
shell: terminalShell,
loginShell: terminalLoginShell,
...terminalAppearanceRef.current,
@@ -476,19 +479,38 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
setTabSessionId(directory, tabId, session.sessionId);
if (!stillActive) return;
terminalId = session.sessionId;
} catch (error) {
if (!cancelled) {
setConnectionError(
error instanceof Error
? error.message
: t('terminalView.error.startSessionFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
setConnecting(directory, tabId, false);
const viewportSize = lastViewportSizeRef.current;
if (
viewportSize &&
(viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows)
) {
void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {});
}
// Storing the session ID reruns this effect. Let that next
// effect own stream startup: starting here would be torn
// down immediately by this effect's cleanup.
return;
} catch (error) {
const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId);
if (!owningTab || owningTab.terminalSessionId) return;
setConnecting(directory, tabId, false);
// Strict Mode replaces the first effect while its create
// request is pending. `cancelled` therefore does not mean
// this tab stopped owning the request; use current store
// ownership so a rejected create cannot leave it spinning.
if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return;
setConnectionError(
error instanceof Error
? error.message
: t('terminalView.error.startSessionFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
return;
} finally {
pendingTerminalCreatesRef.current.delete(createKey);
}
}
@@ -513,7 +535,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
terminalLifecycle,
activeTabId,
hasOpenedTerminalViewport,
hasViewportSize,
enableTabs,
terminalHydrated,
ensureDirectory,
@@ -568,7 +589,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
resetTerminalPreviewScan();
try {
const size = lastViewportSizeRef.current ?? { cols: 80, rows: 24 };
const size = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current });
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
if (owningTab?.terminalSessionId !== originalSessionId) return;
@@ -694,22 +715,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const handleViewportResize = React.useCallback(
(cols: number, rows: number) => {
const previous = lastViewportSizeRef.current;
if (!previous) {
lastViewportSizeRef.current = { cols, rows };
if (!terminalIdRef.current) setHasViewportSize(true);
} else if (previous.cols !== cols || previous.rows !== rows) {
if (!previous || previous.cols !== cols || previous.rows !== rows) {
lastViewportSizeRef.current = { cols, rows };
}
if (!isTerminalVisibleRef.current) {
if (!isTerminalVisible) {
return;
}
const terminalId = terminalIdRef.current;
if (!terminalId) return;
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {
});
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {});
},
[terminal]
[isTerminalVisible, terminal]
);
const handleModifierToggle = React.useCallback(
@@ -801,11 +817,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
// 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';
return `${directoryPart}::${tabPart}`;
}, [effectiveDirectory, activeTabId]);
const terminalViewportKey = `${effectiveDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`;
React.useEffect(() => {
if (!isTerminalVisible || useTouchTerminalInput) {
@@ -2,8 +2,8 @@
* 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,
* the PTY session id, which is null until `createSession` resolves. Historically,
* the viewport had to mount first to report its size before session creation, so
* 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,
@@ -12,6 +12,8 @@
*
* Viewport identity must therefore be directory + tab only. Session changes are
* handled by the chunk replay path, which resets the existing terminal in place.
* New sessions start concurrently with a container-derived size (or 80x24) and
* resize after their viewport fits.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
@@ -25,27 +27,15 @@ const terminalViewportSource = readFileSync(
'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));
})();
const viewportKeyDeclaration = terminalViewSource
.split('\n')
.find((line) => line.includes('const terminalViewportKey =')) ?? '';
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');
expect(viewportKeyDeclaration).toContain('effectiveDirectory');
expect(viewportKeyDeclaration).toContain('activeTabId');
expect(viewportKeyDeclaration).not.toContain('terminalSessionId');
});
test('replay discontinuities reset the terminal in place instead of remounting it', () => {
@@ -61,4 +51,53 @@ describe('terminal viewport remount guard', () => {
expect(terminalViewSource).toContain('getBuffer(');
expect(terminalViewSource).not.toContain('activeTab?.bufferChunks');
});
test('starts the PTY before Ghostty reports its first viewport size', () => {
expect(terminalViewSource).toContain('const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;');
expect(terminalViewSource).toContain('const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;');
expect(terminalViewSource).not.toContain('if (!size && isTerminalVisibleRef.current)');
expect(terminalViewSource).toContain('cols: initialSize.cols');
expect(terminalViewSource).toContain('rows: initialSize.rows');
expect(terminalViewSource).toContain('void terminal.resize({ sessionId: session.sessionId, ...viewportSize })');
expect(terminalViewSource).toContain('if (!isTerminalVisible) {');
expect(terminalViewSource).not.toContain('isTerminalVisibleRef');
});
test('deduplicates create attempts while the viewport layout settles', () => {
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.has(createKey)');
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.delete(createKey)');
});
test('lets the session-ID effect own stream startup after creating a tab', () => {
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
const createEnd = terminalViewSource.indexOf('if (!terminalId || cancelled) return;', createStart);
expect(createStart).toBeGreaterThan(-1);
expect(createEnd).toBeGreaterThan(createStart);
const createBlock = terminalViewSource.slice(createStart, createEnd);
expect(createBlock).toContain('setTabSessionId(directory, tabId, session.sessionId);');
expect(createBlock).toContain('Let that next');
expect(createBlock).not.toContain('startStream(');
});
test('clears a current tab from connecting when a strict-mode create rejects', () => {
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
const catchStart = terminalViewSource.indexOf('} catch (error) {', createStart);
const catchEnd = terminalViewSource.indexOf('} finally {', catchStart);
expect(catchStart).toBeGreaterThan(createStart);
expect(catchEnd).toBeGreaterThan(catchStart);
const catchBlock = terminalViewSource.slice(catchStart, catchEnd);
expect(catchBlock).toContain('owningTab.terminalSessionId');
expect(catchBlock).toContain('activeTabIdRef.current !== tabId');
expect(catchBlock).toContain('setConnecting(directory, tabId, false);');
expect(catchBlock).not.toContain('if (!cancelled)');
});
test('derives the initial PTY size before Ghostty mounts', () => {
expect(terminalViewportSource).toContain('const getProvisionalTerminalSize');
expect(terminalViewportSource).toContain('React.useLayoutEffect(() => {');
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
});
});
+151
View File
@@ -93,6 +93,157 @@ describe('terminal transport', () => {
transport.dispose();
});
test('invalidates URL auth when the current socket closes before opening', async () => {
const socket = new FakeSocket();
let cleared = 0;
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => socket,
clearUrlAuthToken: () => { cleared += 1; },
});
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
socket.close();
await tick();
expect(cleared).toBe(1);
unsubscribe();
transport.dispose();
});
test('invalidates URL auth before retrying a pre-open socket error', async () => {
const socket = new FakeSocket();
let cleared = 0;
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => socket,
clearUrlAuthToken: () => { cleared += 1; },
});
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
socket.onerror?.();
expect(cleared).toBe(1);
unsubscribe();
transport.dispose();
});
test('does not let a cancelled opening reconnect a replacement subscription', async () => {
const sockets = [new FakeSocket(), new FakeSocket()];
let socketIndex = 0;
const replacementEvents: string[] = [];
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => sockets[socketIndex++]!,
});
const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
unsubscribeFirst();
const unsubscribeReplacement = transport.subscribe('term-1', {
onEvent: (event) => replacementEvents.push(event.type),
});
await tick();
sockets[1]?.open();
await tick();
expect(replacementEvents).not.toContain('reconnecting');
unsubscribeReplacement();
transport.dispose();
});
test('starts a fresh reconnect sequence after every terminal has detached', async () => {
const firstEvents: number[] = [];
const replacementEvents: number[] = [];
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => { throw new Error('offline'); },
});
const unsubscribeFirst = transport.subscribe('term-1', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') firstEvents.push(event.attempt);
},
});
await tick();
await tick();
expect(firstEvents).toEqual([1]);
unsubscribeFirst();
const unsubscribeReplacement = transport.subscribe('term-2', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') replacementEvents.push(event.attempt);
},
});
await tick();
await tick();
expect(replacementEvents).toEqual([1]);
unsubscribeReplacement();
transport.dispose();
});
test('waits a minute before reconnecting while hidden', async () => {
const originalSetTimeout = globalThis.setTimeout;
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const delays: number[] = [];
let transport: TerminalTransport | null = null;
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
visibilityState: 'hidden',
addEventListener: () => {},
removeEventListener: () => {},
},
});
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
delays.push(Number(timeout ?? 0));
if (timeout === 0) return originalSetTimeout(handler, 0, ...args);
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
try {
transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => { throw new Error('offline'); },
});
transport.subscribe('term-1', { onEvent: () => {} });
await tick();
await tick();
expect(delays).toContain(60_000);
} finally {
transport?.dispose();
globalThis.setTimeout = originalSetTimeout;
if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument);
else delete (globalThis as { document?: unknown }).document;
}
});
test('attaches a remaining same-terminal subscriber after the first one leaves', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const unsubscribeOther = transport.subscribe('term-other', { onEvent: () => {} });
await tick();
socket.open();
await tick();
const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} });
const unsubscribeRemaining = transport.subscribe('term-1', { onEvent: () => {} });
unsubscribeFirst();
await tick();
expect(socket.sent.filter((message) => message.t === 'attach' && message.s === 'term-1')).toHaveLength(1);
unsubscribeRemaining();
unsubscribeOther();
transport.dispose();
});
test('releases replay projections when the last subscriber detaches', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
+79 -50
View File
@@ -3,7 +3,7 @@ import { openRuntimeWebSocket } from './relay/runtime-socket';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { refreshRuntimeUrlAuthToken } from './runtime-auth';
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth';
import { isTerminalShell } from './terminalShell';
type Message = Record<string, unknown> & { t: string; s?: string; q?: number };
@@ -66,12 +66,12 @@ const trimProjection = (value: string): string => {
type TerminalTransportDependencies = {
refreshAuth: () => Promise<unknown>;
openSocket: () => RelayTunnelWebSocket;
clearUrlAuthToken?: () => void;
};
export class TerminalTransport {
private socket: RelayTunnelWebSocket | null = null;
private opening: Promise<void> | null = null;
private openingGeneration: number | null = null;
private subscribers = new Map<string, Set<Subscriber>>();
private projections = new Map<string, TerminalProjection>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -85,6 +85,7 @@ export class TerminalTransport {
constructor(private readonly dependencies: TerminalTransportDependencies = {
refreshAuth: refreshRuntimeUrlAuthToken,
openSocket: () => openRuntimeWebSocket(getRuntimeUrlResolver().websocket('/api/terminal/ws')),
clearUrlAuthToken: clearRuntimeUrlAuthToken,
}) {}
subscribe(sessionId: string, handlers: TerminalHandlers): () => void {
@@ -100,7 +101,13 @@ export class TerminalTransport {
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
}
const socketWasOpen = this.socket?.readyState === SOCKET_OPEN;
this.ensureConnected().then(() => { if (first && socketWasOpen && set.has(subscriber)) this.send({ t: 'attach', v: 3, s: sessionId }); }).catch((error) => {
this.ensureConnected().then(() => {
const current = this.subscribers.get(sessionId);
if (first && socketWasOpen && current === set && current.size > 0) {
this.send({ t: 'attach', v: 3, s: sessionId });
}
}).catch((error) => {
if (!set.has(subscriber)) return;
handlers.onError?.(error, false);
this.scheduleReconnect();
});
@@ -114,6 +121,7 @@ export class TerminalTransport {
}
if (this.subscribers.size === 0) {
this.cancelReconnect();
this.failures = 0;
if (this.socket?.readyState === SOCKET_OPEN) {
// Healthy socket: hold it briefly so a tab switch can reattach to it.
this.scheduleIdleClose();
@@ -121,6 +129,7 @@ export class TerminalTransport {
}
// Nothing to reuse, so abandon any dial that is still in flight.
this.generation += 1;
this.opening = null;
this.closeSocket();
}
};
@@ -138,6 +147,7 @@ export class TerminalTransport {
dispose(): void {
this.disposed = true;
this.generation += 1;
this.opening = null;
this.subscribers.clear();
this.projections.clear();
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
@@ -155,71 +165,89 @@ export class TerminalTransport {
private async ensureConnected(): Promise<void> {
if (this.disposed) throw new Error('Terminal runtime changed');
if (this.socket?.readyState === SOCKET_OPEN) return;
if (this.opening && this.openingGeneration === this.generation) {
if (this.opening) {
await this.opening;
if (this.socket?.readyState === SOCKET_OPEN) return;
return this.ensureConnected();
}
if (this.openingGeneration !== this.generation) {
this.opening = null;
this.openingGeneration = null;
}
const generation = this.generation;
const opening = (async () => {
await this.dependencies.refreshAuth();
if (generation !== this.generation || this.disposed) throw new Error('Terminal runtime changed');
await new Promise<void>((resolve, reject) => {
let settled = false;
let pendingSocket: RelayTunnelWebSocket | null = null;
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error);
else resolve();
};
const timeout = setTimeout(() => {
pendingSocket?.close();
finish(new Error('Terminal connection timed out'));
}, 10_000);
try {
const socket = this.dependencies.openSocket();
pendingSocket = socket;
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onopen = () => {
if (generation !== this.generation || this.disposed) { socket.close(); finish(new Error('Terminal runtime changed')); return; }
this.failures = 0;
this.send({ t: 'hello', v: 3 });
for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId });
this.startKeepalive();
finish();
let settled = false;
let opened = false;
let authInvalidated = false;
let pendingSocket: RelayTunnelWebSocket | null = null;
const isCurrentSocket = () => (
generation === this.generation &&
!this.disposed &&
pendingSocket !== null &&
this.socket === pendingSocket
);
const invalidatePreOpenAuth = () => {
if (authInvalidated || opened || !isCurrentSocket()) return;
authInvalidated = true;
this.dependencies.clearUrlAuthToken?.();
};
socket.onmessage = (event) => void this.handleMessage(event.data);
socket.onerror = () => {
finish(new Error('Terminal WebSocket failed'));
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error);
else resolve();
};
const timeout = setTimeout(() => {
invalidatePreOpenAuth();
pendingSocket?.close();
finish(new Error('Terminal connection timed out'));
}, 10_000);
try {
const socket = this.dependencies.openSocket();
pendingSocket = socket;
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onopen = () => {
if (!isCurrentSocket()) { socket.close(); finish(new Error('Terminal runtime changed')); return; }
opened = true;
this.failures = 0;
this.send({ t: 'hello', v: 3 });
for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId });
this.startKeepalive();
finish();
};
socket.onmessage = (event) => void this.handleMessage(event.data);
socket.onerror = () => {
const current = isCurrentSocket();
if (current) invalidatePreOpenAuth();
finish(new Error('Terminal WebSocket failed'));
if (current && this.subscribers.size > 0) this.scheduleReconnect();
};
socket.onclose = () => {
const current = isCurrentSocket();
if (current) {
this.stopKeepalive();
// An upgrade rejected before `open` commonly means the cached
// URL-scoped auth token is stale. Retrying it reaches the 8s
// backoff cap instead of minting a fresh token.
invalidatePreOpenAuth();
}
if (this.socket === socket) this.socket = null;
finish(new Error('Terminal WebSocket closed'));
if (current && this.subscribers.size > 0) this.scheduleReconnect();
};
} catch (error) {
finish(error instanceof Error ? error : new Error('Terminal WebSocket failed'));
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
};
socket.onclose = () => {
if (this.socket === socket) this.socket = null;
this.stopKeepalive();
finish(new Error('Terminal WebSocket closed'));
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
};
} catch (error) {
finish(error instanceof Error ? error : new Error('Terminal WebSocket failed'));
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
}
}
});
})();
this.opening = opening;
this.openingGeneration = generation;
try {
await opening;
} finally {
if (this.opening === opening) {
this.opening = null;
this.openingGeneration = null;
}
}
}
@@ -279,7 +307,7 @@ export class TerminalTransport {
if (this.reconnectTimer || this.disposed || this.subscribers.size === 0) return;
this.failures += 1;
const slow = (typeof document !== 'undefined' && document.visibilityState === 'hidden') || (typeof navigator !== 'undefined' && !navigator.onLine);
const delay = Math.min(500 * 2 ** Math.min(this.failures - 1, 10), slow ? 60_000 : 8_000);
const delay = slow ? 60_000 : Math.min(500 * 2 ** Math.min(this.failures - 1, 10), 8_000);
for (const set of this.subscribers.values()) for (const sub of set) sub.handlers.onEvent({ type: 'reconnecting', attempt: this.failures, maxAttempts: Number.POSITIVE_INFINITY });
const wake = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
@@ -304,6 +332,7 @@ export class TerminalTransport {
this.idleCloseTimer = null;
if (this.disposed || this.subscribers.size > 0) return;
this.generation += 1;
this.opening = null;
this.closeSocket();
}, IDLE_SOCKET_GRACE_MS);
}
@@ -10,11 +10,12 @@
- `attach` registers a connection for one terminal. One socket may attach to many terminals.
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence.
- A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery.
- `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
- Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
- `write` always includes the terminal ID; sockets never have mutable single-terminal binding state.
- `detach` removes only that attachment.
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, and Mode 2031 queries immediately, including queries emitted before a WebSocket attachment exists. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, Mode 2031, and primary-device-attribute queries immediately, including queries emitted before a WebSocket attachment exists. The DA1 fallback prevents Fish from waiting ten seconds for a renderer that cannot observe or answer its startup query. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
@@ -23,6 +24,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
- IDs are client-provided or generated with `randomUUID()`.
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
+2 -2
View File
@@ -145,7 +145,7 @@ export function createTerminalRuntime({
background: session.terminalBackground,
foreground: session.terminalForeground,
modeEnabled: session.themeModeEnabled,
});
}, { respondToPrimaryDeviceAttributes: true });
session.pendingThemeControlSequence = theme.pending;
session.themeModeEnabled = theme.modeEnabled;
for (const response of theme.responses) session.process?.write(response);
@@ -331,7 +331,7 @@ export function createTerminalRuntime({
session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.cwd = cwd; session.cols = cols; session.rows = rows;
session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; session.status = 'running'; session.exitCode = null; session.signal = null; session.eventQueue.length = 0;
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
});
pendingSessionRestarts.set(session.id, restart);
try {
@@ -154,8 +154,8 @@ describe('terminal runtime', () => {
expect(harness.processes[0].options.cwd).toBe('/repo');
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']);
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
const appearance = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance);
@@ -2,11 +2,21 @@ const MODE_SET = '\u001b[?2031h';
const MODE_RESET = '\u001b[?2031l';
const CAPABILITY_QUERY = '\u001b[?2031$p';
const MODE_QUERIES = ['\u001b[?996n', '\u001b[?997n'];
// Fish asks this before an unattached browser terminal can reply.
const PRIMARY_DEVICE_ATTRIBUTE_QUERIES = ['\u001b[c', '\u001b[0c'];
const PRIMARY_DEVICE_ATTRIBUTE_RESPONSE = '\u001b[?1;2c';
const OSC_QUERIES = [10, 11].flatMap((code) => [
{ sequence: `\u001b]${code};?\u0007`, code },
{ sequence: `\u001b]${code};?\u001b\\`, code },
]);
const CONTROL_SEQUENCES = [MODE_SET, MODE_RESET, CAPABILITY_QUERY, ...MODE_QUERIES, ...OSC_QUERIES.map(({ sequence }) => sequence)];
const CONTROL_SEQUENCES = [
MODE_SET,
MODE_RESET,
CAPABILITY_QUERY,
...MODE_QUERIES,
...PRIMARY_DEVICE_ATTRIBUTE_QUERIES,
...OSC_QUERIES.map(({ sequence }) => sequence),
];
const parseColor = (value) => {
if (typeof value !== 'string') return null;
@@ -28,7 +38,12 @@ const colorReport = (code, color) => {
export const terminalThemeModeReport = (themeMode) => `\u001b[?997;${themeMode === 'light' ? 2 : 1}n`;
export const consumeTerminalThemeQueries = (pending, data, appearance) => {
export const consumeTerminalThemeQueries = (
pending,
data,
appearance,
{ respondToPrimaryDeviceAttributes = false } = {},
) => {
if (!pending && !data.includes('\u001b')) return { pending: '', responses: [], modeEnabled: appearance.modeEnabled === true };
const input = `${pending}${data}`;
const responses = [];
@@ -56,6 +71,15 @@ export const consumeTerminalThemeQueries = (pending, data, appearance) => {
index += modeQuery.length - 1;
continue;
}
const primaryDeviceAttributeQuery = PRIMARY_DEVICE_ATTRIBUTE_QUERIES.find((query) => input.startsWith(query, index));
if (primaryDeviceAttributeQuery && respondToPrimaryDeviceAttributes) {
// A shell can ask before any browser terminal is attached. Answer with a
// conservative VT100 DA1 response so Fish does not block startup for its
// ten-second query timeout while waiting for a renderer that cannot see it.
responses.push(PRIMARY_DEVICE_ATTRIBUTE_RESPONSE);
index += primaryDeviceAttributeQuery.length - 1;
continue;
}
const oscQuery = OSC_QUERIES.find(({ sequence }) => input.startsWith(sequence, index));
if (oscQuery) {
const response = colorReport(oscQuery.code, oscQuery.code === 10 ? appearance.foreground : appearance.background);
@@ -44,4 +44,27 @@ describe('terminal theme responses', () => {
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
]);
});
test('answers a primary device attribute query when the fallback is enabled', () => {
const attached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance);
const unattached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance, {
respondToPrimaryDeviceAttributes: true,
});
expect(attached.responses).toEqual([]);
expect(unattached.responses).toEqual(['\u001b[?1;2c']);
});
test('answers a primary device attribute query split across PTY chunks', () => {
const first = consumeTerminalThemeQueries('', '\u001b[0', lightAppearance, {
respondToPrimaryDeviceAttributes: true,
});
const second = consumeTerminalThemeQueries(first.pending, 'c', {
...lightAppearance,
modeEnabled: first.modeEnabled,
}, { respondToPrimaryDeviceAttributes: true });
expect(first.pending).toBe('\u001b[0');
expect(second.responses).toEqual(['\u001b[?1;2c']);
});
});