mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Measure and batch restored terminal startup (#6836)
This commit is contained in:
+28
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user