fix(terminal): Ark0N's read of the #464 geometry work

Five items, two of which he could only see by running it, plus six smaller
ones. Taking the two blockers first, because both were wrong in ways the
existing tests could not catch.

**Adopting the PTY's rows put the CLI's input line off-screen.** A phone that
took a desktop's 43 rows into a viewport with room for 18 painted an
`.xterm-screen` far taller than its container; xterm's own viewport then had
nothing to scroll, so the bottom of the frame sat below the container with no
gesture able to reach it. Output visible, typing invisible, for as long as the
desktop kept the claim hot. `reconcilePtyGeometry` adopts COLUMNS ONLY now:
width is the axis Ink's wrap and `eraseLines` arithmetic depend on, and keeping
the local row count keeps the composer at the bottom of a viewport that
scrolls. Measured at his geometry — a 360x300 container against a 198x43 pane
now keeps 13 rows, takes 198 columns, paints 202px into a 210px container, and
the input line is inside the box.

**`capture-geometry-retry.browser.test.ts` failed, and CI could not see it**
because the file is in `BROWSER_TEST_GLOBS`. Its premise WAS the clamp —
`getTerminalDimensions()` floored while `fitAddon.fit()` did not — which this
work removes at the source, so it can never hold again at any viewport. The
case survives on its own terms: a pane already drawing at the requested size
must not be replayed. Its premise is now the #464 invariant itself, that the
floored report and the terminal agree, which is a stronger guard because the
clamp coming back fails it here rather than silently restoring the replay loop.
The helper docblock that repeated the old premise is corrected too.

**A session with no pane reported 120x40 and the client adopted it.**
`resize()` writes `_ptyCols`/`_ptyRows` only when `ptyProcess` is set and
nothing seeds them from the spawn geometry, so a dead-pane session still held
the constructor defaults — clicking that tab resized the browser terminal to
120x40 and, on anything narrower, claimed another device owned the pane when
none existed. `Session.ptyGeometry` returns null without a pane, the HTTP route
answers `{}` and the socket sends no frame at all. The raw `ptyCols`/`ptyRows`
getters are deleted rather than left available to be misused again.

**The 40-column floor clipped the pane with nothing able to reach it.** The
affordance keyed on a PTY mismatch, and the floor produces no mismatch — xterm
and the PTY agree throughout, the terminal is simply wider than the box. It
keys on what does not FIT now, MEASURED (`.xterm-screen` against the container,
on the next frame, because the screen takes its width with the render) rather
than derived from cell arithmetic. Measured at 360px: font 24 applies 40
columns and paints 560px, and all 200px of the overhang is reachable.
`.pty-oversized` is renamed `.term-overflows-x`, because after this the old
name describes only one of the two causes.

**"Scroll sideways" did not work on touch for the sessions it targets.**
`touch-action: pan-x` is cancelled before it starts by the `preventDefault()`
`touchstart` calls on every 'content' tap. The terminal's own touchmove handler
pans the container now, with the axis locked once per gesture so a diagonal
cannot pan and scroll at once, and the CSS grants no `touch-action` at all —
handing the browser a pan AS WELL would move the pane twice for one finger on
the taps where that preventDefault does not run. Measured under real touch
dispatch: a 140px swipe reaches `scrollLeft` 140 where it reached 0 before, the
buffer does not move with it, and a vertical swipe still scrolls the scrollback.

Three defects in the above, found while checking it rather than by being told:

- `canPanHorizontally` first tested `scrollWidth > clientWidth` alone, which is
  true of a container that is not a scroller — a sideways swipe would have
  locked the axis, done nothing, AND suppressed the vertical scroll it should
  have been. Gated on the class as well.
- The notice advised scrolling sideways whenever the PTY was wider, including
  when it still fitted and nothing scrolled. It is gated on measured overflow,
  and on a comparison against the width this container WOULD request rather
  than the one it currently holds — once adopted those are equal, so the second
  question answers itself false while the condition is still true.
- `_syncTerminalOverflowAffordance` could throw out of `document.getElementById`
  before reaching its try block. It runs off every geometry change, so a
  cosmetic affordance could have taken the resize down with it.

The smaller items:

- `docs/architecture-invariants.md` no longer explains the equality guard as a
  clamp signature; it records what the clamp used to do and why it cannot any
  more. Edited by hand — that file is outside the Prettier glob, and letting
  Prettier near it rewrote eleven unrelated emphasis markers.
- `throttledResize`'s HTTP fallback reads the reply. It is the path where a
  declined resize is least likely to be noticed, because no socket means no
  `{"t":"zc"}` frame either.
- The changeset covers the whole release: the geometry work, the queued replay
  clear, the renderer watchdog, the body-covering fetch deadline, the WebSocket
  output-gap reconcile, the build-generated service-worker precache and
  per-build cache key, and the crash-trail hygiene.
- `@xterm/headless` is declared in the root devDependencies instead of being
  reached through workspace hoisting.
- The output-gap marker is cleared after any response arrives, not only when
  the capture was non-empty: a server that answers with an empty capture HAS
  reconciled us, and leaving the marker set refetched on every reconnect.
- `e587d845`'s message claimed a test asserted the failed-load copy against the
  built asset. It did not — that assertion lived in a probe deleted with the
  other scratch scripts, so the claim was false when it was written. There is a
  real test now, and it reads the source rather than `dist/`, because `dist/` is
  not committed and a test that skips when it is absent would pass for the wrong
  reason in CI.

