mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
perf(terminal): tab switch to a pane with no scrollback loads only its screen
After a session's first load per page, a tab switch fetched a 1 MiB tail of the server's byte stream. For a fullscreen claude pane (alternate screen, tmux `history_size` 0) that tail is old repaints of one frame, and an idle tab parsed it twice (the cached copy, then the fresh one). Every terminal response now records `paneHistoryLines` per session, and `selectSession` takes `full=1` whenever the last report was 0. For such a pane that capture is the visible frame, a few KB, which is also what a page load already shows. A response without the field forgets the entry, so unknown never counts as empty; a pane that starts keeping history goes back to the tail on the next switch. Shells and the refresh path are unchanged. Measured with real prod payloads through the real client in chromium: median switch 1.26-1.66 s -> 0.10-0.19 s, parse ~1.1-1.5 s -> 2-3 ms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e2453fe97b
commit
df1a493b8c
8 files changed
+323
-2
No files matched your search
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @fileoverview A tab switch to a pane with no tmux scrollback takes the small capture.
|
||||
*
|
||||
* After the first select of a page, a tab switch to a non-shell session used to
|
||||
* fetch a 1 MiB tail of the server's byte stream. For a fullscreen claude pane
|
||||
* (alternate screen, `#{history_size}` 0) that tail is old repaints of one frame:
|
||||
* measured on live panes it cost 250-1070 ms on the server and 70-510 ms to
|
||||
* parse, and an idle tab parsed it twice (the cached copy, then the fresh one,
|
||||
* which never matched). The `full=1` capture of such a pane is the visible
|
||||
* frame, a few KB, so `selectSession` now takes it whenever the session's last
|
||||
* capture reported `paneHistoryLines: 0`, and goes back to the tail as soon as a
|
||||
* capture reports scrollback again.
|
||||
*
|
||||
* Drives the real client in chromium against a testMode server, with the
|
||||
* terminal route stubbed (the real one needs live tmux to report a hollow pane).
|
||||
*
|
||||
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
|
||||
*
|
||||
* Run: npm run test:browser -- test/fullscreen-tab-switch-capture.browser.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true); // testMode
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
}, 30_000);
|
||||
|
||||
/** What each session's pane reports, set by the test as the "pane" changes. */
|
||||
type PaneHistory = Record<string, number | undefined>;
|
||||
|
||||
/**
|
||||
* Serve every terminal fetch from a stub and log which shape was asked for.
|
||||
* `source` follows the real route: `full=1` answers `mux-full-history`, anything
|
||||
* else `mux-visible`. No capture geometry, so the geometry retry stays out of it.
|
||||
*/
|
||||
async function stubTerminal(page: Page, history: PaneHistory, log: string[]) {
|
||||
await page.route('**/api/sessions/*/terminal*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const id = url.pathname.split('/')[3];
|
||||
const full = url.searchParams.get('full') === '1';
|
||||
log.push(`${id}:${full ? 'full' : 'tail'}`);
|
||||
const frame = `frame of ${id}\r\n`;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
terminalBuffer: full ? frame : `${'old repaint\r\n'.repeat(50)}${frame}`,
|
||||
status: 'idle',
|
||||
fullSize: full ? frame.length : 5 * 1024 * 1024,
|
||||
retainedBytes: full ? frame.length : 1024 * 1024,
|
||||
truncated: !full,
|
||||
truncationReason: full ? null : 'tail',
|
||||
source: full ? 'mux-full-history' : 'mux-visible',
|
||||
paneHistoryLines: history[id],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openApp(page: Page): Promise<void> {
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
|
||||
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function createSession(page: Page, name: string): Promise<string> {
|
||||
return page.evaluate(async (n) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', name: n }),
|
||||
});
|
||||
const body = await res.json();
|
||||
return body.data?.session?.id ?? body.data?.id ?? body.id;
|
||||
}, name);
|
||||
}
|
||||
|
||||
async function select(page: Page, sessionId: string): Promise<void> {
|
||||
await page.evaluate(async (sid) => {
|
||||
const app = (window as unknown as { app: { selectSession: (id: string) => Promise<void> } }).app;
|
||||
await app.selectSession(sid);
|
||||
}, sessionId);
|
||||
}
|
||||
|
||||
async function deleteSession(page: Page, sessionId: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
|
||||
describe('tab switch to a pane that keeps no scrollback', () => {
|
||||
it('takes the small full capture on every switch, and the tail again once the pane keeps history', async () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
await openApp(page);
|
||||
const hollow = await createSession(page, 'fullscreen-claude');
|
||||
const inline = await createSession(page, 'inline-claude');
|
||||
const history: PaneHistory = { [hollow]: 0, [inline]: 40_000 };
|
||||
const log: string[] = [];
|
||||
await stubTerminal(page, history, log);
|
||||
|
||||
// First select of each on this page: the canonical full replay, as before.
|
||||
await select(page, hollow);
|
||||
await select(page, inline);
|
||||
expect(log).toEqual([`${hollow}:full`, `${inline}:full`]);
|
||||
|
||||
// Switching back: the hollow pane takes the frame, the inline one the tail.
|
||||
log.length = 0;
|
||||
await select(page, hollow);
|
||||
await select(page, inline);
|
||||
await select(page, hollow);
|
||||
expect(log).toEqual([`${hollow}:full`, `${inline}:tail`, `${hollow}:full`]);
|
||||
|
||||
// The pane starts keeping history (claude switched to its inline view). The
|
||||
// switch that learns it is still a full capture, which is the canonical
|
||||
// load anyway; the one after goes back to the bounded tail.
|
||||
history[hollow] = 1200;
|
||||
log.length = 0;
|
||||
await select(page, inline);
|
||||
await select(page, hollow);
|
||||
await select(page, inline);
|
||||
await select(page, hollow);
|
||||
expect(log).toEqual([`${inline}:tail`, `${hollow}:full`, `${inline}:tail`, `${hollow}:tail`]);
|
||||
|
||||
// Unknown is never treated as empty: a response without the field (an
|
||||
// older server, a byte-history fallback) keeps the tail.
|
||||
history[hollow] = 0;
|
||||
log.length = 0;
|
||||
await select(page, inline);
|
||||
await select(page, hollow); // learns 0 from this tail response
|
||||
history[hollow] = undefined;
|
||||
await select(page, inline);
|
||||
await select(page, hollow); // last report was 0: frame, which reports nothing
|
||||
await select(page, inline);
|
||||
await select(page, hollow); // forgot it: tail
|
||||
expect(log).toEqual([
|
||||
`${inline}:tail`,
|
||||
`${hollow}:tail`,
|
||||
`${inline}:tail`,
|
||||
`${hollow}:full`,
|
||||
`${inline}:tail`,
|
||||
`${hollow}:tail`,
|
||||
]);
|
||||
|
||||
// What the hollow switch actually paints: the frame, without the repaints.
|
||||
history[hollow] = 0;
|
||||
await select(page, inline);
|
||||
await select(page, hollow); // learns 0
|
||||
await select(page, inline);
|
||||
await select(page, hollow);
|
||||
const screen = await page.evaluate(() => {
|
||||
const t = (window as unknown as { app: { terminal: any } }).app.terminal;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < t.buffer.active.length; i++) lines.push(t.buffer.active.getLine(i)?.translateToString(true));
|
||||
return lines.join('\n');
|
||||
});
|
||||
expect(screen).toContain(`frame of ${hollow}`);
|
||||
expect(screen).not.toContain('old repaint');
|
||||
|
||||
await deleteSession(page, hollow);
|
||||
await deleteSession(page, inline);
|
||||
await context.close();
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -188,7 +188,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
||||
|
||||
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
||||
// A shell never takes the full capture on a tab switch. A TUI takes it on its
|
||||
// first select per page, and on every select while its pane keeps no tmux
|
||||
// scrollback (behaviour pinned in fullscreen-tab-switch-capture.browser.test.ts).
|
||||
expect(app).toContain(
|
||||
"session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId))"
|
||||
);
|
||||
expect(app).toContain('const paneKeepsNoHistory = this._paneHistoryLines?.get(sessionId) === 0;');
|
||||
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
||||
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
|
||||
// Every terminal capture now goes through _fetchTerminalCapture, which adds
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @fileoverview What the client remembers about a pane's tmux scrollback.
|
||||
*
|
||||
* `selectSession` serves a tab switch from the small `full=1` capture when the
|
||||
* session's last terminal response reported `paneHistoryLines: 0` (a fullscreen
|
||||
* CLI in the alternate screen), instead of the 1 MiB byte tail. The memory that
|
||||
* decides it must treat ONLY a reported 0 as empty: a missing field (an older
|
||||
* server, a byte-history fallback) has to forget, so the tab switch goes back to
|
||||
* the bounded tail rather than trusting a stale answer.
|
||||
*
|
||||
* Runs the real `_notePaneHistory` / `_clearHistoryTruncation` from app.js in a
|
||||
* `vm`. The switch itself is driven end to end in
|
||||
* fullscreen-tab-switch-capture.browser.test.ts (browser suite).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const APP = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
|
||||
function methodSource(source: string, method: string): string {
|
||||
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
|
||||
expect(start, `${method} not found`).toBeGreaterThan(-1);
|
||||
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
|
||||
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
|
||||
}
|
||||
|
||||
function makeApp() {
|
||||
const methods = vm.runInContext(
|
||||
`({ ${methodSource(APP, '_notePaneHistory')}, ${methodSource(APP, '_clearHistoryTruncation')} })`,
|
||||
vm.createContext({})
|
||||
) as Record<string, (...args: unknown[]) => void>;
|
||||
return {
|
||||
activeSessionId: null,
|
||||
_paneHistoryLines: new Map<string, number>(),
|
||||
_renderHistoryTruncationBanner() {},
|
||||
...methods,
|
||||
} as Record<string, any>;
|
||||
}
|
||||
|
||||
describe('_notePaneHistory', () => {
|
||||
it('records the count a capture reported, 0 included', () => {
|
||||
const app = makeApp();
|
||||
app._notePaneHistory('s1', { paneHistoryLines: 0 });
|
||||
app._notePaneHistory('s2', { paneHistoryLines: 40000 });
|
||||
expect(app._paneHistoryLines.get('s1')).toBe(0);
|
||||
expect(app._paneHistoryLines.get('s2')).toBe(40000);
|
||||
});
|
||||
|
||||
it('forgets on a response without a usable count, so unknown never reads as empty', () => {
|
||||
const app = makeApp();
|
||||
for (const payload of [{}, { paneHistoryLines: null }, { paneHistoryLines: NaN }, { paneHistoryLines: -1 }, null]) {
|
||||
app._notePaneHistory('s1', { paneHistoryLines: 0 });
|
||||
app._notePaneHistory('s1', payload);
|
||||
expect(app._paneHistoryLines.has('s1')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a call without a session', () => {
|
||||
const app = makeApp();
|
||||
app._notePaneHistory(null, { paneHistoryLines: 0 });
|
||||
expect(app._paneHistoryLines.size).toBe(0);
|
||||
});
|
||||
|
||||
it('is dropped with the session', () => {
|
||||
const app = makeApp();
|
||||
app._notePaneHistory('s1', { paneHistoryLines: 0 });
|
||||
app._clearHistoryTruncation('s1');
|
||||
expect(app._paneHistoryLines.has('s1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every terminal response feeds it (static guard)', () => {
|
||||
it('the tab switch, the refresh and the history re-pull all record the pane', () => {
|
||||
expect(methodSource(APP, 'selectSession')).toContain('this._notePaneHistory?.(sessionId, data);');
|
||||
expect(methodSource(APP, '_onSessionNeedsRefresh')).toContain('this._notePaneHistory?.(sessionId, data);');
|
||||
expect(methodSource(APP, '_maybeRefetchFullHistory')).toContain('this._notePaneHistory?.(sessionId, payload);');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user