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:
@@ -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