diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index cce904ca8b..f621beb29a 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -30,6 +30,7 @@ import { connectOrSpawnRuntimeHost, connectRuntimeHostProfile, type RuntimeHostPeerClient, + type RuntimeHostConnectionPhase, type RuntimeHostSshInteraction, type RuntimeHostSshTunnel, type RuntimeHostSshTunnelInput, @@ -202,6 +203,7 @@ export interface DesktopRuntimeHostCandidateStartInput readonly onExit?: (details: CandidateExitDetails) => void; readonly candidateLaunchBarrier?: RuntimeHostCandidateLaunchBarrier; readonly peerClient?: RuntimeHostPeerClient; + readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void; readonly profileTarget?: { readonly profile: PersistedRuntimeHostProfile; readonly credential?: string; @@ -435,6 +437,9 @@ async function startProfileDesktopRuntimeHostCandidate( : { handshakeTimeoutMs: input.handshakeTimeoutMs }), readyTimeoutMs: input.electionDeadlineMs ?? 45_000, ...(input.peerClient === undefined ? {} : { peerClient: input.peerClient }), + ...(input.onConnectionPhase === undefined + ? {} + : { onConnectionPhase: input.onConnectionPhase }), ...(profileTarget.sshInteraction === undefined ? {} : { sshInteraction: profileTarget.sshInteraction }), diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 8602849434..6a0c4e3f85 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -872,6 +872,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ipcMain: this.#ipcMain.createTarget(target.epoch), isTargetActive: () => this.#ipcMain.isActive(target.epoch), isTargetValid: () => target.valid, + onConnectionPhase: (phase) => { + target.input.onConnectionPhase?.(phase); + }, signal, ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }), }, @@ -1154,6 +1157,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ); } } + } function trackOwnedProcess( diff --git a/packages/runtime-host/src/__tests__/peer-native.test.ts b/packages/runtime-host/src/__tests__/peer-native.test.ts index dd0847f2fe..326ef26634 100644 --- a/packages/runtime-host/src/__tests__/peer-native.test.ts +++ b/packages/runtime-host/src/__tests__/peer-native.test.ts @@ -119,10 +119,14 @@ module.exports = { }, }); const native = await import(nativePath); + const phases: string[] = []; const abort = new AbortController(); - const pending = client.connect(peerConnectInput('pending'), abort.signal); + const pending = client.connect(peerConnectInput('pending'), abort.signal, (phase) => { + phases.push(phase); + }); await waitForRequestCount(native.default.stats, 1); assert.equal(routesPrepared, true); + assert.deepEqual(phases, ['discovering', 'connecting']); abort.abort(); await assert.rejects(pending, /aborted/u); diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index 5d46de2f7b..f3230ff452 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -43,7 +43,7 @@ import { readRuntimeHostPeerAuthenticationResult, writeRuntimeHostPeerAuthentication, } from '../transport/peer-native.js'; -import type { RuntimeHostPeerClient } from './peer-client.js'; +import type { RuntimeHostPeerClient, RuntimeHostPeerConnectionPhase } from './peer-client.js'; import { RuntimeHostPermanentReconnectError } from './reconnect-lifecycle.js'; import { RuntimeHostRemoteCompatibilityError } from './remote-compatibility-error.js'; import { @@ -156,6 +156,12 @@ export interface ResolvedRuntimeHostProfile { readonly credential?: string; } +export type RuntimeHostConnectionPhase = + | RuntimeHostPeerConnectionPhase + | 'authenticating' + | 'handshaking' + | 'waiting_for_ready'; + export function sameResolvedRuntimeHostProfileTarget( left: ResolvedRuntimeHostProfile, right: ResolvedRuntimeHostProfile, @@ -272,6 +278,7 @@ export async function connectRuntimeHostProfile( readonly readyTimeoutMs?: number; readonly sshInteraction?: RuntimeHostSshInteraction; readonly peerClient?: RuntimeHostPeerClient; + readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void; }, overrides: { connect?: typeof connectRemoteRuntimeHost; @@ -326,6 +333,7 @@ export async function connectRemoteRuntimeHostProfile( readonly readyTimeoutMs?: number; readonly sshInteraction?: RuntimeHostSshInteraction; readonly peerClient?: RuntimeHostPeerClient; + readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void; }, overrides: { connect?: typeof connectRemoteRuntimeHost; @@ -351,8 +359,12 @@ export async function connectRemoteRuntimeHostProfile( ...(input.handshakeTimeoutMs === undefined ? {} : { handshakeTimeoutMs: input.handshakeTimeoutMs }), + ...(input.onConnectionPhase === undefined + ? {} + : { onConnectionPhase: input.onConnectionPhase }), }); } else { + notifyConnectionPhase(input.onConnectionPhase, 'connecting'); const activation = transport.kind === 'ssh' && transport.activation ? await (overrides.activateSshOperator ?? activateRuntimeHostSshOperator)({ @@ -420,6 +432,7 @@ export async function connectRemoteRuntimeHostProfile( } try { input.signal?.throwIfAborted(); + notifyConnectionPhase(input.onConnectionPhase, 'waiting_for_ready'); await (overrides.waitForReady ?? waitForRuntimeHostReady)( connection, input.readyTimeoutMs ?? 45_000, @@ -454,6 +467,7 @@ export async function connectPeerRuntimeHost(input: { readonly signal?: AbortSignal; readonly connectTimeoutMs?: number; readonly handshakeTimeoutMs?: number; + readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void; }): Promise { input.signal?.throwIfAborted(); const stream = await input.peerClient.connect( @@ -464,6 +478,7 @@ export async function connectPeerRuntimeHost(input: { directDeadlineMs: Math.min(input.connectTimeoutMs ?? 40_000, 120_000), }, input.signal, + input.onConnectionPhase, ); const abort = () => stream.abort(); input.signal?.addEventListener('abort', abort, { once: true }); @@ -471,6 +486,7 @@ export async function connectPeerRuntimeHost(input: { let transferred = false; try { input.signal?.throwIfAborted(); + notifyConnectionPhase(input.onConnectionPhase, 'authenticating'); await writeRuntimeHostPeerAuthentication(stream, input.credential); const authentication = await readRuntimeHostPeerAuthenticationResult( stream, @@ -481,6 +497,7 @@ export async function connectPeerRuntimeHost(input: { `Runtime Host profile ${input.profileId} rejected its access credential`, ); } + notifyConnectionPhase(input.onConnectionPhase, 'handshaking'); const result = await connectRuntimeHostMessageTransport({ transport: new FramedByteStreamTransport( new RuntimeHostPeerByteStream(stream, authentication.remainder), @@ -512,6 +529,17 @@ export async function connectPeerRuntimeHost(input: { } } +function notifyConnectionPhase( + observer: ((phase: RuntimeHostConnectionPhase) => void) | undefined, + phase: RuntimeHostConnectionPhase, +): void { + try { + observer?.(phase); + } catch { + // Connection progress is diagnostic state and cannot control the connection. + } +} + function requireRuntimeHostPeerClient( peerClient: RuntimeHostPeerClient | undefined, ): RuntimeHostPeerClient { diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index c77d5c0cde..e898eb0e87 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -62,6 +62,7 @@ export { type RuntimeHostProfile, type RuntimeHostProfileAccess, type RuntimeHostProfileCatalog, + type RuntimeHostConnectionPhase, type RuntimeHostProfileDocument, } from './host-profile.js'; export { diff --git a/packages/runtime-host/src/client/peer-client.ts b/packages/runtime-host/src/client/peer-client.ts index 5afaced96a..3a2b567c15 100644 --- a/packages/runtime-host/src/client/peer-client.ts +++ b/packages/runtime-host/src/client/peer-client.ts @@ -39,6 +39,8 @@ export interface RuntimeHostPeerConnectInput { readonly directDeadlineMs: number; } +export type RuntimeHostPeerConnectionPhase = 'discovering' | 'connecting'; + export interface RuntimeHostPeerRouteResolver { resolveRoutes(peerId: string): | { @@ -67,6 +69,7 @@ export interface RuntimeHostPeerClient { connect( input: RuntimeHostPeerConnectInput, signal?: AbortSignal, + onPhase?: (phase: RuntimeHostPeerConnectionPhase) => void, ): Promise; connectMeshControl( input: RuntimeHostPeerConnectInput, @@ -204,8 +207,11 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient { async connect( input: RuntimeHostPeerConnectInput, signal?: AbortSignal, + onPhase?: (phase: RuntimeHostPeerConnectionPhase) => void, ): Promise { + notifyPhase(onPhase, 'discovering'); await this.#prepareRoutes(input, signal); + notifyPhase(onPhase, 'connecting'); return this.#connect(input, signal, 'application'); } @@ -473,6 +479,17 @@ interface InboundConsumer { readonly reject: (error: Error) => void; } +function notifyPhase( + observer: ((phase: RuntimeHostPeerConnectionPhase) => void) | undefined, + phase: RuntimeHostPeerConnectionPhase, +): void { + try { + observer?.(phase); + } catch { + // Connection progress is diagnostic state and cannot control the connection. + } +} + function waitForPeerConnectTurn(previous: Promise, signal?: AbortSignal): Promise { if (!signal) return previous; if (signal.aborted) return Promise.reject(signal.reason);