Skip to content

fix(terminal): replay a pane capture at the geometry it was taken at - #435

Open
irisitymichaelgrundberg wants to merge 1 commit into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/report-the-captured-pane-geometry
Open

irisitymichaelgrundberg wants to merge 1 commit into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/report-the-captured-pane-geometry

Conversation

@irisitymichaelgrundberg

@irisitymichaelgrundberg irisitymichaelgrundberg commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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

formatPaneSnapshot paints each row with ESC[<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/headless at two heights gives:

terminal height probe lines rendered last line visible frame
50 rows (matches the pane) 45 of 45 probe-line-45 correct
30 rows 28 of 45 probe-line-28 duplicated

Seventeen 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.resize declines 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 second DESKTOP_CLAIM_IDLE_MS:

if (isSmallViewport && this._desktopSizeClaims.size > 0) {
  if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
    return;
  }

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/resize returns 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; resizeRetry then 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.tsPaneCaptureOptions.capturedGeometry, an out-parameter the implementation fills with the size the capture was really taken at.
  • src/tmux-manager.tscapturePaneBuffer writes it once, before either replay path returns, and only when queryPaneCursor actually produced geometry. Reporting a size for a frame that was never positioned would be worse than reporting none.
  • src/session.tsptyCols/ptyRows getters. The route needs the pane's real size for the fallback below. resize can 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 passed undefined, purely so the geometry can come back on it. The response carries that geometry as captureCols and captureRows. 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.jsselectSession records the size the capture was taken against. It then compares that against the size that survived the load, and against captureRows. A mismatch on either makes it re-select once. resizeRetry caps that at one attempt, so two competing fits cannot trade replays forever. The retry clears sessionId from _fullHistoryLoaded only 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.
  • Tests: the reported geometry in test/routes/session-routes.test.ts, the write ordering plus a rendering test for why the height is needed at all in test/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.ts had six assertions of the form toHaveBeenCalledWith(muxName, undefined). The undefined was only ever a proxy for "this is not a full-history request", and the visible path now passes an object. They assert expect.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-syntax
  • npm run check:lockfile and npm run generate:cli-catalog -- --check
  • the boot smoke test, which boots and answers /api/status
  • npm test, green at 7014 tests across 369 files

The baseline on master is 7009 tests, so that is the added tests and no regressions.

test/capture-geometry-retry.browser.test.ts drives 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:

capture taller than the terminal capture that fits
on master fails: one fetch, no repair passes: one fetch
with this change passes: one fetch plus one retry passes: one fetch

The 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: 50 on 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_modules needs node scripts/prepare-test-vendor.mjs first. Without the vendored xterm bundles the page never defines Terminal, and the test fails on its wait for app.terminal rather 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.

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor Author

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>
@irisitymichaelgrundberg
irisitymichaelgrundberg force-pushed the fix/report-the-captured-pane-geometry branch from cb8d5de to 42efd78 Compare September 15, 2026 16:14
@irisitymichaelgrundberg
irisitymichaelgrundberg marked this pull request as ready for review September 15, 2026 16:23
@Ark0N

Ark0N commented Sep 15, 2026

Copy link
Copy Markdown
Owner

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 config/test-suites.ts; that was my test being wrong, not your claim. Merged onto master the way they actually would be, it is clean.)

And master has moved since you opened this: 1.29.1 went out an hour ago and carries #376's auto-naming, which touches session.ts and app.js. Your branch still merges clean over it, so no rebase is needed, but it is worth knowing the base is not what it was this morning.

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.

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants