diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index e432ed383d2..fc381f15714 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -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'), diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 89be84d2119..918fd844364 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.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, diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 7e5f278fdc5..9df28ecea8c 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -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({ diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 88cb86dc32b..b2d6eed966c 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -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 + /** 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 /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ prepareTerminalStartupRestoration: () => Promise /** Reconciles legacy worker authority around persisted terminal reconnect. */ diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 22d46cc40d2..2a0d50e9de4 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -43,6 +43,8 @@ export const appApi = { awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(), awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + awaitGitEnvironmentStartupBarrier: (): Promise => + ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => diff --git a/src/renderer/src/app-shell/startup-actions-selector.test.ts b/src/renderer/src/app-shell/startup-actions-selector.test.ts index 1d559eb0dcb..45a1b9cf5df 100644 --- a/src/renderer/src/app-shell/startup-actions-selector.test.ts +++ b/src/renderer/src/app-shell/startup-actions-selector.test.ts @@ -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(), diff --git a/src/renderer/src/app-shell/startup-actions-selector.ts b/src/renderer/src/app-shell/startup-actions-selector.ts index c7dca311a03..ac18349909a 100644 --- a/src/renderer/src/app-shell/startup-actions-selector.ts +++ b/src/renderer/src/app-shell/startup-actions-selector.ts @@ -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, diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 447fbfb3780..c3a87280736 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -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() ) diff --git a/src/renderer/src/startup/active-workspace-ssh-targets.test.ts b/src/renderer/src/startup/active-workspace-ssh-targets.test.ts new file mode 100644 index 00000000000..f36a186b0ac --- /dev/null +++ b/src/renderer/src/startup/active-workspace-ssh-targets.test.ts @@ -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, + pendingReconnectPtyIdByTabId: {} as Record, + terminalLayoutsByTabId: {} as Record }>, + 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']) + }) +}) diff --git a/src/renderer/src/startup/active-workspace-ssh-targets.ts b/src/renderer/src/startup/active-workspace-ssh-targets.ts new file mode 100644 index 00000000000..c7514abf4a1 --- /dev/null +++ b/src/renderer/src/startup/active-workspace-ssh-targets.ts @@ -0,0 +1,49 @@ +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +type ActiveWorkspaceSshTargetInput = { + activeWorktreeId: string | null + tabsByWorktree: Readonly> + /** Restored tab-level PTY ids, keyed by tab id. */ + pendingReconnectPtyIdByTabId: Readonly> + terminalLayoutsByTabId: Readonly< + Record> } | 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() + 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] +} diff --git a/src/renderer/src/startup/ssh-startup-reconnect.ts b/src/renderer/src/startup/ssh-startup-reconnect.ts index d312239a878..e31bd963d6f 100644 --- a/src/renderer/src/startup/ssh-startup-reconnect.ts +++ b/src/renderer/src/startup/ssh-startup-reconnect.ts @@ -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 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 | null = null try { - const timeout = new Promise((_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((_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) { diff --git a/src/renderer/src/startup/startup-ssh-connection-restore.test.ts b/src/renderer/src/startup/startup-ssh-connection-restore.test.ts new file mode 100644 index 00000000000..74973a3197b --- /dev/null +++ b/src/renderer/src/startup/startup-ssh-connection-restore.test.ts @@ -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> + getState: Mock<(targetId: string) => Promise> + 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(() => {}) + ) + + 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(() => {})) + + 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(() => {})) + + 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']) + }) +}) diff --git a/src/renderer/src/startup/startup-ssh-connection-restore.ts b/src/renderer/src/startup/startup-ssh-connection-restore.ts index b3ad1a42d61..f79359aa727 100644 --- a/src/renderer/src/startup/startup-ssh-connection-restore.ts +++ b/src/renderer/src/startup/startup-ssh-connection-restore.ts @@ -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 { - 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. diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index f182b8a7790..28fd9f99176 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -33,6 +33,7 @@ export function createWebAppApi(): Partial { // 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(), diff --git a/tests/tools/benchmarks/startup-bench-state-fixture.mjs b/tests/tools/benchmarks/startup-bench-state-fixture.mjs new file mode 100644 index 00000000000..87f353bcfce --- /dev/null +++ b/tests/tools/benchmarks/startup-bench-state-fixture.mjs @@ -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) +} diff --git a/tests/tools/benchmarks/startup-time-bench.mjs b/tests/tools/benchmarks/startup-time-bench.mjs index 29363432c7a..e39f2d1d133 100644 --- a/tests/tools/benchmarks/startup-time-bench.mjs +++ b/tests/tools/benchmarks/startup-time-bench.mjs @@ -12,6 +12,7 @@ * node tests/tools/benchmarks/startup-time-bench.mjs --label baseline * [--iterations 5] [--files 28000] [--fixture-dir ] * [--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 ] [--timeout-ms 240000] @@ -27,20 +28,14 @@ * Results: tests/tools/benchmarks/results/startup-