/** * Repro: xterm <-> PTY column desync. * * Symptom (observed live, macOS, build 1.4.103): an interactive TUI (Claude * Code) renders garbled — ~1 char per line, overlapping text. Ruled out: * xterm version, font/letter-spacing, pane width, GPU (DOM renderer). * * Hypothesis: xterm reflows to the visible width but the PTY's * process.stdout.columns is pinned to a stale/tiny value, so the program keeps * emitting output sized for the wrong width and xterm faithfully paints * garbage. The suspected gate is isRendererPtyResizeAuthoritative() in * pty-connection.ts: while a pane is hidden it returns false, so xterm reflows * that happen off-screen never reach the PTY, and the resume-time correction * (safeFit + transport.resize, pty-connection.ts ~3316) is the only thing that * can re-sync. If that correction is missed, the PTY stays stale. * * The proof is a direct comparison: process.stdout.columns inside the PTY must * equal terminal.cols in xterm. This spec drives several scenarios that mimic * the real usage and asserts the two match. * * Reproduces reliably (always on the first mount) when run in isolation: * SKIP_BUILD=1 npx playwright test --config tests/playwright.config.ts \ * tests/e2e/terminal-column-desync-repro.spec.ts --project electron-headless \ * -g "during initial mount" * Observed: the first terminal spawns its PTY at the full window width * (e.g. 203 cols) while xterm fits the pane to the real layout width (79 cols); * the gap never closes. Under heavy parallel load the timing can shift, so the * `-g "during initial mount"` test (serial, reload-looped) is the golden repro. */ import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, getAllWorktreeIds, switchToWorktree, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { splitActiveTerminalPane, waitForActivePanePtyId, waitForActiveTerminalManager, waitForPaneIdentitySnapshot } from './helpers/terminal' import { waitForPtyColumnsAtMost } from './terminal-column-probes' import { waitForPtyShellEcho } from './terminal-pty-readiness' // Why: a generous ceiling so the PTY column probe returns the actual current // process.stdout.columns instead of waiting for it to drop below a target. const READ_ANY_COLS = 100_000 /** Read xterm's authoritative column count for the active terminal pane. */ async function readRenderedTerminalCols(page: Page): Promise { return page.evaluate(() => { const store = window.__store const state = store?.getState() const worktreeId = state?.activeWorktreeId const tabId = state?.activeTabType === 'terminal' ? state.activeTabId : worktreeId ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) : null const manager = tabId ? window.__paneManagers?.get(tabId) : null const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null return pane?.terminal?.cols ?? 0 }) } /** Read process.stdout.columns as seen by the program inside the PTY. */ async function readPtyCols(page: Page, ptyId: string): Promise { return waitForPtyColumnsAtMost(page, ptyId, READ_ANY_COLS, 30_000) } /** Read xterm cols for the pane bound to a specific PTY id. */ async function readRenderedColsForPty(page: Page, ptyId: string): Promise { return page.evaluate((ptyId) => { for (const manager of window.__paneManagers?.values() ?? []) { for (const pane of manager.getPanes?.() ?? []) { if (pane.container?.dataset?.ptyId === ptyId) { return pane.terminal?.cols ?? 0 } } } return 0 }, ptyId) } /** Read the size the main process reports as APPLIED for a PTY (pty:getSize). * After the applied-size fix this must reflect what the PTY actually took * (process.stdout.columns), not the renderer's last-requested size. */ async function readReportedPtyCols(page: Page, ptyId: string): Promise { return page.evaluate(async (ptyId) => { const size = await window.api?.pty?.getSize?.(ptyId) return size?.cols ?? 0 }, ptyId) } type ColumnSnapshot = { xtermCols: number; ptyCols: number } async function readColumnSnapshot(page: Page, ptyId: string): Promise { const xtermCols = await readRenderedTerminalCols(page) const ptyCols = await readPtyCols(page, ptyId) return { xtermCols, ptyCols } } async function closeRightSidebarAndFeatureTips(page: Page): Promise { await page.evaluate(() => { const store = window.__store if (!store) { return } store.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) if (store.getState().rightSidebarOpen) { store.getState().setRightSidebarOpen(false) } }) } async function settleTerminal(page: Page): Promise { await waitForActiveTerminalManager(page, 30_000) const ptyId = await waitForActivePanePtyId(page) await waitForPtyShellEcho(page, ptyId, 15_000) return ptyId } test.describe('Terminal column desync repro', () => { test('PTY columns stay in sync with xterm across a visible resize', async ({ orcaPage }) => { test.setTimeout(120_000) await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await closeRightSidebarAndFeatureTips(orcaPage) await ensureTerminalVisible(orcaPage) const ptyId = await settleTerminal(orcaPage) // Why: the resize chain (ResizeObserver → rAF fit → PTY resize IPC) needs // longer than a fixed wait under loaded CI, and the two columns are sampled // non-atomically. Poll until they converge — a genuinely dropped resize // never converges and still fails, so this keeps the regression guard. const expectColumnsInSync = async (label: string): Promise => { await expect .poll( async () => { const snap = await readColumnSnapshot(orcaPage, ptyId) return snap.ptyCols === snap.xtermCols ? 'synced' : `pty=${snap.ptyCols} xterm=${snap.xtermCols}` }, { timeout: 30_000, message: `${label}: PTY cols should converge to xterm cols` } ) .toBe('synced') } // Baseline: a freshly fit terminal should agree with its PTY. await expectColumnsInSync('baseline') // Shrink the window while the terminal is visible, then widen it. xterm // reflows via the ResizeObserver; the PTY must follow. await orcaPage.setViewportSize({ width: 760, height: 800 }) await expectColumnsInSync('after shrink') await orcaPage.setViewportSize({ width: 1280, height: 800 }) await expectColumnsInSync('after widen') }) // Why: guards the applied-size IPC contract the desync fix relies on. The // renderer's resume/handoff drift-check compares xterm against pty:getSize; if // pty:getSize reports the renderer's last-REQUESTED size (the old intent-only // behavior) instead of the size the PTY actually APPLIED, a dropped resize is // invisible and the TUI stays garbled. So pty:getSize must equal the real // in-PTY process.stdout.columns, not just xterm. test('pty:getSize reports the size the PTY actually applied', async ({ orcaPage }) => { test.setTimeout(120_000) await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await closeRightSidebarAndFeatureTips(orcaPage) await ensureTerminalVisible(orcaPage) const ptyId = await settleTerminal(orcaPage) await orcaPage.setViewportSize({ width: 900, height: 800 }) // Why: poll until pty:getSize converges to the real applied columns instead // of sampling once after a fixed wait — the resize can still be settling on // loaded CI. A getSize that reports intent (not the applied size) never // converges to process.stdout.columns and still fails the guard. await expect .poll( async () => { const ptyCols = await readPtyCols(orcaPage, ptyId) const reportedCols = await readReportedPtyCols(orcaPage, ptyId) return reportedCols === ptyCols ? 'match' : `reported=${reportedCols} pty=${ptyCols}` }, { timeout: 30_000, message: 'pty:getSize must converge to the applied PTY columns (process.stdout.columns) ' + 'so the drift-check can detect a dropped resize' } ) .toBe('match') }) test('PTY columns re-sync after the terminal is resized while hidden', async ({ orcaPage }) => { test.setTimeout(120_000) await waitForSessionReady(orcaPage) const homeWorktreeId = await waitForActiveWorktree(orcaPage) const otherWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== homeWorktreeId) test.skip(!otherWorktreeId, 'hidden-resize repro needs the seeded secondary worktree') if (!otherWorktreeId) { return } await closeRightSidebarAndFeatureTips(orcaPage) await ensureTerminalVisible(orcaPage) const ptyId = await settleTerminal(orcaPage) await orcaPage.setViewportSize({ width: 1280, height: 800 }) await orcaPage.waitForTimeout(400) const baseline = await readColumnSnapshot(orcaPage, ptyId) expect(baseline.ptyCols).toBe(baseline.xtermCols) // Hide the terminal by switching worktrees, resize the window narrow while // it is in the background (so isRendererPtyResizeAuthoritative() is false // and the off-screen reflow's pty:resize is dropped), then return. await switchToWorktree(orcaPage, otherWorktreeId) await waitForActiveTerminalManager(orcaPage, 30_000) await orcaPage.setViewportSize({ width: 720, height: 800 }) await orcaPage.waitForTimeout(500) await switchToWorktree(orcaPage, homeWorktreeId) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) await orcaPage.waitForTimeout(600) const afterReturn = await readColumnSnapshot(orcaPage, ptyId) expect( afterReturn.ptyCols, `after hidden resize + return, PTY cols (${afterReturn.ptyCols}) should equal xterm cols ` + `(${afterReturn.xtermCols}); a stale PTY width is the column-desync bug` ).toBe(afterReturn.xtermCols) }) test('PTY columns re-sync after repeated background resizes', async ({ orcaPage }) => { test.setTimeout(180_000) await waitForSessionReady(orcaPage) const homeWorktreeId = await waitForActiveWorktree(orcaPage) const otherWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== homeWorktreeId) test.skip( !otherWorktreeId, 'repeated background-resize repro needs the seeded secondary worktree' ) if (!otherWorktreeId) { return } await closeRightSidebarAndFeatureTips(orcaPage) await ensureTerminalVisible(orcaPage) const ptyId = await settleTerminal(orcaPage) // Several hide/resize/show cycles at different widths. Terminal timing bugs // need repetition: each cycle is a fresh chance for the resume-time // correction to miss and leave the PTY pinned at a stale column count. const widths = [700, 1320, 640, 1180, 600] for (const [index, width] of widths.entries()) { await switchToWorktree(orcaPage, otherWorktreeId) await waitForActiveTerminalManager(orcaPage, 30_000) await orcaPage.setViewportSize({ width, height: 800 }) await orcaPage.waitForTimeout(350) await switchToWorktree(orcaPage, homeWorktreeId) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) await orcaPage.waitForTimeout(500) const snapshot = await readColumnSnapshot(orcaPage, ptyId) expect( snapshot.ptyCols, `cycle ${index} (width ${width}): PTY cols (${snapshot.ptyCols}) should equal xterm cols ` + `(${snapshot.xtermCols})` ).toBe(snapshot.xtermCols) } }) test('both panes keep PTY columns synced after a vertical split reparent', async ({ orcaPage }) => { test.setTimeout(180_000) await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await closeRightSidebarAndFeatureTips(orcaPage) await ensureTerminalVisible(orcaPage) await orcaPage.setViewportSize({ width: 1280, height: 800 }) await orcaPage.waitForTimeout(300) const firstPtyId = await settleTerminal(orcaPage) const baseline = await readColumnSnapshot(orcaPage, firstPtyId) expect(baseline.ptyCols).toBe(baseline.xtermCols) // Splitting halves the width of the original pane: xterm reflows to ~half // the columns. The PTY must follow, otherwise the existing shell keeps // emitting full-width output into a half-width pane. await splitActiveTerminalPane(orcaPage, 'vertical') await expect .poll( async () => { const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) return snapshot.panes .map((pane) => pane.ptyId) .filter((ptyId): ptyId is string => Boolean(ptyId)) }, { timeout: 30_000, message: 'vertical split should produce two PTY-backed panes' } ) .toHaveLength(2) const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) for (const pane of snapshot.panes) { const ptyId = pane.ptyId expect(ptyId, 'split pane should be bound to a PTY').toBeTruthy() if (!ptyId) { continue } const ptyCols = await readPtyCols(orcaPage, ptyId) const xtermCols = await readRenderedColsForPty(orcaPage, ptyId) expect( ptyCols, `after split, pane ${ptyId} PTY cols (${ptyCols}) should equal its xterm cols (${xtermCols})` ).toBe(xtermCols) } }) // Why: the user-reported case — "start a new worktree with the side split // panel on". A tab that MOUNTS with a split layout already present (two panes // side by side from frame 0) spawns each PTY at the wide window width, then // the split equalize narrows each pane AFTER the post-spawn reconcile window // has closed. The corrective onResize is dropped by the visibility gate during // the mount window, so the PTY stays pinned wide while xterm shows the narrow // split width — only a later manual resize re-syncs it ("resizing fixed it"). // We reproduce the fresh split mount by splitting then reloading: the split // layout persists across reload, so the tab remounts with two panes already // present, re-running the first-mount spawn for each. test('both panes stay PTY-synced when a tab MOUNTS with a split layout present', async ({ orcaPage }) => { test.setTimeout(240_000) // A single mount only trips the race intermittently, so reload-loop the // restored-split first-mount and assert none of the attempts desynced. const MOUNT_ATTEMPTS = 6 const desyncs: { attempt: number; ptyId: string; ptyCols: number; xtermCols: number }[] = [] await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await closeRightSidebarAndFeatureTips(orcaPage) await ensureTerminalVisible(orcaPage) await orcaPage.setViewportSize({ width: 1440, height: 900 }) await orcaPage.waitForTimeout(300) await settleTerminal(orcaPage) // Establish the persisted split layout once; reloads below rebuild it. await splitActiveTerminalPane(orcaPage, 'vertical') await waitForPaneIdentitySnapshot(orcaPage, 2) for (let attempt = 0; attempt < MOUNT_ATTEMPTS; attempt += 1) { // Re-run the split first-mount path: a wide window, reload so the tab // remounts and re-spawns both PTYs at the wide width from the restored // split layout, then resize down while the panes are still mounting. await orcaPage.setViewportSize({ width: 1440, height: 900 }) await orcaPage.reload() await orcaPage.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await closeRightSidebarAndFeatureTips(orcaPage) await ensureTerminalVisible(orcaPage) // Resize narrower while the split panes are mounting / their PTYs spawn. await orcaPage.setViewportSize({ width: 1180, height: 800 }) await orcaPage.waitForTimeout(300) const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) // Let layout equalize and the (current) reconcile window run to completion. await orcaPage.waitForTimeout(900) for (const pane of snapshot.panes) { const ptyId = pane.ptyId expect(ptyId, 'restored split pane should be bound to a PTY').toBeTruthy() if (!ptyId) { continue } const ptyCols = await readPtyCols(orcaPage, ptyId) const xtermCols = await readRenderedColsForPty(orcaPage, ptyId) if (ptyCols !== xtermCols) { desyncs.push({ attempt, ptyId, ptyCols, xtermCols }) } } } expect( desyncs, `PTY columns desynced from xterm on a restored-split mount (${desyncs.length} pane(s) ` + `across ${MOUNT_ATTEMPTS} attempts). A PTY pinned at the wide startup width while xterm ` + `reflowed to the narrower split width is the column-desync bug that garbles interactive ` + `TUIs: ${JSON.stringify(desyncs)}` ).toEqual([]) }) // Why: this is the tightest isolation of the real bug. A viewport resize that // lands in the terminal's initial mount window — after xterm exists but // before the PTY binding/visibility settle — reflows xterm to the new width, // but forwardPtyResize drops it (isRendererPtyResizeAuthoritative()===false, // or the spawn captures the pre-resize cols and no later resize fires). The // PTY stays pinned at the startup width while xterm shows the new width, and // nothing re-syncs. A long-output program then prints sized for the stale // PTY width into the narrower pane → the garbled "1 char per line" render. test('PTY columns stay synced when the window is resized during initial mount', async ({ orcaPage }) => { test.setTimeout(240_000) // Why: the desync is a race in the *initial* mount window — the first // terminal spawns its PTY at the wide default window width, and a resize // landing before the PTY binding/visibility settle reflows xterm but is // dropped by forwardPtyResize, with no later correction. A single attempt // only trips it ~1 in 3 runs, so reload the renderer to re-run the full // first-mount sequence each attempt and resize mid-mount. We assert none of // the attempts desynced, so a single stale PTY fails the test. const MOUNT_ATTEMPTS = 8 const desyncs: { attempt: number; ptyCols: number; xtermCols: number }[] = [] for (let attempt = 0; attempt < MOUNT_ATTEMPTS; attempt += 1) { if (attempt > 0) { // Re-run the first-mount path: a wide window, then reload so the // terminal remounts and spawns its PTY at the wide width. await orcaPage.setViewportSize({ width: 1440, height: 900 }) await orcaPage.reload() await orcaPage.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) } await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await closeRightSidebarAndFeatureTips(orcaPage) await ensureTerminalVisible(orcaPage) // Resize down while the terminal is mounting / the PTY is spawning. await orcaPage.setViewportSize({ width: 1280, height: 800 }) await orcaPage.waitForTimeout(300) const ptyId = await settleTerminal(orcaPage) await orcaPage.waitForTimeout(700) const snapshot = await readColumnSnapshot(orcaPage, ptyId) if (snapshot.ptyCols !== snapshot.xtermCols) { desyncs.push({ attempt, ptyCols: snapshot.ptyCols, xtermCols: snapshot.xtermCols }) } } expect( desyncs, `PTY columns desynced from xterm on ${desyncs.length}/${MOUNT_ATTEMPTS} mount attempts. ` + `A PTY pinned at the wider startup width while xterm reflowed narrower is the ` + `column-desync bug that garbles interactive TUIs: ${JSON.stringify(desyncs)}` ).toEqual([]) }) })