mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #436 from irisitymichaelgrundberg/fix/replay-output-that-arrived-after-the-capture
fix(terminal): keep the output a pane capture could not contain
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
fix(terminal): keep the output a pane capture could not contain
|
||||
|
||||
Live terminal events are queued while a buffer load runs, and the load discards
|
||||
that queue when it ends. That is right when the loaded buffer is the server's
|
||||
accumulated byte history: the route appends to that history right up to the
|
||||
moment it serializes the response, so the queued events already appear in it and
|
||||
replaying them would duplicate output.
|
||||
|
||||
A tmux pane capture is a photograph, current only as of the instant
|
||||
`capture-pane` ran. Output printed afterwards was queued and then dropped, with
|
||||
nothing scheduling a re-fetch, and the CLI's next partial redraw landed on a
|
||||
frame the terminal never received. A `?full=1` load returns the capture alone,
|
||||
so it lost everything from the capture to the end of the chunked write. A
|
||||
`?tail=` load carries the byte history in front of the capture, so it lost
|
||||
everything from the response to the end of that write. A shell session shows
|
||||
this most plainly, because its output is linear and nothing repaints it.
|
||||
|
||||
Queue entries now carry their arrival time, and `_finishBufferLoad` takes a
|
||||
`since` cutoff so a capture load replays exactly the tail that arrived after the
|
||||
response headers. All four paths that fetch a terminal buffer and write it use
|
||||
the same rule, through one shared `_bufferLoadFinishOpts` helper: selecting a
|
||||
session, the backpressure refresh, the clear-terminal reload, and the
|
||||
full-history re-pull. The backpressure refresh matters most, because it exists
|
||||
to restore output the client already dropped once and could drop more while
|
||||
doing it.
|
||||
|
||||
Two things had to change for that tail to still exist when the load ends.
|
||||
`chunkedTerminalWrite` is what ends the load for any non-empty buffer, so it
|
||||
takes the flush policy and applies it at its own finish sites.
|
||||
`_beginBufferLoad` no longer empties the queue when the same load re-enters it,
|
||||
which it does on every write, because that reset discarded the fetch window
|
||||
before anything could replay it.
|
||||
|
||||
A path that replays its queue and then restores a scroll position re-takes the
|
||||
sticky-scroll baseline (`_syncStickyScrollBaseline`). The replay runs with the
|
||||
terminal freshly reset, so it reads as sitting at the bottom, and the next flush
|
||||
would scroll there and undo the restore. The backpressure refresh and the
|
||||
full-history re-pull are the two paths that restore a position, and both are
|
||||
ones a reader reaches while scrolled up.
|
||||
|
||||
One duplicate window stays open and is not closable from the browser. The server
|
||||
appends output to the byte buffer in the same tick it emits, but broadcasts on a
|
||||
batch timer, 8ms over WebSocket and 16 to 50ms over SSE. A batch already pending
|
||||
when `capture-pane` ran therefore leaves the server after the reply and is
|
||||
replayed although the capture holds it. It is one batch interval wide, against a
|
||||
recovery window that spans the whole chunked write, and closing it means
|
||||
flushing that session's pending batch before taking the capture.
|
||||
@@ -27,6 +27,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/webgl-fallback.test.ts',
|
||||
'test/terminal-copy-shortcut.test.ts',
|
||||
'test/terminal-keycode229-recovery.browser.test.ts',
|
||||
'test/capture-load-window.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
];
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+85
-6
@@ -1910,6 +1910,40 @@ class CodemanApp {
|
||||
this._onSessionClearTerminal(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a buffer load that just fetched `payload` must end.
|
||||
*
|
||||
* A tmux pane capture is a point-in-time frame, so nothing that reached the
|
||||
* browser after the response headers can already be in it. Such a load
|
||||
* replays exactly that tail; discarding it drops the CLI's output for the
|
||||
* rest of the load window, and its next partial redraw then lands on a frame
|
||||
* the terminal never received. A payload built from the server's accumulated
|
||||
* byte history needs the opposite: that history is current up to the
|
||||
* response, so replaying the queue on top of it would duplicate output.
|
||||
*
|
||||
* `headersReceivedAt` is the caller's own `performance.now()` reading from
|
||||
* the moment the response arrived, compared only against other client-side
|
||||
* readings, so there is no clock skew to worry about.
|
||||
*
|
||||
* What this cutoff does NOT cover: the server appends output to the byte
|
||||
* buffer and emits it in the same tick, but it BROADCASTS on a batch timer —
|
||||
* 8ms over WebSocket, 16 to 50ms over SSE. The terminal route runs
|
||||
* synchronously from `capture-pane` to its return, so a batch that was
|
||||
* already pending when the capture ran leaves the server after the reply,
|
||||
* arrives after `headersReceivedAt`, and is replayed although the capture
|
||||
* holds it. The duplicate is one batch interval wide, against a recovery
|
||||
* window that spans the whole chunked write. Closing it belongs on the
|
||||
* server: flush that session's pending batch before taking the capture.
|
||||
*
|
||||
* @param {{source?: string}} payload - The parsed `data` of a terminal response.
|
||||
* @param {number} headersReceivedAt - When that response reached this client.
|
||||
* @returns {{flushQueued: boolean, since: number}} Options for `_finishBufferLoad`.
|
||||
*/
|
||||
_bufferLoadFinishOpts(payload, headersReceivedAt) {
|
||||
const capturedFromMux = payload?.source === 'mux-visible' || payload?.source === 'mux-full-history';
|
||||
return { flushQueued: capturedFromMux, since: headersReceivedAt };
|
||||
}
|
||||
|
||||
_onSessionTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
|
||||
@@ -1919,7 +1953,7 @@ class CodemanApp {
|
||||
// jump over the cap. Dropped data is recovered from the canonical buffer.
|
||||
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
|
||||
+ (this.flickerFilterBuffer?.length || 0)
|
||||
+ (this._loadBufferQueue?.reduce((s, w) => s + w.length, 0) || 0)
|
||||
+ (this._loadBufferQueue?.reduce((s, w) => s + w.data.length, 0) || 0)
|
||||
+ (this._terminalWriteInFlightBytes || 0);
|
||||
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
|
||||
// Schedule a self-recovery once the
|
||||
@@ -2502,9 +2536,11 @@ class CodemanApp {
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
let headersReceivedAt = performance.now();
|
||||
let data = (await res.json())?.data ?? {};
|
||||
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
|
||||
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
headersReceivedAt = performance.now();
|
||||
data = (await res.json())?.data ?? {};
|
||||
}
|
||||
// Bail on a tab switch mid-fetch: writing here would paint this session's
|
||||
@@ -2520,7 +2556,12 @@ class CodemanApp {
|
||||
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
||||
await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
undefined,
|
||||
this._bufferLoadFinishOpts(data, headersReceivedAt)
|
||||
);
|
||||
// A tail fetch can be partial, and the banner would otherwise keep
|
||||
// describing the pre-refresh buffer (#258).
|
||||
this._setHistoryTruncation(sessionId, data);
|
||||
@@ -2530,6 +2571,10 @@ class CodemanApp {
|
||||
});
|
||||
if (target === null || typeof this.terminal.scrollToLine !== 'function') this.terminal.scrollToBottom();
|
||||
else this.terminal.scrollToLine(target);
|
||||
// The load's own replay sampled the sticky-scroll baseline while the
|
||||
// terminal sat at the bottom of a just-rewritten buffer, so the next
|
||||
// flush would scroll back down and undo the restore above.
|
||||
this._syncStickyScrollBaseline();
|
||||
// Re-position local echo overlay at new prompt location
|
||||
this._localEchoOverlay?.rerender();
|
||||
// Resize PTY to match actual browser dimensions (critical for OpenCode
|
||||
@@ -2556,6 +2601,7 @@ class CodemanApp {
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const termData = (await res.json())?.data ?? {};
|
||||
|
||||
this.terminal.clear();
|
||||
@@ -2565,7 +2611,12 @@ class CodemanApp {
|
||||
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
|
||||
const cleanBuffer = termData.terminalBuffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||
// Use chunked write to avoid UI freeze with large buffers (can be 1-2MB)
|
||||
await this.chunkedTerminalWrite(cleanBuffer);
|
||||
await this.chunkedTerminalWrite(
|
||||
cleanBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
undefined,
|
||||
this._bufferLoadFinishOpts(termData, headersReceivedAt)
|
||||
);
|
||||
}
|
||||
|
||||
// Fire-and-forget resize — don't block on it
|
||||
@@ -5862,7 +5913,12 @@ class CodemanApp {
|
||||
parsedAt,
|
||||
bufferLength: parsedBufferLength,
|
||||
completed,
|
||||
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
} = await this.chunkedTerminalWrite(
|
||||
buffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
sessionId,
|
||||
this._bufferLoadFinishOpts(payload, headersReceivedAt)
|
||||
);
|
||||
timing.resetAndParseMs = parsedAt - replayStartedAt;
|
||||
if (!completed || this.activeSessionId !== sessionId) return;
|
||||
// Keep shell tab restores bounded too. A user-triggered full-history pull
|
||||
@@ -5880,6 +5936,12 @@ class CodemanApp {
|
||||
const delta = parsedBufferLength - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
// The load's own replay sampled the sticky-scroll baseline while the
|
||||
// terminal sat at the bottom of a just-rewritten buffer, so the next
|
||||
// flush would scroll back down and undo the restore above. This path is
|
||||
// reached only from a scroll-up gesture, so being dragged down is the
|
||||
// exact opposite of what the user asked for.
|
||||
this._syncStickyScrollBaseline();
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
} catch {
|
||||
@@ -6321,6 +6383,15 @@ class CodemanApp {
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
// How this load must end, decided here because `chunkedTerminalWrite` is
|
||||
// what actually ends it for a non-empty buffer. A tmux pane capture is a
|
||||
// point-in-time frame, so nothing that reached the browser after the
|
||||
// response headers can already be in it. Replay exactly that tail;
|
||||
// discarding it drops the CLI's output for the rest of the load window,
|
||||
// and its next partial redraw then lands on a frame the terminal never
|
||||
// received. `since` keeps the pre-capture events dropped, because the
|
||||
// capture does hold those and replaying them would duplicate output.
|
||||
const finishOpts = this._bufferLoadFinishOpts(data, headersReceivedAt);
|
||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||
|
||||
let freshResetAndParseMs = 0;
|
||||
@@ -6347,7 +6418,8 @@ class CodemanApp {
|
||||
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
bufferLoadOwner
|
||||
bufferLoadOwner,
|
||||
finishOpts
|
||||
);
|
||||
freshResetAndParseMs = freshParsedAt - replayStartedAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
@@ -6397,7 +6469,14 @@ class CodemanApp {
|
||||
// COD-144: when the load painted nothing, FLUSH the queued events instead of
|
||||
// discarding — a new session's prompt arrives only as a queued SSE event.
|
||||
if (this._isLoadingBuffer) {
|
||||
this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty });
|
||||
// Only reached when the write was skipped. COD-144 lives here: a new
|
||||
// session's first prompt exists only as a queued event that predates the
|
||||
// response, so an empty paint replays its queue WHOLE rather than from
|
||||
// the header timestamp.
|
||||
this._finishBufferLoad(
|
||||
bufferLoadOwner,
|
||||
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
|
||||
);
|
||||
}
|
||||
// Drop the guard so user input clears state normally
|
||||
this._restoringFlushedState = false;
|
||||
|
||||
@@ -3159,6 +3159,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
return buffer.viewportY >= buffer.baseY - 2;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-take the sticky-scroll baseline from where the viewport now sits.
|
||||
*
|
||||
* `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` before it queues
|
||||
* data, and `flushPendingWrites` scrolls to the bottom off that sample. A
|
||||
* buffer load that replays its queue samples at the worst possible moment:
|
||||
* `_finishBufferLoad` runs inside `chunkedTerminalWrite`, before its promise
|
||||
* resolves, with the terminal freshly reset and rewritten, so the sample is
|
||||
* always true. A caller that then restores the reader's position would have
|
||||
* that restore undone by the next flush.
|
||||
*
|
||||
* `_onSessionNeedsRefresh` and `_maybeRefetchFullHistory` restore a position
|
||||
* and both call this, so their baseline describes the position they chose.
|
||||
*
|
||||
* The other two load paths do not call it, for different reasons.
|
||||
* `_onSessionClearTerminal` resets and rewrites with no scroll afterwards,
|
||||
* so the sampled true is already the truth there. `selectSession` does NOT
|
||||
* end at the bottom, whatever its `scrollToBottom()` after the write
|
||||
* suggests: it ends at `scrollToLastNonEmptyLine()`, which targets
|
||||
* `lastNonEmptyLine - rows + 2` and therefore parks ABOVE `baseY` whenever
|
||||
* the replayed frame keeps trailing blank rows, which a full capture does on
|
||||
* purpose. Its baseline is a stale true. What decides whether that matters
|
||||
* is the sticky snap in `flushPendingWrites`, and since de864e7d that snap
|
||||
* fires only when the flush found the viewport already at the bottom
|
||||
* (`preserveViewportY === null`), which a parked selectSession viewport is
|
||||
* not. Do not read the absent call here as a claim that selectSession lands
|
||||
* at the bottom.
|
||||
*/
|
||||
_syncStickyScrollBaseline() {
|
||||
this._wasAtBottomBeforeWrite = this.isTerminalAtBottom();
|
||||
},
|
||||
|
||||
// Record manual scroll gestures so sticky-scroll can give an upward scroll a
|
||||
// short grace window (see _hasRecentUserScrollUp). A downward scroll that
|
||||
// lands back at the bottom clears the suppression immediately.
|
||||
@@ -3295,7 +3327,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// to prevent interleaving historical buffer data with live SSE data.
|
||||
// This is critical: interleaving causes cursor position chaos with Ink redraws.
|
||||
if (this._isLoadingBuffer) {
|
||||
if (this._loadBufferQueue) this._loadBufferQueue.push(data);
|
||||
// Each entry records when it arrived. A flush of a tmux-capture load
|
||||
// replays only what arrived after the capture; without the timestamp it
|
||||
// would have to replay the whole queue, duplicating the events the
|
||||
// capture already contains. See _finishBufferLoad's `since`.
|
||||
if (this._loadBufferQueue) this._loadBufferQueue.push({ at: performance.now(), data });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3809,9 +3845,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
|
||||
* @param {string} buffer - The full terminal buffer to write
|
||||
* @param {number} chunkSize - Size of each chunk (default 32KB)
|
||||
* @param {string} [loadOwner] - Load token to finish under
|
||||
* @param {{ flushQueued?: boolean, since?: number }} [finishOpts] - Passed to
|
||||
* `_finishBufferLoad`. This method ends the load for every non-empty buffer,
|
||||
* so a caller that wants the queue replayed has to say so HERE; the call in
|
||||
* `selectSession` only runs when the write was skipped entirely.
|
||||
* @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot
|
||||
*/
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner, finishOpts) {
|
||||
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
||||
// older writes abort instead of continuing to push stale data into the terminal.
|
||||
const writeGen = ++this._chunkedWriteGen;
|
||||
@@ -3824,7 +3865,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
completed,
|
||||
});
|
||||
if (!buffer || buffer.length === 0) {
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
resolve(parseSnapshot());
|
||||
return;
|
||||
}
|
||||
@@ -3838,7 +3879,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(cleanBuffer, () => resolve(parseSnapshot()));
|
||||
// The write is now ordered in xterm's queue. Release live output before
|
||||
// parsing completes; subsequent writes stay behind it without being lost.
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3869,7 +3910,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
resolve(result);
|
||||
});
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3888,10 +3929,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
|
||||
*
|
||||
* By default queued SSE events are DISCARDED, not flushed. For an established
|
||||
* session the loaded buffer from the API is the source of truth up to the
|
||||
* response timestamp; SSE events queued during the fetch+write overlap already
|
||||
* appear in that buffer, so flushing them writes duplicate data (especially Ink
|
||||
* cursor-up redraws), corrupting the terminal display.
|
||||
* session whose buffer came from the server's accumulated byte history, that
|
||||
* history is the source of truth up to the response timestamp; SSE events
|
||||
* queued during the fetch+write overlap already appear in it, so flushing
|
||||
* them writes duplicate data (especially Ink cursor-up redraws), corrupting
|
||||
* the terminal display.
|
||||
*
|
||||
* A tmux PANE CAPTURE is the exception, and the reason `since` exists. A
|
||||
* capture is a point-in-time frame taken part-way through the fetch, so it is
|
||||
* the source of truth only up to CAPTURE time — not up to the response. Every
|
||||
* event that arrives between the capture and the end of the chunked write is
|
||||
* queued and, under a plain discard, lost outright: nothing re-fetches, and
|
||||
* the CLI's next partial redraw lands on a frame the terminal never received.
|
||||
* The caller passes the response's own arrival time as `since` so exactly
|
||||
* that tail is replayed and the pre-capture events stay dropped.
|
||||
*
|
||||
* COD-144: a brand-new session is the exception. Its terminal fetch can resolve
|
||||
* BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the
|
||||
@@ -3905,14 +3956,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
||||
*
|
||||
* @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op.
|
||||
* @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events.
|
||||
* @param {{ flushQueued?: boolean, since?: number }} [opts] When `flushQueued`
|
||||
* is true, replay queued events whose arrival timestamp is at or after
|
||||
* `since` (default 0, meaning the whole queue).
|
||||
*/
|
||||
_beginBufferLoad(owner) {
|
||||
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
|
||||
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
|
||||
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
|
||||
// opens it again under the SAME owner when it starts writing. Resetting the
|
||||
// queue on that second call would throw away everything that arrived during
|
||||
// the fetch, which on the capture path is output no buffer holds. Re-entering
|
||||
// one load keeps its queue; a genuinely new load still starts empty.
|
||||
const reentering = this._bufferLoadOwner === loadOwner && Array.isArray(this._loadBufferQueue);
|
||||
this._bufferLoadOwner = loadOwner;
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
if (!reentering) this._loadBufferQueue = [];
|
||||
return loadOwner;
|
||||
},
|
||||
|
||||
@@ -3926,9 +3985,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._bufferLoadOwner = null;
|
||||
// COD-144: replay (rather than discard) queued live events when the load
|
||||
// painted nothing — the queued prompt is the only content a new session has.
|
||||
// A tmux-capture load replays too, but only the tail: `since` cuts the queue
|
||||
// at the moment the capture stopped being able to contain what arrived.
|
||||
if (opts?.flushQueued && queued && queued.length) {
|
||||
for (const data of queued) {
|
||||
this.batchTerminalWrite(data);
|
||||
const since = typeof opts.since === 'number' ? opts.since : 0;
|
||||
for (const entry of queued) {
|
||||
if (entry.at < since) continue;
|
||||
this.batchTerminalWrite(entry.data);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @fileoverview Output arriving after a pane capture survives the buffer load.
|
||||
*
|
||||
* `batchTerminalWrite` queues live terminal events while a buffer load runs,
|
||||
* and `_finishBufferLoad` discards that queue by default. That is right when
|
||||
* the loaded buffer is the server's accumulated byte history, which is current
|
||||
* up to the response. A tmux pane capture is current only up to CAPTURE time,
|
||||
* so anything arriving between the capture and the end of the chunked write is
|
||||
* queued and then dropped, with nothing scheduling a re-fetch.
|
||||
*
|
||||
* The queue now stamps each entry with its arrival time, and a capture load
|
||||
* replays the tail that arrived after the response headers. These drive the
|
||||
* real client in chromium: the event is injected from inside the response's
|
||||
* own `json()` call, which is the one place guaranteed to land after the
|
||||
* headers and before the chunked write.
|
||||
*
|
||||
* Port: 3256 (capture load window)
|
||||
*
|
||||
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3256;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
|
||||
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true); // testMode
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
}, 30_000);
|
||||
|
||||
/**
|
||||
* Select the session with the terminal fetch stubbed, injecting one live event
|
||||
* from inside `json()`. Returns how many terminal rows carry the marker, so a
|
||||
* flush that replays too much fails as loudly as one that replays nothing.
|
||||
*/
|
||||
async function runLoad(page: Page, sessionId: string, source: string): Promise<number> {
|
||||
return page.evaluate(
|
||||
async ({ sid, src, marker }) => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
selectSession: (id: string, o?: object) => Promise<void>;
|
||||
_onSessionTerminal: (e: { id: string; data: string }) => void;
|
||||
terminal: {
|
||||
buffer: {
|
||||
active: {
|
||||
length: number;
|
||||
getLine: (i: number) => { translateToString: (t: boolean) => string } | undefined;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).app;
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(typeof input === 'string' ? input : ((input as Request).url ?? input));
|
||||
if (!url.includes('/terminal')) return realFetch(input as RequestInfo, init);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
// `selectSession` timestamps the headers the moment this promise
|
||||
// resolves, then calls json(). Injecting here puts the event after
|
||||
// that timestamp and inside the load window, which is exactly the
|
||||
// gap a pane capture cannot cover.
|
||||
json: async () => {
|
||||
app._onSessionTerminal({ id: sid, data: `\r\n${marker}\r\n` });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
terminalBuffer: '\x1b[1;1Hcaptured frame line one\r\n',
|
||||
status: 'idle',
|
||||
fullSize: 512,
|
||||
retainedBytes: 512,
|
||||
truncated: false,
|
||||
truncationReason: null,
|
||||
source: src,
|
||||
captureCols: 80,
|
||||
captureRows: 24,
|
||||
},
|
||||
};
|
||||
},
|
||||
}) as unknown as Promise<Response>;
|
||||
}) as typeof window.fetch;
|
||||
|
||||
try {
|
||||
await app.selectSession(sid);
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
const buf = app.terminal.buffer.active;
|
||||
let hits = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes(marker)) hits += 1;
|
||||
}
|
||||
return hits;
|
||||
} finally {
|
||||
window.fetch = realFetch;
|
||||
}
|
||||
},
|
||||
{ sid: sessionId, src: source, marker: MARKER }
|
||||
);
|
||||
}
|
||||
|
||||
async function openSession(page: Page): Promise<string> {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
|
||||
// xterm loads from /vendor, so the terminal appears a beat after the app.
|
||||
// Without it every buffer assertion below would throw rather than compare.
|
||||
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
return page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', name: 'capture-load-window-test' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
return body.data?.session?.id ?? body.data?.id ?? body.id;
|
||||
});
|
||||
}
|
||||
|
||||
describe('output emitted during a capture load', () => {
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
afterAll(async () => {
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
it('reaches the terminal exactly once when the buffer came from a pane capture', async () => {
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
page = await context.newPage();
|
||||
const sessionId = await openSession(page);
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
// Exactly once. The cutoff exists so the flush cannot also replay events the
|
||||
// payload already carried, which would double the output rather than heal it.
|
||||
expect(await runLoad(page, sessionId, 'mux-visible')).toBe(1);
|
||||
|
||||
await page.evaluate(
|
||||
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
||||
sessionId
|
||||
);
|
||||
await context.close();
|
||||
}, 60_000);
|
||||
|
||||
it('stays dropped when the buffer came from the accumulated byte history', async () => {
|
||||
// The byte history already contains everything up to the response, so
|
||||
// replaying the queue on top of it would duplicate the output — most
|
||||
// visibly Ink's cursor-up redraws. The discard has to survive this fix.
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
page = await context.newPage();
|
||||
const sessionId = await openSession(page);
|
||||
// Without this, a failed create passes the zero-hit assertion below
|
||||
// vacuously — nothing was loaded, so nothing was replayed.
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
expect(await runLoad(page, sessionId, 'history')).toBe(0);
|
||||
|
||||
await page.evaluate(
|
||||
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
||||
sessionId
|
||||
);
|
||||
await context.close();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -56,10 +56,10 @@ type BufferLoadApp = {
|
||||
_bufferLoadSeq: number;
|
||||
_bufferLoadOwner: string | null;
|
||||
_isLoadingBuffer: boolean;
|
||||
_loadBufferQueue: string[] | null;
|
||||
_loadBufferQueue: { at: number; data: string }[] | null;
|
||||
batchTerminalWrite: (data: string) => void;
|
||||
_beginBufferLoad: (owner?: string) => string;
|
||||
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean }) => boolean;
|
||||
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean; since?: number }) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,10 +84,57 @@ function makeApp() {
|
||||
return { app, writes };
|
||||
}
|
||||
|
||||
/** Simulate live SSE events arriving while a buffer load is in progress (the queue path). */
|
||||
function pushWhileLoading(app: BufferLoadApp, data: string) {
|
||||
// Mirrors batchTerminalWrite's queue branch: if loading, push to the queue.
|
||||
if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push(data);
|
||||
/**
|
||||
* A stub carrying the REAL `batchTerminalWrite` on top of the real begin/finish
|
||||
* methods, so a replay samples the sticky-scroll baseline exactly as it does in
|
||||
* the browser. The terminal is a fake whose `buffer.active` the test moves by
|
||||
* hand, which is what a caller's `scrollToLine` does to a real one.
|
||||
*/
|
||||
function makeScrollApp() {
|
||||
const buffer = { viewportY: 0, baseY: 100 };
|
||||
const app = {
|
||||
buffer,
|
||||
terminal: { buffer: { active: buffer } },
|
||||
sessions: new Map(),
|
||||
activeSessionId: null,
|
||||
pendingWrites: [] as string[],
|
||||
writeFrameScheduled: false,
|
||||
_wasAtBottomBeforeWrite: false,
|
||||
_bufferLoadSeq: 0,
|
||||
_bufferLoadOwner: null as string | null,
|
||||
_isLoadingBuffer: false,
|
||||
_loadBufferQueue: null as { at: number; data: string }[] | null,
|
||||
_scheduleTerminalWriteFlush: vi.fn(),
|
||||
batchTerminalWrite: mixin.batchTerminalWrite as (data: string) => void,
|
||||
isTerminalAtBottom: mixin.isTerminalAtBottom as () => boolean,
|
||||
_syncStickyScrollBaseline: mixin._syncStickyScrollBaseline as () => void,
|
||||
_beginBufferLoad: mixin._beginBufferLoad as BufferLoadApp['_beginBufferLoad'],
|
||||
_finishBufferLoad: mixin._finishBufferLoad as BufferLoadApp['_finishBufferLoad'],
|
||||
};
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice one class method out of app.js, from its header to the next method's.
|
||||
*
|
||||
* Bounding the slice matters: the two methods checked below are not followed by
|
||||
* a JSDoc block, so a scan for the next comment would run on into unrelated
|
||||
* code and match its scroll calls instead of theirs.
|
||||
*/
|
||||
function methodBody(source: string, method: string): string {
|
||||
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
|
||||
expect(start, `${method} not found in app.js`).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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a live SSE event arriving while a buffer load is in progress.
|
||||
* Mirrors batchTerminalWrite's queue branch, which stamps each entry with its
|
||||
* arrival time so a flush can replay only the tail (see the `since` tests).
|
||||
*/
|
||||
function pushWhileLoading(app: BufferLoadApp, data: string, at = performance.now()) {
|
||||
if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push({ at, data });
|
||||
}
|
||||
|
||||
describe('buffer-load flush (COD-144)', () => {
|
||||
@@ -153,11 +200,165 @@ describe('buffer-load flush (COD-144)', () => {
|
||||
// State untouched — still loading, queue intact, nothing replayed.
|
||||
expect(app._isLoadingBuffer).toBe(true);
|
||||
expect(app._bufferLoadOwner).toBe('real-owner');
|
||||
expect(app._loadBufferQueue).toEqual(['queued']);
|
||||
expect(app._loadBufferQueue).toEqual([{ at: expect.any(Number), data: 'queued' }]);
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
// ── The tmux-capture tail: `since` ──
|
||||
//
|
||||
// A pane capture is a point-in-time frame taken part-way through the fetch, so
|
||||
// it holds what arrived BEFORE the capture and nothing after. selectSession
|
||||
// passes the response's arrival time as `since`, which splits the queue at
|
||||
// exactly that line: pre-capture events are already painted and must stay
|
||||
// dropped, post-capture events exist nowhere else and must be replayed.
|
||||
|
||||
it('flushes only the entries at or after `since`', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-since');
|
||||
pushWhileLoading(app, 'already-in-the-capture', 100);
|
||||
pushWhileLoading(app, 'arrived-at-the-headers', 200);
|
||||
pushWhileLoading(app, 'arrived-after-the-headers', 300);
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 200 });
|
||||
|
||||
// The pre-capture event stays dropped; the boundary entry counts as after.
|
||||
expect(writes).toEqual(['arrived-at-the-headers', 'arrived-after-the-headers']);
|
||||
});
|
||||
|
||||
it('flushQueued without `since` still replays the whole queue', () => {
|
||||
// The COD-144 path: a brand-new session's first prompt predates the
|
||||
// response, so cutting the queue would drop the only content it has.
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-no-since');
|
||||
pushWhileLoading(app, 'prompt', 10);
|
||||
pushWhileLoading(app, 'more', 20);
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true });
|
||||
|
||||
expect(writes).toEqual(['prompt', 'more']);
|
||||
});
|
||||
|
||||
it('a `since` past every entry flushes nothing', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-since-late');
|
||||
pushWhileLoading(app, 'old', 10);
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 999 });
|
||||
|
||||
expect(writes).toEqual([]);
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Re-entering one load ──
|
||||
//
|
||||
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
|
||||
// opens it again under the SAME owner when it starts writing. A reset on that
|
||||
// second call would silently throw away everything queued during the fetch,
|
||||
// which on the capture path is output no buffer holds.
|
||||
|
||||
it('re-entering the same load keeps what the queue already holds', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-reenter');
|
||||
pushWhileLoading(app, 'arrived-during-the-fetch', 100);
|
||||
|
||||
// chunkedTerminalWrite re-opens the load it was handed.
|
||||
app._beginBufferLoad(owner);
|
||||
pushWhileLoading(app, 'arrived-during-the-write', 200);
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 50 });
|
||||
|
||||
expect(writes).toEqual(['arrived-during-the-fetch', 'arrived-during-the-write']);
|
||||
});
|
||||
|
||||
it('a genuinely different load still starts with an empty queue', () => {
|
||||
const { app, writes } = makeApp();
|
||||
app._beginBufferLoad('load-first');
|
||||
pushWhileLoading(app, 'belongs-to-the-abandoned-load', 100);
|
||||
|
||||
// A tab switch starts a new load under a new owner. Its events are not ours.
|
||||
const second = app._beginBufferLoad('load-second');
|
||||
pushWhileLoading(app, 'belongs-to-this-load', 200);
|
||||
|
||||
app._finishBufferLoad(second, { flushQueued: true, since: 0 });
|
||||
|
||||
expect(writes).toEqual(['belongs-to-this-load']);
|
||||
});
|
||||
|
||||
// ── The sticky-scroll baseline across a replay ──
|
||||
//
|
||||
// `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` before queueing, and
|
||||
// `flushPendingWrites` scrolls to the bottom off that sample. The replay runs
|
||||
// inside `chunkedTerminalWrite` before its promise resolves, with the terminal
|
||||
// freshly reset and rewritten, so the sample is always true. A caller that
|
||||
// then restores the reader's position would have that restore undone.
|
||||
|
||||
it('the replay latches the baseline true, and the viewport restore re-takes it', () => {
|
||||
const app = makeScrollApp();
|
||||
const owner = app._beginBufferLoad('load-scroll');
|
||||
pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
|
||||
|
||||
// The load ends with the terminal reset and rewritten, so it reads as bottom.
|
||||
app.buffer.viewportY = app.buffer.baseY;
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
|
||||
expect(app._wasAtBottomBeforeWrite).toBe(true);
|
||||
|
||||
// The caller now puts the reader back where they were reading.
|
||||
app.buffer.viewportY = 40;
|
||||
app._syncStickyScrollBaseline();
|
||||
|
||||
// The next flush must leave them there.
|
||||
expect(app._wasAtBottomBeforeWrite).toBe(false);
|
||||
});
|
||||
|
||||
it('a restore that lands back at the bottom keeps sticky scroll armed', () => {
|
||||
const app = makeScrollApp();
|
||||
const owner = app._beginBufferLoad('load-scroll-bottom');
|
||||
pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
|
||||
|
||||
app.buffer.viewportY = app.buffer.baseY;
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
|
||||
app._syncStickyScrollBaseline();
|
||||
|
||||
// A reader who was already at the bottom still wants to be carried along.
|
||||
expect(app._wasAtBottomBeforeWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('both callers that restore a scroll position re-take the baseline', () => {
|
||||
// The wiring lives in app.js, outside this file's vm harness. Without it the
|
||||
// two methods below restore the viewport and the next flush undoes it.
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
|
||||
for (const method of ['_onSessionNeedsRefresh', '_maybeRefetchFullHistory']) {
|
||||
const body = methodBody(source, method);
|
||||
const restoreAt = body.lastIndexOf('scrollToLine(');
|
||||
const syncAt = body.indexOf('this._syncStickyScrollBaseline()');
|
||||
expect(restoreAt, `${method} no longer restores a scroll position`).toBeGreaterThan(-1);
|
||||
expect(syncAt, `${method} never re-takes the baseline`).toBeGreaterThan(-1);
|
||||
expect(syncAt, `${method} re-takes the baseline before its restore`).toBeGreaterThan(restoreAt);
|
||||
}
|
||||
});
|
||||
|
||||
it('every path that fetches a terminal buffer and writes it asks the shared helper', () => {
|
||||
// Drift guard. The first version of this fix covered one of the four paths,
|
||||
// and a later pass found it still covering one of four. Nothing else in the
|
||||
// gate stops a fifth path, or an inlined `{ flushQueued: true }`, from
|
||||
// splitting the policy up again; the browser suite that would notice does
|
||||
// not run in CI.
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
|
||||
for (const method of [
|
||||
'selectSession',
|
||||
'_onSessionNeedsRefresh',
|
||||
'_onSessionClearTerminal',
|
||||
'_maybeRefetchFullHistory',
|
||||
]) {
|
||||
expect(methodBody(source, method), `${method} decides the flush policy itself`).toContain(
|
||||
'this._bufferLoadFinishOpts('
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('empty queue + flushQueued is a no-op (no throw, no writes)', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-empty');
|
||||
|
||||
@@ -355,6 +355,51 @@ describe('terminal flush budget', () => {
|
||||
expect(app._bufferLoadOwner).toBe(null);
|
||||
});
|
||||
|
||||
// ── Which payloads end their load by replaying the queue ──
|
||||
//
|
||||
// A pane capture is current only up to capture time, so the tail that arrived
|
||||
// after the response exists nowhere else and has to be replayed. The server's
|
||||
// accumulated byte history is current up to the response, so replaying on top
|
||||
// of it would duplicate output. `_bufferLoadFinishOpts` is the one place that
|
||||
// decides this, for all four paths that fetch a terminal buffer and write it.
|
||||
|
||||
it('replays the tail for a visible-pane capture', () => {
|
||||
const { CodemanApp } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
|
||||
expect(app._bufferLoadFinishOpts({ source: 'mux-visible' }, 1234)).toEqual({
|
||||
flushQueued: true,
|
||||
since: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it('replays the tail for a full-history capture', () => {
|
||||
const { CodemanApp } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
|
||||
expect(app._bufferLoadFinishOpts({ source: 'mux-full-history' }, 1234)).toEqual({
|
||||
flushQueued: true,
|
||||
since: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it('discards the queue for the accumulated byte history', () => {
|
||||
const { CodemanApp } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
|
||||
expect(app._bufferLoadFinishOpts({ source: 'history' }, 1234).flushQueued).toBe(false);
|
||||
});
|
||||
|
||||
it('discards the queue for a payload that names no source', () => {
|
||||
// Fails toward the safe answer: a duplicated Ink redraw corrupts the screen,
|
||||
// while a dropped tail is repaired by the CLI's next full repaint.
|
||||
const { CodemanApp } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
|
||||
expect(app._bufferLoadFinishOpts({}, 1234).flushQueued).toBe(false);
|
||||
expect(app._bufferLoadFinishOpts(undefined, 1234).flushQueued).toBe(false);
|
||||
});
|
||||
|
||||
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const scrollToBottom = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user