`Session.ptyGeometry` gets behavioural coverage against the real class in
`session-resize-arbitration.test.ts` rather than a source guard, including the
contrast — a pane that does exist still reports, and still follows a resize —
so "always null" would fail it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rounak Datta
2026-09-22 18:53:46 +05:30
co-authored by Claude Opus 5
parent e587d84590
commit e1e7dc5bd8
16 changed files with 438 additions and 145 deletions
+37 -13
View File
@@ -136,9 +136,10 @@ async function stubTerminalDynamic(
/**
* Answer every fetch with the geometry the client itself is asking for, read
* live from the page. That is the clamp signature: `getTerminalDimensions()`
* floors at 40x10 while `fitAddon.fit()` does not, so a small enough viewport
* makes the pane permanently bigger than the terminal at a size the client
* requested itself.
* floors at 40x10, and since #464 `syncTerminalGeometry()` applies that floor
* to xterm too — so at a small enough viewport the pane, the report and the
* terminal all agree on the floored size, which is the case the equality guard
* is left covering.
*/
async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; urls: string[] }) {
await page.route('**/api/sessions/*/terminal*', async (route) => {
@@ -498,13 +499,22 @@ describe('a capture bigger than the terminal', () => {
}, 60_000);
it('does not replay a pane already at the size the client asked for', async () => {
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does
// not, so a viewport this small leaves the terminal shorter than the size
// the client itself requests, and the pane obligingly draws at the floored
// size. The captured height then exceeds the terminal's forever. A replay
// cannot converge, because it re-requests the same floored size and
// captures the same frame, so without the equality guard this retries on
// every tab switch for the life of the page.
// ⚠️ The premise of this case CHANGED with issue #464, and the old one can
// never hold again. It used to be the clamp: `getTerminalDimensions()`
// floors at 40x10 while `fitAddon.fit()` did not, so a viewport this small
// left the terminal shorter than the size the client itself requested, the
// pane drew at the floored size, and the captured height exceeded the
// terminal's forever — a replay that re-requested the same floored size and
// captured the same frame, on every tab switch, for the life of the page.
//
// `syncTerminalGeometry()` now applies the floor to xterm as well, so the
// browser terminal IS the size it reports and that divergence is gone at
// the source. The case survives on its own terms — a pane already drawing
// at the requested size must not be replayed, because the retry would
// capture the identical frame — and its premise is now the #464 invariant
// itself, asserted below: the floored report and the terminal agree. That
// is a stronger guard than the old one, since the clamp coming back would
// fail it here rather than silently restoring the replay loop.
context = await browser.newContext({ viewport: { width: 320, height: 200 } });
page = await context.newPage();
const sessionId = await openSession(page);
@@ -514,8 +524,9 @@ describe('a capture bigger than the terminal', () => {
await consumeFullHistory(page, sessionId, fetches);
await select(page, sessionId, { forceReload: true });
// The premise: the floor really does bind here. Without this the case
// would pass on any viewport, proving nothing.
// The premise: the floor really does bind at this viewport — otherwise the
// case would pass on any viewport, proving nothing — AND the terminal holds
// exactly what it reports, which is what stops the old replay loop.
const requested = await page.evaluate(
() =>
(
@@ -523,7 +534,20 @@ describe('a capture bigger than the terminal', () => {
).app.getTerminalDimensions?.() ?? null
);
expect(requested).not.toBeNull();
expect(requested!.rows).toBeGreaterThan(await terminalRows(page));
const proposed = await page.evaluate(
() =>
(
window as unknown as { app: { fitAddon?: { proposeDimensions?: () => { cols: number; rows: number } } } }
).app.fitAddon?.proposeDimensions?.() ?? null
);
expect(proposed, 'the terminal could not be measured').not.toBeNull();
expect(
proposed!.rows < requested!.rows || proposed!.cols < requested!.cols,
`the floor must bind at this viewport, or the case proves nothing (proposed ${proposed!.cols}x${proposed!.rows}, reported ${requested!.cols}x${requested!.rows})`
).toBe(true);
// The #464 invariant: what the client reports is what the terminal holds.
expect(requested!.rows).toBe(await terminalRows(page));
expect(requested!.cols).toBe(await terminalCols(page));
expect(fetches.n).toBe(1);
@@ -66,6 +66,11 @@ function makeApp(overrides: Record<string, unknown> = {}) {
// now, so the harness must let it (#464).
syncTerminalGeometry: mixin.syncTerminalGeometry,
_resizeTerminalTo: mixin._resizeTerminalTo,
// Real, so a geometry change really does re-check whether the terminal now
// overflows its container (#464 item 4) — the fake DOM has no container, so
// it measures nothing and settles on "no overflow", which is the truth here.
_scheduleOverflowAffordanceSync: mixin._scheduleOverflowAffordanceSync,
_syncTerminalOverflowAffordance: mixin._syncTerminalOverflowAffordance,
_onPtyGeometryReport: vi.fn(),
getTerminalDimensions: () => ({ cols: 120, rows: 40 }),
terminal: { cols: 120, rows: 40, resize: vi.fn() },
+31
View File
@@ -19,6 +19,37 @@ function attachFakePty(session: Session, cols = 160, rows = 48) {
return resize;
}
describe('Session.ptyGeometry', () => {
// ⚠️ `resize()` writes `_ptyCols`/`_ptyRows` only when `ptyProcess` is set,
// and nothing seeds them from the spawn geometry — so the fields hold the
// constructor defaults of 120x40 for any session whose pane is not running.
// Reporting those to a client made it adopt a width no process had ever been
// told, and on anything narrower than 120 columns claim another device owned
// the pane when none existed (issue #464).
it('reports nothing for a session that has no pane', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
expect(session.ptyGeometry).toBeNull();
});
it('still reports nothing after a resize it could not apply', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
session.resize(45, 20, { viewportType: 'mobile' });
// The resize was swallowed (no pty to resize), so there is no geometry to
// report — NOT the 120x40 the fields still hold.
expect(session.ptyGeometry).toBeNull();
});
it('reports the pane geometry once a pane exists, and follows a resize', () => {
// The contrast, so "always null" would fail this.
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
attachFakePty(session, 160, 48);
expect(session.ptyGeometry).toEqual({ cols: 160, rows: 48 });
session.resize(62, 40, { viewportType: 'mobile' });
expect(session.ptyGeometry).toEqual({ cols: 62, rows: 40 });
});
});
describe('Session resize arbitration', () => {
it('lets a mobile-only session shrink below the spawn default (no desktop connected)', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
+120 -39
View File
@@ -161,39 +161,40 @@ describe('clampTerminalDimensions', () => {
describe('reconcilePtyGeometry', () => {
const { reconcilePtyGeometry } = loadGeometry();
it('does nothing when the terminal already matches the PTY', () => {
expect(reconcilePtyGeometry({ cols: 62, rows: 40 }, { cols: 62, rows: 40 })).toEqual({
adopt: false,
oversized: false,
});
it('does nothing when the terminal already has the PTY\u2019s width', () => {
expect(reconcilePtyGeometry({ cols: 62, rows: 40 }, { cols: 62, rows: 40 })).toEqual({ adopt: false, cols: null });
});
it('adopts a PTY the client never asked for — a declined resize is still the truth', () => {
it('adopts a width the client never asked for \u2014 a declined resize is still the truth', () => {
// Session.resize ignores a small viewport while a desktop claim is live.
expect(reconcilePtyGeometry({ cols: 62, rows: 40 }, { cols: 120, rows: 40 })).toEqual({
adopt: true,
oversized: true,
});
expect(reconcilePtyGeometry({ cols: 62, rows: 40 }, { cols: 120, rows: 40 })).toEqual({ adopt: true, cols: 120 });
});
it('adopts without claiming oversized when the PTY is merely shorter or narrower', () => {
expect(reconcilePtyGeometry({ cols: 120, rows: 40 }, { cols: 80, rows: 40 })).toEqual({
adopt: true,
oversized: false,
});
expect(reconcilePtyGeometry({ cols: 62, rows: 40 }, { cols: 62, rows: 12 })).toEqual({
adopt: true,
oversized: false,
});
// \u26a0\ufe0f The regression this pins: adopting the PTY's ROWS put a phone that took
// a desktop's 43 into a viewport with room for 18, which painted the CLI's
// input line below the container with nothing able to scroll to it. Width is
// the axis the wrap arithmetic needs; rows only decide how much is on screen.
it('never asks for the PTY\u2019s rows, however far off they are', () => {
for (const ptyRows of [43, 4, 400]) {
const out = reconcilePtyGeometry({ cols: 62, rows: 18 }, { cols: 120, rows: ptyRows });
expect(out).toEqual({ adopt: true, cols: 120 });
expect(out).not.toHaveProperty('rows');
}
});
it('does nothing when only the rows differ', () => {
expect(reconcilePtyGeometry({ cols: 62, rows: 40 }, { cols: 62, rows: 12 })).toEqual({ adopt: false, cols: null });
});
it('adopts a narrower PTY too \u2014 the width it was told is the width it draws for', () => {
expect(reconcilePtyGeometry({ cols: 120, rows: 40 }, { cols: 80, rows: 40 })).toEqual({ adopt: true, cols: 80 });
});
it('keeps its own geometry when the server reported none', () => {
// An older server answers the resize POST with `{}`; that is not evidence.
for (const bad of [null, {}, { cols: 62 }, { cols: 'wide', rows: 40 }]) {
expect(reconcilePtyGeometry({ cols: 62, rows: 40 }, bad as Partial<Dims>)).toEqual({
adopt: false,
oversized: false,
});
// A session with no pane answers `{}` (Session.ptyGeometry is null), and an
// older server answers `{}` too. Neither is evidence about any PTY.
for (const bad of [null, {}, { rows: 40 }, { cols: 'wide', rows: 40 }]) {
expect(reconcilePtyGeometry({ cols: 62, rows: 40 }, bad as Partial<Dims>)).toEqual({ adopt: false, cols: null });
}
});
});
@@ -313,15 +314,78 @@ describe('exactly one function may change the terminal size', () => {
});
});
describe('the failed-load notice fits the narrowest terminal this app will render', () => {
// ⚠️ `e587d845`'s commit message claimed a test asserted this against the
// BUILT asset. It did not: that assertion lived in a throwaway probe that was
// deleted with the rest of the scratch scripts, so the claim was wrong when it
// was written. This is the real one, and it reads the source rather than
// `dist/`, because `dist/` is not committed and a test that skips when it is
// absent would pass for the wrong reason in CI.
const app = read('src/web/public/app.js');
const { TERMINAL_MIN_COLS } = loadGeometry();
/** The literal the catch writes, escapes resolved, SGR stripped. */
function noticeLines(): string[] {
const at = app.indexOf('if (clearedBeforeFresh && this.terminal)');
expect(at, 'the failed-load branch is gone — renamed?').toBeGreaterThan(-1);
// Anchored past the comments: one of them quotes a lone '.', which a
// first-quote match happily returns instead of the notice.
const writeAt = app.indexOf('this.terminal.write(', at);
expect(writeAt, 'the failed-load branch no longer writes to the terminal').toBeGreaterThan(-1);
const call = app.slice(writeAt, app.indexOf('\n }', writeAt));
const literal = call.match(/'((?:[^'\\]|\\.)*)'/);
expect(literal, 'no string literal in the failed-load branch').not.toBeNull();
return literal![1]
.replace(/\\x1b\[[0-9;]*m/g, '')
.split('\\r\\n')
.filter((line) => line.trim().length > 0);
}
it('says what failed, that the session lives, and what to do', () => {
const lines = noticeLines();
expect(lines.length).toBe(3);
expect(lines[0]).toMatch(/did not load/i);
expect(lines[1]).toMatch(/live output/i);
// A dead end is the most expensive defect here: the pane is blank and the
// reader has no idea whether the session is recoverable.
expect(lines[2], 'the notice must name the next step').toMatch(/reload/i);
});
it('never wraps, down to the 40-column floor', () => {
// A 52-character sentence measured at 320px wrapped and left a lone '.' on
// a line of its own. The floor is the narrowest this app renders, and it is
// two taps away on a small phone via increaseFontSize.
for (const line of noticeLines()) {
expect(
line.length,
`"${line}" is ${line.length} columns, over the ${TERMINAL_MIN_COLS} floor`
).toBeLessThanOrEqual(TERMINAL_MIN_COLS);
}
});
it('does not tell the reader to reopen the tab, which retries nothing', () => {
// selectSession early-returns when the session is already active, so
// clicking the tab you are already on does not re-fetch.
expect(app).toContain('if (this.activeSessionId === sessionId && !forceReload)');
expect(noticeLines().join(' ')).not.toMatch(/reopen|switch tab/i);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Resize stopped being write-only.
// ───────────────────────────────────────────────────────────────────────────
describe('the server reports the geometry the PTY actually holds', () => {
it('Session exposes the effective dimensions', () => {
it('Session reports its geometry only while a pane is actually drawing', () => {
const session = read('src/session.ts');
expect(session).toMatch(/get ptyCols\(\): number \{\s*return this\._ptyCols;/);
expect(session).toMatch(/get ptyRows\(\): number \{\s*return this\._ptyRows;/);
// ⚠️ `resize()` writes _ptyCols/_ptyRows only when ptyProcess is set and
// nothing seeds them from the spawn geometry, so a dead-pane session still
// holds the constructor defaults of 120x40. Reporting those made a client
// adopt a size no process was ever told, and claim another device owned the
// pane when none existed.
expect(session).toMatch(/get ptyGeometry\(\): \{ cols: number; rows: number \} \| null \{/);
expect(session).toContain('return this.ptyProcess ? { cols: this._ptyCols, rows: this._ptyRows } : null;');
expect(session, 'the raw getters would report the defaults again').not.toMatch(/get ptyCols\(\)/);
});
it('the WebSocket answers a resize with what took', () => {
@@ -330,8 +394,9 @@ describe('the server reports the geometry the PTY actually holds', () => {
expect(at).toBeGreaterThan(-1);
const after = ws.slice(at, at + 1400);
expect(after).toContain('"t":"zc"');
expect(after).toContain('session.ptyCols');
expect(after).toContain('session.ptyRows');
expect(after).toContain('const applied = session.ptyGeometry;');
// No pane, no frame at all.
expect(after).toContain('if (applied && socket.readyState === 1)');
// Documented in the protocol block at the top of the file, like every other frame.
expect(ws).toContain('{"t":"zc","c":N,"r":N}');
});
@@ -341,7 +406,7 @@ describe('the server reports the geometry the PTY actually holds', () => {
const at = routes.indexOf("app.post('/api/sessions/:id/resize'");
expect(at).toBeGreaterThan(-1);
const handler = routes.slice(at, at + 1600);
expect(handler).toContain('return { cols: session.ptyCols, rows: session.ptyRows };');
expect(handler).toContain('return session.ptyGeometry ?? {};');
});
it('the client adopts the report and re-bases its dedupe on it', () => {
@@ -350,10 +415,11 @@ describe('the server reports the geometry the PTY actually holds', () => {
expect(start).toBeGreaterThan(-1);
const body = terminalUi.slice(start, terminalUi.indexOf('\n },', start));
expect(body).toContain('reconcilePtyGeometry');
expect(body).toContain('this._resizeTerminalTo({ cols, rows })');
// Columns only: the local row count is carried through untouched.
expect(body).toContain('this._resizeTerminalTo({ cols, rows: local.rows })');
// Without this the next resize is deduped against a request that was
// REFUSED, which suppresses the retry that recovers the pane.
expect(body).toContain('this._lastResizeDims = { cols, rows }');
expect(body).toContain('this._lastResizeDims = { cols, rows: local.rows }');
// And the WS frame is wired up at all.
expect(read('src/web/public/app.js')).toContain("msg.t === 'zc'");
});
@@ -370,15 +436,30 @@ describe('the server reports the geometry the PTY actually holds', () => {
const body = css.slice(at + selector.length, css.indexOf('\n}', at));
return body.replace(/\/\*[\s\S]*?\*\//g, '');
};
const oversized = declarationsOf('.terminal-container.pty-oversized');
const oversized = declarationsOf('.terminal-container.term-overflows-x');
expect(oversized).toContain('overflow-x: auto;');
// Both axes, explicitly: mobile.css sets `overflow: visible` on the bare
// selector, and a lone overflow-x would leave overflow-y computing to auto.
expect(oversized).toContain('overflow-y: hidden;');
// On the base rule, not only the .touch-device variant — mobile.css's
// `.terminal-container { touch-action: none }` is unscoped.
expect(oversized).toContain('touch-action: pan-x;');
// The class is only ever on while the mismatch is.
expect(read('src/web/public/terminal-ui.js')).toContain("classList.toggle('pty-oversized', !!oversized)");
// ⚠️ NO touch-action here, deliberately. `touch-action: pan-x` does nothing
// for the sessions this targets — `touchstart` preventDefault()s every
// 'content' tap, which cancels the browser's pan before it starts — and
// granting it as well as the JS pan would move the pane twice for one
// finger on the taps where that preventDefault does not run. The terminal's
// own touchmove handler owns both axes; mobile.css's unscoped
// `touch-action: none` is what keeps it the only owner.
expect(css.slice(css.indexOf('.terminal-container.term-overflows-x'))).not.toMatch(
/\.terminal-container\.term-overflows-x[^{]*\{[^}]*touch-action/
);
expect(read('src/web/public/terminal-ui.js')).toContain('const canPanHorizontally = () =>');
expect(read('src/web/public/terminal-ui.js')).toContain("if (panAxis === 'x') {");
// The class is only ever on while the terminal really is too wide, and it
// is MEASURED rather than derived: the floor widens the terminal past a
// narrow container with the PTY agreeing throughout, so a mismatch test
// would never fire for it (360px, font 24: 218px unreachable).
expect(read('src/web/public/terminal-ui.js')).toContain("classList.toggle('term-overflows-x', overflows)");
expect(read('src/web/public/terminal-ui.js')).toContain(
'screen.getBoundingClientRect().width - container.clientWidth > 1'
);
});
});