mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
perf(terminal): mount only the visible pane on a worktree switch
Activating a worktree mounted a TerminalPane for every tab it holds, not just the one on screen. Cold-activation deferral existed for this but engaged only past four deferrable hidden tabs, which exempted the 2-5 tab worktrees that make up almost every real switch. Deferral now engages for any deferrable hidden tab, and the siblings it skips are admitted one per idle frame after the reveal, capped at the population the old threshold would have mounted eagerly. Steady-state pane, WebGL-context and heap population are therefore unchanged; only the frame the mounts land on moved.
This commit is contained in:
@@ -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<string, ReadonlySet<string>>()
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string> | 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)
|
||||
}
|
||||
@@ -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<string, ReadonlySet<string>>()
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
|
||||
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<string, ReadonlySet<string>>()
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
|
||||
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<string, ReadonlySet<string>>([['wt-1', new Set(['tab-1'])]])
|
||||
const allTabIds = tabIds(3)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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<SwitchSample> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user