Measure and batch restored terminal startup (#6836)

This commit is contained in:
Neil
2026-06-29 20:37:26 -07:00
committed by GitHub
parent 220d5cb7fe
commit 380db44d77
10 changed files with 485 additions and 90 deletions
+28 -4
View File
@@ -523,23 +523,41 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
await firstWindowStartupServicesReady
})
ipcMain.handle(
'app:startupDiagnostic',
(_event, event: string, details?: Record<string, unknown>) => {
if (!startupDiagnosticsEnabled || !event.startsWith('renderer-')) {
return
}
logStartupMilestone(event, details && typeof details === 'object' ? details : {})
}
)
function startDesktopFirstWindowStartupServices(): Promise<void> {
logStartupMilestone('first-window-startup-services-start')
const startupServices = startFirstWindowStartupServices({
// Why: the persistent-terminal daemon is desktop-only. Headless `orca serve`
// registers its PTY runtime separately and must not spawn the desktop daemon
// or hook loopback listener.
startDaemonPtyProvider: (signal) => initDaemonPtyProvider(signal),
startDaemonPtyProvider: async (signal) => {
logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' })
await initDaemonPtyProvider(signal)
logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' })
},
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, so
// the renderer awaits this barrier before restored terminals reconnect.
startAgentHookServer: () =>
agentHookServer.start({
startAgentHookServer: async () => {
logStartupMilestone('startup-service-start', { service: 'agent-hook-server' })
await agentHookServer.start({
env: app.isPackaged ? 'production' : 'development',
// Why: hooks source this endpoint file at invocation time, so old PTY
// env still reaches the current Orca process after an app restart.
// Dev uses a namespace because all worktrees share `orca-dev`.
userDataPath: app.getPath('userData'),
endpointNamespace: devAgentHookEndpointNamespace
}),
})
logStartupMilestone('startup-service-done', { service: 'agent-hook-server' })
},
onDaemonError: (error) => {
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
},
@@ -551,6 +569,12 @@ function startDesktopFirstWindowStartupServices(): Promise<void> {
})
firstWindowStartupServicesReady = startupServices.firstWindowReady
localPtyStartupReady = startupServices.localPtyReady
void firstWindowStartupServicesReady.then(() => {
logStartupMilestone('first-window-startup-services-ready')
})
void localPtyStartupReady.then(() => {
logStartupMilestone('local-pty-startup-ready')
})
return firstWindowStartupServicesReady
}
+26 -1
View File
@@ -209,6 +209,7 @@ import {
} from './terminal-scrollback-snapshots'
import { track } from './telemetry/client'
import { getCohortAtEmit } from './telemetry/cohort-classifier'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
function encrypt(plaintext: string): string {
if (!plaintext || !safeStorage.isEncryptionAvailable()) {
@@ -382,6 +383,15 @@ const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set<keyof WorkspaceS
'terminalLayoutsByTabId'
])
function logPersistenceStartupMilestone(
event: string,
details: Record<string, unknown> = {}
): void {
if (isStartupDiagnosticsEnabled()) {
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
}
}
function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean {
return Object.keys(patch).some((key) =>
WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState)
@@ -2617,12 +2627,22 @@ export class Store {
// social contract we installed them under.
const dataFile = getDataFile()
const fileExistedOnLoad = existsSync(dataFile)
logPersistenceStartupMilestone('persistence-load-start', {
fileExists: fileExistedOnLoad
})
let result: PersistedState | null = null
try {
if (fileExistedOnLoad) {
const readStartedAt = performance.now()
const raw = readFileSync(dataFile, 'utf-8')
logPersistenceStartupMilestone('persistence-read-done', {
bytes: Buffer.byteLength(raw),
durationMs: Math.round(performance.now() - readStartedAt)
})
logPersistenceStartupMilestone('persistence-json-parse-start')
const parsed = JSON.parse(raw) as PersistedState
logPersistenceStartupMilestone('persistence-json-parse-done')
// Why: secret settings are stored encrypted on disk via safeStorage.
// Decrypt at the load boundary so the rest of the app sees plaintext.
@@ -3215,7 +3235,12 @@ export class Store {
}
result = folderScopeConnectionMigration.state
return this.migrateTelemetry(result, fileExistedOnLoad)
const migrated = this.migrateTelemetry(result, fileExistedOnLoad)
logPersistenceStartupMilestone('persistence-load-done', {
repos: migrated.repos.length,
workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession))
})
return migrated
}
// One-shot telemetry cohort migration. Runs on every `load()` but is a
+2
View File
@@ -804,6 +804,8 @@ export type AppApi = {
/** Resolves when the daemon PTY provider and hook receiver have either
* started or failed open for the first BrowserWindow. */
awaitFirstWindowStartupServices: () => Promise<void>
/** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */
startupDiagnostic: (event: string, details?: Record<string, unknown>) => Promise<void>
/** Returns the macOS active input mode, or layout ID when no IME mode is
* selected (e.g. `com.apple.keylayout.PolishPro`). Used by the
* keyboard-layout probe to distinguish CJK IMEs and layouts whose base
+6
View File
@@ -434,6 +434,8 @@ document.addEventListener(
true
)
const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1'
// Custom APIs for renderer
const api = {
app: {
@@ -456,6 +458,10 @@ const api = {
reload: (): Promise<void> => ipcRenderer.invoke('app:reload'),
awaitFirstWindowStartupServices: (): Promise<void> =>
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
startupDiagnostic: (event: string, details?: Record<string, unknown>): Promise<void> =>
startupDiagnosticsEnabled
? ipcRenderer.invoke('app:startupDiagnostic', event, details)
: Promise.resolve(),
// Why: on macOS this returns the active input mode, or the layout ID when
// no IME mode is selected, so renderer keyboard workarounds can distinguish
// CJK IMEs and compose layouts from plain US QWERTY (see issue #1205).
+86 -45
View File
@@ -120,6 +120,11 @@ import {
getStartupErrorFallbackUI,
hydratePersistedUIAfterStartupRead
} from './lib/startup-ui-hydration'
import {
logRendererStartupDiagnostic,
timeRendererStartupStep,
timeRendererStartupSyncStep
} from './startup/startup-diagnostics'
import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility'
import { applyDocumentTheme } from './lib/document-theme'
import { isEditableTarget } from './lib/editable-target'
@@ -843,39 +848,50 @@ function App(): React.JSX.Element {
// UI mounts.
let reconnectStarted = false
void (async () => {
const startupStartedAt = performance.now()
logRendererStartupDiagnostic('startup-chain-start')
try {
// Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId.
// Load settings first so a persisted remote runtime does not boot against
// the local filesystem and then hydrate stale local workspace state.
await actions.fetchSettings()
await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings())
// Why: load local + every configured runtime environment (not just the
// active one) so a cold start that restored a remote workspace doesn't
// hide local repos. The sidebar "All hosts" scope then shows them all.
await actions.fetchReposForAllHosts()
await actions.fetchProjectGroupsForAllHosts()
await actions.fetchFolderWorkspacesForAllHosts()
await actions.fetchAllWorktrees()
await actions.fetchWorktreeLineage()
const persistedUI = await window.api.ui.get()
uiHydrated = hydratePersistedUIAfterStartupRead({
persistedUI,
cancelled,
hydratePersistedUI: actions.hydratePersistedUI
})
await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts())
await timeRendererStartupStep('fetch-project-groups', () =>
actions.fetchProjectGroupsForAllHosts()
)
await timeRendererStartupStep('fetch-folder-workspaces', () =>
actions.fetchFolderWorkspacesForAllHosts()
)
await timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees())
await timeRendererStartupStep('fetch-worktree-lineage', () =>
actions.fetchWorktreeLineage()
)
const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get())
uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () =>
hydratePersistedUIAfterStartupRead({
persistedUI,
cancelled,
hydratePersistedUI: actions.hydratePersistedUI
})
)
// Why: runtime-owned worktree slices live in per-host partitions.
// Repos were fetched above, so the known runtime hosts are derivable
// here; merge their slices into the unified session the hydrators
// expect. An unreadable host partition is skipped (fail-soft).
const session = await fetchWorkspaceSessionFromHosts(
window.api.session,
useAppStore.getState().repos
const session = await timeRendererStartupStep('session-get', () =>
fetchWorkspaceSessionFromHosts(window.api.session, useAppStore.getState().repos)
)
await actions.fetchKeybindings()
await timeRendererStartupStep('fetch-keybindings', () => actions.fetchKeybindings())
if (!cancelled) {
actions.hydrateWorkspaceSession(session)
actions.hydrateTabsSession(session)
actions.hydrateEditorSession(session)
actions.hydrateBrowserSession(session)
timeRendererStartupSyncStep('hydrate-session-stores', () => {
actions.hydrateWorkspaceSession(session)
actions.hydrateTabsSession(session)
actions.hydrateEditorSession(session)
actions.hydrateBrowserSession(session)
})
// Why: prune lastVisitedAtByWorktreeId entries whose worktrees
// no longer exist. Must run AFTER hydration — before this point,
// async repo loads may not have populated worktreesByRepo yet and
@@ -884,10 +900,16 @@ function App(): React.JSX.Element {
// so users upgrading from a pre-feature build don't see the active
// worktree sink in the empty-query list.
// See docs/cmd-j-empty-query-ordering.md.
actions.pruneLastVisitedTimestamps()
actions.seedActiveWorktreeLastVisitedIfMissing()
await actions.fetchBrowserSessionProfiles()
const onboardingState = await window.api.onboarding.get()
timeRendererStartupSyncStep('visit-timestamp-prune', () => {
actions.pruneLastVisitedTimestamps()
actions.seedActiveWorktreeLastVisitedIfMissing()
})
await timeRendererStartupStep('fetch-browser-session-profiles', () =>
actions.fetchBrowserSessionProfiles()
)
const onboardingState = await timeRendererStartupStep('onboarding-get', () =>
window.api.onboarding.get()
)
if (!cancelled) {
setOnboarding(onboardingState)
setOnboardingLoaded(true)
@@ -902,7 +924,9 @@ function App(): React.JSX.Element {
if (connectionIds.length > 0) {
try {
const SSH_RECONNECT_TIMEOUT_MS = 15_000
const allTargets = await window.api.ssh.listTargets()
const allTargets = await timeRendererStartupStep('ssh-list-targets', () =>
window.api.ssh.listTargets()
)
const targetMap = new Map(allTargets.map((t) => [t.id, t]))
const targets = connectionIds.map((targetId) => ({
targetId,
@@ -923,25 +947,33 @@ function App(): React.JSX.Element {
// reattached when the user focuses the tab (by which time the
// slow connect will likely have succeeded).
const timedOutTargets: string[] = []
await Promise.allSettled(
eagerTargets.map(({ targetId }) =>
Promise.race([
window.api.ssh.connect({ targetId }),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('SSH reconnect timeout')),
SSH_RECONNECT_TIMEOUT_MS
)
await timeRendererStartupStep(
'ssh-reconnect',
() =>
Promise.allSettled(
eagerTargets.map(({ targetId }) =>
Promise.race([
window.api.ssh.connect({ targetId }),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('SSH reconnect timeout')),
SSH_RECONNECT_TIMEOUT_MS
)
)
]).catch((err) => {
const isTimeout =
err instanceof Error && err.message === 'SSH reconnect timeout'
if (isTimeout) {
timedOutTargets.push(targetId)
}
console.warn(`SSH auto-reconnect failed for ${targetId}:`, err)
})
)
]).catch((err) => {
const isTimeout =
err instanceof Error && err.message === 'SSH reconnect timeout'
if (isTimeout) {
timedOutTargets.push(targetId)
}
console.warn(`SSH auto-reconnect failed for ${targetId}:`, err)
})
)
),
{
eagerTargets: eagerTargets.length,
deferredTargets: deferredTargets.length
}
)
if (timedOutTargets.length > 0) {
actions.setDeferredSshReconnectTargets([
@@ -974,14 +1006,20 @@ function App(): React.JSX.Element {
} catch (err) {
console.warn('SSH startup reconnect failed:', err)
}
} else {
logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 })
}
// Why: main overlaps daemon/hook startup with renderer hydration for
// first paint, but restored terminals still need those services ready
// before they mount and spawn/reconnect PTYs.
await window.api.app.awaitFirstWindowStartupServices()
await timeRendererStartupStep('first-window-services-await', () =>
window.api.app.awaitFirstWindowStartupServices()
)
reconnectStarted = true
await actions.reconnectPersistedTerminals(abortController.signal)
await timeRendererStartupStep('reconnect-terminals', () =>
actions.reconnectPersistedTerminals(abortController.signal)
)
syncZoomCSSVar()
// Why (issue #1158): unlock the debounced session writer only after
// hydration AND all dependent startup steps (SSH reconnect, terminal
@@ -991,6 +1029,9 @@ function App(): React.JSX.Element {
// and the writer would serialize a partially-mutated store back to
// disk — the exact data-loss mode this PR fixes.
actions.setHydrationSucceeded(true)
logRendererStartupDiagnostic('startup-hydration-done', {
durationMs: Math.round(performance.now() - startupStartedAt)
})
}
} catch (error) {
// Why (issue #1158): previously this catch called hydrateWorkspaceSession
@@ -0,0 +1,69 @@
type StartupDiagnosticDetails = Record<string, unknown>
function nowMs(): number {
return Math.round(performance.now())
}
export function logRendererStartupDiagnostic(
event: string,
details: StartupDiagnosticDetails = {}
): void {
const api = window.api?.app
if (!api?.startupDiagnostic) {
return
}
void api
.startupDiagnostic(`renderer-${event}`, {
rendererT: nowMs(),
...details
})
.catch(() => {
// Diagnostics are best-effort and must never perturb startup behavior.
})
}
export async function timeRendererStartupStep<T>(
event: string,
operation: () => Promise<T>,
details: StartupDiagnosticDetails = {}
): Promise<T> {
const startedAt = performance.now()
try {
const result = await operation()
logRendererStartupDiagnostic(`${event}-done`, {
durationMs: Math.round(performance.now() - startedAt),
...details
})
return result
} catch (error) {
logRendererStartupDiagnostic(`${event}-failed`, {
durationMs: Math.round(performance.now() - startedAt),
message: error instanceof Error ? error.message : String(error),
...details
})
throw error
}
}
export function timeRendererStartupSyncStep<T>(
event: string,
operation: () => T,
details: StartupDiagnosticDetails = {}
): T {
const startedAt = performance.now()
try {
const result = operation()
logRendererStartupDiagnostic(`${event}-done`, {
durationMs: Math.round(performance.now() - startedAt),
...details
})
return result
} catch (error) {
logRendererStartupDiagnostic(`${event}-failed`, {
durationMs: Math.round(performance.now() - startedAt),
message: error instanceof Error ? error.message : String(error),
...details
})
throw error
}
}
@@ -224,6 +224,58 @@ describe('hydrateWorkspaceSession', () => {
expect(store.getState().pendingReconnectWorktreeIds).toEqual([FLOATING_TERMINAL_WORKTREE_ID])
})
it('batches restored terminal reconnect wake hints into one store update', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/wt-1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })]
}
})
const session: WorkspaceSessionState = {
activeRepoId: 'repo1',
activeWorktreeId: worktreeId,
activeTabId: 'tab-1',
tabsByWorktree: {
[worktreeId]: [
makeTab({ id: 'tab-1', worktreeId, ptyId: 'pty-1' }),
makeTab({ id: 'tab-2', worktreeId, ptyId: 'pty-2' }),
makeTab({ id: 'tab-3', worktreeId, ptyId: 'pty-3' })
]
},
terminalLayoutsByTabId: {
'tab-1': makeLayout(),
'tab-2': makeLayout(),
'tab-3': makeLayout()
},
activeWorktreeIdsOnShutdown: [worktreeId]
}
store.getState().hydrateWorkspaceSession(session)
let updateCount = 0
const unsubscribe = store.subscribe(() => {
updateCount += 1
})
await store.getState().reconnectPersistedTerminals()
unsubscribe()
// Why: startup restores every daemon wake hint, but subscribers should see
// one ready-state transition instead of one update per restored tab.
expect(updateCount).toBe(1)
expect(store.getState().workspaceSessionReady).toBe(true)
expect(store.getState().ptyIdsByTabId).toMatchObject({
'tab-1': ['pty-1'],
'tab-2': ['pty-2'],
'tab-3': ['pty-3']
})
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([
expect.objectContaining({ id: 'tab-1', ptyId: 'pty-1' }),
expect.objectContaining({ id: 'tab-2', ptyId: 'pty-2' }),
expect.objectContaining({ id: 'tab-3', ptyId: 'pty-3' })
])
})
it('resets persisted agent titles to the fallback label on hydration', () => {
const store = createTestStore()
const worktreeId = 'repo1::/wt-1'
+26 -27
View File
@@ -2887,7 +2887,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
pendingReconnectTabByWorktree,
pendingReconnectPtyIdByTabId,
terminalLayoutsByTabId,
tabsByWorktree
tabsByWorktree,
ptyIdsByTabId
} = get()
const ids = pendingReconnectWorktreeIds ?? []
@@ -2911,6 +2912,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// The layout's ptyIdsByLeafId (preserved from shutdown) already has per-leaf
// mappings. For single-pane tabs without leaf mappings, store the tab-level
// ptyId as a sentinel so connectPanePty knows to reattach.
let reconnectedTabsByWorktree: Record<string, TerminalTab[]> | null = null
let reconnectedPtyIdsByTabId: Record<string, string[]> | null = null
for (const worktreeId of ids) {
const tabs = tabsByWorktree[worktreeId] ?? []
const worktree = Object.values(get().worktreesByRepo)
@@ -2961,33 +2964,27 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
`[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}`
)
if (tabLevelPtyId) {
set((s) => {
const next = { ...s.tabsByWorktree }
if (!next[worktreeId]) {
return {}
}
reconnectedTabsByWorktree ??= { ...tabsByWorktree }
const nextTabs = reconnectedTabsByWorktree[worktreeId]
if (!nextTabs) {
continue
}
// Why: populate ptyIdsByTabId so the sessions status segment
// can map daemon session IDs back to tabs (for bound/orphan
// detection and click-to-navigate). Without this, all sessions
// appear as orphans until the terminal pane mounts.
const allPtyIds = hasLeafMappings
? (Object.values(leafPtyMap).filter(Boolean) as string[])
: [tabLevelPtyId!]
next[worktreeId] = next[worktreeId].map((t) =>
t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t
)
return {
tabsByWorktree: next,
// Why: hide-sleeping uses ptyIdsByTabId as the liveness source.
// Restored daemon sessions are still running even before their
// pane remounts, so background workspaces must advertise them.
ptyIdsByTabId: {
...s.ptyIdsByTabId,
[tabId]: allPtyIds
}
}
})
// Why: populate ptyIdsByTabId so the sessions status segment
// can map daemon session IDs back to tabs (for bound/orphan
// detection and click-to-navigate). Without this, all sessions
// appear as orphans until the terminal pane mounts.
const allPtyIds = hasLeafMappings
? (Object.values(leafPtyMap).filter(Boolean) as string[])
: [tabLevelPtyId]
reconnectedTabsByWorktree[worktreeId] = nextTabs.map((t) =>
t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t
)
// Why: hide-sleeping uses ptyIdsByTabId as the liveness source.
// Restored daemon sessions are still running even before their
// pane remounts, so background workspaces must advertise them.
reconnectedPtyIdsByTabId ??= { ...ptyIdsByTabId }
reconnectedPtyIdsByTabId[tabId] = allPtyIds
}
}
}
@@ -3019,6 +3016,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
set({
...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}),
...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}),
workspaceSessionReady: true,
pendingReconnectWorktreeIds: [],
pendingReconnectTabByWorktree: {},
+1
View File
@@ -464,6 +464,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
restart: () => Promise.resolve(window.location.reload()),
reload: () => Promise.resolve(window.location.reload()),
awaitFirstWindowStartupServices: () => Promise.resolve(),
startupDiagnostic: () => Promise.resolve(),
getKeyboardInputSourceId: () => Promise.resolve(null),
setUnreadDockBadgeCount: () => Promise.resolve(),
getFloatingTerminalCwd: () => Promise.resolve(''),
+189 -13
View File
@@ -11,19 +11,30 @@
* Usage:
* node tools/benchmarks/startup-time-bench.mjs --label baseline
* [--iterations 5] [--files 28000] [--fixture-dir <path>]
* [--exe <path-to-packaged-Orca.exe>] [--timeout-ms 240000]
* [--state-profile none|restored-local-tabs] [--session-tabs 200]
* [--wait-for-event renderer-startup-hydration-done]
* [--exe <path-to-packaged-Orca>] [--timeout-ms 240000]
*
* Prereq (when not using --exe): `pnpm build:electron-vite` so out/ exists.
* Results: tools/benchmarks/results/startup-<label>-<timestamp>.json
*/
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import {
existsSync,
mkdirSync,
readFileSync,
realpathSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(scriptDir, '..', '..')
const require = createRequire(import.meta.url)
function parseArgs(argv) {
const args = {
@@ -33,6 +44,9 @@ function parseArgs(argv) {
fixtureDir: null,
exe: null,
timeoutMs: 240000,
stateProfile: 'none',
sessionTabs: 0,
waitForEvent: 'did-finish-load',
// How long the app stays alive after did-finish-load before the harness
// kills it. Raise to let background work (e.g. the async win32 ACL grant)
// complete the way it would in a real session.
@@ -59,6 +73,15 @@ function parseArgs(argv) {
case '--timeout-ms':
args.timeoutMs = Number(next())
break
case '--state-profile':
args.stateProfile = next()
break
case '--session-tabs':
args.sessionTabs = Number(next())
break
case '--wait-for-event':
args.waitForEvent = next()
break
case '--linger-ms':
args.lingerMs = Number(next())
break
@@ -74,13 +97,18 @@ function parseArgs(argv) {
* drives the win32 icacls walk cost; contents are irrelevant, so files are
* tiny. Layout mirrors Chromium cache dirs plus a few Orca-owned dirs.
*/
function ensureFixture(fixtureDir, fileCount) {
function ensureFixture(fixtureDir, options) {
const { fileCount, stateProfile, sessionTabs } = options
const manifestPath = join(fixtureDir, 'bench-fixture-manifest.json')
if (existsSync(manifestPath)) {
try {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
if (manifest.files === fileCount) {
console.log(`[fixture] reusing ${fixtureDir} (${fileCount} files)`)
if (
manifest.files === fileCount &&
manifest.stateProfile === stateProfile &&
manifest.sessionTabs === sessionTabs
) {
console.log(`[fixture] reusing ${fixtureDir} (${fileCount} files, state=${stateProfile})`)
return
}
} catch {
@@ -110,10 +138,115 @@ function ensureFixture(fixtureDir, fileCount) {
}
written += batch
}
writeFileSync(manifestPath, JSON.stringify({ files: fileCount, createdAt: Date.now() }))
const persistedStateBytes = writePersistedStateFixture(fixtureDir, {
stateProfile,
sessionTabs
})
writeFileSync(
manifestPath,
JSON.stringify({
files: fileCount,
stateProfile,
sessionTabs,
persistedStateBytes,
createdAt: Date.now()
})
)
console.log(`[fixture] done in ${((Date.now() - started) / 1000).toFixed(1)}s`)
}
function writePersistedStateFixture(fixtureDir, { stateProfile, sessionTabs }) {
const dataPath = join(fixtureDir, 'orca-data.json')
if (stateProfile === 'none') {
try {
unlinkSync(dataPath)
} catch {
// no persisted state fixture
}
return 0
}
if (stateProfile !== 'restored-local-tabs') {
throw new Error(`Unknown state profile: ${stateProfile}`)
}
const repoDir = join(fixtureDir, 'bench-repo')
mkdirSync(repoDir, { recursive: true })
if (!existsSync(join(repoDir, '.git'))) {
const init = spawnSync('git', ['init', repoDir], { stdio: 'ignore' })
if (init.status !== 0) {
throw new Error('Failed to create git repo for restored-local-tabs fixture')
}
}
const repoPath = realpathSync(repoDir)
const repoId = 'bench-repo'
const worktreeId = `${repoId}::${repoPath}`
const tabCount = Math.max(1, sessionTabs)
const tabs = []
const terminalLayoutsByTabId = {}
const activeTabIdByWorktree = {}
for (let i = 0; i < tabCount; i++) {
const tabId = `bench-tab-${String(i).padStart(5, '0')}`
const ptyId = `bench-pty-${String(i).padStart(5, '0')}`
tabs.push({
id: tabId,
ptyId,
worktreeId,
title: `Terminal ${i + 1}`,
customTitle: null,
color: null,
sortOrder: i,
createdAt: 1
})
terminalLayoutsByTabId[tabId] = {
root: null,
activeLeafId: null,
expandedLeafId: null
}
}
activeTabIdByWorktree[worktreeId] = tabs[0]?.id ?? null
const state = {
schemaVersion: 1,
repos: [
{
id: repoId,
path: repoPath,
displayName: 'Bench Repo',
badgeColor: '#000000',
addedAt: 1,
externalWorktreeVisibility: 'show'
}
],
settings: {
telemetry: {
installId: 'startup-bench',
optedIn: false,
existedBeforeTelemetryRelease: true
}
},
ui: {
lastActiveRepoId: repoId,
lastActiveWorktreeId: worktreeId
},
workspaceSession: {
activeRepoId: repoId,
activeWorktreeId: worktreeId,
activeTabId: tabs[0]?.id ?? null,
tabsByWorktree: {
[worktreeId]: tabs
},
terminalLayoutsByTabId,
activeTabIdByWorktree,
activeWorktreeIdsOnShutdown: [worktreeId],
defaultTerminalTabsAppliedByWorktreeId: {
[worktreeId]: true
}
}
}
const json = JSON.stringify(state, null, 2)
writeFileSync(dataPath, json, 'utf-8')
return Buffer.byteLength(json)
}
function killProcessTree(proc) {
if (proc.exitCode !== null || proc.signalCode !== null) {
return
@@ -152,9 +285,11 @@ function parseStartupLine(line) {
return { event: match[1], details }
}
function runIteration({ exe, fixtureDir, timeoutMs, lingerMs }) {
function runIteration({ exe, fixtureDir, timeoutMs, lingerMs, waitForEvent }) {
return new Promise((resolvePromise) => {
const command = exe ?? join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe')
// Why: npm's `electron` package exposes the platform-specific executable;
// hardcoding electron.exe made this benchmark unusable on macOS/Linux.
const command = exe ?? require('electron')
const commandArgs = exe ? [] : [repoRoot]
const events = []
const startedAt = process.hrtime.bigint()
@@ -197,7 +332,7 @@ function runIteration({ exe, fixtureDir, timeoutMs, lingerMs }) {
}
const harnessMs = Number(process.hrtime.bigint() - startedAt) / 1e6
events.push({ ...parsed, harnessMs: Math.round(harnessMs * 10) / 10 })
if (parsed.event === 'did-finish-load') {
if (parsed.event === waitForEvent) {
finish('ok')
}
}
@@ -223,17 +358,42 @@ function derivePhases(events) {
const aclStart = eventTime(events, 'acl-grant-start', 't')
const aclDone = eventTime(events, 'acl-grant-done', 't')
return {
startupJsonParseMs: delta(
events,
'persistence-json-parse-start',
'persistence-json-parse-done'
),
startupStoreLoadMs: delta(events, 'persistence-load-start', 'persistence-load-done'),
spawnToAppReady: eventTime(events, 'app-ready', 'harness'),
appReadyToServices: delta(events, 'app-ready', 'services-initialized'),
servicesToI18n: delta(events, 'services-initialized', 'i18n-ready'),
i18nToOpenWindow: delta(events, 'i18n-ready', 'open-main-window-start'),
daemonInitMs: delta(events, 'daemon-init-start', 'daemon-init-done'),
aclGrantMs: aclStart !== null && aclDone !== null ? aclDone - aclStart : null,
windowCreatedToLoaded: delta(events, 'window-created', 'did-finish-load'),
totalToWindowCreated: eventTime(events, 'window-created', 'harness'),
totalToDidFinishLoad: eventTime(events, 'did-finish-load', 'harness')
totalToDidFinishLoad: eventTime(events, 'did-finish-load', 'harness'),
didFinishLoadToWorkspaceReady: delta(
events,
'did-finish-load',
'renderer-startup-hydration-done'
),
totalToWorkspaceReady: eventTime(events, 'renderer-startup-hydration-done', 'harness'),
rendererReconnectTerminalsMs:
eventDetailsNumber(events, 'renderer-reconnect-terminals-done', 'durationMs') ??
delta(
events,
'renderer-first-window-services-await-done',
'renderer-reconnect-terminals-done'
)
}
}
function eventDetailsNumber(events, name, key) {
const value = events.find((event) => event.event === name)?.details[key]
return typeof value === 'number' ? value : null
}
function delta(events, from, to) {
const a = eventTime(events, from, 't')
const b = eventTime(events, to, 't')
@@ -258,11 +418,23 @@ function formatMs(value) {
async function main() {
const args = parseArgs(process.argv)
if (!['none', 'restored-local-tabs'].includes(args.stateProfile)) {
throw new Error(`Unknown state profile: ${args.stateProfile}`)
}
const fixtureDir = resolve(
args.fixtureDir ?? join(os.tmpdir(), 'orca-startup-bench', `userdata-${args.files}`)
args.fixtureDir ??
join(
os.tmpdir(),
'orca-startup-bench',
`userdata-${args.files}-${args.stateProfile}-${args.sessionTabs}`
)
)
mkdirSync(fixtureDir, { recursive: true })
ensureFixture(fixtureDir, args.files)
ensureFixture(fixtureDir, {
fileCount: args.files,
stateProfile: args.stateProfile,
sessionTabs: args.sessionTabs
})
if (!args.exe && !existsSync(join(repoRoot, 'out', 'main', 'index.js'))) {
throw new Error('out/main/index.js missing — run `pnpm build:electron-vite` first')
@@ -275,7 +447,8 @@ async function main() {
exe: args.exe,
fixtureDir,
timeoutMs: args.timeoutMs,
lingerMs: args.lingerMs
lingerMs: args.lingerMs,
waitForEvent: args.waitForEvent
})
const phases = derivePhases(result.events)
iterations.push({ ...result, phases })
@@ -306,6 +479,9 @@ async function main() {
cpus: os.cpus()[0]?.model,
fixtureDir,
fixtureFiles: args.files,
stateProfile: args.stateProfile,
sessionTabs: args.sessionTabs,
waitForEvent: args.waitForEvent,
exe: args.exe,
iterations,
summaryMedianMs: summary