mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Measure startup hydration phases
This commit is contained in:
+174
-108
@@ -70,7 +70,12 @@ import {
|
|||||||
logSingleInstanceLockFailure,
|
logSingleInstanceLockFailure,
|
||||||
shouldBypassSingleInstanceLock
|
shouldBypassSingleInstanceLock
|
||||||
} from './startup/single-instance-lock'
|
} from './startup/single-instance-lock'
|
||||||
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
|
import {
|
||||||
|
isStartupDiagnosticsEnabled,
|
||||||
|
logStartupDiagnostic,
|
||||||
|
logStartupTimingReport
|
||||||
|
} from './startup/startup-diagnostics'
|
||||||
|
import { createStartupPhaseTimer } from '../shared/startup-phase-timing'
|
||||||
import { RateLimitService } from './rate-limits/service'
|
import { RateLimitService } from './rate-limits/service'
|
||||||
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
|
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
|
||||||
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
|
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
|
||||||
@@ -150,6 +155,8 @@ let runtimeRpc: OrcaRuntimeRpcServer | null = null
|
|||||||
let starNag: StarNagService | null = null
|
let starNag: StarNagService | null = null
|
||||||
let agentAwakeService: AgentAwakeService | null = null
|
let agentAwakeService: AgentAwakeService | null = null
|
||||||
let crashReports: CrashReportStore | null = null
|
let crashReports: CrashReportStore | null = null
|
||||||
|
let firstWindowVisibleRecorded = false
|
||||||
|
let firstWindowLoadRecorded = false
|
||||||
let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null
|
let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null
|
||||||
let watcherShutdownPromise: Promise<void> | null = null
|
let watcherShutdownPromise: Promise<void> | null = null
|
||||||
let watcherShutdownDone = false
|
let watcherShutdownDone = false
|
||||||
@@ -244,6 +251,15 @@ if (app.isPackaged && process.platform !== 'win32') {
|
|||||||
configureDevUserDataPath(is.dev)
|
configureDevUserDataPath(is.dev)
|
||||||
configureOrcaUserDataPathEnv()
|
configureOrcaUserDataPathEnv()
|
||||||
const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled()
|
const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled()
|
||||||
|
const mainStartupTiming = createStartupPhaseTimer({
|
||||||
|
origin: 'main',
|
||||||
|
details: {
|
||||||
|
packaged: app.isPackaged,
|
||||||
|
platform: process.platform,
|
||||||
|
serveMode: isServeMode
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mainStartupTiming.markMilestone('process-entry')
|
||||||
if (startupDiagnosticsEnabled) {
|
if (startupDiagnosticsEnabled) {
|
||||||
logStartupDiagnostic('before-single-instance-lock', {
|
logStartupDiagnostic('before-single-instance-lock', {
|
||||||
version: app.getVersion(),
|
version: app.getVersion(),
|
||||||
@@ -255,6 +271,13 @@ if (startupDiagnosticsEnabled) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logMainStartupTiming(details: Record<string, unknown> = {}): void {
|
||||||
|
if (!startupDiagnosticsEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logStartupTimingReport(mainStartupTiming.snapshot({ details }))
|
||||||
|
}
|
||||||
|
|
||||||
function focusExistingWindow(): void {
|
function focusExistingWindow(): void {
|
||||||
focusExistingMainWindow({
|
focusExistingMainWindow({
|
||||||
app,
|
app,
|
||||||
@@ -447,6 +470,17 @@ function openMainWindow(): BrowserWindow {
|
|||||||
if (!keybindings) {
|
if (!keybindings) {
|
||||||
throw new Error('Keybinding service must be initialized before opening the main window')
|
throw new Error('Keybinding service must be initialized before opening the main window')
|
||||||
}
|
}
|
||||||
|
const currentStore = store
|
||||||
|
const currentRuntime = runtime
|
||||||
|
const currentStats = stats
|
||||||
|
const currentClaudeUsage = claudeUsage
|
||||||
|
const currentCodexUsage = codexUsage
|
||||||
|
const currentOpenCodeUsage = openCodeUsage
|
||||||
|
const currentRateLimits = rateLimits
|
||||||
|
const currentAutomations = automations
|
||||||
|
const currentCodexAccounts = codexAccounts
|
||||||
|
const currentClaudeAccounts = claudeAccounts
|
||||||
|
const currentKeybindings = keybindings
|
||||||
|
|
||||||
// Why: Chromium's BrowserWindow constructor resets the userData DACL to a
|
// Why: Chromium's BrowserWindow constructor resets the userData DACL to a
|
||||||
// Protected DACL. Grant explicit Full Control ACEs on all existing children
|
// Protected DACL. Grant explicit Full Control ACEs on all existing children
|
||||||
@@ -461,47 +495,49 @@ function openMainWindow(): BrowserWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const window = createMainWindow(store, {
|
const window = mainStartupTiming.measureSync('main-window.create', () =>
|
||||||
getIsQuitting: () => isQuitting,
|
createMainWindow(currentStore, {
|
||||||
onQuitAborted: () => {
|
getIsQuitting: () => isQuitting,
|
||||||
isQuitting = false
|
onQuitAborted: () => {
|
||||||
clearExpectedRendererReload()
|
isQuitting = false
|
||||||
},
|
clearExpectedRendererReload()
|
||||||
onRendererProcessGone: (details, webContentsId) => {
|
},
|
||||||
recordProcessGoneCrash(
|
onRendererProcessGone: (details, webContentsId) => {
|
||||||
'renderer',
|
recordProcessGoneCrash(
|
||||||
'renderer',
|
'renderer',
|
||||||
details.reason,
|
'renderer',
|
||||||
details.exitCode ?? null,
|
details.reason,
|
||||||
{
|
details.exitCode ?? null,
|
||||||
processType: 'renderer'
|
{
|
||||||
},
|
processType: 'renderer'
|
||||||
webContentsId
|
},
|
||||||
)
|
webContentsId
|
||||||
},
|
)
|
||||||
shouldRecordRendererCrash: (details, webContentsId) =>
|
},
|
||||||
shouldRecordProcessGoneCrash({
|
shouldRecordRendererCrash: (details, webContentsId) =>
|
||||||
source: 'renderer',
|
shouldRecordProcessGoneCrash({
|
||||||
processType: 'renderer',
|
source: 'renderer',
|
||||||
reason: details.reason,
|
processType: 'renderer',
|
||||||
exitCode: details.exitCode ?? null,
|
reason: details.reason,
|
||||||
expectedTeardown: getExpectedTeardownScope(webContentsId)
|
exitCode: details.exitCode ?? null,
|
||||||
}),
|
expectedTeardown: getExpectedTeardownScope(webContentsId)
|
||||||
shouldRecoverRenderer: (details, webContentsId) =>
|
}),
|
||||||
shouldRecoverRendererAfterProcessGone({
|
shouldRecoverRenderer: (details, webContentsId) =>
|
||||||
reason: details.reason,
|
shouldRecoverRendererAfterProcessGone({
|
||||||
expectedTeardown: getExpectedTeardownScope(webContentsId)
|
reason: details.reason,
|
||||||
}),
|
expectedTeardown: getExpectedTeardownScope(webContentsId)
|
||||||
deferLoad: true,
|
}),
|
||||||
title: devInstanceIdentity.name,
|
deferLoad: true,
|
||||||
getKeybindings: () => keybindings?.getOverrides(),
|
title: devInstanceIdentity.name,
|
||||||
onBeforeReload: ({ ignoreCache, webContentsId }) => {
|
getKeybindings: () => keybindings?.getOverrides(),
|
||||||
if (mainWindow?.webContents.id === webContentsId) {
|
onBeforeReload: ({ ignoreCache, webContentsId }) => {
|
||||||
markExpectedRendererReload(webContentsId)
|
if (mainWindow?.webContents.id === webContentsId) {
|
||||||
|
markExpectedRendererReload(webContentsId)
|
||||||
|
}
|
||||||
|
recordCrashBreadcrumb('manual_reload_requested', { ignoreCache })
|
||||||
}
|
}
|
||||||
recordCrashBreadcrumb('manual_reload_requested', { ignoreCache })
|
})
|
||||||
}
|
)
|
||||||
})
|
|
||||||
recordCrashBreadcrumb('main_window_created')
|
recordCrashBreadcrumb('main_window_created')
|
||||||
|
|
||||||
// Why: telemetry-plan.md§First-launch experience anchors default-on
|
// Why: telemetry-plan.md§First-launch experience anchors default-on
|
||||||
@@ -512,6 +548,11 @@ function openMainWindow(): BrowserWindow {
|
|||||||
const onFirstWindowLoad = (): void => {
|
const onFirstWindowLoad = (): void => {
|
||||||
clearExpectedRendererReload(rendererWebContentsId)
|
clearExpectedRendererReload(rendererWebContentsId)
|
||||||
recordCrashBreadcrumb('main_window_loaded')
|
recordCrashBreadcrumb('main_window_loaded')
|
||||||
|
if (!firstWindowLoadRecorded) {
|
||||||
|
firstWindowLoadRecorded = true
|
||||||
|
mainStartupTiming.markMilestone('main-window-did-finish-load', { rendererWebContentsId })
|
||||||
|
logMainStartupTiming({ stage: 'renderer-load' })
|
||||||
|
}
|
||||||
if (!store) {
|
if (!store) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -522,33 +563,43 @@ function openMainWindow(): BrowserWindow {
|
|||||||
trackAppOpenedOnce()
|
trackAppOpenedOnce()
|
||||||
}
|
}
|
||||||
window.webContents.on('did-finish-load', onFirstWindowLoad)
|
window.webContents.on('did-finish-load', onFirstWindowLoad)
|
||||||
|
window.once('show', () => {
|
||||||
registerCoreHandlers(
|
if (firstWindowVisibleRecorded) {
|
||||||
store,
|
return
|
||||||
runtime,
|
|
||||||
stats,
|
|
||||||
claudeUsage,
|
|
||||||
codexUsage,
|
|
||||||
openCodeUsage,
|
|
||||||
codexAccounts,
|
|
||||||
claudeAccounts,
|
|
||||||
rateLimits,
|
|
||||||
rendererWebContentsId,
|
|
||||||
automations,
|
|
||||||
{
|
|
||||||
prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch,
|
|
||||||
prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch()
|
|
||||||
},
|
|
||||||
agentAwakeService ?? undefined,
|
|
||||||
crashReports ?? undefined,
|
|
||||||
keybindings,
|
|
||||||
{
|
|
||||||
onBeforeRelaunch: () => {
|
|
||||||
isQuitting = true
|
|
||||||
store?.flush()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
firstWindowVisibleRecorded = true
|
||||||
|
mainStartupTiming.markMilestone('first-window-visible', { rendererWebContentsId })
|
||||||
|
logMainStartupTiming({ stage: 'first-window-visible' })
|
||||||
|
})
|
||||||
|
|
||||||
|
mainStartupTiming.measureSync('core-handlers.register', () => {
|
||||||
|
registerCoreHandlers(
|
||||||
|
currentStore,
|
||||||
|
currentRuntime,
|
||||||
|
currentStats,
|
||||||
|
currentClaudeUsage,
|
||||||
|
currentCodexUsage,
|
||||||
|
currentOpenCodeUsage,
|
||||||
|
currentCodexAccounts,
|
||||||
|
currentClaudeAccounts,
|
||||||
|
currentRateLimits,
|
||||||
|
rendererWebContentsId,
|
||||||
|
currentAutomations,
|
||||||
|
{
|
||||||
|
prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch,
|
||||||
|
prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch()
|
||||||
|
},
|
||||||
|
agentAwakeService ?? undefined,
|
||||||
|
crashReports ?? undefined,
|
||||||
|
currentKeybindings,
|
||||||
|
{
|
||||||
|
onBeforeRelaunch: () => {
|
||||||
|
isQuitting = true
|
||||||
|
store?.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
automations.setWebContents(window.webContents)
|
automations.setWebContents(window.webContents)
|
||||||
automations.start()
|
automations.start()
|
||||||
attachMainWindowServices(
|
attachMainWindowServices(
|
||||||
@@ -1033,6 +1084,7 @@ function driveSyntheticTitleFromHook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
mainStartupTiming.markMilestone('electron-ready')
|
||||||
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
|
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
|
||||||
app.setName(devInstanceIdentity.name)
|
app.setName(devInstanceIdentity.name)
|
||||||
|
|
||||||
@@ -1041,17 +1093,19 @@ app.whenReady().then(async () => {
|
|||||||
app.dock?.setIcon(dockIcon)
|
app.dock?.setIcon(dockIcon)
|
||||||
}
|
}
|
||||||
|
|
||||||
store = new Store()
|
store = mainStartupTiming.measureSync('store.init', () => new Store())
|
||||||
if (shouldSuppressDevEducation({ isDev: is.dev })) {
|
if (shouldSuppressDevEducation({ isDev: is.dev })) {
|
||||||
suppressDevEducationForStore(store)
|
suppressDevEducationForStore(store)
|
||||||
}
|
}
|
||||||
try {
|
await mainStartupTiming.measure('proxy.apply', async () => {
|
||||||
// Why: Dock/Launchpad launches do not inherit shell proxy env vars, so the
|
try {
|
||||||
// persisted proxy must be applied before any app-owned network fetchers run.
|
// Why: Dock/Launchpad launches do not inherit shell proxy env vars, so the
|
||||||
await applyElectronProxySettings(store.getSettings())
|
// persisted proxy must be applied before any app-owned network fetchers run.
|
||||||
} catch {
|
await applyElectronProxySettings(store!.getSettings())
|
||||||
console.warn('[proxy] Failed to apply network proxy settings')
|
} catch {
|
||||||
}
|
console.warn('[proxy] Failed to apply network proxy settings')
|
||||||
|
}
|
||||||
|
})
|
||||||
agentAwakeService = new AgentAwakeService()
|
agentAwakeService = new AgentAwakeService()
|
||||||
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
|
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
|
||||||
// Why: disk-hydrated status rows are UI continuity only. The service starts
|
// Why: disk-hydrated status rows are UI continuity only. The service starts
|
||||||
@@ -1164,6 +1218,7 @@ app.whenReady().then(async () => {
|
|||||||
onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId)
|
onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
mainStartupTiming.markMilestone('main-services-ready')
|
||||||
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
|
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
|
||||||
if (shouldInstallManagedHooks(is.dev)) {
|
if (shouldInstallManagedHooks(is.dev)) {
|
||||||
// Why: the persisted off switch must run before any auto-install path so
|
// Why: the persisted off switch must run before any auto-install path so
|
||||||
@@ -1291,31 +1346,36 @@ app.whenReady().then(async () => {
|
|||||||
registerMobileHandlers(runtimeRpc)
|
registerMobileHandlers(runtimeRpc)
|
||||||
|
|
||||||
if (!isServeMode) {
|
if (!isServeMode) {
|
||||||
await startFirstWindowStartupServices({
|
await mainStartupTiming.measure('first-window-services.start', () =>
|
||||||
// Why: the persistent-terminal daemon is desktop-only. Headless
|
startFirstWindowStartupServices({
|
||||||
// `orca serve` registers its PTY runtime below and must not spawn the
|
// Why: the persistent-terminal daemon is desktop-only. Headless
|
||||||
// desktop daemon or hook loopback listener.
|
// `orca serve` registers its PTY runtime below and must not spawn the
|
||||||
startDaemonPtyProvider: () => initDaemonPtyProvider(),
|
// desktop daemon or hook loopback listener.
|
||||||
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state,
|
startDaemonPtyProvider: () => initDaemonPtyProvider(),
|
||||||
// so the hook server must start before restored terminals can mount.
|
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state,
|
||||||
startAgentHookServer: () =>
|
// so the hook server must start before restored terminals can mount.
|
||||||
agentHookServer.start({
|
startAgentHookServer: () =>
|
||||||
env: app.isPackaged ? 'production' : 'development',
|
agentHookServer.start({
|
||||||
// Why: hooks source this endpoint file at invocation time, so old PTY
|
env: app.isPackaged ? 'production' : 'development',
|
||||||
// env still reaches the current Orca process after an app restart.
|
// Why: hooks source this endpoint file at invocation time, so old PTY
|
||||||
// Dev uses a namespace because all worktrees share `orca-dev`.
|
// env still reaches the current Orca process after an app restart.
|
||||||
userDataPath: app.getPath('userData'),
|
// Dev uses a namespace because all worktrees share `orca-dev`.
|
||||||
endpointNamespace: devAgentHookEndpointNamespace
|
userDataPath: app.getPath('userData'),
|
||||||
}),
|
endpointNamespace: devAgentHookEndpointNamespace
|
||||||
onDaemonError: (error) => {
|
}),
|
||||||
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
|
onDaemonError: (error) => {
|
||||||
},
|
console.error(
|
||||||
onAgentHookServerError: (error) => {
|
'[daemon] Failed to start daemon PTY provider, falling back to local:',
|
||||||
// Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar
|
error
|
||||||
// enrichment only. Orca must still boot if the loopback receiver fails.
|
)
|
||||||
console.error('[agent-hooks] Failed to start local hook server:', error)
|
},
|
||||||
}
|
onAgentHookServerError: (error) => {
|
||||||
})
|
// Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar
|
||||||
|
// enrichment only. Orca must still boot if the loopback receiver fails.
|
||||||
|
console.error('[agent-hooks] Failed to start local hook server:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serveOptions) {
|
if (serveOptions) {
|
||||||
@@ -1330,22 +1390,28 @@ app.whenReady().then(async () => {
|
|||||||
// explicit empty graph so status clients see a ready server while
|
// explicit empty graph so status clients see a ready server while
|
||||||
// renderer-only operations still fail at their own window boundary.
|
// renderer-only operations still fail at their own window boundary.
|
||||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||||
await runtimeRpc.start().catch((error) => {
|
await mainStartupTiming.measure('runtime-rpc.start', () =>
|
||||||
console.error('[runtime] Failed to start headless RPC transport:', error)
|
runtimeRpc!.start().catch((error) => {
|
||||||
throw error
|
console.error('[runtime] Failed to start headless RPC transport:', error)
|
||||||
})
|
throw error
|
||||||
|
})
|
||||||
|
)
|
||||||
installServeSignalHandlers()
|
installServeSignalHandlers()
|
||||||
await printServeReady(serveOptions)
|
await printServeReady(serveOptions)
|
||||||
|
mainStartupTiming.markMilestone('headless-server-ready')
|
||||||
|
logMainStartupTiming({ stage: 'headless-server-ready' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Why: once the hook server is ready (or has already failed open), window
|
// Why: once the hook server is ready (or has already failed open), window
|
||||||
// creation and runtime RPC startup are independent.
|
// creation and runtime RPC startup are independent.
|
||||||
const [win] = await Promise.all([
|
const [win] = await Promise.all([
|
||||||
Promise.resolve(openMainWindow()),
|
Promise.resolve().then(() => mainStartupTiming.measureSync('main-window.open', openMainWindow)),
|
||||||
runtimeRpc.start().catch((error) => {
|
mainStartupTiming.measure('runtime-rpc.start', () =>
|
||||||
console.error('[runtime] Failed to start local RPC transport:', error)
|
runtimeRpc!.start().catch((error) => {
|
||||||
})
|
console.error('[runtime] Failed to start local RPC transport:', error)
|
||||||
|
})
|
||||||
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
// Why: the macOS notification permission dialog must fire after the window
|
// Why: the macOS notification permission dialog must fire after the window
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { registerWorkspacePortHandlers } from './workspace-ports'
|
|||||||
import { registerAutomationHandlers } from './automations'
|
import { registerAutomationHandlers } from './automations'
|
||||||
import { registerKeybindingHandlers } from './keybindings'
|
import { registerKeybindingHandlers } from './keybindings'
|
||||||
import { registerTelemetryHandlers } from './telemetry'
|
import { registerTelemetryHandlers } from './telemetry'
|
||||||
|
import { registerStartupTimingHandlers } from './startup-timing'
|
||||||
import { registerBrowserHandlers } from './browser'
|
import { registerBrowserHandlers } from './browser'
|
||||||
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||||
import { registerShellHandlers } from './shell'
|
import { registerShellHandlers } from './shell'
|
||||||
@@ -139,6 +140,7 @@ export function registerCoreHandlers(
|
|||||||
registerKeybindingHandlers(keybindings)
|
registerKeybindingHandlers(keybindings)
|
||||||
}
|
}
|
||||||
registerTelemetryHandlers(store)
|
registerTelemetryHandlers(store)
|
||||||
|
registerStartupTimingHandlers()
|
||||||
registerBrowserHandlers()
|
registerBrowserHandlers()
|
||||||
// Why: applyPendingCookieImport MUST run before restorePersistedUserAgent
|
// Why: applyPendingCookieImport MUST run before restorePersistedUserAgent
|
||||||
// because the latter calls session.fromPartition() which initializes
|
// because the latter calls session.fromPartition() which initializes
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { isStartupTimingReport, type StartupTimingReport } from '../../shared/startup-phase-timing'
|
||||||
|
import { isStartupDiagnosticsEnabled, logStartupTimingReport } from '../startup/startup-diagnostics'
|
||||||
|
|
||||||
|
let registered = false
|
||||||
|
|
||||||
|
export function registerStartupTimingHandlers(): void {
|
||||||
|
if (registered) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registered = true
|
||||||
|
|
||||||
|
ipcMain.on('startup:timing-report', (_event, report: StartupTimingReport) => {
|
||||||
|
if (!isStartupDiagnosticsEnabled() || !isStartupTimingReport(report)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logStartupTimingReport(report)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
isStartupDiagnosticsEnabled,
|
isStartupDiagnosticsEnabled,
|
||||||
logStartupDiagnostic,
|
logStartupDiagnostic,
|
||||||
|
logStartupTimingReport,
|
||||||
STARTUP_DIAGNOSTICS_ENV,
|
STARTUP_DIAGNOSTICS_ENV,
|
||||||
writeStartupDiagnosticLine
|
writeStartupDiagnosticLine
|
||||||
} from './startup-diagnostics'
|
} from './startup-diagnostics'
|
||||||
@@ -36,3 +37,40 @@ describe('logStartupDiagnostic', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('logStartupTimingReport', () => {
|
||||||
|
it('formats structured startup timing records through the diagnostic sink', () => {
|
||||||
|
const write = vi.fn()
|
||||||
|
|
||||||
|
logStartupTimingReport(
|
||||||
|
{
|
||||||
|
origin: 'main',
|
||||||
|
startedAtMs: 1,
|
||||||
|
capturedAtMs: 5,
|
||||||
|
elapsedMs: 4,
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
name: 'store.init',
|
||||||
|
status: 'ok',
|
||||||
|
startedAtMs: 1,
|
||||||
|
endedAtMs: 3,
|
||||||
|
durationMs: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
milestones: [
|
||||||
|
{
|
||||||
|
name: 'first-window-visible',
|
||||||
|
atMs: 5,
|
||||||
|
elapsedMs: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
write
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(write).toHaveBeenCalledWith(
|
||||||
|
2,
|
||||||
|
'[startup] timing-report report={"origin":"main","startedAtMs":1,"capturedAtMs":5,"elapsedMs":4,"phases":[{"name":"store.init","status":"ok","startedAtMs":1,"endedAtMs":3,"durationMs":2}],"milestones":[{"name":"first-window-visible","atMs":5,"elapsedMs":4}]}\n'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { writeSync } from 'node:fs'
|
import { writeSync } from 'node:fs'
|
||||||
|
import type { StartupTimingReport } from '../../shared/startup-phase-timing'
|
||||||
|
|
||||||
export const STARTUP_DIAGNOSTICS_ENV = 'ORCA_STARTUP_DIAGNOSTICS'
|
export const STARTUP_DIAGNOSTICS_ENV = 'ORCA_STARTUP_DIAGNOSTICS'
|
||||||
|
|
||||||
@@ -29,3 +30,10 @@ export function logStartupDiagnostic(
|
|||||||
.join(' ')
|
.join(' ')
|
||||||
writeStartupDiagnosticLine(`[startup] ${event}${detailText ? ` ${detailText}` : ''}`, write)
|
writeStartupDiagnosticLine(`[startup] ${event}${detailText ? ` ${detailText}` : ''}`, write)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logStartupTimingReport(
|
||||||
|
report: StartupTimingReport,
|
||||||
|
write?: StartupDiagnosticSink
|
||||||
|
): void {
|
||||||
|
logStartupDiagnostic('timing-report', { report }, write)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
import type { NativeFileDropPayload } from '../shared/native-file-drop'
|
import type { NativeFileDropPayload } from '../shared/native-file-drop'
|
||||||
import type { AppIdentity } from '../shared/app-identity'
|
import type { AppIdentity } from '../shared/app-identity'
|
||||||
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
|
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
|
||||||
|
import type { StartupTimingReport } from '../shared/startup-phase-timing'
|
||||||
import type {
|
import type {
|
||||||
BaseRefDefaultResult,
|
BaseRefDefaultResult,
|
||||||
BaseRefSearchResult,
|
BaseRefSearchResult,
|
||||||
@@ -1712,6 +1713,9 @@ export type PreloadApi = {
|
|||||||
patch: (args: WorkspaceSessionPatch) => Promise<void>
|
patch: (args: WorkspaceSessionPatch) => Promise<void>
|
||||||
setSync: (args: WorkspaceSessionState) => void
|
setSync: (args: WorkspaceSessionState) => void
|
||||||
}
|
}
|
||||||
|
startupTiming: {
|
||||||
|
record: (report: StartupTimingReport) => void
|
||||||
|
}
|
||||||
remoteWorkspace: {
|
remoteWorkspace: {
|
||||||
get: (args: { targetId: string }) => Promise<RemoteWorkspaceSnapshot | null>
|
get: (args: { targetId: string }) => Promise<RemoteWorkspaceSnapshot | null>
|
||||||
setForConnectedTargets: (args: {
|
setForConnectedTargets: (args: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { AppIdentity } from '../shared/app-identity'
|
|||||||
import type { CliInstallStatus } from '../shared/cli-install-types'
|
import type { CliInstallStatus } from '../shared/cli-install-types'
|
||||||
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
|
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
|
||||||
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
|
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
|
||||||
|
import type { StartupTimingReport } from '../shared/startup-phase-timing'
|
||||||
import type {
|
import type {
|
||||||
BaseRefSearchResult,
|
BaseRefSearchResult,
|
||||||
BaseRefDefaultResult,
|
BaseRefDefaultResult,
|
||||||
@@ -2081,6 +2082,12 @@ const api = {
|
|||||||
}
|
}
|
||||||
} satisfies PreloadApi['session'],
|
} satisfies PreloadApi['session'],
|
||||||
|
|
||||||
|
startupTiming: {
|
||||||
|
record: (report: StartupTimingReport): void => {
|
||||||
|
ipcRenderer.send('startup:timing-report', report)
|
||||||
|
}
|
||||||
|
} satisfies PreloadApi['startupTiming'],
|
||||||
|
|
||||||
remoteWorkspace: {
|
remoteWorkspace: {
|
||||||
get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args),
|
get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args),
|
||||||
setForConnectedTargets: (args) =>
|
setForConnectedTargets: (args) =>
|
||||||
|
|||||||
+133
-85
@@ -115,6 +115,7 @@ import {
|
|||||||
} from '@/store/slices/worktree-nav-history'
|
} from '@/store/slices/worktree-nav-history'
|
||||||
import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor'
|
import type { VirtualizedScrollAnchor } from './hooks/useVirtualizedScrollAnchor'
|
||||||
import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types'
|
import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-types'
|
||||||
|
import { createStartupPhaseTimer } from '../../shared/startup-phase-timing'
|
||||||
import type { OnboardingState } from '../../shared/types'
|
import type { OnboardingState } from '../../shared/types'
|
||||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
|
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
|
||||||
import { ContextualTourOverlay } from './components/contextual-tours/ContextualTourOverlay'
|
import { ContextualTourOverlay } from './components/contextual-tours/ContextualTourOverlay'
|
||||||
@@ -653,24 +654,54 @@ function App(): React.JSX.Element {
|
|||||||
// workspaceSessionReady — so we still need to force the flag true so the
|
// workspaceSessionReady — so we still need to force the flag true so the
|
||||||
// UI mounts.
|
// UI mounts.
|
||||||
let reconnectStarted = false
|
let reconnectStarted = false
|
||||||
|
const startupTiming = createStartupPhaseTimer({ origin: 'renderer' })
|
||||||
|
const recordStartupTiming = (details: Record<string, unknown>): void => {
|
||||||
|
window.api.startupTiming.record(startupTiming.snapshot({ details }))
|
||||||
|
}
|
||||||
|
const loadDeferredStartupReads = async (): Promise<void> => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
startupTiming.measure('browser-profiles.fetch', () =>
|
||||||
|
actions.fetchBrowserSessionProfiles()
|
||||||
|
),
|
||||||
|
startupTiming.measure('onboarding.read', async () => {
|
||||||
|
const onboardingState = await window.api.onboarding.get()
|
||||||
|
if (!cancelled) {
|
||||||
|
setOnboarding(onboardingState)
|
||||||
|
setOnboardingLoaded(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
startupTiming.markMilestone('hydration-effect-start')
|
||||||
// Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId.
|
// Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId.
|
||||||
// Load settings first so a persisted remote runtime does not boot against
|
// Load settings first so a persisted remote runtime does not boot against
|
||||||
// the local filesystem and then hydrate stale local workspace state.
|
// the local filesystem and then hydrate stale local workspace state.
|
||||||
await actions.fetchSettings()
|
await startupTiming.measure('settings.fetch', () => actions.fetchSettings())
|
||||||
await actions.fetchRepos()
|
const persistedReads = Promise.all([
|
||||||
await actions.fetchProjectGroups()
|
startupTiming.measure('persisted-ui.read', () => window.api.ui.get()),
|
||||||
await actions.fetchAllWorktrees()
|
startupTiming.measure('session.read', () => window.api.session.get())
|
||||||
await actions.fetchWorktreeLineage()
|
])
|
||||||
const persistedUI = await window.api.ui.get()
|
const [[persistedUI, session]] = await Promise.all([
|
||||||
|
persistedReads,
|
||||||
|
(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
startupTiming.measure('repos.fetch', () => actions.fetchRepos()),
|
||||||
|
startupTiming.measure('project-groups.fetch', () => actions.fetchProjectGroups()),
|
||||||
|
startupTiming.measure('keybindings.fetch', () => actions.fetchKeybindings())
|
||||||
|
])
|
||||||
|
await Promise.all([
|
||||||
|
startupTiming.measure('worktrees.fetch-all', () => actions.fetchAllWorktrees()),
|
||||||
|
startupTiming.measure('worktree-lineage.fetch', () => actions.fetchWorktreeLineage())
|
||||||
|
])
|
||||||
|
})()
|
||||||
|
])
|
||||||
uiHydrated = hydratePersistedUIAfterStartupRead({
|
uiHydrated = hydratePersistedUIAfterStartupRead({
|
||||||
persistedUI,
|
persistedUI,
|
||||||
cancelled,
|
cancelled,
|
||||||
hydratePersistedUI: actions.hydratePersistedUI
|
hydratePersistedUI: actions.hydratePersistedUI
|
||||||
})
|
})
|
||||||
const session = await window.api.session.get()
|
|
||||||
await actions.fetchKeybindings()
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
actions.hydrateWorkspaceSession(session)
|
actions.hydrateWorkspaceSession(session)
|
||||||
actions.hydrateTabsSession(session)
|
actions.hydrateTabsSession(session)
|
||||||
@@ -686,12 +717,6 @@ function App(): React.JSX.Element {
|
|||||||
// See docs/cmd-j-empty-query-ordering.md.
|
// See docs/cmd-j-empty-query-ordering.md.
|
||||||
actions.pruneLastVisitedTimestamps()
|
actions.pruneLastVisitedTimestamps()
|
||||||
actions.seedActiveWorktreeLastVisitedIfMissing()
|
actions.seedActiveWorktreeLastVisitedIfMissing()
|
||||||
await actions.fetchBrowserSessionProfiles()
|
|
||||||
const onboardingState = await window.api.onboarding.get()
|
|
||||||
if (!cancelled) {
|
|
||||||
setOnboarding(onboardingState)
|
|
||||||
setOnboardingLoaded(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Why: SSH connections must be re-established BEFORE terminal
|
// Why: SSH connections must be re-established BEFORE terminal
|
||||||
// reconnect so that reconnectPersistedTerminals can route SSH-backed
|
// reconnect so that reconnectPersistedTerminals can route SSH-backed
|
||||||
@@ -699,85 +724,94 @@ function App(): React.JSX.Element {
|
|||||||
// are deferred to tab focus to avoid stacking credential dialogs at
|
// are deferred to tab focus to avoid stacking credential dialogs at
|
||||||
// startup before the user has context.
|
// startup before the user has context.
|
||||||
const connectionIds = session.activeConnectionIdsAtShutdown ?? []
|
const connectionIds = session.activeConnectionIdsAtShutdown ?? []
|
||||||
if (connectionIds.length > 0) {
|
await startupTiming.measure(
|
||||||
try {
|
'ssh.reconnect',
|
||||||
const SSH_RECONNECT_TIMEOUT_MS = 15_000
|
async () => {
|
||||||
const allTargets = await window.api.ssh.listTargets()
|
if (connectionIds.length === 0) {
|
||||||
const targetMap = new Map(allTargets.map((t) => [t.id, t]))
|
return
|
||||||
const targets = connectionIds.map((targetId) => ({
|
|
||||||
targetId,
|
|
||||||
needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false
|
|
||||||
}))
|
|
||||||
|
|
||||||
const eagerTargets = targets.filter((t) => !t.needsPassphrase)
|
|
||||||
const deferredTargets = targets.filter((t) => t.needsPassphrase)
|
|
||||||
|
|
||||||
if (deferredTargets.length > 0) {
|
|
||||||
actions.setDeferredSshReconnectTargets(deferredTargets.map((t) => t.targetId))
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const SSH_RECONNECT_TIMEOUT_MS = 15_000
|
||||||
|
const allTargets = await window.api.ssh.listTargets()
|
||||||
|
const targetMap = new Map(allTargets.map((t) => [t.id, t]))
|
||||||
|
const targets = connectionIds.map((targetId) => ({
|
||||||
|
targetId,
|
||||||
|
needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false
|
||||||
|
}))
|
||||||
|
|
||||||
// Why: track which eager targets timed out so we can treat them
|
const eagerTargets = targets.filter((t) => !t.needsPassphrase)
|
||||||
// as deferred — the underlying ssh.connect() keeps running in the
|
const deferredTargets = targets.filter((t) => t.needsPassphrase)
|
||||||
// main process, but reconnectPersistedTerminals won't see them as
|
|
||||||
// connected. Adding them to the deferred list ensures PTYs get
|
if (deferredTargets.length > 0) {
|
||||||
// reattached when the user focuses the tab (by which time the
|
actions.setDeferredSshReconnectTargets(deferredTargets.map((t) => t.targetId))
|
||||||
// slow connect will likely have succeeded).
|
}
|
||||||
const timedOutTargets: string[] = []
|
|
||||||
await Promise.allSettled(
|
// Why: track which eager targets timed out so we can treat them
|
||||||
eagerTargets.map(({ targetId }) =>
|
// as deferred — the underlying ssh.connect() keeps running in the
|
||||||
Promise.race([
|
// main process, but reconnectPersistedTerminals won't see them as
|
||||||
window.api.ssh.connect({ targetId }),
|
// connected. Adding them to the deferred list ensures PTYs get
|
||||||
new Promise((_, reject) =>
|
// reattached when the user focuses the tab (by which time the
|
||||||
setTimeout(
|
// slow connect will likely have succeeded).
|
||||||
() => reject(new Error('SSH reconnect timeout')),
|
const timedOutTargets: string[] = []
|
||||||
SSH_RECONNECT_TIMEOUT_MS
|
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
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
]).catch((err) => {
|
||||||
]).catch((err) => {
|
const isTimeout =
|
||||||
const isTimeout =
|
err instanceof Error && err.message === 'SSH reconnect timeout'
|
||||||
err instanceof Error && err.message === 'SSH reconnect timeout'
|
if (isTimeout) {
|
||||||
if (isTimeout) {
|
timedOutTargets.push(targetId)
|
||||||
timedOutTargets.push(targetId)
|
}
|
||||||
}
|
console.warn(`SSH auto-reconnect failed for ${targetId}:`, err)
|
||||||
console.warn(`SSH auto-reconnect failed for ${targetId}:`, err)
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (timedOutTargets.length > 0) {
|
|
||||||
actions.setDeferredSshReconnectTargets([
|
|
||||||
...deferredTargets.map((t) => t.targetId),
|
|
||||||
...timedOutTargets
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Why: ssh.connect() resolves before the ssh:state-changed IPC
|
|
||||||
// event updates sshConnectionStates in the store. Without this,
|
|
||||||
// reconnectPersistedTerminals reads stale state and misclassifies
|
|
||||||
// successfully connected targets as disconnected, stranding their
|
|
||||||
// persisted PTYs. Polling getState ensures the store is current.
|
|
||||||
for (const { targetId } of eagerTargets) {
|
|
||||||
if (timedOutTargets.includes(targetId)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const state = await window.api.ssh.getState({ targetId })
|
|
||||||
console.warn(
|
|
||||||
`[ssh-restore] Polled state for ${targetId}: status=${state?.status}`
|
|
||||||
)
|
)
|
||||||
if (state?.status === 'connected') {
|
)
|
||||||
actions.setSshConnectionState(targetId, state)
|
if (timedOutTargets.length > 0) {
|
||||||
}
|
actions.setDeferredSshReconnectTargets([
|
||||||
} catch {
|
...deferredTargets.map((t) => t.targetId),
|
||||||
/* best-effort */
|
...timedOutTargets
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Why: ssh.connect() resolves before the ssh:state-changed IPC
|
||||||
|
// event updates sshConnectionStates in the store. Without this,
|
||||||
|
// reconnectPersistedTerminals reads stale state and misclassifies
|
||||||
|
// successfully connected targets as disconnected, stranding their
|
||||||
|
// persisted PTYs. Polling getState ensures the store is current.
|
||||||
|
for (const { targetId } of eagerTargets) {
|
||||||
|
if (timedOutTargets.includes(targetId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const state = await window.api.ssh.getState({ targetId })
|
||||||
|
console.warn(
|
||||||
|
`[ssh-restore] Polled state for ${targetId}: status=${state?.status}`
|
||||||
|
)
|
||||||
|
if (state?.status === 'connected') {
|
||||||
|
actions.setSshConnectionState(targetId, state)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('SSH startup reconnect failed:', err)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
},
|
||||||
console.warn('SSH startup reconnect failed:', err)
|
{ details: { connectionCount: connectionIds.length } }
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
reconnectStarted = true
|
reconnectStarted = true
|
||||||
await actions.reconnectPersistedTerminals(abortController.signal)
|
await startupTiming.measure('terminal.reconnect', () =>
|
||||||
|
actions.reconnectPersistedTerminals(abortController.signal)
|
||||||
|
)
|
||||||
syncZoomCSSVar()
|
syncZoomCSSVar()
|
||||||
// Why (issue #1158): unlock the debounced session writer only after
|
// Why (issue #1158): unlock the debounced session writer only after
|
||||||
// hydration AND all dependent startup steps (SSH reconnect, terminal
|
// hydration AND all dependent startup steps (SSH reconnect, terminal
|
||||||
@@ -787,6 +821,14 @@ function App(): React.JSX.Element {
|
|||||||
// and the writer would serialize a partially-mutated store back to
|
// and the writer would serialize a partially-mutated store back to
|
||||||
// disk — the exact data-loss mode this PR fixes.
|
// disk — the exact data-loss mode this PR fixes.
|
||||||
actions.setHydrationSucceeded(true)
|
actions.setHydrationSucceeded(true)
|
||||||
|
startupTiming.markMilestone('first-usable-workspace', {
|
||||||
|
sshConnectionCount: connectionIds.length
|
||||||
|
})
|
||||||
|
await loadDeferredStartupReads()
|
||||||
|
startupTiming.markMilestone('deferred-startup-complete')
|
||||||
|
if (!cancelled) {
|
||||||
|
recordStartupTiming({ outcome: 'ok' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Why (issue #1158): previously this catch called hydrateWorkspaceSession
|
// Why (issue #1158): previously this catch called hydrateWorkspaceSession
|
||||||
@@ -797,6 +839,9 @@ function App(): React.JSX.Element {
|
|||||||
// false so the writer stays gated. We still ensure persistedUIReady and
|
// false so the writer stays gated. We still ensure persistedUIReady and
|
||||||
// workspaceSessionReady flip so the UI can mount without a session.
|
// workspaceSessionReady flip so the UI can mount without a session.
|
||||||
const stepLabel = error instanceof Error && error.message ? error.message : String(error)
|
const stepLabel = error instanceof Error && error.message ? error.message : String(error)
|
||||||
|
startupTiming.markMilestone('workspace-session-hydration-failed', {
|
||||||
|
step: stepLabel
|
||||||
|
})
|
||||||
console.error(
|
console.error(
|
||||||
'[startup] Workspace session hydration failed; leaving disk state untouched:',
|
'[startup] Workspace session hydration failed; leaving disk state untouched:',
|
||||||
stepLabel,
|
stepLabel,
|
||||||
@@ -888,6 +933,9 @@ function App(): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
recordStartupTiming({ outcome: 'error', step: stepLabel })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
void actions.initGitHubCache()
|
void actions.initGitHubCache()
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -488,6 +488,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
|||||||
writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session))
|
writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
startupTiming: {
|
||||||
|
record: () => {}
|
||||||
|
},
|
||||||
onboarding: {
|
onboarding: {
|
||||||
get: () => Promise.resolve(getStoredOnboarding()),
|
get: () => Promise.resolve(getStoredOnboarding()),
|
||||||
update: async (updates) => {
|
update: async (updates) => {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createStartupPhaseTimer, isStartupTimingReport } from './startup-phase-timing'
|
||||||
|
|
||||||
|
describe('startup phase timing', () => {
|
||||||
|
it('records structured phases and milestones with elapsed timings', async () => {
|
||||||
|
let now = 100
|
||||||
|
const timer = createStartupPhaseTimer({
|
||||||
|
origin: 'renderer',
|
||||||
|
now: () => now,
|
||||||
|
details: { runtime: 'local' }
|
||||||
|
})
|
||||||
|
|
||||||
|
timer.markMilestone('hydration-effect-start')
|
||||||
|
const value = timer.measureSync('settings.fetch', () => {
|
||||||
|
now += 12.3
|
||||||
|
return 'settings'
|
||||||
|
})
|
||||||
|
await timer.measure('session.read', async () => {
|
||||||
|
now += 8.1
|
||||||
|
})
|
||||||
|
timer.markMilestone('first-usable-workspace', { sshConnectionCount: 0 })
|
||||||
|
now += 4
|
||||||
|
|
||||||
|
const report = timer.snapshot({ details: { outcome: 'ok' } })
|
||||||
|
|
||||||
|
expect(value).toBe('settings')
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
origin: 'renderer',
|
||||||
|
startedAtMs: 100,
|
||||||
|
capturedAtMs: 124.4,
|
||||||
|
elapsedMs: 24.4,
|
||||||
|
details: { runtime: 'local', outcome: 'ok' },
|
||||||
|
milestones: [
|
||||||
|
{ name: 'hydration-effect-start', atMs: 100, elapsedMs: 0 },
|
||||||
|
{
|
||||||
|
name: 'first-usable-workspace',
|
||||||
|
atMs: 120.4,
|
||||||
|
elapsedMs: 20.4,
|
||||||
|
details: { sshConnectionCount: 0 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
name: 'settings.fetch',
|
||||||
|
status: 'ok',
|
||||||
|
startedAtMs: 100,
|
||||||
|
endedAtMs: 112.3,
|
||||||
|
durationMs: 12.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'session.read',
|
||||||
|
status: 'ok',
|
||||||
|
startedAtMs: 112.3,
|
||||||
|
endedAtMs: 120.4,
|
||||||
|
durationMs: 8.1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
expect(isStartupTimingReport(report)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records failed phases before rethrowing', () => {
|
||||||
|
let now = 20
|
||||||
|
const timer = createStartupPhaseTimer({ origin: 'main', now: () => now })
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
timer.measureSync('store.init', () => {
|
||||||
|
now += 3
|
||||||
|
throw new Error('bad data')
|
||||||
|
})
|
||||||
|
).toThrow('bad data')
|
||||||
|
|
||||||
|
expect(timer.snapshot().phases).toEqual([
|
||||||
|
{
|
||||||
|
name: 'store.init',
|
||||||
|
status: 'error',
|
||||||
|
startedAtMs: 20,
|
||||||
|
endedAtMs: 23,
|
||||||
|
durationMs: 3,
|
||||||
|
details: { error: 'bad data', name: 'Error' }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed timing reports', () => {
|
||||||
|
expect(isStartupTimingReport({ origin: 'renderer', phases: [], milestones: [] })).toBe(false)
|
||||||
|
expect(
|
||||||
|
isStartupTimingReport({
|
||||||
|
origin: 'renderer',
|
||||||
|
startedAtMs: 0,
|
||||||
|
capturedAtMs: 1,
|
||||||
|
elapsedMs: 1,
|
||||||
|
phases: [{ name: 'phase', status: 'ok', startedAtMs: 0, endedAtMs: 1 }],
|
||||||
|
milestones: []
|
||||||
|
})
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
export type StartupTimingOrigin = 'main' | 'renderer'
|
||||||
|
|
||||||
|
export type StartupTimingPhaseStatus = 'ok' | 'error'
|
||||||
|
|
||||||
|
export type StartupTimingDetails = Record<string, unknown>
|
||||||
|
|
||||||
|
export type StartupTimingPhase = {
|
||||||
|
name: string
|
||||||
|
status: StartupTimingPhaseStatus
|
||||||
|
startedAtMs: number
|
||||||
|
endedAtMs: number
|
||||||
|
durationMs: number
|
||||||
|
details?: StartupTimingDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StartupTimingMilestone = {
|
||||||
|
name: string
|
||||||
|
atMs: number
|
||||||
|
elapsedMs: number
|
||||||
|
details?: StartupTimingDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StartupTimingReport = {
|
||||||
|
origin: StartupTimingOrigin
|
||||||
|
startedAtMs: number
|
||||||
|
capturedAtMs: number
|
||||||
|
elapsedMs: number
|
||||||
|
phases: StartupTimingPhase[]
|
||||||
|
milestones: StartupTimingMilestone[]
|
||||||
|
details?: StartupTimingDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
type StartupPhaseTimerOptions = {
|
||||||
|
origin: StartupTimingOrigin
|
||||||
|
now?: () => number
|
||||||
|
details?: StartupTimingDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
type PhaseOptions = {
|
||||||
|
details?: StartupTimingDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapshotOptions = {
|
||||||
|
details?: StartupTimingDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultNow(): number {
|
||||||
|
return globalThis.performance?.now() ?? Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundedMs(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDetails(details?: StartupTimingDetails): StartupTimingDetails | undefined {
|
||||||
|
if (!details || Object.keys(details).length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorDetails(error: unknown): StartupTimingDetails {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return { error: error.message, name: error.name }
|
||||||
|
}
|
||||||
|
return { error: String(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StartupPhaseTimer {
|
||||||
|
private readonly origin: StartupTimingOrigin
|
||||||
|
private readonly now: () => number
|
||||||
|
private readonly startedAtMs: number
|
||||||
|
private readonly details?: StartupTimingDetails
|
||||||
|
private readonly phases: StartupTimingPhase[] = []
|
||||||
|
private readonly milestones: StartupTimingMilestone[] = []
|
||||||
|
|
||||||
|
constructor(options: StartupPhaseTimerOptions) {
|
||||||
|
this.origin = options.origin
|
||||||
|
this.now = options.now ?? defaultNow
|
||||||
|
this.startedAtMs = this.now()
|
||||||
|
this.details = normalizeDetails(options.details)
|
||||||
|
}
|
||||||
|
|
||||||
|
markMilestone(name: string, details?: StartupTimingDetails): void {
|
||||||
|
const atMs = this.now()
|
||||||
|
const normalizedDetails = normalizeDetails(details)
|
||||||
|
this.milestones.push({
|
||||||
|
name,
|
||||||
|
atMs: roundedMs(atMs),
|
||||||
|
elapsedMs: roundedMs(atMs - this.startedAtMs),
|
||||||
|
...(normalizedDetails ? { details: normalizedDetails } : {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
measureSync<T>(name: string, task: () => T, options: PhaseOptions = {}): T {
|
||||||
|
const startedAtMs = this.now()
|
||||||
|
try {
|
||||||
|
const result = task()
|
||||||
|
this.recordPhase(name, startedAtMs, this.now(), 'ok', options.details)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
this.recordPhase(name, startedAtMs, this.now(), 'error', {
|
||||||
|
...options.details,
|
||||||
|
...errorDetails(error)
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async measure<T>(name: string, task: () => Promise<T>, options: PhaseOptions = {}): Promise<T> {
|
||||||
|
const startedAtMs = this.now()
|
||||||
|
try {
|
||||||
|
const result = await task()
|
||||||
|
this.recordPhase(name, startedAtMs, this.now(), 'ok', options.details)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
this.recordPhase(name, startedAtMs, this.now(), 'error', {
|
||||||
|
...options.details,
|
||||||
|
...errorDetails(error)
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(options: SnapshotOptions = {}): StartupTimingReport {
|
||||||
|
const capturedAtMs = this.now()
|
||||||
|
const details = normalizeDetails({
|
||||||
|
...this.details,
|
||||||
|
...options.details
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
origin: this.origin,
|
||||||
|
startedAtMs: roundedMs(this.startedAtMs),
|
||||||
|
capturedAtMs: roundedMs(capturedAtMs),
|
||||||
|
elapsedMs: roundedMs(capturedAtMs - this.startedAtMs),
|
||||||
|
phases: this.phases.map((phase) => ({ ...phase })),
|
||||||
|
milestones: this.milestones.map((milestone) => ({ ...milestone })),
|
||||||
|
...(details ? { details } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordPhase(
|
||||||
|
name: string,
|
||||||
|
startedAtMs: number,
|
||||||
|
endedAtMs: number,
|
||||||
|
status: StartupTimingPhaseStatus,
|
||||||
|
details?: StartupTimingDetails
|
||||||
|
): void {
|
||||||
|
const normalizedDetails = normalizeDetails(details)
|
||||||
|
this.phases.push({
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
startedAtMs: roundedMs(startedAtMs),
|
||||||
|
endedAtMs: roundedMs(endedAtMs),
|
||||||
|
durationMs: roundedMs(endedAtMs - startedAtMs),
|
||||||
|
...(normalizedDetails ? { details: normalizedDetails } : {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStartupPhaseTimer(options: StartupPhaseTimerOptions): StartupPhaseTimer {
|
||||||
|
return new StartupPhaseTimer(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStartupTimingReport(value: unknown): value is StartupTimingReport {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const report = value as Partial<StartupTimingReport>
|
||||||
|
return (
|
||||||
|
(report.origin === 'main' || report.origin === 'renderer') &&
|
||||||
|
typeof report.startedAtMs === 'number' &&
|
||||||
|
typeof report.capturedAtMs === 'number' &&
|
||||||
|
typeof report.elapsedMs === 'number' &&
|
||||||
|
Array.isArray(report.phases) &&
|
||||||
|
report.phases.every(isStartupTimingPhase) &&
|
||||||
|
Array.isArray(report.milestones) &&
|
||||||
|
report.milestones.every(isStartupTimingMilestone)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStartupTimingPhase(value: unknown): value is StartupTimingPhase {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const phase = value as Partial<StartupTimingPhase>
|
||||||
|
return (
|
||||||
|
typeof phase.name === 'string' &&
|
||||||
|
(phase.status === 'ok' || phase.status === 'error') &&
|
||||||
|
typeof phase.startedAtMs === 'number' &&
|
||||||
|
typeof phase.endedAtMs === 'number' &&
|
||||||
|
typeof phase.durationMs === 'number'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStartupTimingMilestone(value: unknown): value is StartupTimingMilestone {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const milestone = value as Partial<StartupTimingMilestone>
|
||||||
|
return (
|
||||||
|
typeof milestone.name === 'string' &&
|
||||||
|
typeof milestone.atMs === 'number' &&
|
||||||
|
typeof milestone.elapsedMs === 'number'
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user