fix(terminal): replay a pane capture at the geometry it was taken at - #435
irisitymichaelgrundberg wants to merge 1 commit into
Conversation
|
Companion: #436, which keeps the output a capture could not contain. The two are independent and merge cleanly in either order. |
A visible-frame capture repaints each row at an absolute position, counting up to the pane's height. A terminal shorter than that clamps every address past its own height onto its last line. The overflow rows then overwrite one another, and the rows underneath are lost. Replaying a real 50-row capture into a 30-row terminal rendered 28 lines of a 45-line command and drew the frame twice. Nothing in the response said what height the frame was built for, so the client could not detect this. A capture now reports the geometry it was really taken at through `capturedGeometry` on `PaneCaptureOptions`, and the terminal response carries it as `captureCols` and `captureRows`. When the captured pane is taller than the terminal, or the size that produced the capture did not survive the load, `selectSession` replays once at the size that stuck. `resizeRetry` caps that at one attempt, so two competing fits cannot trade replays forever. The retry re-arms the full-history flag only when the pass that ran had consumed it. A tab switch takes the bounded tail, so its retry takes the tail too: clearing the flag unconditionally would upgrade that switch into a fresh scrollback capture the user never asked for, which the route's own comments put at tens of megabytes. What this repairs is a capture that won a race against the resize meant to precede it. It does not repair a capture whose pane was too tall because `Session.resize` declined the resize outright, which it does for a small viewport while a desktop viewport's size claim is live. The retry re-sends the same declined resize and captures the same pane, and `resizeRetry` then stops it. Repairing that means changing who owns the pane size, which is a policy question this does not touch. The reported geometry still helps there, because the client can see the mismatch at all rather than being blind to it. Follows Ark0N#395, Ark0N#396 and Ark0N#397, which fixed the other ways the replayed frame and the terminal could disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cb8d5de to
42efd78
Compare
|
Both jobs are green on this head now, so if the draft flag is just "not finished yet", say what is left and I will wait. If it is habit, please mark it ready for review. The practical reason I ask: the reviewer that goes over PRs here skips drafts, so both this and #436 are currently sitting outside the review queue rather than at the back of it. I am assembling 1.29.2 and these two are the kind of thing I would want in it, but I am not going to merge a pair of terminal capture changes unreviewed. Two things I did check while triaging, so you have them: Your companion note is correct. I merged each onto the current master in sequence, both orders, and there is no conflict. They are also clean alongside the other four open PRs, so ordering is not a constraint on any of this. (My first pass compared the two heads against each other directly and reported a conflict in And master has moved since you opened this: 1.29.1 went out an hour ago and carries #376's auto-naming, which touches On the content, without pre-empting the review: the thing I appreciate most here is the paragraph saying the retry does not repair the desktop-claim case and that you want that stated rather than implied. That is the right call and the right way to write it up. Who owns the pane size while a desktop claim is live really is a policy question, and I would rather decide it deliberately than have it fall out of a retry loop. Reporting the geometry so the client can at least see the mismatch is a genuine improvement on being blind to it, and it is the part that makes the policy question answerable later. Mark it ready when you are happy with it and it goes into the queue. |
|
Note on the "draft" status: I put both in draft while I reviewed them myself, only moving them out of draft when I think they are ready for your review. |
Summary
A visible-frame capture repaints each row at an absolute position, counting up to the pane's height. The terminal response says nothing about that height, so a terminal shorter than the pane silently loses rows and neither side can tell. The capture now reports the geometry it was taken at, and the client replays once when the frame does not fit.
The problem
formatPaneSnapshotpaints each row withESC[<row>;1H. A terminal shorter than the pane clamps every address past its own height onto its last line. The overflow rows then overwrite one another, and the rows they land on are gone.I measured this against a real session on 1.28.2. A shell pane sized 100x50 ran
for i in $(seq 1 45); do echo probe-line-$i; done. The visible-frame capture came back addressing rows 1 through 50. Replaying that same capture into@xterm/headlessat two heights gives:probe-line-45probe-line-28Seventeen lines of output are unreachable, and the surviving frame is drawn twice.
The mismatch is not only a race between a resize and a capture.
Session.resizedeclines a small viewport's request outright while a desktop viewport's size claim is live. It returns before touching the PTY whenever the desktop has been active within the 90 secondDESKTOP_CLAIM_IDLE_MS:A phone opening a session that a desktop tab is holding therefore gets a frame built for the desktop's rows, every time rather than occasionally.
POST /api/sessions/:id/resizereturns an empty body, so the phone believes its resize took.The retry does not repair that second case, and I want that stated rather than implied. It re-sends the same resize, the server declines it the same way, and the capture comes back at the same height;
resizeRetrythen stops it after the one extra attempt and the frame is shown as it is. Repairing it means changing who owns the pane size while a desktop claim is live, which is a policy question this change does not touch. What the reported geometry buys there is that the client can see the mismatch at all rather than being blind to it. The retry does repair the race case, where the resize has landed by the second attempt.Changes
src/mux-interface.ts—PaneCaptureOptions.capturedGeometry, an out-parameter the implementation fills with the size the capture was really taken at.src/tmux-manager.ts—capturePaneBufferwrites it once, before either replay path returns, and only whenqueryPaneCursoractually produced geometry. Reporting a size for a frame that was never positioned would be worse than reporting none.src/session.ts—ptyCols/ptyRowsgetters. The route needs the pane's real size for the fallback below.resizecan decline a request, so the last size asked for is not always the size in force.src/web/routes/session-routes.ts— the visible path now passes an options object where it passedundefined, purely so the geometry can come back on it. The response carries that geometry ascaptureColsandcaptureRows. Both fall back to the session's own size when the capture reported none, because a missing field would read as "no mismatch" and suppress the repair.src/web/public/app.js—selectSessionrecords the size the capture was taken against. It then compares that against the size that survived the load, and againstcaptureRows. A mismatch on either makes it re-select once.resizeRetrycaps that at one attempt, so two competing fits cannot trade replays forever. The retry clearssessionIdfrom_fullHistoryLoadedonly when the pass that ran had consumed it, so it replays at the same scope rather than a wider one. A page load retries as a full-history pull; a tab switch retries on the bounded tail. Clearing it unconditionally would upgrade a tab switch into a fresh scrollback capture it never asked for, which the route's own comments put at tens of megabytes.test/routes/session-routes.test.ts, the write ordering plus a rendering test for why the height is needed at all intest/tmux-capture-full-history.test.ts, pane geometry on the mock session, and a new browser suite.A note on six assertions I changed
test/routes/session-routes.test.tshad six assertions of the formtoHaveBeenCalledWith(muxName, undefined). Theundefinedwas only ever a proxy for "this is not a full-history request", and the visible path now passes an object. They assertexpect.not.objectContaining({ fullHistory: true })instead, which is what they were checking for. No coverage is lost.Verification
Every CI gate passes locally:
npm run typecheck,npm run lint,npm run format:check,npm run check:frontend-syntaxnpm run check:lockfileandnpm run generate:cli-catalog -- --check/api/statusnpm test, green at 7014 tests across 369 filesThe baseline on
masteris 7009 tests, so that is the added tests and no regressions.test/capture-geometry-retry.browser.test.tsdrives the real client in chromium and stubs only the terminal endpoint, because staging the mismatch against live tmux needs two viewports. Run before and after the change:masterThe second column is the guard against a retry that fires when nothing is wrong, which would double the work of every tab switch. A third case asserts that a retry stays at the scope its first pass used, and it fails against a version that clears the full-history flag unconditionally.
I also checked the geometry reporting against a running server, not only in tests. A 100x50 pane returns
captureCols: 100, captureRows: 50on both the visible and the full-history path. After a resize to 24 rows it returns 24, and the capture portion of the payload addresses exactly 24 rows.Running the browser suite in a tree that borrows another checkout's
node_modulesneedsnode scripts/prepare-test-vendor.mjsfirst. Without the vendored xterm bundles the page never definesTerminal, and the test fails on its wait forapp.terminalrather than passing vacuously against a zero-row terminal.Related
Follows #395, #396 and #397, which fixed the other ways a replayed frame and the terminal could disagree — row alignment in the full-history replay, fitting only once the font is measurable, and a detached window owning its own pane size. This is the remaining case: the frame is built correctly, for a pane the terminal is not.