diff --git a/src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts b/src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts new file mode 100644 index 00000000000..1bb0be78700 --- /dev/null +++ b/src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ACTIVATION_DEFERRED_ADMISSION_LIMIT, + isActivationAdmissionEligible, + pickNextActivationDeferredTabId, + scheduleActivationDeferredAdmission +} from './activation-deferred-tab-admission' +import { + planColdActivationTabDeferral, + revealActivationDeferredTabs +} from './background-terminal-worktree-mount' + +const tabIds = (count: number): string[] => + Array.from({ length: count }, (_, index) => `tab-${index + 1}`) + +/** Runs the scheduler against timers instead of idle callbacks. */ +function withoutIdleCallbacks(run: () => void): void { + const originalRequest = globalThis.requestIdleCallback + const originalCancel = globalThis.cancelIdleCallback + vi.useFakeTimers() + try { + // @ts-expect-error -- exercising the no-requestIdleCallback environment + globalThis.requestIdleCallback = undefined + // @ts-expect-error -- exercising the no-requestIdleCallback environment + globalThis.cancelIdleCallback = undefined + run() + } finally { + globalThis.requestIdleCallback = originalRequest + globalThis.cancelIdleCallback = originalCancel + vi.useRealTimers() + } +} + +describe('activation-deferred tab admission', () => { + it('picks deferred tabs in tab order', () => { + expect(pickNextActivationDeferredTabId(tabIds(4), new Set(['tab-4', 'tab-2']))).toBe('tab-2') + }) + + it('reports nothing to admit for an empty, absent, or stale deferred set', () => { + expect(pickNextActivationDeferredTabId(tabIds(3), new Set())).toBeNull() + expect(pickNextActivationDeferredTabId(tabIds(3), null)).toBeNull() + expect(pickNextActivationDeferredTabId(tabIds(2), new Set(['tab-9']))).toBeNull() + }) + + it('only warms up a deferred population the pre-deferral behaviour would have mounted', () => { + expect(isActivationAdmissionEligible(0)).toBe(false) + expect(isActivationAdmissionEligible(ACTIVATION_DEFERRED_ADMISSION_LIMIT)).toBe(true) + expect(isActivationAdmissionEligible(ACTIVATION_DEFERRED_ADMISSION_LIMIT + 1)).toBe(false) + }) + + // The contract that makes deferral free: repeated admission ends with the + // worktree exactly as fully mounted as it would have been without deferral. + it('drains to a fully mounted worktree with no restriction left behind', () => { + const restrictions = new Map>() + const deferredMountTabIdsByWorktree = new Map>() + const allTabIds = tabIds(4) + planColdActivationTabDeferral({ + restrictions, + deferredMountTabIdsByWorktree, + worktreeId: 'wt-1', + allTabIds, + isTabLive: () => false, + isTabDeferrable: () => true, + immediateTabIds: new Set(['tab-1']) + }) + expect(deferredMountTabIdsByWorktree.get('wt-1')?.size).toBe(3) + + const admitted: string[] = [] + for (let step = 0; step < allTabIds.length; step += 1) { + const nextTabId = pickNextActivationDeferredTabId( + allTabIds, + deferredMountTabIdsByWorktree.get('wt-1') + ) + if (!nextTabId) { + break + } + admitted.push(nextTabId) + revealActivationDeferredTabs({ + restrictions, + deferredMountTabIdsByWorktree, + worktreeId: 'wt-1', + allTabIds, + immediateTabIds: new Set([nextTabId]) + }) + } + + expect(admitted).toEqual(['tab-2', 'tab-3', 'tab-4']) + expect(restrictions.has('wt-1')).toBe(false) + expect(deferredMountTabIdsByWorktree.has('wt-1')).toBe(false) + }) + + it('falls back to a timer when idle callbacks are unavailable', () => { + withoutIdleCallbacks(() => { + let ran = false + scheduleActivationDeferredAdmission(() => { + ran = true + }) + vi.advanceTimersByTime(1) + expect(ran).toBe(true) + }) + }) + + it('cancels a scheduled admission before it can run', () => { + withoutIdleCallbacks(() => { + const cancel = scheduleActivationDeferredAdmission(() => { + throw new Error('cancelled admission must not run') + }) + cancel() + vi.advanceTimersByTime(1_000) + }) + }) +}) diff --git a/src/renderer/src/components/terminal/activation-deferred-tab-admission.ts b/src/renderer/src/components/terminal/activation-deferred-tab-admission.ts new file mode 100644 index 00000000000..b6cf749b2ca --- /dev/null +++ b/src/renderer/src/components/terminal/activation-deferred-tab-admission.ts @@ -0,0 +1,60 @@ +/** + * Idle-frame admission of the tabs a worktree activation deferred. + * + * Why: activation mounts only what the user can see, so the switch paints at + * one-pane cost. The hidden siblings still belong in the warm working set — + * admitting them one per idle frame restores the pre-deferral steady state + * without putting any of it on the switch's critical path. + */ + +// Why a cap, and why exactly this number: before deferral shrank to "visible +// only", an activation eagerly mounted its hidden tabs whenever no more than +// four of them were deferrable, and left the rest unmounted for good. Admission +// reproduces that warm set and never exceeds it — a worktree that deferred more +// than this at activation keeps every deferred tab unmounted, exactly as it did +// before. So the steady-state pane, WebGL-context and heap population is +// unchanged; only the frame the mounts land on moved. +export const ACTIVATION_DEFERRED_ADMISSION_LIMIT = 4 + +// Why a timeout: a renderer that never goes idle (an agent flooding a pane) +// must still finish admitting, or those tabs stay unmounted until the next visit. +export const ACTIVATION_DEFERRED_ADMISSION_IDLE_TIMEOUT_MS = 250 + +/** Whether an activation's deferred population is small enough to warm up in the background. */ +export function isActivationAdmissionEligible(deferredTabCount: number): boolean { + return deferredTabCount > 0 && deferredTabCount <= ACTIVATION_DEFERRED_ADMISSION_LIMIT +} + +/** Next deferred tab in tab order, so admission follows the tab bar the user reads. */ +export function pickNextActivationDeferredTabId( + allTabIds: readonly string[], + deferredTabIds: ReadonlySet | null | undefined +): string | null { + if (!deferredTabIds || deferredTabIds.size === 0) { + return null + } + for (const tabId of allTabIds) { + if (deferredTabIds.has(tabId)) { + return tabId + } + } + return null +} + +/** + * Runs `callback` on an idle frame. Idle is the whole scheduling contract: the + * reveal's own restore is foreground work, so it takes the frame first and + * warm-up cannot contend with the paint it exists to serve. + */ +export function scheduleActivationDeferredAdmission(callback: () => void): () => void { + const requestIdle = globalThis.requestIdleCallback + const cancelIdle = globalThis.cancelIdleCallback + if (typeof requestIdle !== 'function' || typeof cancelIdle !== 'function') { + const timer = globalThis.setTimeout(callback, 0) + return () => globalThis.clearTimeout(timer) + } + const handle = requestIdle(() => callback(), { + timeout: ACTIVATION_DEFERRED_ADMISSION_IDLE_TIMEOUT_MS + }) + return () => cancelIdle(handle) +} diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts index 9cf64002290..0978393bba7 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts @@ -369,7 +369,7 @@ describe('cold activation tab deferral', () => { expect(resolve).toHaveBeenCalledOnce() }) - it('does not defer when most tabs are already live', () => { + it('does not defer when every tab must mount anyway', () => { const restrictions = new Map>() const deferredMountTabIdsByWorktree = new Map>() const deferring = planColdActivationTabDeferral({ @@ -377,7 +377,7 @@ describe('cold activation tab deferral', () => { deferredMountTabIdsByWorktree, worktreeId: 'wt-1', allTabIds: tabIds(10), - isTabLive: (tabId) => tabId !== 'tab-10', + isTabLive: () => true, isTabDeferrable: () => true, immediateTabIds: new Set() }) @@ -385,6 +385,23 @@ describe('cold activation tab deferral', () => { expect(restrictions.has('wt-1')).toBe(false) }) + it('defers a single hidden tab so an ordinary switch mounts only what is visible', () => { + const restrictions = new Map>() + const deferredMountTabIdsByWorktree = new Map>() + const deferring = planColdActivationTabDeferral({ + restrictions, + deferredMountTabIdsByWorktree, + worktreeId: 'wt-1', + allTabIds: tabIds(2), + isTabLive: () => false, + isTabDeferrable: () => true, + immediateTabIds: new Set(['tab-1']) + }) + expect(deferring).toBe(true) + expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1'])) + expect(deferredMountTabIdsByWorktree.get('wt-1')).toEqual(new Set(['tab-2'])) + }) + it('reveals newly visible tabs and lifts the restriction once all are revealed', () => { const restrictions = new Map>([['wt-1', new Set(['tab-1'])]]) const allTabIds = tabIds(3) diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts index 7433a559fed..8f8bf4461b0 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts @@ -140,7 +140,15 @@ export function shouldMountBackgroundWorktreeTab( // seconds (field trace: 200+ replay-guard stall releases in one activation // window). Deferred tabs behave like cold-parked tabs from birth: no view // until first reveal, parked byte watchers own their side effects meanwhile. -export const COLD_ACTIVATION_TAB_DEFER_THRESHOLD = 4 +// +// Why 0 and not a small budget: measured switch-to-first-paint scales linearly +// with the tabs an activation mounts (~40ms/tab even without a GPU), and the +// hidden ones buy the visible pane nothing. The old budget of 4 exempted the +// 2-5 tab worktrees that make up almost every real switch, so they paid the +// full fan-out. Nothing is traded away: useActivationDeferredTabAdmission +// mounts the hidden siblings on idle frames right after the reveal, so the warm +// working set (and later tab switches) ends up exactly where it was before. +export const COLD_ACTIVATION_TAB_DEFER_THRESHOLD = 0 export function canMountTerminalWorkspaceForStartup(args: { workspaceSessionReady: boolean diff --git a/src/renderer/src/components/terminal/use-activation-deferred-tab-admission.ts b/src/renderer/src/components/terminal/use-activation-deferred-tab-admission.ts new file mode 100644 index 00000000000..5ebef1d7146 --- /dev/null +++ b/src/renderer/src/components/terminal/use-activation-deferred-tab-admission.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react' +import { useAppStore } from '@/store' +import { + isActivationAdmissionEligible, + pickNextActivationDeferredTabId, + scheduleActivationDeferredAdmission +} from './activation-deferred-tab-admission' +import { revealActivationDeferredTabs } from './background-terminal-worktree-mount' +import type { TerminalColdActivationController } from '../terminal-cold-activation' + +/** + * Mounts the active worktree's activation-deferred tabs, one per idle frame. + * + * Why after the reveal and not during it: the switch only owes the user the + * pane they are looking at. Everything else is warm-up, so it runs where it + * cannot delay a frame — and the worktree still ends up as fully mounted as it + * was before deferral, which is what keeps later tab switches instant. + * + * One tab per effect run, not a loop: admitting bumps the mount revision, which + * re-runs this effect and schedules the next one. The drain is the render cycle. + */ +export function useActivationDeferredTabAdmission( + controller: TerminalColdActivationController +): void { + const { + activationDeferredMountTabIdsByWorktreeRef, + backgroundMountRevision, + backgroundMountTabIdsByWorktreeRef, + renderedActiveWorktreeId, + setBackgroundMountRevision + } = controller + // Why the verdict is taken once per activation: draining the set must not walk + // an over-cap worktree down into eligibility and warm up tabs the pre-deferral + // behaviour would have left unmounted. + const admissionRef = useRef<{ worktreeId: string; eligible: boolean } | null>(null) + + useEffect(() => { + const worktreeId = renderedActiveWorktreeId + if (!worktreeId) { + return + } + const deferredTabIds = activationDeferredMountTabIdsByWorktreeRef.current.get(worktreeId) + if (admissionRef.current?.worktreeId !== worktreeId) { + admissionRef.current = { + worktreeId, + eligible: isActivationAdmissionEligible(deferredTabIds?.size ?? 0) + } + } + if (!admissionRef.current.eligible || !deferredTabIds?.size) { + return + } + return scheduleActivationDeferredAdmission(() => { + // Why re-read: tabs can be created or closed between the scheduling frame + // and this one, and admitting a stale id would strand the restriction. + const allTabIds = (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).map( + (tab) => tab.id + ) + const nextTabId = pickNextActivationDeferredTabId( + allTabIds, + activationDeferredMountTabIdsByWorktreeRef.current.get(worktreeId) + ) + if (!nextTabId) { + return + } + revealActivationDeferredTabs({ + restrictions: backgroundMountTabIdsByWorktreeRef.current, + deferredMountTabIdsByWorktree: activationDeferredMountTabIdsByWorktreeRef.current, + worktreeId, + allTabIds, + immediateTabIds: new Set([nextTabId]) + }) + setBackgroundMountRevision((revision) => revision + 1) + }) + // oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs and setters preserve their original stable identities. + }, [backgroundMountRevision, renderedActiveWorktreeId]) +} diff --git a/src/renderer/src/components/use-terminal-controller.ts b/src/renderer/src/components/use-terminal-controller.ts index 07ebf7eb13d..29d5bdfebe8 100644 --- a/src/renderer/src/components/use-terminal-controller.ts +++ b/src/renderer/src/components/use-terminal-controller.ts @@ -8,6 +8,7 @@ import { useTerminalParkingFoundation } from './use-terminal-parking-foundation' import { useTerminalParkingPass } from './use-terminal-parking-pass' import { useTerminalBrowserRetention } from './use-terminal-browser-retention' import { applyTerminalColdActivation } from './terminal-cold-activation' +import { useActivationDeferredTabAdmission } from './terminal/use-activation-deferred-tab-admission' import { useTerminalWatcherEffects } from './use-terminal-watcher-effects' import { useTerminalCreateActions } from './use-terminal-create-actions' import { useTerminalCloseActions } from './use-terminal-close-actions' @@ -28,6 +29,7 @@ export function useTerminalController() { useTerminalBrowserRetention(parking) const coldActivation = Object.assign(parking, applyTerminalColdActivation(parking)) useTerminalWatcherEffects(coldActivation) + useActivationDeferredTabAdmission(coldActivation) const create = Object.assign(coldActivation, useTerminalCreateActions(coldActivation)) const close = Object.assign(create, useTerminalCloseActions(create)) const bulkClose = Object.assign(close, useTerminalBulkCloseActions(close)) diff --git a/tests/e2e/worktree-switch-first-paint.spec.ts b/tests/e2e/worktree-switch-first-paint.spec.ts new file mode 100644 index 00000000000..6524afca4c3 --- /dev/null +++ b/tests/e2e/worktree-switch-first-paint.spec.ts @@ -0,0 +1,437 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { loadWorktreesUntilPathsPresent } from './helpers/worktree-registration' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +/** + * Worktree-switch first-paint budget. + * + * Why this exists: worktree-switch-responsiveness.spec.ts proves the click task + * stays short, and the reveal-convergence spec proves the buffer eventually + * matches. Neither covers the symptom users report — the revealed terminal is + * BLANK for a beat after the switch. This measures the phase that owns that + * beat: switch click -> revealed pane has painted its restored content. + * + * The scenario is the one that dominates at many-worktree scale: a switch to a + * worktree whose tabs are in the persisted session but have never been mounted + * in this renderer. Hot-retain only keeps 4 worktrees warm, so with hundreds of + * worktrees essentially every switch is this one. Reloading the renderer between + * rounds reproduces it exactly, at production parking timings. + */ + +// Why 3: the field profile that motivated this budget has 449 worktrees whose +// median tab count is 2-3, so a 3-tab worktree is the switch users actually pay for. +const TABS_PER_WORKTREE = Number(process.env.ORCA_SWITCH_TABS ?? '3') +const SCROLLBACK_LINES = 1_500 +// Budget: a switch has to look instant. Anything over this reads as a stall. +const FIRST_PAINT_BUDGET_MS = Number(process.env.ORCA_SWITCH_BUDGET_MS ?? '250') +// Why repeat: a single cold reveal on a loaded dev machine swings by tens of ms, +// which is the same order as the effect under test. +const SWITCH_SAMPLE_COUNT = Number(process.env.ORCA_SWITCH_ROUNDS ?? '5') + +type SwitchSample = { + activationMs: number | null + paneMountedMs: number | null + contentPaintedMs: number | null + maxFrameGapMs: number + longTaskTotalMs: number + worstLongTaskMs: number + mountedAtActivation: number + settledPaneManagers: number + settledPanes: number + settledWebglContexts: number +} + +type SwitchPaintProbe = { + t0: number + activationMs: number | null + paneMountedMs: number | null + contentPaintedMs: number | null + frames: number[] + longTasks: number[] + mountedAtActivation: number + stop: () => void +} + +declare global { + var __switchPaintProbe: SwitchPaintProbe | undefined +} + +async function ensureTabs(page: Page, worktreeId: string, marker: string): Promise { + await switchToWorktree(page, worktreeId) + await ensureTerminalVisible(page) + const tabIds: string[] = [] + for (let index = 0; index < TABS_PER_WORKTREE; index += 1) { + const tabId = await page.evaluate( + ({ id, wanted }) => { + const state = window.__store!.getState() + const existing = state.tabsByWorktree[id] ?? [] + const reuse = existing[wanted] + const tab = reuse ?? state.createTab(id, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { id: worktreeId, wanted: index } + ) + await waitForActiveTerminalManager(page, 30_000) + const ptyId = await waitForActivePanePtyId(page, 30_000) + const label = `${marker}_T${index}` + await execInTerminal( + page, + ptyId, + `for i in $(seq 1 ${SCROLLBACK_LINES}); do echo "${label}_$i ${'y'.repeat(48)}"; done; echo ${label}_READY` + ) + await waitForTerminalOutput(page, `${label}_READY`, 60_000) + tabIds.push(tabId) + } + return tabIds +} + +async function waitForUnmountedTabs(page: Page, tabIds: readonly string[]): Promise { + return expect + .poll( + () => + page.evaluate((ids) => ids.every((id) => window.__paneManagers?.has(id) !== true), tabIds), + { timeout: 20_000, message: 'switch target still had mounted panes' } + ) + .toBe(true) + .then( + () => true, + () => false + ) +} + +/** Tabs with a mounted pane, once the post-reveal warm-up has settled. */ +async function waitForMountedTabs(page: Page, tabIds: readonly string[]): Promise { + const read = () => + page.evaluate( + (ids) => ids.filter((id) => window.__paneManagers?.has(id) === true).sort(), + [...tabIds] + ) + await expect + .poll(async () => (await read()).length, { + timeout: 20_000, + message: 'activation-deferred tabs never mounted after the reveal' + }) + .toBe(tabIds.length) + .catch(() => undefined) + return read() +} + +async function measureSwitch( + page: Page, + targetWorktreeId: string, + targetTabIds: readonly string[] +): Promise { + await page.evaluate( + ({ worktreeId, tabIds }) => { + const probe = { + t0: performance.now(), + activationMs: null as number | null, + paneMountedMs: null as number | null, + contentPaintedMs: null as number | null, + frames: [] as number[], + longTasks: [] as number[], + mountedAtActivation: 0, + stop: () => {} + } + globalThis.__switchPaintProbe = probe + let observer: PerformanceObserver | null = null + try { + observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + probe.longTasks.push(entry.duration) + } + }) + observer.observe({ entryTypes: ['longtask'] }) + } catch { + /* longtask unsupported */ + } + let running = true + const visibleTabId = () => { + const state = window.__store!.getState() + return state.activeWorktreeId === worktreeId && state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[worktreeId] ?? null) + } + const tick = () => { + if (!running) { + return + } + const now = performance.now() - probe.t0 + probe.frames.push(now) + const state = window.__store!.getState() + if (probe.activationMs === null && state.activeWorktreeId === worktreeId) { + probe.activationMs = now + // Why here and not at paint: this is the switch's own frame, before any + // idle admission can run, so it measures what the SWITCH mounted. + probe.mountedAtActivation = tabIds.filter( + (id) => window.__paneManagers?.has(id) === true + ).length + } + const tabId = visibleTabId() + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (probe.paneMountedMs === null && pane?.container?.isConnected) { + probe.paneMountedMs = now + } + if (probe.contentPaintedMs === null && pane) { + // Painted = the revealed viewport actually carries restored text, not + // an empty grid. A blank reveal fails this until the replay lands. + const buffer = pane.terminal.buffer.active + let filledRows = 0 + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + if (line && line.translateToString(true).trim().length > 0) { + filledRows += 1 + } + } + if (filledRows >= Math.min(5, pane.terminal.rows)) { + probe.contentPaintedMs = now + } + } + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + probe.stop = () => { + running = false + try { + observer?.disconnect() + } catch { + /* ignore */ + } + } + window.__store!.getState().setActiveWorktree(worktreeId) + }, + { worktreeId: targetWorktreeId, tabIds: [...targetTabIds] } + ) + + await page.waitForTimeout(4_000) + + return page.evaluate(() => { + const probe = globalThis.__switchPaintProbe! + probe.stop() + let maxGap = 0 + let previous = 0 + for (const frame of probe.frames) { + maxGap = Math.max(maxGap, frame - previous) + previous = frame + } + let settledPanes = 0 + let settledWebglContexts = 0 + const managers = window.__paneManagers + for (const manager of managers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + settledPanes += 1 + if ((pane as { webglAddon?: unknown }).webglAddon) { + settledWebglContexts += 1 + } + } + } + return { + settledPaneManagers: managers?.size ?? 0, + settledPanes, + settledWebglContexts, + activationMs: probe.activationMs, + paneMountedMs: probe.paneMountedMs, + contentPaintedMs: probe.contentPaintedMs, + maxFrameGapMs: +maxGap.toFixed(1), + longTaskTotalMs: +probe.longTasks.reduce((total, value) => total + value, 0).toFixed(1), + worstLongTaskMs: +probe.longTasks + .reduce((worst, value) => Math.max(worst, value), 0) + .toFixed(1), + mountedAtActivation: probe.mountedAtActivation + } + }) +} + +function report(label: string, sample: SwitchSample): string { + return [ + `${label}:`, + ` activation ${sample.activationMs?.toFixed(1) ?? 'n/a'}ms`, + ` pane mounted ${sample.paneMountedMs?.toFixed(1) ?? 'n/a'}ms`, + ` content painted ${sample.contentPaintedMs?.toFixed(1) ?? 'never'}ms`, + ` max frame gap ${sample.maxFrameGapMs}ms`, + ` long tasks total=${sample.longTaskTotalMs}ms worst=${sample.worstLongTaskMs}ms`, + ` panes at switch ${sample.mountedAtActivation}/${TABS_PER_WORKTREE}`, + ` settled resources managers=${sample.settledPaneManagers} panes=${sample.settledPanes} webgl=${sample.settledWebglContexts}` + ].join('\n') +} + +async function publish(testInfo: TestInfo, name: string, body: string): Promise { + console.log(body) + await testInfo.attach(name, { body, contentType: 'text/plain' }) +} + +// Why 8 extra: hot-retain keeps the 4 most recently hidden worktrees mounted and +// exempts the last-active one, so a target only cold-parks once enough other +// worktrees have been visited after it. That is the steady state at field scale. +const FILLER_WORKTREE_COUNT = Number(process.env.ORCA_SWITCH_FILLER_WORKTREES ?? '8') + +async function addFillerWorktrees( + page: Page, + testRepoPath: string +): Promise<{ ids: string[]; cleanup: () => void }> { + const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-switch-paint-'))) + const paths = Array.from({ length: FILLER_WORKTREE_COUNT }, (_, index) => + path.join(parent, `filler-${index}`) + ) + for (const worktreePath of paths) { + execFileSync('git', ['worktree', 'add', '--detach', worktreePath, 'HEAD'], { + cwd: testRepoPath, + stdio: 'ignore' + }) + } + const repoId = await page.evaluate( + (repoPath) => + window.__store!.getState().repos.find((repo) => repo.path === repoPath)?.id ?? null, + testRepoPath + ) + if (!repoId) { + throw new Error(`seeded repo not registered: ${testRepoPath}`) + } + await loadWorktreesUntilPathsPresent(page, repoId, paths) + const ids = await page.evaluate( + ({ id, wanted }) => + (window.__store!.getState().worktreesByRepo[id] ?? []) + .filter((worktree) => wanted.includes(worktree.path)) + .map((worktree) => worktree.id), + { id: repoId, wanted: paths } + ) + return { + ids, + cleanup: () => { + for (const worktreePath of paths) { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: testRepoPath, + stdio: 'ignore' + }) + } catch { + /* best effort */ + } + } + rmSync(parent, { recursive: true, force: true }) + } + } +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +test.describe('Worktree switch first paint', () => { + test('repaints an unmounted worktree within the switch budget', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.setTimeout(900_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + const worktreeIds = await getAllWorktreeIds(orcaPage) + expect(worktreeIds.length).toBeGreaterThanOrEqual(2) + const [primaryId, targetId] = worktreeIds + const filler = await addFillerWorktrees(orcaPage, testRepoPath) + + const targetTabIds = await ensureTabs(orcaPage, targetId, 'WTB') + + // Give the filler worktrees persisted tabs without mounting them, so the + // store carries a field-scale tab population (the profile that motivated + // this budget has 846 tabs across 449 worktrees). + await orcaPage.evaluate( + ({ ids, perWorktree }) => { + const state = window.__store!.getState() + for (const id of ids) { + const existing = state.tabsByWorktree[id] ?? [] + for (let index = existing.length; index < perWorktree; index += 1) { + state.createTab(id) + } + } + }, + { ids: filler.ids, perWorktree: 2 } + ) + + const samples: SwitchSample[] = [] + const lines: string[] = [] + for (let round = 0; round < SWITCH_SAMPLE_COUNT; round += 1) { + // Leave the primary active and let the session persist before reloading: + // startup restores the persisted active worktree, so this is what makes + // the target come back with tabs in the session and no pane ever mounted + // — the state every switch lands in once the worktree count exceeds the + // hot-retain working set. + await switchToWorktree(orcaPage, primaryId) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(2_500) + await orcaPage.reload() + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(2_500) + const unmounted = await waitForUnmountedTabs(orcaPage, targetTabIds) + expect(unmounted, 'target worktree was already mounted before the switch').toBe(true) + + const sample = await measureSwitch(orcaPage, targetId, targetTabIds) + samples.push(sample) + lines.push(report(`round ${round + 1} (target unmounted=${unmounted})`, sample)) + + // The half of the contract that keeps the speed-up free: the hidden tabs + // the switch skipped still end up mounted, so the next tab switch is as + // warm as it was before the reveal stopped mounting them up front. + const warmedTabIds = await waitForMountedTabs(orcaPage, targetTabIds) + expect(warmedTabIds, 'deferred tabs never joined the warm working set').toEqual( + [...targetTabIds].sort() + ) + } + + filler.cleanup() + + const painted = samples + .map((sample) => sample.contentPaintedMs) + .filter((value): value is number => value !== null) + expect(painted.length, 'revealed terminal never painted restored content').toBe(samples.length) + const summary = [ + `first activation -> ${TABS_PER_WORKTREE}-tab worktree, ${samples.length} rounds`, + ` content painted: median=${median(painted).toFixed(1)}ms samples=${painted + .map((value) => value.toFixed(0)) + .join(', ')}ms`, + ` activation: median=${median( + samples.map((sample) => sample.activationMs ?? 0) + ).toFixed(1)}ms`, + ` panes at switch: ${samples.map((sample) => sample.mountedAtActivation).join(', ')}`, + ` settled panes: ${samples.map((sample) => sample.settledPanes).join(', ')}`, + ` settled webgl: ${samples.map((sample) => sample.settledWebglContexts).join(', ')}`, + '', + ...lines + ].join('\n') + await publish(testInfo, 'first-activation-switch.txt', summary) + + for (const sample of samples) { + expect( + sample.mountedAtActivation, + 'the switch mounted more than the pane the user is looking at' + ).toBe(1) + } + expect(median(painted)).toBeLessThanOrEqual(FIRST_PAINT_BUDGET_MS) + }) +}) diff --git a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs index 63b7222a93d..ffac29bddea 100644 --- a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs @@ -27,19 +27,19 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { createCompletedOnboardingProfile, safeRemoveLocalDirectory -} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const PARK_DELAY_MS = 1_500 const SETTLE_AFTER_PARK_MS = 4_000 // Short root so the daemon Unix socket fits under the macOS 104-char limit; diff --git a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs index a01cbf2cc3a..70bf1f66944 100644 --- a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs @@ -33,19 +33,19 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { createCompletedOnboardingProfile, safeRemoveLocalDirectory -} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const scenarioTimeoutMs = 300_000 // Short enough that a tab parks within a few seconds of being hidden, long diff --git a/tests/tools/benchmarks/terminal-perf-bench.mjs b/tests/tools/benchmarks/terminal-perf-bench.mjs index a73ffae6bc9..551f33d0433 100644 --- a/tests/tools/benchmarks/terminal-perf-bench.mjs +++ b/tests/tools/benchmarks/terminal-perf-bench.mjs @@ -19,16 +19,16 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' +import { safeRemoveLocalDirectory } from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const scenarioTimeoutMs = 300_000 const defaultIterations = 8 const defaultSwitches = 24