diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 4ce57ac50c1..275df362463 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -142,8 +142,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ): Promise> => { const environment = resolveEnvironment(getUserDataPath(), args.selector) clearRuntimeEnvironmentManualDisconnect(environment.id) - return getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).refresh({ - timeoutMs: args.timeoutMs, + return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs, { reconnect: true }) } diff --git a/src/main/ipc/runtime-environment-federated-read-routing.test.ts b/src/main/ipc/runtime-environment-federated-read-routing.test.ts index 8e45b56ac3e..51c77510a01 100644 --- a/src/main/ipc/runtime-environment-federated-read-routing.test.ts +++ b/src/main/ipc/runtime-environment-federated-read-routing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -59,6 +60,7 @@ describe('federated read RPC transport routing', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environment-status-connection.test.ts b/src/main/ipc/runtime-environment-status-connection.test.ts index c2f8bf58aa0..8d4fad6f9b9 100644 --- a/src/main/ipc/runtime-environment-status-connection.test.ts +++ b/src/main/ipc/runtime-environment-status-connection.test.ts @@ -39,10 +39,13 @@ it('publishes real same-socket verification after every authenticated reconnect' }) await getRuntimeEnvironmentStatus(profile, environment.id) const owner = getRuntimeEnvironmentStatusOwner(profile, environment.id) - await vi.waitFor(() => { - expect(owner.read()).toMatchObject({ transport: 'ready', verification: 'verified' }) - expect(server.requests).toHaveLength(2) - }) + await vi.waitFor( + () => { + expect(owner.read()).toMatchObject({ transport: 'ready', verification: 'verified' }) + expect(server.requests).toHaveLength(2) + }, + { timeout: 3_000 } + ) expect(server.connectionCount()).toBe(2) // Bootstrap plus persistent control. runtimeId = 'host-after' server.closeClients() diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 404fe8720bb..b19b0d9e376 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -39,7 +39,7 @@ export async function getRuntimeEnvironmentStatus( userDataPath: string, selector: string, timeoutMs?: number, - options?: { observeOnly?: true; signal?: AbortSignal } + options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } ): Promise> { const environment = resolveEnvironment(userDataPath, selector) if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { diff --git a/src/main/ipc/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments-call-routing.test.ts index bca73aecb33..e6f8946527d 100644 --- a/src/main/ipc/runtime-environments-call-routing.test.ts +++ b/src/main/ipc/runtime-environments-call-routing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -118,6 +119,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments-capability-cache.test.ts index a5dcc5c3895..32f724df981 100644 --- a/src/main/ipc/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments-capability-cache.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -111,6 +112,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index fccfd6d97d1..0df24d99ca2 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -131,6 +132,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-status-diagnostics.test.ts b/src/main/ipc/runtime-environments-status-diagnostics.test.ts index 93fbf9c37c0..9fe3d6baf0e 100644 --- a/src/main/ipc/runtime-environments-status-diagnostics.test.ts +++ b/src/main/ipc/runtime-environments-status-diagnostics.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -120,6 +121,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -325,36 +327,41 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) }) - it('returns shared-control diagnostics when saved remote runtime status throws', async () => { - registerRuntimeEnvironmentHandlers(store as never) - getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ - state: 'reconnecting', - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 123, - lastClose: { code: 1006, reason: '' }, - lastError: 'closed' - }) - sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('socket closed')) + it.each(['runtimeEnvironments:getStatus', 'runtimeEnvironments:connect'])( + 'preserves failure diagnostics and guidance on %s', + async (channel) => { + registerRuntimeEnvironmentHandlers(store as never) + getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ + state: 'reconnecting', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 2, + lastConnectedAt: 123, + lastClose: { code: 1006, reason: '' }, + lastError: 'closed' + }) + sendRemoteRuntimeRequestMock.mockRejectedValue( + new Error('Could not connect to the remote Orca runtime.') + ) - const add = handler< - { name: string; pairingCode: string }, - { environment: { id: string; name: string } } - >('runtimeEnvironments:addFromPairingCode') - await add(null, { name: 'desk', pairingCode: pairingCode() }) + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) - const getStatus = handler< - { selector: string; timeoutMs?: number }, - { ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } } - >('runtimeEnvironments:getStatus') + const getStatus = handler< + { selector: string; timeoutMs?: number }, + { ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } } + >(channel) - await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({ - ok: false, - error: { - message: 'socket closed', - data: { remoteControl: { state: 'reconnecting' } } - } - }) - }) + await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({ + ok: false, + error: { + message: expect.stringContaining('connect both devices to Tailscale'), + data: { remoteControl: { state: 'reconnecting' } } + } + }) + } + ) }) diff --git a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts index a1eeccba343..ed4dcd62182 100644 --- a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts +++ b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -115,6 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-subscription-routing.test.ts b/src/main/ipc/runtime-environments-subscription-routing.test.ts index c83c3d921e1..0ef70f7ac96 100644 --- a/src/main/ipc/runtime-environments-subscription-routing.test.ts +++ b/src/main/ipc/runtime-environments-subscription-routing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -114,6 +115,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index b8ad2ede8be..afb1adf457a 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -121,6 +122,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/renderer/src/store/slices/runtime-status-refresh.ts b/src/renderer/src/store/slices/runtime-status-refresh.ts index 0b5f3cb17e4..fb27eec6a43 100644 --- a/src/renderer/src/store/slices/runtime-status-refresh.ts +++ b/src/renderer/src/store/slices/runtime-status-refresh.ts @@ -16,14 +16,18 @@ export async function refreshRuntimeEnvironmentStatus( timeoutMs }) if (window.api.runtimeEnvironments.getStatusSnapshots) { - const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() - const snapshot = snapshots.find((entry) => entry.environmentId === environmentId) - if (snapshot) { - publish({ - snapshot, - status: snapshot.verification === 'verified' ? snapshot.status : null, - checkedAt: snapshot.checkedAt - }) + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + const snapshot = snapshots.find((entry) => entry.environmentId === environmentId) + if (snapshot) { + publish({ + snapshot, + status: snapshot.verification === 'verified' ? snapshot.status : null, + checkedAt: snapshot.checkedAt + }) + } + } catch (error) { + console.error('Failed to read runtime host status snapshot:', error) } return response.ok } diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 6709eec4e80..fdc843c748a 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -723,6 +723,26 @@ describe('runtime-status slice', () => { expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(null) }) + it('preserves successful reachability when reading its snapshot fails', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + getStatus: vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a')), + getStatusSnapshots: vi.fn().mockRejectedValue(new Error('IPC read failed')) + } + } + }) + try { + const store = createSliceStore() + expect(await store.getState().refreshRuntimeEnvironmentStatus('env-a')).toBe(true) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + expect(log).toHaveBeenCalled() + } finally { + log.mockRestore() + } + }) + it('hydrates saved environments through the single-environment refresh path', async () => { const getStatus = vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a')) const list = vi.fn().mockResolvedValue([ diff --git a/src/renderer/src/web/web-runtime-status-owner.test.ts b/src/renderer/src/web/web-runtime-status-owner.test.ts index 493f96f02d8..7abfe6f19f4 100644 --- a/src/renderer/src/web/web-runtime-status-owner.test.ts +++ b/src/renderer/src/web/web-runtime-status-owner.test.ts @@ -34,11 +34,15 @@ it('primary browser status follows the authenticated socket and closing it retir status: { environmentId: 'browser', pairingRevision: 1, publish, verified: vi.fn() } }) clients.push(client) - await expect.poll(() => client.statusOwner?.read().verification).toBe('verified') + await expect + .poll(() => client.statusOwner?.read().verification, { timeout: 3_000 }) + .toBe('verified') expect(client.statusOwner?.read().status?.runtimeId).toBe('before') runtimeId = 'after' server.closeClients() - await expect.poll(() => client.statusOwner?.read().status?.runtimeId).toBe('after') + await expect + .poll(() => client.statusOwner?.read().status?.runtimeId, { timeout: 3_000 }) + .toBe('after') expect(client.statusOwner?.read().transport).toBe('ready') client.close() expect(publish.mock.lastCall?.[0]).toMatchObject({ retired: true, verification: 'blocked' }) diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index e509fe05bfc..e397e8ed5bf 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -320,17 +320,15 @@ describe('execution host registry', () => { ]) }) - it('includes runtime hosts from repo ownership but marks them disconnected without live status', () => { + it('keeps runtime hosts checking before their first status result', () => { const hosts = buildExecutionHostRegistry({ repos: [{ connectionId: null, executionHostId: 'runtime:env-2' }], settings: { activeRuntimeEnvironmentId: null } }) - // No live status means no evidence the Orca server is reachable, so it must - // read 'disconnected' rather than defaulting to 'available'/"Connected". expect(hosts).toMatchObject([ { id: 'local', health: 'local' }, - { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'disconnected' } + { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'connecting' } ]) }) @@ -373,3 +371,29 @@ describe('execution host registry', () => { ]) }) }) + +it('keeps an initial unknown-transport verification connecting', () => { + const hosts = buildExecutionHostRegistry({ + repos: [], + settings: null, + runtimeEnvironments: [{ id: 'host', name: 'Host' }], + runtimeStatusByEnvironmentId: new Map([ + [ + 'host', + { + status: null, + snapshot: { + environmentId: 'host', + pairingRevision: 1, + sequence: 1, + checkedAt: 0, + status: null, + verification: 'checking', + transport: 'unknown' + } + } + ] + ]) + }) + expect(hosts.find((host) => host.id === 'runtime:host')?.health).toBe('connecting') +}) diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index 4e0bacac91b..bad6b40f257 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -168,7 +168,10 @@ function addRuntimeHost( ? 'disconnected' : snapshot?.verification === 'blocked' ? 'blocked' - : snapshot?.transport === 'disconnected' || snapshot?.transport === 'connecting' + : !runtimeStatus || + snapshot?.verification === 'checking' || + snapshot?.transport === 'disconnected' || + snapshot?.transport === 'connecting' ? 'connecting' : snapshot?.transport === 'ready' ? compatibility?.kind === 'blocked' diff --git a/tests/e2e/runtime-host-status-recovery.spec.ts b/tests/e2e/runtime-host-status-recovery.spec.ts index 93ae3487ff9..de9c1e9de06 100644 --- a/tests/e2e/runtime-host-status-recovery.spec.ts +++ b/tests/e2e/runtime-host-status-recovery.spec.ts @@ -82,21 +82,24 @@ for (const topology of ['desktop', 'headless'] as const) { testRepoPath }, testInfo) => { test.setTimeout(180_000) - const headless = - topology === 'headless' - ? await launchHeadlessPairedRuntimeHost({ pinnedServePort: true }) - : null - const offer = headless?.offer ?? (await createRuntimeDesktopPairingOffer(page)) - await (headless - ? headless.client.call('repo.add', { path: testRepoPath }) - : page.evaluate(async (path) => { - await window.api.repos.add({ path }) - await window.__store?.getState().fetchRepos() - }, testRepoPath)) - const proxy = await interruptibleHost(offer) - const client = await launchPairedElectronClient(offer, testInfo, 'Direct host') + let headless: Awaited> | null = null + let proxy: Awaited> | undefined + let client: Awaited> | undefined let browser: Awaited> | undefined try { + headless = + topology === 'headless' + ? await launchHeadlessPairedRuntimeHost({ pinnedServePort: true }) + : null + const offer = headless?.offer ?? (await createRuntimeDesktopPairingOffer(page)) + await (headless + ? headless.client.call('repo.add', { path: testRepoPath }) + : page.evaluate(async (path) => { + await window.api.repos.add({ path }) + await window.__store?.getState().fetchRepos() + }, testRepoPath)) + proxy = await interruptibleHost(offer) + client = await launchPairedElectronClient(offer, testInfo, 'Direct host') proxy.setOnline(false) const offlineId = await client.page.evaluate(async (pairingCode) => { const { environment } = await window.api.runtimeEnvironments.addFromPairingCode({ @@ -109,16 +112,16 @@ for (const topology of ['desktop', 'headless'] as const) { return environment.id }, proxy.offer.pairingUrl) await expect - .poll(() => statusEvidence(client.page, offlineId)) + .poll(() => statusEvidence(client!.page, offlineId)) .toMatchObject({ verification: 'unavailable' }) - expect(await statusEvidence(client.page, client.environmentId)).toMatchObject({ + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ verification: 'verified' }) proxy.setOnline(true) await expect - .poll(() => statusEvidence(client.page, offlineId), { timeout: 30_000 }) + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) .toMatchObject({ verification: 'verified', transport: 'ready' }) - const initial = await statusEvidence(client.page, offlineId) + const initial = await statusEvidence(client!.page, offlineId) await expect(client.page.getByText('Recovering host', { exact: true }).first()).toBeVisible() await client.page.screenshot({ path: testInfo.outputPath(`${topology}-recovered.png`) }) browser = await launchPairedWebClient(electronApp, proxy.offer) @@ -127,20 +130,23 @@ for (const topology of ['desktop', 'headless'] as const) { .toMatchObject({ verification: 'verified', transport: 'ready' }) proxy.setOnline(false) await expect - .poll(() => statusEvidence(client.page, offlineId)) + .poll(() => statusEvidence(client!.page, offlineId)) .toMatchObject({ transport: 'disconnected' }) - expect(await statusEvidence(client.page, client.environmentId)).toMatchObject({ + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ transport: 'disconnected' }) + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ verification: 'verified', transport: 'ready' }) proxy.setOnline(true) await expect - .poll(() => statusEvidence(client.page, offlineId), { timeout: 30_000 }) + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) .toMatchObject({ verification: 'verified', transport: 'ready' }) await expect .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) .toMatchObject({ verification: 'verified', transport: 'ready' }) - expect((await statusEvidence(client.page, offlineId))!.sequence).toBeGreaterThan( + expect((await statusEvidence(client!.page, offlineId))!.sequence).toBeGreaterThan( initial!.sequence ) await browser.page.screenshot({ @@ -150,12 +156,12 @@ for (const topology of ['desktop', 'headless'] as const) { await window.api.runtimeEnvironments.disconnect({ selector }) }, offlineId) await expect - .poll(() => statusEvidence(client.page, offlineId)) + .poll(() => statusEvidence(client!.page, offlineId)) .toMatchObject({ verification: 'blocked', transport: 'disconnected' }) } finally { await browser?.dispose() - await client.dispose() - await proxy.close() + await client?.dispose() + await proxy?.close() await headless?.dispose() } })