diff --git a/src/web/public/app.js b/src/web/public/app.js index 172625e1..d2a3b9d2 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -549,6 +549,12 @@ class CodemanApp { // repaint-mode CLI pane, where tmux keeps no history of its own). The pull is // refused for those and retried far more slowly — see _maybeRefetchFullHistory. this._fullHistoryRepullUseless = new Set(); + // Sessions where the geometry replay has already been tried and did NOT + // converge, so the pane is one this browser cannot size. Mirrors the Set + // above: `resizeRetry` caps the recursion inside one select, and this is + // what stops a fresh select from paying for the same answer again — see + // the geometry gate in selectSession. + this._geometryRetryUseless = new Set(); this.terminalLoadStates = new Map(); // Map this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers @@ -6718,6 +6724,33 @@ class CodemanApp { this._clearTerminalLoadState(sessionId, selectGen); _crashDiag.log(`SELECT_DONE: ${selectDoneMs.toFixed(0)}ms`); console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${selectDoneMs.toFixed(0)}ms`); + // Remember whether the replay was worth it, because `resizeRetry` only + // caps the recursion INSIDE one select and says nothing about the next + // one. A pane this browser cannot size — one whose resize `Session.resize` + // declines while a desktop claim is live, or one a second tmux client is + // also holding — reports the same mismatch on every select, so without a + // memo the diagnosis is paid for again on every tab switch, forever: two + // fetches per select rather than one. Each extra pass costs a second + // `capture-pane`, which is `execSync` and blocks the server's event loop, + // plus a reset and chunked rewrite, a discarded snapshot and cache entry, + // and a dropped and reopened WebSocket. + // + // A retry pass that STILL does not fit is the proof, since the retry ran + // at the size that stuck and the pane ignored it. Geometry that fits + // clears the memo, so a pane that becomes sizeable again (the desktop tab + // closes, the claim goes idle) is repaired on the next select. The race + // case is untouched: it converges on its first attempt, so it never + // reaches the branch that latches. + const capturedGeometryFits = + framePositionsRowsAbsolutely && + Number.isFinite(data.captureRows) && + !capturedTallerThanTerminal && + !capturedWiderThanTerminal; + if (capturedGeometryFits) { + this._geometryRetryUseless?.delete(sessionId); + } else if (options?.resizeRetry && (capturedTallerThanTerminal || capturedWiderThanTerminal)) { + (this._geometryRetryUseless ||= new Set()).add(sessionId); + } // What is on screen was drawn for a geometry this terminal does not have. // Replaying once against the size that stuck is the only thing that // repairs it: SIGWINCH reaches the CLI only on a real size change, and @@ -6727,6 +6760,7 @@ class CodemanApp { if ( (sizeMovedUnderLoad || capturedTallerThanTerminal || capturedWiderThanTerminal) && !captureMatchesRequestedSize && + !this._geometryRetryUseless?.has(sessionId) && !options?.resizeRetry && !this._isStaleSelect(selectGen) ) { diff --git a/test/capture-geometry-retry.browser.test.ts b/test/capture-geometry-retry.browser.test.ts index df209e8a..1f8301de 100644 --- a/test/capture-geometry-retry.browser.test.ts +++ b/test/capture-geometry-retry.browser.test.ts @@ -98,6 +98,41 @@ async function stubTerminal( }); } +/** + * As `stubTerminal`, but reading its geometry from a holder the test can change + * between selects. That is what lets one case watch a pane stop fitting and + * start fitting again, which a stub fixed at construction cannot show. + */ +async function stubTerminalDynamic( + page: Page, + counter: { n: number; urls: string[] }, + state: { captureRows: number; captureCols: number } +) { + await page.route('**/api/sessions/*/terminal*', async (route) => { + const url = route.request().url(); + counter.n += 1; + counter.urls.push(url); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + terminalBuffer: paneSnapshot(state.captureRows), + status: 'idle', + fullSize: 1024, + retainedBytes: 1024, + truncated: false, + truncationReason: null, + source: url.includes('full=1') ? 'mux-full-history' : 'mux-visible', + captureCols: state.captureCols, + captureRows: state.captureRows, + }, + }), + }); + }); +} + /** * Answer every fetch with the geometry the client itself is asking for, read * live from the page. That is the clamp signature: `getTerminalDimensions()` @@ -341,6 +376,55 @@ describe('a capture bigger than the terminal', () => { await context.close(); }, 60_000); + it('replays once per session, not once per tab switch, when it cannot converge', async () => { + // `resizeRetry` caps the recursion inside ONE select and says nothing about + // the next one, so a pane this browser cannot size reported the same + // mismatch on every select and bought the same failed repair every time: + // two fetches per tab switch for the life of the page. That is the case the + // description calls "every time rather than occasionally", a phone whose + // resize is declined while a desktop claim is live, and it is not the only + // one — any pane Codeman cannot size lands there, a second tmux client + // attached to it included. Each wasted pass costs another `capture-pane`, + // which is `execSync` on the server's event loop, plus a reset and rewrite, + // a discarded snapshot, and a dropped and reopened WebSocket. + context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + page = await context.newPage(); + const sessionId = await openSession(page); + + const fetches = { n: 0, urls: [] as string[] }; + const pane = { captureRows: 200, captureCols: 200 }; + await stubTerminalDynamic(page, fetches, pane); + await consumeFullHistory(page, sessionId, fetches); + + // First tab switch: one load, one replay, and the replay does not fit + // either, which is the proof that this pane ignores the size it is given. + await select(page, sessionId, { forceReload: true }); + expect(fetches.n).toBe(2); + + // Every switch after it pays once. Unlatched this reads 4 then 6. + await select(page, sessionId, { forceReload: true }); + expect(fetches.n).toBe(3); + await select(page, sessionId, { forceReload: true }); + expect(fetches.n).toBe(4); + + // The memo has to lift when the pane becomes sizeable again, or closing the + // desktop tab that was holding it would leave this session permanently + // unrepaired. A frame that fits clears it... + pane.captureRows = 5; + pane.captureCols = 40; + await select(page, sessionId, { forceReload: true }); + expect(fetches.n).toBe(5); + + // ...so the next genuine mismatch is diagnosed again. + pane.captureRows = 200; + pane.captureCols = 200; + await select(page, sessionId, { forceReload: true }); + expect(fetches.n).toBe(7); + + await closeSession(page, sessionId); + await context.close(); + }, 60_000); + it('hands over text typed but not yet submitted before it replays', async () => { // On a touch device the characters the user has typed live ONLY in the // local-echo overlay until Enter; they have never reached the PTY. The