mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
perf(capture): a grid tile's full capture reads no more history than it keeps
GET /api/sessions/:id/terminal?full=1 captured the whole tmux history (capture-pane -S -<history limit>, 100,000 lines by default) and cut it to `tail` only afterwards, all of it synchronous on the server's event loop. A grid tile keeps TILE_SCROLLBACK lines plus its screen, so the rest was captured to be thrown away, once per tile on every grid open, restore and deploy reconnect. The route now takes an optional `lines=<n>` (an integer of at least 1, clamped to the configured history limit) and passes it as the capture's history bound (the existing historyLimitLines, so -S -<n>); absent or malformed, the limit itself, so every existing caller gets the same capture as before. Only full captures read it: the visible-frame path (a shell tile's `tail=` load) reads no history and is untouched. The capture still ends with its RELATIVE cursor move back to the caret, still counts as a full capture (isFullCapture: the line-deleting transforms stay off) and still reports captureCols/captureRows. Grid tiles (boundedLoad) send lines=<scrollback + rows> on every full capture of theirs: a TUI load and a shell history pull. The split's Pane B asks for everything, as before. Measured: - A real haiku Claude pane on tileperf (about 3k lines of history): bounded captures (lines=50, 500, 2000, 100000) against the unbounded one, 4 PASS 0 FAIL: each a line-aligned suffix of it, ending in the same relative cursor move (ESC[4A CR ESC[2C), same source (mux-full-history) and capture geometry. Capture time there 72 ms both ways, that history being shorter than the tile's bound. As a grid tile (it sent lines=10047) its screen matched the pane row for row, 47 of 47 at the pane's own 77x47, caret on the composer. - Six tiles restoring with Claude-style loads (full=1&tail=1MiB forced on shells with about 19k lines of tmux history each, above the tile's bound; n=3+3 interleaved, load 4.2 to 7.2): capture per tile med 219 ms [194 to 294] -> 155 ms [127 to 211]; server event-loop delay in the capture window, max med 262 -> 201 ms; all painted 4.5 -> 3.6 s. At checkpoint 1 a 30k-line history cost 713 ms per capture (event loop blocked up to 765 ms each); the bound caps that at the tile's size. Tests: the route passes lines= through, clamps it, ignores every malformed form and leaves the visible-frame capture exactly as it was; a bounded capture keeps its rows and ends in the cursor restore; grid tiles send it on full captures and the split's Pane B does not. Mutation-checked six ways (lines ignored, no clamp, a lenient parse, lines on the visible path, the tile sending none, Pane B sending it). Documented in docs/api-reference.md (/api/v1 is public). Scope: PR 2 (the grid's loads; server route plus terminal-tile.js). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -445,6 +445,20 @@ count against the same 16, not 16 of each. An abandoned request no longer holds
|
|||||||
slot, because the routes release the waiter when the client disconnects, but a
|
slot, because the routes release the waiter when the client disconnects, but a
|
||||||
client that opens many concurrent waits against one session will still hit the cap.
|
client that opens many concurrent waits against one session will still hit the cap.
|
||||||
|
|
||||||
|
## Terminal capture (`GET /api/v1/sessions/:id/terminal`)
|
||||||
|
|
||||||
|
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
|
||||||
|
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
|
||||||
|
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
|
||||||
|
geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||||
|
`Server-Timing` header reports `capture`, `prepare` and `total`.
|
||||||
|
|
||||||
|
| Query | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
|
||||||
|
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
|
||||||
|
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||||
|
|
||||||
## Session lineage (`parentSessionId`)
|
## Session lineage (`parentSessionId`)
|
||||||
|
|
||||||
A create request may name the session that spawned it, which the web UI draws as a
|
A create request may name the session that spawned it, which the web UI draws as a
|
||||||
|
|||||||
@@ -638,7 +638,7 @@
|
|||||||
}
|
}
|
||||||
const shell = this.sessionMode === 'shell';
|
const shell = this.sessionMode === 'shell';
|
||||||
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||||
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}`;
|
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
|
||||||
// A deadline covering the body as well as the headers (the primary
|
// A deadline covering the body as well as the headers (the primary
|
||||||
// pane's budgets, CodemanFetchDeadline): a capture that never answers
|
// pane's budgets, CodemanFetchDeadline): a capture that never answers
|
||||||
// would otherwise hold this pane's single-flight flag, and in the grid
|
// would otherwise hold this pane's single-flight flag, and in the grid
|
||||||
@@ -807,9 +807,10 @@
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
const res = await fetch(
|
||||||
signal: controller?.signal,
|
`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`,
|
||||||
});
|
{ signal: controller?.signal }
|
||||||
|
);
|
||||||
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
||||||
// The cutoff below is the response's arrival, the same `since` rule the
|
// The cutoff below is the response's arrival, the same `since` rule the
|
||||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
||||||
@@ -893,6 +894,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A bounded load's `lines=` (grid tiles): tmux history beyond what this
|
||||||
|
// xterm keeps (its scrollback plus the screen) would only be captured to be
|
||||||
|
// thrown away, and a full capture is synchronous work on the server, about
|
||||||
|
// 0.7 s for a 30k-line history. Unbounded panes (the split's Pane B) ask
|
||||||
|
// for everything, as before.
|
||||||
|
_historyLinesQuery() {
|
||||||
|
if (!this.boundedLoad || !Number.isFinite(this.scrollback)) return '';
|
||||||
|
return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||||
// frames in a row used to start two concurrent replays, each clearing
|
// frames in a row used to start two concurrent replays, each clearing
|
||||||
// the terminal under the other's chunked write. A refresh that arrives
|
// the terminal under the other's chunked write. A refresh that arrives
|
||||||
|
|||||||
@@ -775,6 +775,20 @@ export function resolveOmpConfigForCreate(
|
|||||||
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tmux history lines a full capture may read (`capture-pane -S -<n>`): the
|
||||||
|
* optional `lines` query parameter, an integer of at least 1, never more than
|
||||||
|
* the configured history limit; absent or malformed, the limit itself, as
|
||||||
|
* before. A grid tile sends its own scrollback size: its xterm keeps no more
|
||||||
|
* than that, while a capture of the whole history (tens of thousands of lines,
|
||||||
|
* cut to `tail` only afterwards) is synchronous work on this event loop.
|
||||||
|
*/
|
||||||
|
function captureHistoryLines(raw: string | undefined, historyLimit: number): number {
|
||||||
|
if (typeof raw !== 'string' || !/^\d{1,9}$/.test(raw)) return historyLimit;
|
||||||
|
const lines = Number(raw);
|
||||||
|
return lines >= 1 ? Math.min(lines, historyLimit) : historyLimit;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
|
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
|
||||||
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
|
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
|
||||||
@@ -2986,7 +3000,7 @@ export function registerSessionRoutes(
|
|||||||
app.get('/api/sessions/:id/terminal', async (req, reply) => {
|
app.get('/api/sessions/:id/terminal', async (req, reply) => {
|
||||||
const routeStartedAt = performance.now();
|
const routeStartedAt = performance.now();
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const query = req.query as { tail?: string; full?: string };
|
const query = req.query as { tail?: string; full?: string; lines?: string };
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
|
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
|
||||||
@@ -3010,8 +3024,14 @@ export function registerSessionRoutes(
|
|||||||
// single reason: `capturedGeometry` comes BACK on it, and the response has
|
// single reason: `capturedGeometry` comes BACK on it, and the response has
|
||||||
// to tell the client what size the frame it is about to render was built
|
// to tell the client what size the frame it is about to render was built
|
||||||
// for. See PaneCaptureOptions.capturedGeometry.
|
// for. See PaneCaptureOptions.capturedGeometry.
|
||||||
|
// `lines` bounds only the history a FULL capture reads; the visible-frame
|
||||||
|
// path reads no history and is untouched by it.
|
||||||
const captureOpts: PaneCaptureOptions = isFullReload
|
const captureOpts: PaneCaptureOptions = isFullReload
|
||||||
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
? {
|
||||||
|
fullHistory: true,
|
||||||
|
historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
|
||||||
|
maxCaptureBytes: terminalBufferMaxBytes,
|
||||||
|
}
|
||||||
: {};
|
: {};
|
||||||
const liveMuxBuffer =
|
const liveMuxBuffer =
|
||||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||||
|
|||||||
@@ -1037,6 +1037,62 @@ describe('session-routes', () => {
|
|||||||
expect(body.data.terminalBuffer.endsWith(`${newestMarker}${cursorRestore}`)).toBe(true);
|
expect(body.data.terminalBuffer.endsWith(`${newestMarker}${cursorRestore}`)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('`lines=` bounds the history a full capture reads, clamped to the configured limit', async () => {
|
||||||
|
// Grid tiles send it (TerminalTile._historyLinesQuery): without it a full
|
||||||
|
// capture reads the whole history limit synchronously, and `tail` cuts it
|
||||||
|
// only afterwards. Absent or malformed, the limit itself, as before.
|
||||||
|
const limit = (await harness.ctx.getTerminalHistoryConfig()).tmuxHistoryLimit;
|
||||||
|
harness.ctx._session.mode = 'claude';
|
||||||
|
const captureSpy = vi.fn((_name: string, _opts?: { historyLimitLines?: number }) => 'captured frame');
|
||||||
|
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||||
|
const linesFor = async (query: string) => {
|
||||||
|
captureSpy.mockClear();
|
||||||
|
await harness.app.inject({ method: 'GET', url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}` });
|
||||||
|
return captureSpy.mock.calls[0]?.[1]?.historyLimitLines;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(await linesFor(`full=1&tail=${1024 * 1024}&lines=10040`)).toBe(10040);
|
||||||
|
expect(await linesFor('full=1&lines=1')).toBe(1);
|
||||||
|
expect(await linesFor('full=1')).toBe(limit);
|
||||||
|
expect(await linesFor(`full=1&lines=${limit + 5}`)).toBe(limit);
|
||||||
|
for (const bad of ['0', '-5', 'abc', '1.5', '', '1e4', '9999999999']) {
|
||||||
|
expect(await linesFor(`full=1&lines=${bad}`), `lines=${bad}`).toBe(limit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`lines=` leaves a visible-frame capture (no full=1) exactly as it was', async () => {
|
||||||
|
harness.ctx._session.mode = 'shell';
|
||||||
|
const captureSpy = vi.fn(() => 'only the visible frame');
|
||||||
|
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||||
|
await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=${1024 * 1024}&lines=500`,
|
||||||
|
});
|
||||||
|
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a capture bounded by `lines=` is still a full capture: rows kept, cursor restore last', async () => {
|
||||||
|
// Only how much history tmux reads changes. The row-preserving skips key
|
||||||
|
// on isFullCapture, and the relative cursor move must still end it.
|
||||||
|
const cursorRestore = '\x1b[3A\r\x1b[2C';
|
||||||
|
const capture = `\r\n${['first row', 'second row', 'last row'].join('\r\n')}${cursorRestore}`;
|
||||||
|
harness.ctx._session.mode = 'claude';
|
||||||
|
harness.ctx._session.terminalBuffer = 'byte history that must not be prepended';
|
||||||
|
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
||||||
|
opts?.fullHistory ? capture : 'only the visible frame'
|
||||||
|
);
|
||||||
|
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${1024 * 1024}&lines=40`,
|
||||||
|
});
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.source).toBe('mux-full-history');
|
||||||
|
expect(body.data.terminalBuffer.startsWith('\r\nfirst row')).toBe(true);
|
||||||
|
expect(body.data.terminalBuffer.endsWith(`last row${cursorRestore}`)).toBe(true);
|
||||||
|
expect(body.data.terminalBuffer).not.toContain('byte history');
|
||||||
|
});
|
||||||
|
|
||||||
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
||||||
// The full-history capture is the rendered form of everything already in
|
// The full-history capture is the rendered form of everything already in
|
||||||
// the byte buffer; prepending the byte history would replay the whole
|
// the byte buffer; prepending the byte history would replay the whole
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ vm.runInContext(
|
|||||||
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||||
const TileGrid = windowStub.CodemanTileGrid as { TILE_SCROLLBACK: number };
|
const TileGrid = windowStub.CodemanTileGrid as { TILE_SCROLLBACK: number };
|
||||||
const TAIL = 1024 * 1024;
|
const TAIL = 1024 * 1024;
|
||||||
|
// A grid tile's full captures read no more tmux history than its xterm keeps:
|
||||||
|
// TILE_SCROLLBACK plus the screen (the fake terminal has 24 rows).
|
||||||
|
const LINES = `&lines=${TileGrid.TILE_SCROLLBACK + 24}`;
|
||||||
|
|
||||||
type Tile = {
|
type Tile = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -292,7 +295,7 @@ describe('initial loads', () => {
|
|||||||
await drain();
|
await drain();
|
||||||
await Promise.all(connecting);
|
await Promise.all(connecting);
|
||||||
expect(captures.map((c) => c.url)).toEqual([
|
expect(captures.map((c) => c.url)).toEqual([
|
||||||
`/api/sessions/tui/terminal?full=1&tail=${TAIL}`,
|
`/api/sessions/tui/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||||
`/api/sessions/sh/terminal?tail=${TAIL}`,
|
`/api/sessions/sh/terminal?tail=${TAIL}`,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -361,9 +364,9 @@ describe('refreshes', () => {
|
|||||||
expect(inFlight()).toBe(1);
|
expect(inFlight()).toBe(1);
|
||||||
await drain();
|
await drain();
|
||||||
expect(captures.map((c) => c.url)).toEqual([
|
expect(captures.map((c) => c.url)).toEqual([
|
||||||
`/api/sessions/a/terminal?full=1&tail=${TAIL}`,
|
`/api/sessions/a/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||||
`/api/sessions/sh/terminal?full=1&tail=${TAIL}`,
|
`/api/sessions/sh/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||||
`/api/sessions/b/terminal?full=1&tail=${TAIL}`,
|
`/api/sessions/b/terminal?full=1&tail=${TAIL}${LINES}`,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user