diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 8489048d0f5..b99134b858e 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -726,6 +726,20 @@ describe('remote hook service installers', () => { expect(byAgent.get('copilot')).toBe('installed') }) + it('keeps installer result order and isolates per-agent failures under concurrency', async () => { + // Malformed Factory settings force droid into its error path while every + // other agent must still install. + const { sftp } = createFakeSftp({ '/home/dev/.factory/settings.json': '{"hooks": }' }) + + const results = await installRemoteManagedAgentHooks(sftp, '/home/dev') + + expect(results.map((r) => r.agent)).toEqual([...REMOTE_MANAGED_HOOK_INSTALLER_AGENTS]) + const byAgent = new Map(results.map((r) => [r.agent, r.state])) + expect(byAgent.get('droid')).toBe('error') + expect(byAgent.get('claude')).toBe('installed') + expect(byAgent.get('copilot')).toBe('installed') + }) + it('installs remote Droid hooks into Factory settings.json (issue #7253)', async () => { const { sftp, fs } = createFakeSftp() diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 39507f091f2..7b65b0f597d 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -76,32 +76,37 @@ export async function installRemoteManagedAgentHooks( remoteHome: string, options?: RemoteManagedHookInstallOptions ): Promise { - const results: AgentHookInstallStatus[] = [] - for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) { - try { - const result = await install(sftp, remoteHome, options) - results.push(result) - if (result.state === 'error') { - console.warn( - `[agent-hooks] Remote ${agent} managed hook install failed for ${result.configPath}: ${ - result.detail ?? 'unknown error' - }` - ) + // Why: concurrent is safe because installers touch disjoint per-agent paths + // and shared script-dir writes are atomic (tmp+rename). Serially, their + // SFTP round trips dominated SSH connect (~90s measured at 150ms RTT). + return Promise.all( + REMOTE_MANAGED_HOOK_INSTALLERS.map( + async ([agent, install]): Promise => { + try { + const result = await install(sftp, remoteHome, options) + if (result.state === 'error') { + console.warn( + `[agent-hooks] Remote ${agent} managed hook install failed for ${result.configPath}: ${ + result.detail ?? 'unknown error' + }` + ) + } + return result + } catch (error) { + // Why: remote hook installation must not block SSH workspace startup. + // A broken agent config or transient SFTP failure should degrade status + // reporting only, while terminals/filesystem/git still come online. + const detail = error instanceof Error ? error.message : String(error) + console.warn(`[agent-hooks] Remote ${agent} managed hook install threw: ${detail}`) + return { + agent, + state: 'error', + configPath: remoteHome, + managedHooksPresent: false, + detail + } + } } - } catch (error) { - // Why: remote hook installation must not block SSH workspace startup. - // A broken agent config or transient SFTP failure should degrade status - // reporting only, while terminals/filesystem/git still come online. - const detail = error instanceof Error ? error.message : String(error) - console.warn(`[agent-hooks] Remote ${agent} managed hook install threw: ${detail}`) - results.push({ - agent, - state: 'error', - configPath: remoteHome, - managedHooksPresent: false, - detail - }) - } - } - return results + ) + ) } diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 51801ecdef5..4212c746a05 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -213,7 +213,12 @@ vi.mock('../ssh/ssh-port-scanner', () => ({ } })) -import { getSshConnectionManager, registerSshHandlers, resetSshHandlerStateForTests } from './ssh' +import { + eagerReconnectSshTargetsFromShutdown, + getSshConnectionManager, + registerSshHandlers, + resetSshHandlerStateForTests +} from './ssh' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, type SshTarget } from '../../shared/ssh-types' import { clearProviderPtyState, @@ -227,6 +232,7 @@ describe('SSH IPC handlers', () => { const mockStore = { getRepos: () => [], getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + getWorkspaceSession: vi.fn().mockReturnValue({}), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), removeSshRemotePtyLeases: vi.fn() @@ -301,6 +307,7 @@ describe('SSH IPC handlers', () => { mockSshStore.lastRepoReadoptions = [] mockWindow.webContents.send.mockReset() mockStore.getSshRemotePtyLeases.mockReset().mockReturnValue([]) + mockStore.getWorkspaceSession.mockReset().mockReturnValue({}) mockStore.markSshRemotePtyLease.mockReset() mockStore.markSshRemotePtyLeases.mockReset() mockStore.removeSshRemotePtyLeases.mockReset() @@ -498,6 +505,170 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) }) + it('eager startup reconnect connects shutdown targets, skipping passphrase and missing ones', async () => { + const eager: SshTarget = { + id: 'ssh-eager', + label: 'Eager', + host: 'eager.example.com', + port: 22, + username: 'deploy' + } + const passphrase: SshTarget = { + id: 'ssh-pass', + label: 'Pass', + host: 'pass.example.com', + port: 22, + username: 'deploy', + lastRequiredPassphrase: true + } + mockStore.getWorkspaceSession.mockReturnValue({ + activeConnectionIdsAtShutdown: ['ssh-eager', 'ssh-pass', 'ssh-gone'] + }) + mockSshStore.getTarget.mockImplementation((id: string) => + id === 'ssh-eager' ? eager : id === 'ssh-pass' ? passphrase : undefined + ) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-eager', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:credentialListenerReady')!(null, {}) + eagerReconnectSshTargetsFromShutdown() + + await vi.waitFor(() => { + expect(mockConnectionManager.connect).toHaveBeenCalledWith(eager) + }) + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + }) + + it('eager startup reconnect stays off until the renderer credential listener is ready', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockStore.getWorkspaceSession.mockReturnValue({ + activeConnectionIdsAtShutdown: ['ssh-1'] + }) + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + eagerReconnectSshTargetsFromShutdown({ listenerReadyTimeoutMs: 5_000 }) + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(mockConnectionManager.connect).not.toHaveBeenCalled() + + await handlers.get('ssh:credentialListenerReady')!(null, {}) + await vi.waitFor(() => { + expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) + }) + }) + + it('eager startup reconnect is skipped entirely when the ready signal never arrives', async () => { + mockStore.getWorkspaceSession.mockReturnValue({ + activeConnectionIdsAtShutdown: ['ssh-1'] + }) + mockSshStore.getTarget.mockReturnValue({ + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } as SshTarget) + mockConnectionManager.connect.mockResolvedValue({}) + + eagerReconnectSshTargetsFromShutdown({ listenerReadyTimeoutMs: 30 }) + await new Promise((resolve) => setTimeout(resolve, 80)) + expect(mockConnectionManager.connect).not.toHaveBeenCalled() + + // A late signal must not resurrect the abandoned eager pass. + await handlers.get('ssh:credentialListenerReady')!(null, {}) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(mockConnectionManager.connect).not.toHaveBeenCalled() + }) + + it('eager startup reconnect runs only once per app launch', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockStore.getWorkspaceSession.mockReturnValue({ + activeConnectionIdsAtShutdown: ['ssh-1'] + }) + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:credentialListenerReady')!(null, {}) + eagerReconnectSshTargetsFromShutdown() + await vi.waitFor(() => { + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + }) + + // Second pass after the first completed: without the once-guard this + // would start a fresh doConnect (mock sessions never look 'ready'). + eagerReconnectSshTargetsFromShutdown() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + }) + + it('renderer ssh:connect joins the in-flight eager reconnect instead of reconnecting', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockStore.getWorkspaceSession.mockReturnValue({ + activeConnectionIdsAtShutdown: ['ssh-1'] + }) + mockSshStore.getTarget.mockReturnValue(target) + let resolveConnect: ((value: unknown) => void) | undefined + mockConnectionManager.connect.mockImplementation( + () => + new Promise((resolve) => { + resolveConnect = resolve + }) + ) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:credentialListenerReady')!(null, {}) + eagerReconnectSshTargetsFromShutdown() + await vi.waitFor(() => { + expect(resolveConnect).toBeDefined() + }) + + const rendererConnect = handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + resolveConnect!({}) + await rendererConnect + + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + }) + it('ssh:connect exposes the detected remote platform in public state', async () => { const target: SshTarget = { id: 'ssh-1', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index affb1aba004..00e438f416d 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -38,6 +38,7 @@ import { getSshPtyProvider } from './pty' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { logStartupMilestone } from '../startup/startup-diagnostics' let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null @@ -58,6 +59,7 @@ const SSH_IPC_CHANNELS = [ 'ssh:removeTarget', 'ssh:importConfig', 'ssh:connect', + 'ssh:credentialListenerReady', 'ssh:disconnect', 'ssh:terminateSessions', 'ssh:resetRelay', @@ -87,6 +89,95 @@ export async function connectRegisteredSshTarget(targetId: string): Promise void + timer: ReturnType +} +let credentialListenerReadyWaiters: CredentialListenerReadyWaiter[] = [] + +function signalSshCredentialListenerReady(): void { + if (credentialListenerReadySignaled) { + return + } + credentialListenerReadySignaled = true + const waiters = credentialListenerReadyWaiters + credentialListenerReadyWaiters = [] + for (const waiter of waiters) { + clearTimeout(waiter.timer) + waiter.resolve() + } +} + +function whenSshCredentialListenerReady(timeoutMs: number): Promise { + if (credentialListenerReadySignaled) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const waiter: CredentialListenerReadyWaiter = { + resolve: () => resolve(true), + // Why: drop the waiter as the timer fires so a later signal cannot + // resolve an already-settled promise or retain a dead callback. + timer: setTimeout(() => { + credentialListenerReadyWaiters = credentialListenerReadyWaiters.filter( + (entry) => entry !== waiter + ) + resolve(false) + }, timeoutMs) + } + credentialListenerReadyWaiters.push(waiter) + }) +} + +const EAGER_RECONNECT_LISTENER_READY_TIMEOUT_MS = 10_000 + +/** Start reconnecting the targets that were connected at shutdown so SSH + * establish overlaps renderer hydration. The renderer's own reconnect joins + * these attempts via connectInFlight, so state transitions are unchanged. */ +export function eagerReconnectSshTargetsFromShutdown(options?: { + listenerReadyTimeoutMs?: number +}): void { + if (eagerStartupReconnectAttempted) { + return + } + eagerStartupReconnectAttempted = true + if (!sshStore || !persistedStore || !registeredConnectSshTarget) { + return + } + const targetStore = sshStore + const sessionStore = persistedStore + const connect = registeredConnectSshTarget + const timeoutMs = options?.listenerReadyTimeoutMs ?? EAGER_RECONNECT_LISTENER_READY_TIMEOUT_MS + void whenSshCredentialListenerReady(timeoutMs).then((listenerReady) => { + // Why: no signal means no eager pass. The renderer's own startup + // reconnect then proceeds exactly as it did before this feature. + if (!listenerReady) { + return + } + const connectionIds = sessionStore.getWorkspaceSession().activeConnectionIdsAtShutdown ?? [] + for (const targetId of connectionIds) { + const target = targetStore.getTarget(targetId) + // Why: same rule as renderer startup. Targets that prompted last time + // defer to tab focus so credential dialogs never stack at launch. + if (!target || target.lastRequiredPassphrase) { + continue + } + logStartupMilestone('ssh-eager-reconnect-start', { target: targetId }) + void connect(targetId).catch(() => { + // Best-effort: the renderer's startup reconnect owns error surfacing. + }) + } + }) +} + export function getRegisteredSshState(targetId: string): SshConnectionState | undefined { return registeredGetSshState?.(targetId) } @@ -824,6 +915,10 @@ export function registerSshHandlers( return connectTarget(args.targetId) }) + ipcMain.handle('ssh:credentialListenerReady', () => { + signalSshCredentialListenerReady() + }) + async function doConnect(targetId: string): Promise { const target = sshStore!.getTarget(targetId) if (!target) { @@ -1308,6 +1403,12 @@ export async function resetSshHandlerStateForTests(): Promise { registeredGetSshState = null currentGetMainWindow = () => null currentRuntime = undefined + eagerStartupReconnectAttempted = false + credentialListenerReadySignaled = false + for (const waiter of credentialListenerReadyWaiters) { + clearTimeout(waiter.timer) + } + credentialListenerReadyWaiters = [] } export function getSshConnectionStore(): SshConnectionStore | null { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b449daabb70..85125f6ecd3 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -77,6 +77,7 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/executi import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { shellEscape } from './ssh-connection-utils' +import { timeStartupStep } from '../startup/startup-diagnostics' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -333,7 +334,11 @@ export class SshRelaySession { try { const { transport, remoteHome, remoteRelayDir, nodePath, sockPath, hostPlatform } = - await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) + await timeStartupStep( + 'ssh-establish-deploy', + () => deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId), + { target: this.targetId } + ) this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform @@ -368,9 +373,17 @@ export class SshRelaySession { // registerRelayRoots would silently swallow all mux errors, leaving // the session in 'ready' state with a dead mux. A round-trip request // here fails fast so doConnect() can report the real error. - await mux.request('session.resolveHome', { path: '~' }) + await timeStartupStep( + 'ssh-establish-health-check', + () => mux.request('session.resolveHome', { path: '~' }), + { target: this.targetId } + ) - const registered = await this.registerProviders(mux, ownsAttempt) + const registered = await timeStartupStep( + 'ssh-establish-register-providers', + () => this.registerProviders(mux, ownsAttempt), + { target: this.targetId } + ) if (!registered) { if (!mux.isDisposed()) { mux.dispose() @@ -394,7 +407,11 @@ export class SshRelaySession { // Why: explicit disconnect keeps PTY ownership so a later manual connect // must reattach those remote PTYs through the fresh relay connection. - await this.reattachKnownPtys(ownsAttempt) + await timeStartupStep( + 'ssh-establish-reattach-ptys', + () => this.reattachKnownPtys(ownsAttempt), + { target: this.targetId } + ) if (!ownsAttempt()) { throw new Error('Session disposed during establish') @@ -762,9 +779,21 @@ export class SshRelaySession { let sftp: Awaited> | null = null try { const connection = this.requireReadyConnection() - const remoteGrokHome = await resolveRemoteGrokHome(connection, remoteHome) + // Why: the probe and the SFTP fan-out dominate cold SSH connects on + // high-latency links, so keep them separately visible in diagnostics. + const remoteGrokHome = await timeStartupStep( + 'ssh-hooks-grok-home-probe', + () => resolveRemoteGrokHome(connection, remoteHome), + { target: this.targetId } + ) sftp = await connection.sftp() - await installRemoteManagedAgentHooks(sftp, remoteHome, { grokHomeDir: remoteGrokHome }) + const openedSftp = sftp + await timeStartupStep( + 'ssh-hooks-sftp-install', + () => + installRemoteManagedAgentHooks(openedSftp, remoteHome, { grokHomeDir: remoteGrokHome }), + { target: this.targetId } + ) } catch (error) { console.warn( `[ssh-relay-session] remote managed hook install failed for ${this.targetId}: ${ diff --git a/src/main/startup/startup-diagnostics.ts b/src/main/startup/startup-diagnostics.ts index d5cd7718d88..4efcd55944a 100644 --- a/src/main/startup/startup-diagnostics.ts +++ b/src/main/startup/startup-diagnostics.ts @@ -37,3 +37,33 @@ export function logStartupMilestone(event: string, details: Record( + event: string, + operation: () => Promise, + details: Record = {} +): Promise { + if (!isStartupDiagnosticsEnabled()) { + return operation() + } + const startedAt = performance.now() + try { + const result = await operation() + logStartupDiagnostic(`${event}-done`, { + t: Math.round(performance.now()), + durationMs: Math.round(performance.now() - startedAt), + ...details + }) + return result + } catch (error) { + logStartupDiagnostic(`${event}-failed`, { + t: Math.round(performance.now()), + durationMs: Math.round(performance.now() - startedAt), + message: error instanceof Error ? error.message : String(error), + ...details + }) + throw error + } +} diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index c2a730f9026..5d60b0c4ef5 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -17,7 +17,8 @@ const { hydrateLocalPtyRegistryAtBootMock, setupAutoUpdaterMock, browserManagerUnregisterAllMock, - runWorktreeChangeInvalidatorsMock + runWorktreeChangeInvalidatorsMock, + eagerReconnectSshTargetsFromShutdownMock } = vi.hoisted(() => ({ onMock: vi.fn(), removeAllListenersMock: vi.fn(), @@ -34,7 +35,8 @@ const { hydrateLocalPtyRegistryAtBootMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), browserManagerUnregisterAllMock: vi.fn(), - runWorktreeChangeInvalidatorsMock: vi.fn() + runWorktreeChangeInvalidatorsMock: vi.fn(), + eagerReconnectSshTargetsFromShutdownMock: vi.fn() })) vi.mock('electron', () => ({ @@ -74,6 +76,11 @@ vi.mock('../ipc/pty', () => ({ registerPtyHandlers: registerPtyHandlersMock })) +vi.mock('../ipc/ssh', () => ({ + registerSshHandlers: vi.fn(), + eagerReconnectSshTargetsFromShutdown: eagerReconnectSshTargetsFromShutdownMock +})) + vi.mock('../memory/hydrate-local-pty-registry', () => ({ hydrateLocalPtyRegistryAtBoot: hydrateLocalPtyRegistryAtBootMock })) @@ -105,6 +112,7 @@ type MainWindowStub = { id?: number isDestroyed?: MockFn on: MockFn + once: MockFn send?: MockFn reload?: MockFn session: { @@ -131,6 +139,7 @@ function createMainWindow(extraWebContents: { on?: MockFn; send?: MockFn } = {}) id: 1, isDestroyed: vi.fn(() => false), on: vi.fn(), + once: vi.fn(), reload: vi.fn(), session: { setPermissionRequestHandler: setPermissionRequestHandlerMock, @@ -142,7 +151,11 @@ function createMainWindow(extraWebContents: { on?: MockFn; send?: MockFn } = {}) } function createStore(): Store & { flush: MockFn } { - return { flush: vi.fn() } as Store & { flush: MockFn } + // Why: eager SSH reconnect reads the persisted shutdown session at + // did-finish-load; an empty session keeps it a no-op in these tests. + return { flush: vi.fn(), getWorkspaceSession: vi.fn(() => ({})) } as unknown as Store & { + flush: MockFn + } } function createRuntime(): RuntimeStub { @@ -195,6 +208,7 @@ describe('attachMainWindowServices', () => { hydrateLocalPtyRegistryAtBootMock.mockReset() setupAutoUpdaterMock.mockReset() browserManagerUnregisterAllMock.mockReset() + eagerReconnectSshTargetsFromShutdownMock.mockReset() systemPreferencesAskForMediaAccessMock.mockResolvedValue(true) systemPreferencesGetMediaAccessStatusMock.mockReturnValue('granted') }) @@ -225,6 +239,23 @@ describe('attachMainWindowServices', () => { expect(mainWindow.webContents.reload).toHaveBeenCalledTimes(1) }) + it('fires eager SSH reconnect exactly once at did-finish-load, not before', () => { + const mainWindow = createMainWindow() + + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + + // Registration alone must not reconnect; the load event drives it. + expect(eagerReconnectSshTargetsFromShutdownMock).not.toHaveBeenCalled() + const loadHandler = mainWindow.webContents.once.mock.calls.find( + ([event]) => event === 'did-finish-load' + )?.[1] as (() => void) | undefined + expect(loadHandler).toBeTypeOf('function') + + loadHandler!() + + expect(eagerReconnectSshTargetsFromShutdownMock).toHaveBeenCalledTimes(1) + }) + it('retries local PTY registry hydration after local startup services are ready', async () => { const localStartup = deferred() const store = createStore() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0dd54c2c598..56ed85a9929 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -14,7 +14,7 @@ import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' import { getLocalPtyProvider, registerPtyHandlers } from '../ipc/pty' import { registerDaemonManagementHandlers } from '../ipc/pty-management' -import { registerSshHandlers } from '../ipc/ssh' +import { eagerReconnectSshTargetsFromShutdown, registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import { browserManager } from '../browser/browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' @@ -134,6 +134,12 @@ export function attachMainWindowServices( }) } registerSshHandlers(store, () => mainWindow, runtime) + // Why: overlap SSH reconnect with renderer hydration. The eager pass + // itself waits for the renderer's credential-listener-ready signal and + // skips entirely without it, so a prompt can never fire into a void. + mainWindow.webContents.once('did-finish-load', () => { + eagerReconnectSshTargetsFromShutdown() + }) registerRemoteWorkspaceHandlers(store, () => mainWindow) registerFileDropRelay(mainWindow) // Why: setupAutoUpdater's first getAutoUpdater() call synchronously diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9d5e0b1fd90..b0cebe4a360 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3112,6 +3112,7 @@ export type PreloadApi = { }) => void ) => () => void onCredentialResolved: (callback: (data: { requestId: string }) => void) => () => void + notifyCredentialListenerReady: () => Promise submitCredential: (args: { requestId: string; value: string | null }) => Promise } automations: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 699853f6c8d..710f45178ea 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4239,6 +4239,9 @@ const api = { return () => ipcRenderer.removeListener('ssh:credential-resolved', listener) }, + notifyCredentialListenerReady: (): Promise => + ipcRenderer.invoke('ssh:credentialListenerReady'), + submitCredential: (args: { requestId: string; value: string | null }): Promise => ipcRenderer.invoke('ssh:submitCredential', args) }, diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 9e188a80805..f01f12fbce5 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -299,6 +299,7 @@ describe('useIpcEvents zoom routing', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onCredentialResolved: () => () => {}, onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {} @@ -441,6 +442,7 @@ describe('useIpcEvents zoom routing', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onCredentialResolved: () => () => {}, onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {} @@ -652,6 +654,7 @@ describe('useIpcEvents rate-limit hydration', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onCredentialResolved: () => () => {}, onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {} @@ -1034,6 +1037,7 @@ describe('useIpcEvents browser tab create routing', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} @@ -1261,6 +1265,7 @@ describe('useIpcEvents updater integration', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: (listener: (data: { requestId: string }) => void) => { @@ -1506,6 +1511,7 @@ describe('useIpcEvents updater integration', () => { return () => {} }, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onCredentialResolved: () => () => {}, onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {} @@ -2003,6 +2009,7 @@ describe('useIpcEvents updater integration', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} @@ -2890,6 +2897,7 @@ describe('useIpcEvents browser tab close routing', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} @@ -3369,6 +3377,7 @@ describe('useIpcEvents browser tab close routing', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} @@ -3587,6 +3596,7 @@ describe('useIpcEvents browser tab close routing', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} @@ -3800,6 +3810,7 @@ describe('useIpcEvents browser tab close routing', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} @@ -4031,6 +4042,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} @@ -4279,6 +4291,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} @@ -4520,6 +4533,7 @@ describe('useIpcEvents agent status snapshot integration', () => { getState: () => Promise.resolve(null), onStateChanged: () => () => {}, onCredentialRequest: () => () => {}, + notifyCredentialListenerReady: () => Promise.resolve(), onCredentialResolved: () => () => {}, onPortForwardsChanged: () => () => {}, onDetectedPortsChanged: () => () => {} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b3e2dae6b17..6505fa15563 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2707,6 +2707,16 @@ export function useIpcEvents(): void { }) ) + // Why: main defers eager SSH reconnect until the credential listeners + // above provably exist, so a startup prompt can never fire into a void. + // A preload without this method (dev hot-reload skew) must never crash + // the app root; main then simply skips eager reconnect. + try { + void window.api.ssh.notifyCredentialListenerReady().catch(() => {}) + } catch { + // Stale preload; the renderer's own startup reconnect still runs. + } + unsubs.push( window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => { useAppStore.getState().setPortForwards(targetId, forwards) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5fa4365a344..8f3339ff4f1 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2942,6 +2942,7 @@ function createSshApi(): NonNullable['ssh']> { browseDir: () => Promise.resolve({ entries: [], resolvedPath: '' }), onCredentialRequest: () => noopUnsubscribe, onCredentialResolved: () => noopUnsubscribe, + notifyCredentialListenerReady: () => Promise.resolve(), submitCredential: () => Promise.resolve() } }