perf(startup): stop an unreachable SSH host from gating local terminal restore

An asleep or unreachable SSH target held the terminal-restoration gate for the
full 15s reconnect timeout, so no terminal restored — local ones included.
Startup now awaits only the target that owns the active workspace's tabs and
lets the rest connect in the background, folded into the existing deferred path
that reattaches their PTYs on tab focus.

Also splits the renderer's git-environment fence out of the first-window PTY
services barrier: worktree hydration needs shell-PATH generation and the managed
WSL CLI registration, not a daemon PTY spawn or a hook-server bind. Terminal
restoration still fences on the first-window services via
app:prepareTerminalStartupRestoration.

Measured with tests/tools/benchmarks/startup-time-bench.mjs (382 restored tabs,
28k-file profile, medians of 3):
  unreachable SSH host: 17.27s -> 1.34s to renderer-startup-hydration-done
  all-local:             1.98s -> 1.33s
This commit is contained in:
Neil
2026-09-02 12:55:33 -07:00
parent 104f9655e4
commit 50d46c577a
16 changed files with 733 additions and 188 deletions
@@ -183,6 +183,48 @@ describe('startup ordering', () => {
)
})
it('keeps the git-environment barrier off the PTY startup services', () => {
const barrierSource = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'),
'utf8'
)
const launchSource = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'),
'utf8'
)
const gitBarrierStart = barrierSource.indexOf(
"ipcMain.handle('app:awaitGitEnvironmentStartupBarrier'"
)
const gitBarrierEnd = barrierSource.indexOf(
"'app:prepareTerminalStartupRestoration'",
gitBarrierStart
)
expect(gitBarrierStart).toBeGreaterThanOrEqual(0)
expect(gitBarrierEnd).toBeGreaterThan(gitBarrierStart)
const gitBarrier = barrierSource.slice(gitBarrierStart, gitBarrierEnd)
// The git environment fence is shell PATH + WSL registration; a daemon PTY provider or a
// hook-server bind here puts terminal startup back in front of worktree hydration.
expect(gitBarrier).toContain('state.shellPathReady')
expect(gitBarrier).toContain('state.managedWslCliStartupBarrierReady')
expect(gitBarrier).not.toContain('firstWindowStartupServicesReady')
// The published promise must be the same one the terminal startup services wait on.
expect(launchSource).toContain('state.shellPathReady = shellPathReady')
expect(launchSource.indexOf('state.shellPathReady = shellPathReady')).toBeLessThan(
launchSource.indexOf('await launchDesktopMode(')
)
// Terminal restoration itself must still fence on the first-window services.
const restorationStart = barrierSource.indexOf(
"ipcMain.handle('app:prepareTerminalStartupRestoration'"
)
const restorationEnd = barrierSource.indexOf(
"'app:recoverLegacyWorkerTerminalsForRendererStartup'",
restorationStart
)
expect(barrierSource.slice(restorationStart, restorationEnd)).toContain(
'state.firstWindowStartupServicesReady'
)
})
it('reconciles retained Codex homes after authoritative daemon inventory', () => {
const source = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'),
@@ -11,6 +11,13 @@ export function registerMainProcessIpcHandlers(): void {
state.managedWslCliStartupBarrierReady
])
})
// Why separate from the first-window barrier: host Git needs the shell-PATH
// generation and the managed WSL CLI registration, not a daemon PTY provider
// or a hook-server bind. Bundling them made worktree hydration wait on a
// terminal service it never calls.
ipcMain.handle('app:awaitGitEnvironmentStartupBarrier', async () => {
await Promise.all([state.shellPathReady, state.managedWslCliStartupBarrierReady])
})
ipcMain.handle('app:prepareTerminalStartupRestoration', async () => {
await Promise.all([
state.firstWindowStartupServicesReady,
@@ -289,6 +289,9 @@ export async function initializeMainProcessRuntimeLaunch(
state.serveOptions = serveOptions
const runtimeRpc = installRuntimeRpc(runtime, serveOptions)
const shellPathReady = shellPathHydration.whenReady()
// Why published: the renderer's git-environment barrier must fence on the same
// generation the terminal startup services wait for, not a later re-read.
state.shellPathReady = shellPathReady
let desktopWindow: BrowserWindow | null = null
if (process.platform === 'win32' && app.isPackaged && !serveOptions) {
const desktopStartup = startWindowsDesktopBeforeShellPathReady({
+3
View File
@@ -38,6 +38,9 @@ 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>
/** Resolves when host Git can run: shell-PATH generation is published and the
* managed WSL CLI registration has reconciled. Does not wait on PTY services. */
awaitGitEnvironmentStartupBarrier: () => Promise<void>
/** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */
prepareTerminalStartupRestoration: () => Promise<void>
/** Reconciles legacy worker authority around persisted terminal reconnect. */
+2
View File
@@ -43,6 +43,8 @@ export const appApi = {
awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(),
awaitFirstWindowStartupServices: (): Promise<void> =>
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
awaitGitEnvironmentStartupBarrier: (): Promise<void> =>
ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'),
prepareTerminalStartupRestoration: (): Promise<void> =>
ipcRenderer.invoke('app:prepareTerminalStartupRestoration'),
recoverLegacyWorkerTerminalsForRendererStartup: (): Promise<void> =>
@@ -30,6 +30,7 @@ function makeActions(): StartupActions {
reconnectPersistedTerminals: vi.fn(),
setTerminalStartupRestorationReady: vi.fn(),
setDeferredSshReconnectTargets: vi.fn(),
removeDeferredSshReconnectTarget: vi.fn(),
setSshConnectionState: vi.fn(),
hydratePersistedUI: vi.fn(),
setHydrationSucceeded: vi.fn(),
@@ -22,6 +22,7 @@ export type StartupActions = Pick<
| 'reconnectPersistedTerminals'
| 'setTerminalStartupRestorationReady'
| 'setDeferredSshReconnectTargets'
| 'removeDeferredSshReconnectTarget'
| 'setSshConnectionState'
| 'hydratePersistedUI'
| 'setHydrationSucceeded'
@@ -59,6 +60,8 @@ export function selectStartupActions(state: StartupActions): StartupActions {
cachedStartupActions.setTerminalStartupRestorationReady ===
state.setTerminalStartupRestorationReady &&
cachedStartupActions.setDeferredSshReconnectTargets === state.setDeferredSshReconnectTargets &&
cachedStartupActions.removeDeferredSshReconnectTarget ===
state.removeDeferredSshReconnectTarget &&
cachedStartupActions.setSshConnectionState === state.setSshConnectionState &&
cachedStartupActions.hydratePersistedUI === state.hydratePersistedUI &&
cachedStartupActions.setHydrationSucceeded === state.setHydrationSucceeded &&
@@ -91,6 +94,7 @@ export function selectStartupActions(state: StartupActions): StartupActions {
reconnectPersistedTerminals: state.reconnectPersistedTerminals,
setTerminalStartupRestorationReady: state.setTerminalStartupRestorationReady,
setDeferredSshReconnectTargets: state.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: state.removeDeferredSshReconnectTarget,
setSshConnectionState: state.setSshConnectionState,
hydratePersistedUI: state.hydratePersistedUI,
setHydrationSucceeded: state.setHydrationSucceeded,
@@ -19,6 +19,7 @@ import {
} from '../startup/startup-diagnostics'
import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery'
import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore'
import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets'
import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance'
import { getSystemPrefersDark } from '../lib/terminal-theme'
import {
@@ -155,9 +156,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
// Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders.
parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'
)
// Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first.
await timeRendererStartupStep('first-window-services-await', () =>
window.api.app.awaitFirstWindowStartupServices()
// Why this barrier and not the first-window one: worktree refresh can spawn host Git,
// which needs the shell-PATH generation and the managed WSL CLI registration. It never
// needs the daemon PTY provider or the hook-server bind, and `prepare-terminal-startup-restoration`
// below still fences those before any terminal is restored.
await timeRendererStartupStep('git-environment-barrier-await', () =>
window.api.app.awaitGitEnvironmentStartupBarrier()
)
await timeRendererStartupStep('fetch-hydration-worktrees', () =>
mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) =>
@@ -213,9 +217,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
actions.pruneLastVisitedTimestamps()
actions.seedActiveWorktreeLastVisitedIfMissing()
})
await timeRendererStartupStep('fetch-browser-session-profiles', () =>
// Why started here but not awaited: on a remote runtime this is an RPC with a 15s
// timeout, and nothing between here and terminal restoration reads the profile list —
// awaiting it put that timeout on the terminal-restoration gate. Starting it at the
// original point keeps the profiles landing no later than they did before; the action
// swallows its own failures, so the `.catch` only marks the timing wrapper handled.
void timeRendererStartupStep('fetch-browser-session-profiles', () =>
actions.fetchBrowserSessionProfiles()
)
).catch(() => {})
const onboardingState = await onboardingPromise
if (!cancelled) {
onOnboardingLoadedRef.current(onboardingState)
@@ -228,9 +237,17 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
)
if (connectionIds.length > 0) {
try {
// Why scoped: an unreachable host used to hold every restored terminal — local ones
// included — for the full reconnect timeout. Only the targets whose panes mount as
// soon as the gate opens are worth waiting for; the rest reattach on tab focus.
const blockingConnectionIds = collectActiveWorkspaceSshTargetIds(
useAppStore.getState()
)
await restoreSshConnectionsForStartup({
connectionIds,
blockingConnectionIds,
setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: actions.removeDeferredSshReconnectTarget,
publishSshConnectionState: actions.setSshConnectionState
})
} catch (err) {
@@ -240,7 +257,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 })
}
// first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state.
// Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited
// the first-window services, and main re-awaits them inside this handler anyway.
await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { toAppSshPtyId } from '../../../shared/ssh-pty-id'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import { collectActiveWorkspaceSshTargetIds } from './active-workspace-ssh-targets'
function tab(id: string, ptyId: string | null = null): TerminalTab {
return {
id,
ptyId,
worktreeId: 'repo-a::/w/a',
title: id,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
} as TerminalTab
}
const emptyInput = {
activeWorktreeId: null as string | null,
tabsByWorktree: {} as Record<string, TerminalTab[]>,
pendingReconnectPtyIdByTabId: {} as Record<string, string>,
terminalLayoutsByTabId: {} as Record<string, { ptyIdsByLeafId?: Record<string, string | null> }>,
repos: [] as { id: string; connectionId?: string | null }[]
}
describe('collectActiveWorkspaceSshTargetIds', () => {
it('returns nothing when no workspace is active', () => {
expect(collectActiveWorkspaceSshTargetIds(emptyInput)).toEqual([])
})
it('returns nothing for a purely local active workspace', () => {
expect(
collectActiveWorkspaceSshTargetIds({
...emptyInput,
activeWorktreeId: 'repo-a::/w/a',
tabsByWorktree: { 'repo-a::/w/a': [tab('t1', 'local-pty-1')] },
repos: [{ id: 'repo-a', connectionId: null }]
})
).toEqual([])
})
it('names the target from the active workspace repo connection', () => {
expect(
collectActiveWorkspaceSshTargetIds({
...emptyInput,
activeWorktreeId: 'repo-remote::/srv/w',
repos: [{ id: 'repo-remote', connectionId: 'ssh-1' }]
})
).toEqual(['ssh-1'])
})
it('names the target from a restored PTY id when the repo catalog has no connection', () => {
// SSH worktrees are absent from worktreesByRepo at cold start; the PTY id is the durable name.
expect(
collectActiveWorkspaceSshTargetIds({
...emptyInput,
activeWorktreeId: 'repo-a::/w/a',
tabsByWorktree: { 'repo-a::/w/a': [tab('t1')] },
pendingReconnectPtyIdByTabId: { t1: toAppSshPtyId('ssh-2', 'pty-9') },
repos: [{ id: 'repo-a' }]
})
).toEqual(['ssh-2'])
})
it('names split-leaf targets on the active workspace', () => {
expect(
collectActiveWorkspaceSshTargetIds({
...emptyInput,
activeWorktreeId: 'repo-a::/w/a',
tabsByWorktree: { 'repo-a::/w/a': [tab('t1')] },
terminalLayoutsByTabId: {
t1: {
ptyIdsByLeafId: {
leaf1: toAppSshPtyId('ssh-3', 'pty-1'),
leaf2: null,
leaf3: 'local-pty-2'
}
}
},
repos: [{ id: 'repo-a' }]
})
).toEqual(['ssh-3'])
})
it('ignores targets that only own an inactive workspace', () => {
expect(
collectActiveWorkspaceSshTargetIds({
...emptyInput,
activeWorktreeId: 'repo-a::/w/a',
tabsByWorktree: {
'repo-a::/w/a': [tab('t1', 'local-pty-1')],
'repo-b::/w/b': [tab('t2', toAppSshPtyId('ssh-other', 'pty-1'))]
},
repos: [{ id: 'repo-a' }, { id: 'repo-b', connectionId: 'ssh-other' }]
})
).toEqual([])
})
it('deduplicates a target named by both the repo and its PTY ids', () => {
expect(
collectActiveWorkspaceSshTargetIds({
...emptyInput,
activeWorktreeId: 'repo-remote::/srv/w',
tabsByWorktree: {
'repo-remote::/srv/w': [tab('t1', toAppSshPtyId('ssh-1', 'pty-1'))]
},
repos: [{ id: 'repo-remote', connectionId: 'ssh-1' }]
})
).toEqual(['ssh-1'])
})
})
@@ -0,0 +1,49 @@
import { parseAppSshPtyId } from '../../../shared/ssh-pty-id'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
type ActiveWorkspaceSshTargetInput = {
activeWorktreeId: string | null
tabsByWorktree: Readonly<Record<string, readonly { id: string; ptyId?: string | null }[]>>
/** Restored tab-level PTY ids, keyed by tab id. */
pendingReconnectPtyIdByTabId: Readonly<Record<string, string>>
terminalLayoutsByTabId: Readonly<
Record<string, { ptyIdsByLeafId?: Readonly<Record<string, string | null>> } | undefined>
>
repos: readonly { id: string; connectionId?: string | null }[]
}
/**
* SSH targets that own terminals the user sees the moment the startup gate opens: the ones
* whose reconnect must still be awaited. Everything else can connect in the background and
* reattach on tab focus.
*
* Derived from the restored PTY ids rather than the repo catalog alone, because SSH worktrees
* are absent from `worktreesByRepo` at cold start — the PTY id is the durable name of the
* target the pane will reattach.
*/
export function collectActiveWorkspaceSshTargetIds(input: ActiveWorkspaceSshTargetInput): string[] {
const { activeWorktreeId } = input
if (!activeWorktreeId) {
return []
}
const targetIds = new Set<string>()
const repoId = getRepoIdFromWorktreeId(activeWorktreeId)
const connectionId = input.repos.find((repo) => repo.id === repoId)?.connectionId
if (connectionId) {
targetIds.add(connectionId)
}
for (const tab of input.tabsByWorktree[activeWorktreeId] ?? []) {
const ptyIds = [
tab.ptyId,
input.pendingReconnectPtyIdByTabId[tab.id],
...Object.values(input.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {})
]
for (const ptyId of ptyIds) {
const parsed = ptyId ? parseAppSshPtyId(ptyId) : null
if (parsed) {
targetIds.add(parsed.connectionId)
}
}
}
return [...targetIds]
}
@@ -6,7 +6,9 @@ export type SshStartupReconnectResult = {
export async function reconnectSshTargetForRendererStartup(args: {
targetId: string
timeoutMs: number
/** Omitted for a connect nobody is waiting on — no timer, so it cannot report
* a timeout the caller has no use for. */
timeoutMs?: number
connect: (targetId: string) => Promise<SshConnectionState | null>
publishState: (targetId: string, state: SshConnectionState) => void
onFailure: (targetId: string, error: unknown) => void
@@ -14,10 +16,16 @@ export async function reconnectSshTargetForRendererStartup(args: {
const { targetId, timeoutMs, connect, publishState, onFailure } = args
let timeoutId: ReturnType<typeof setTimeout> | null = null
try {
const timeout = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error('SSH reconnect timeout')), timeoutMs)
})
const state = await Promise.race([connect(targetId), timeout])
const connected = connect(targetId)
const state =
timeoutMs === undefined
? await connected
: await Promise.race([
connected,
new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error('SSH reconnect timeout')), timeoutMs)
})
])
// Why: the state-change IPC can trail connect's resolution. Publish the
// authoritative result before restored terminals inspect renderer state.
if (state) {
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { SshConnectionState, SshProviderEpoch, SshTarget } from '../../../shared/ssh-types'
import { restoreSshConnectionsForStartup } from './startup-ssh-connection-restore'
function connectedState(targetId: string): SshConnectionState {
return {
targetId,
status: 'connected',
error: null,
reconnectAttempt: 0,
providerEpoch: 'epoch' as SshProviderEpoch,
connectionGeneration: 1,
remotePlatform: 'linux'
}
}
function target(id: string, lastRequiredPassphrase = false): SshTarget {
return {
id,
label: id,
host: `${id}.example`,
port: 22,
username: 'orca',
lastRequiredPassphrase
}
}
type Harness = {
connect: Mock<(targetId: string) => Promise<SshConnectionState | null>>
getState: Mock<(targetId: string) => Promise<SshConnectionState | null>>
setDeferredSshReconnectTargets: Mock<(targetIds: string[]) => void>
removeDeferredSshReconnectTarget: Mock<(targetId: string) => void>
publishSshConnectionState: Mock<(targetId: string, state: SshConnectionState) => void>
}
let harness: Harness
function installWindowApi(targets: SshTarget[]): void {
harness = {
connect: vi.fn(),
getState: vi.fn().mockResolvedValue(null),
setDeferredSshReconnectTargets: vi.fn(),
removeDeferredSshReconnectTarget: vi.fn(),
publishSshConnectionState: vi.fn()
}
vi.stubGlobal('window', {
api: {
app: { startupDiagnostic: undefined },
ssh: {
listTargets: vi.fn().mockResolvedValue(targets),
connect: (args: { targetId: string }) => harness.connect(args.targetId),
getState: (args: { targetId: string }) => harness.getState(args.targetId)
}
}
})
}
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('restoreSshConnectionsForStartup', () => {
it('does not wait on a target that owns no immediately-mounted pane', async () => {
vi.useFakeTimers()
installWindowApi([target('ssh-active'), target('ssh-asleep')])
// The asleep host never answers — the old code awaited it for the full timeout.
harness.connect.mockImplementation((targetId: string) =>
targetId === 'ssh-active'
? Promise.resolve(connectedState(targetId))
: new Promise<SshConnectionState>(() => {})
)
let settled = false
const restore = restoreSshConnectionsForStartup({
connectionIds: ['ssh-active', 'ssh-asleep'],
blockingConnectionIds: ['ssh-active'],
setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget,
publishSshConnectionState: harness.publishSshConnectionState
}).then(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(0)
await restore
expect(settled).toBe(true)
// Both were dialled; only the active one gated restoration.
expect(harness.connect).toHaveBeenCalledWith('ssh-active')
expect(harness.connect).toHaveBeenCalledWith('ssh-asleep')
expect(harness.publishSshConnectionState).toHaveBeenCalledWith(
'ssh-active',
connectedState('ssh-active')
)
// The unreachable host is deferred, so its panes reattach on tab focus.
expect(harness.setDeferredSshReconnectTargets).toHaveBeenCalledWith(['ssh-asleep'])
})
it('awaits the target that owns the active workspace', async () => {
vi.useFakeTimers()
installWindowApi([target('ssh-active')])
harness.connect.mockReturnValue(new Promise<SshConnectionState>(() => {}))
let settled = false
const restore = restoreSshConnectionsForStartup({
connectionIds: ['ssh-active'],
blockingConnectionIds: ['ssh-active'],
setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget,
publishSshConnectionState: harness.publishSshConnectionState
}).then(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(14_000)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1_000)
await restore
expect(settled).toBe(true)
expect(harness.setDeferredSshReconnectTargets).toHaveBeenLastCalledWith(['ssh-active'])
})
it('clears the deferred flag once a background target connects', async () => {
installWindowApi([target('ssh-bg')])
harness.connect.mockResolvedValue(connectedState('ssh-bg'))
await restoreSshConnectionsForStartup({
connectionIds: ['ssh-bg'],
blockingConnectionIds: [],
setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget,
publishSshConnectionState: harness.publishSshConnectionState
})
await vi.waitFor(() =>
expect(harness.removeDeferredSshReconnectTarget).toHaveBeenCalledWith('ssh-bg')
)
expect(harness.publishSshConnectionState).toHaveBeenCalledWith(
'ssh-bg',
connectedState('ssh-bg')
)
})
it('keeps passphrase targets deferred and never dials them', async () => {
installWindowApi([target('ssh-key', true), target('ssh-bg')])
harness.connect.mockResolvedValue(connectedState('ssh-bg'))
await restoreSshConnectionsForStartup({
connectionIds: ['ssh-key', 'ssh-bg'],
blockingConnectionIds: [],
setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget,
publishSshConnectionState: harness.publishSshConnectionState
})
expect(harness.connect).not.toHaveBeenCalledWith('ssh-key')
expect(harness.setDeferredSshReconnectTargets).toHaveBeenCalledWith(['ssh-key', 'ssh-bg'])
})
it('awaits every target when no blocking set is supplied', async () => {
vi.useFakeTimers()
installWindowApi([target('ssh-a'), target('ssh-b')])
harness.connect.mockReturnValue(new Promise<SshConnectionState>(() => {}))
let settled = false
const restore = restoreSshConnectionsForStartup({
connectionIds: ['ssh-a', 'ssh-b'],
setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget,
publishSshConnectionState: harness.publishSshConnectionState
}).then(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(14_000)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1_000)
await restore
expect(harness.setDeferredSshReconnectTargets).toHaveBeenLastCalledWith(['ssh-a', 'ssh-b'])
})
})
@@ -8,13 +8,27 @@ const SSH_RECONNECT_TIMEOUT_MS = 15_000
* Re-establishes the SSH targets that were live at shutdown before terminal reconnect, so
* SSH-backed tabs route through pty.attach. Passphrase-protected and timed-out targets are
* handed back as deferred so their PTYs reattach on tab focus instead of stacking dialogs.
*
* Only `blockingConnectionIds` are awaited. Every other target connects in the background and
* is registered as deferred up front, so an unreachable host cannot hold local terminal
* restoration for the reconnect timeout. Background connects keep running in main; the pane's
* deferred flow joins the same in-flight `ssh.connect` on tab focus.
*/
export async function restoreSshConnectionsForStartup(args: {
connectionIds: string[]
/** Targets whose panes mount as soon as the startup gate opens. Omitted = await all. */
blockingConnectionIds?: readonly string[]
setDeferredSshReconnectTargets: (targetIds: string[]) => void
removeDeferredSshReconnectTarget: (targetId: string) => void
publishSshConnectionState: (targetId: string, state: SshConnectionState) => void
}): Promise<void> {
const { connectionIds, setDeferredSshReconnectTargets, publishSshConnectionState } = args
const {
connectionIds,
blockingConnectionIds,
setDeferredSshReconnectTargets,
removeDeferredSshReconnectTarget,
publishSshConnectionState
} = args
const allTargets = await timeRendererStartupStep('ssh-list-targets', () =>
window.api.ssh.listTargets()
)
@@ -24,11 +38,38 @@ export async function restoreSshConnectionsForStartup(args: {
needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false
}))
const eagerTargets = targets.filter((t) => !t.needsPassphrase)
const deferredTargets = targets.filter((t) => t.needsPassphrase)
const passphraseTargetIds = targets.filter((t) => t.needsPassphrase).map((t) => t.targetId)
const blocking = blockingConnectionIds ? new Set(blockingConnectionIds) : null
const eagerTargets = targets.filter(
(t) => !t.needsPassphrase && (blocking === null || blocking.has(t.targetId))
)
const backgroundTargets = targets.filter(
(t) => !t.needsPassphrase && blocking !== null && !blocking.has(t.targetId)
)
if (deferredTargets.length > 0) {
setDeferredSshReconnectTargets(deferredTargets.map((t) => t.targetId))
const deferredTargetIds = [...passphraseTargetIds, ...backgroundTargets.map((t) => t.targetId)]
if (deferredTargetIds.length > 0) {
setDeferredSshReconnectTargets(deferredTargetIds)
}
// Why fired before the awaited group: a background target that lands before terminal
// reconnect reads as an ordinary connected target, exactly as it does today.
for (const { targetId } of backgroundTargets) {
void reconnectSshTargetForRendererStartup({
targetId,
connect: (id) => window.api.ssh.connect({ targetId: id }),
publishState: (id, state) => {
publishSshConnectionState(id, state)
if (state.status === 'connected') {
// Why: a still-deferred connected target sends fresh panes down the deferred
// spawn path instead of the normal one. Clear it as soon as it is reachable.
removeDeferredSshReconnectTarget(id)
}
},
onFailure: (id, error) => {
console.warn(`SSH background auto-reconnect failed for ${id}:`, error)
}
})
}
// Why: treat timed-out eager targets as deferred so their PTYs reattach on tab focus (ssh.connect keeps running in main and likely finishes by then).
@@ -52,10 +93,14 @@ export async function restoreSshConnectionsForStartup(args: {
}
})
),
{ eagerTargets: eagerTargets.length, deferredTargets: deferredTargets.length }
{
eagerTargets: eagerTargets.length,
deferredTargets: passphraseTargetIds.length,
backgroundTargets: backgroundTargets.length
}
)
if (timedOutTargets.length > 0) {
setDeferredSshReconnectTargets([...deferredTargets.map((t) => t.targetId), ...timedOutTargets])
setDeferredSshReconnectTargets([...deferredTargetIds, ...timedOutTargets])
}
// Why: older/wrapped providers may return no state from connect; poll main once as a compatibility fallback before terminal restoration.
@@ -33,6 +33,7 @@ export function createWebAppApi(): Partial<PreloadApi> {
// Staging already wrote through to browser storage, so there is nothing left to join.
awaitBeforeUnloadCheckpoint: () => Promise.resolve(),
awaitFirstWindowStartupServices: () => Promise.resolve(),
awaitGitEnvironmentStartupBarrier: () => Promise.resolve(),
prepareTerminalStartupRestoration: () => Promise.resolve(),
recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(),
startupDiagnostic: () => Promise.resolve(),
@@ -0,0 +1,193 @@
/**
* Persisted-state fixtures for the startup benchmark: the git repos, GitHub
* remotes, restored terminal tabs, and unreachable SSH targets that `orca-data.json`
* must contain for a run to exercise the corresponding startup path.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
function initFixtureGitRepo(repoDir) {
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 fixture at ${repoDir}`)
}
}
return realpathSync(repoDir)
}
/**
* Seed repos whose hydration reaches the `gh` login probe: a GitHub `origin`
* remote and no github.user/user.username config (the bench also points
* GIT_CONFIG_GLOBAL away from the developer's real config at launch).
*/
function buildGithubRepoFixtures(fixtureDir, githubRepos) {
const repos = []
for (let i = 0; i < githubRepos; i++) {
const repoPath = initFixtureGitRepo(join(fixtureDir, `bench-gh-repo-${i}`))
const remote = spawnSync(
'git',
[
'-C',
repoPath,
'remote',
'add',
'origin',
`https://github.com/orca-bench/bench-gh-repo-${i}.git`
],
{ stdio: 'ignore' }
)
// Exit 3 (remote exists) is fine on fixture reuse; anything else is not.
if (remote.status !== 0 && remote.status !== 3) {
throw new Error(`Failed to add GitHub remote to ${repoPath}`)
}
repos.push({
id: `bench-gh-repo-${i}`,
path: repoPath,
displayName: `Bench GH Repo ${i}`,
badgeColor: '#000000',
addedAt: 1,
externalWorktreeVisibility: 'show'
})
}
return repos
}
/**
* SSH targets on TEST-NET-3 (RFC 5737). The address is guaranteed unroutable,
* so the TCP handshake never completes and never gets a reset — the wire
* behaviour of a host that is asleep or behind a dropped VPN.
*/
function buildUnreachableSshTargets(count) {
const targets = []
for (let i = 0; i < count; i++) {
targets.push({
id: `bench-ssh-unreachable-${i}`,
label: `Unreachable Host ${i}`,
host: `203.0.113.${i + 1}`,
port: 22,
username: 'orca',
source: 'manual',
lastRequiredPassphrase: false
})
}
return targets
}
export function writePersistedStateFixture(
fixtureDir,
{ stateProfile, sessionTabs, githubRepos, sshUnreachableTargets = 0 }
) {
const dataPath = join(fixtureDir, 'orca-data.json')
if (stateProfile === 'none' && githubRepos === 0 && sshUnreachableTargets === 0) {
try {
unlinkSync(dataPath)
} catch {
// no persisted state fixture
}
return 0
}
if (!['none', 'restored-local-tabs'].includes(stateProfile)) {
throw new Error(`Unknown state profile: ${stateProfile}`)
}
const githubRepoEntries = buildGithubRepoFixtures(fixtureDir, githubRepos)
const sshTargets = buildUnreachableSshTargets(sshUnreachableTargets)
if (stateProfile === 'none') {
const state = {
schemaVersion: 1,
...(sshTargets.length > 0 ? { sshTargets } : {}),
repos: githubRepoEntries,
settings: {
telemetry: {
installId: 'startup-bench',
optedIn: false,
existedBeforeTelemetryRelease: true
}
}
}
const json = JSON.stringify(state, null, 2)
writeFileSync(dataPath, json, 'utf-8')
return Buffer.byteLength(json)
}
const repoPath = initFixtureGitRepo(join(fixtureDir, 'bench-repo'))
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'
},
...githubRepoEntries
],
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
},
// Why on the session and not just the target list: startup reconnect only
// dials targets that were connected at shutdown.
...(sshTargets.length > 0
? { activeConnectionIdsAtShutdown: sshTargets.map((target) => target.id) }
: {})
}
}
if (sshTargets.length > 0) {
state.sshTargets = sshTargets
}
const json = JSON.stringify(state, null, 2)
writeFileSync(dataPath, json, 'utf-8')
return Buffer.byteLength(json)
}
+42 -170
View File
@@ -12,6 +12,7 @@
* node tests/tools/benchmarks/startup-time-bench.mjs --label baseline
* [--iterations 5] [--files 28000] [--fixture-dir <path>]
* [--state-profile none|restored-local-tabs] [--session-tabs 200]
* [--ssh-unreachable-targets 1]
* [--github-repos 3] [--gh-hang-ms 30000]
* [--wait-for-event renderer-startup-hydration-done]
* [--exe <path-to-packaged-Orca>] [--timeout-ms 240000]
@@ -27,20 +28,14 @@
* Results: tests/tools/benchmarks/results/startup-<label>-<timestamp>.json
*/
import { spawn, spawnSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
readFileSync,
realpathSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import { delimiter, join, resolve } from 'node:path'
import { writePersistedStateFixture } from './startup-bench-state-fixture.mjs'
const scriptDir = import.meta.dirname
const repoRoot = resolve(scriptDir, '..', '..')
const repoRoot = resolve(scriptDir, '..', '..', '..')
const require = createRequire(import.meta.url)
function parseArgs(argv) {
@@ -53,6 +48,11 @@ function parseArgs(argv) {
timeoutMs: 240000,
stateProfile: 'none',
sessionTabs: 0,
// Seeds N SSH targets pointed at TEST-NET-3 (RFC 5737, guaranteed
// unroutable) and lists them as live-at-shutdown, so startup reconnect
// dials a host that never answers. Reproduces "one asleep remote machine
// delays every local terminal".
sshUnreachableTargets: 0,
githubRepos: 0,
ghHangMs: 0,
waitForEvent: 'did-finish-load',
@@ -88,6 +88,9 @@ function parseArgs(argv) {
case '--session-tabs':
args.sessionTabs = Number(next())
break
case '--ssh-unreachable-targets':
args.sshUnreachableTargets = Number(next())
break
case '--github-repos':
args.githubRepos = Number(next())
break
@@ -113,7 +116,7 @@ function parseArgs(argv) {
* tiny. Layout mirrors Chromium cache dirs plus a few Orca-owned dirs.
*/
function ensureFixture(fixtureDir, options) {
const { fileCount, stateProfile, sessionTabs, githubRepos } = options
const { fileCount, stateProfile, sessionTabs, githubRepos, sshUnreachableTargets } = options
const manifestPath = join(fixtureDir, 'bench-fixture-manifest.json')
if (existsSync(manifestPath)) {
try {
@@ -122,7 +125,8 @@ function ensureFixture(fixtureDir, options) {
manifest.files === fileCount &&
manifest.stateProfile === stateProfile &&
manifest.sessionTabs === sessionTabs &&
(manifest.githubRepos ?? 0) === githubRepos
(manifest.githubRepos ?? 0) === githubRepos &&
(manifest.sshUnreachableTargets ?? 0) === sshUnreachableTargets
) {
console.log(`[fixture] reusing ${fixtureDir} (${fileCount} files, state=${stateProfile})`)
return
@@ -157,7 +161,8 @@ function ensureFixture(fixtureDir, options) {
const persistedStateBytes = writePersistedStateFixture(fixtureDir, {
stateProfile,
sessionTabs,
githubRepos
githubRepos,
sshUnreachableTargets
})
writeFileSync(
manifestPath,
@@ -166,6 +171,7 @@ function ensureFixture(fixtureDir, options) {
stateProfile,
sessionTabs,
githubRepos,
sshUnreachableTargets,
persistedStateBytes,
createdAt: Date.now()
})
@@ -173,157 +179,6 @@ function ensureFixture(fixtureDir, options) {
console.log(`[fixture] done in ${((Date.now() - started) / 1000).toFixed(1)}s`)
}
function initFixtureGitRepo(repoDir) {
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 fixture at ${repoDir}`)
}
}
return realpathSync(repoDir)
}
/**
* Seed repos whose hydration reaches the `gh` login probe: a GitHub `origin`
* remote and no github.user/user.username config (the bench also points
* GIT_CONFIG_GLOBAL away from the developer's real config at launch).
*/
function buildGithubRepoFixtures(fixtureDir, githubRepos) {
const repos = []
for (let i = 0; i < githubRepos; i++) {
const repoPath = initFixtureGitRepo(join(fixtureDir, `bench-gh-repo-${i}`))
const remote = spawnSync(
'git',
[
'-C',
repoPath,
'remote',
'add',
'origin',
`https://github.com/orca-bench/bench-gh-repo-${i}.git`
],
{ stdio: 'ignore' }
)
// Exit 3 (remote exists) is fine on fixture reuse; anything else is not.
if (remote.status !== 0 && remote.status !== 3) {
throw new Error(`Failed to add GitHub remote to ${repoPath}`)
}
repos.push({
id: `bench-gh-repo-${i}`,
path: repoPath,
displayName: `Bench GH Repo ${i}`,
badgeColor: '#000000',
addedAt: 1,
externalWorktreeVisibility: 'show'
})
}
return repos
}
function writePersistedStateFixture(fixtureDir, { stateProfile, sessionTabs, githubRepos }) {
const dataPath = join(fixtureDir, 'orca-data.json')
if (stateProfile === 'none' && githubRepos === 0) {
try {
unlinkSync(dataPath)
} catch {
// no persisted state fixture
}
return 0
}
if (!['none', 'restored-local-tabs'].includes(stateProfile)) {
throw new Error(`Unknown state profile: ${stateProfile}`)
}
const githubRepoEntries = buildGithubRepoFixtures(fixtureDir, githubRepos)
if (stateProfile === 'none') {
const state = {
schemaVersion: 1,
repos: githubRepoEntries,
settings: {
telemetry: {
installId: 'startup-bench',
optedIn: false,
existedBeforeTelemetryRelease: true
}
}
}
const json = JSON.stringify(state, null, 2)
writeFileSync(dataPath, json, 'utf-8')
return Buffer.byteLength(json)
}
const repoPath = initFixtureGitRepo(join(fixtureDir, 'bench-repo'))
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'
},
...githubRepoEntries
],
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)
}
/**
* Fake `gh` that hangs like a blackholed api.github.com. The hang lives in a
* child process (ping/sleep) that inherits the probe's stdio pipes, so even
@@ -385,12 +240,19 @@ function buildLaunchEnvironment({ fixtureDir, githubRepos, ghShimDir }) {
}
function killProcessTree(proc) {
if (proc.exitCode !== null || proc.signalCode !== null) {
if (process.platform === 'win32') {
if (proc.exitCode === null && proc.signalCode === null) {
spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { stdio: 'ignore' })
}
return
}
if (process.platform === 'win32') {
spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { stdio: 'ignore' })
} else {
// Why the whole group and not just the child: Electron's helper processes
// inherit the stderr pipe, so killing only the launcher leaves them holding it
// open — the harness then never sees EOF and never exits after its last run.
// Requires the `detached: true` spawn below.
try {
process.kill(-proc.pid, 'SIGKILL')
} catch {
try {
proc.kill('SIGKILL')
} catch {
@@ -432,7 +294,10 @@ function runIteration({ exe, timeoutMs, lingerMs, waitForEvent, launchEnv }) {
const startedAt = process.hrtime.bigint()
const child = spawn(command, commandArgs, {
env: launchEnv,
stdio: ['ignore', 'ignore', 'pipe']
stdio: ['ignore', 'ignore', 'pipe'],
// Why: gives the launcher its own process group so teardown can reap every
// Electron helper it spawned (see killProcessTree).
detached: process.platform !== 'win32'
})
let finished = false
let buffer = ''
@@ -512,6 +377,12 @@ function derivePhases(events) {
'renderer-startup-hydration-done'
),
totalToWorkspaceReady: eventTime(events, 'renderer-startup-hydration-done', 'harness'),
// Time the terminal-restoration gate spent on SSH reconnect, and on the
// main-process barrier that used to fence worktree hydration.
rendererSshReconnectMs: eventDetailsNumber(events, 'renderer-ssh-reconnect-done', 'durationMs'),
rendererStartupBarrierMs:
eventDetailsNumber(events, 'renderer-git-environment-barrier-await-done', 'durationMs') ??
eventDetailsNumber(events, 'renderer-first-window-services-await-done', 'durationMs'),
rendererReconnectTerminalsMs:
eventDetailsNumber(events, 'renderer-reconnect-terminals-done', 'durationMs') ??
delta(
@@ -585,7 +456,7 @@ async function main() {
join(
os.tmpdir(),
'orca-startup-bench',
`userdata-${args.files}-${args.stateProfile}-${args.sessionTabs}-gh${args.githubRepos}`
`userdata-${args.files}-${args.stateProfile}-${args.sessionTabs}-gh${args.githubRepos}-ssh${args.sshUnreachableTargets}`
)
)
mkdirSync(fixtureDir, { recursive: true })
@@ -593,7 +464,8 @@ async function main() {
fileCount: args.files,
stateProfile: args.stateProfile,
sessionTabs: args.sessionTabs,
githubRepos: args.githubRepos
githubRepos: args.githubRepos,
sshUnreachableTargets: args.sshUnreachableTargets
})
const ghShimDir = writeGhShim(fixtureDir, args.ghHangMs)
const launchEnv = buildLaunchEnvironment({