fix(terminal): start PTY before viewport mounts without dropping output or startup replies
Terminal creation no longer waits for the Ghostty viewport to report its size: it starts the PTY immediately with a container/font-derived provisional size (falling back to 80x24), then resizes once the real viewport dimensions are known, with a dedupe guard while sizing settles. Starting the shell earlier means it can emit device/theme queries before a browser terminal is attached to answer them, so the server now answers primary device attribute queries itself (Fish blocks ~10s on this at startup) and bun-pty buffers output emitted before a data subscriber attaches. Also fixes a few WebSocket transport reconnect races surfaced by session creation now overlapping renderer setup.
This commit is contained in:
@@ -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 {
|
||||
@@ -354,6 +354,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
@@ -178,6 +178,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,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);
|
||||
@@ -487,18 +487,27 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
) {
|
||||
void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {});
|
||||
}
|
||||
terminalId = session.sessionId;
|
||||
// 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) {
|
||||
if (!cancelled) {
|
||||
setConnectionError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('terminalView.error.startSessionFailed')
|
||||
);
|
||||
setIsFatalError(true);
|
||||
setIsReconnectPending(false);
|
||||
setConnecting(directory, tabId, false);
|
||||
}
|
||||
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);
|
||||
@@ -706,9 +715,7 @@ 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 };
|
||||
} else if (previous.cols !== cols || previous.rows !== rows) {
|
||||
if (!previous || previous.cols !== cols || previous.rows !== rows) {
|
||||
lastViewportSizeRef.current = { cols, rows };
|
||||
}
|
||||
if (!isTerminalVisible) {
|
||||
@@ -716,9 +723,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
}
|
||||
const terminalId = terminalIdRef.current;
|
||||
if (!terminalId) return;
|
||||
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {
|
||||
|
||||
});
|
||||
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {});
|
||||
},
|
||||
[isTerminalVisible, terminal]
|
||||
);
|
||||
@@ -812,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) {
|
||||
|
||||
@@ -27,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', () => {
|
||||
@@ -80,6 +68,32 @@ describe('terminal viewport remount guard', () => {
|
||||
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(() => {');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user