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:
Serhii Dziupin
2026-08-03 12:29:32 +03:00
parent aa1875b6f0
commit 88937ade72
12 changed files with 421 additions and 99 deletions
+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 });