From c57e530a5052dce235d7a4afa8d7a6e002e6249a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:51:10 -0700 Subject: [PATCH] refactor(runtime-environments): one exhaustive host-kind classifier for connect, remove, call readiness, hint and phone relay (#29500) * test(runtime-environments): pin connect, remove, call readiness, hint and phone relay per host kind * refactor(runtime-environments): dispatch on one exhaustive host-kind classifier * test: avoid type assertions in host-kind characterization fakes * refactor(runtime-environments): keep service-owned connect/remove in one module; tighten characterization tests * style: break, not bare return, in host service ops switches --- ...ntime-environment-connectivity-handlers.ts | 20 +- .../runtime-environment-host-dispatch.test.ts | 250 ++++++++++++++++++ .../runtime-environment-host-service-ops.ts | 32 +++ ...runtime-environment-managed-tunnel.test.ts | 101 ++++++- .../ipc/runtime-environment-managed-tunnel.ts | 24 +- ...ime-environment-mobile-relay-hosts.test.ts | 21 ++ .../runtime-environment-mobile-relay-hosts.ts | 16 +- ...ntime-environment-unreachable-hint.test.ts | 37 +++ .../runtime-environment-unreachable-hint.ts | 21 +- .../runtime-environment-host-kind.test.ts | 23 ++ src/shared/runtime-environment-host-kind.ts | 29 ++ 11 files changed, 530 insertions(+), 44 deletions(-) create mode 100644 src/main/ipc/runtime-environment-host-dispatch.test.ts create mode 100644 src/main/ipc/runtime-environment-host-service-ops.ts create mode 100644 src/shared/runtime-environment-host-kind.test.ts create mode 100644 src/shared/runtime-environment-host-kind.ts diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index b842fbac89d..827b76c08e5 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -6,10 +6,13 @@ import { resolveEnvironment } from '../../shared/runtime-environment-store' import { - isWslHostRuntimeEnvironment, redactRuntimeEnvironment, type PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' +import { + connectThroughOwningService, + removeThroughOwningService +} from './runtime-environment-host-service-ops' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-queue' import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' @@ -41,7 +44,6 @@ import { observeWslOrcadHostResponse, wslOrcadHostSupports } from '../wsl/wsl-orcad-host-environments' -import { getWslHostService } from '../wsl/wsl-host-service-instance' import { assertWslHostReachable, isGatedOffWslHost } from '../wsl/wsl-host-gate' function manuallyDisconnectedResponse( @@ -127,12 +129,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ } // A closed gate leaves persisted WSL hosts untouched on disk. assertWslHostReachable(environment) - const wsl = isWslHostRuntimeEnvironment(environment) ? getWslHostService() : null - if (wsl) { - // Also releases the distro's holder and drops the WSL record. - return wsl - .remove(environment.id) - .then(() => ({ removed: redactRuntimeEnvironment(environment) })) + const ownedRemoval = removeThroughOwningService(environment) + if (ownedRemoval) { + return ownedRemoval.then(() => ({ removed: redactRuntimeEnvironment(environment) })) } const removed = removeEnvironment(getUserDataPath(), args.selector) void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, store) @@ -162,10 +161,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ assertWslHostReachable(environment) clearRuntimeEnvironmentManualDisconnect(environment.id) clearSshCredentialDeclinedForEnvironment(environment) - if (isWslHostRuntimeEnvironment(environment)) { - // An explicit connect may boot the distro. - await getWslHostService()?.connect(environment.id) - } + await connectThroughOwningService(environment) return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs, { reconnect: true }) diff --git a/src/main/ipc/runtime-environment-host-dispatch.test.ts b/src/main/ipc/runtime-environment-host-dispatch.test.ts new file mode 100644 index 00000000000..cdf919f9082 --- /dev/null +++ b/src/main/ipc/runtime-environment-host-dispatch.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Pins what connect and remove do for each kind of host, so host-kind dispatch stays exhaustive. +type WslServiceFake = { connect: ReturnType; remove: ReturnType } + +const mocks = vi.hoisted(() => { + const order: string[] = [] + const wsl: { service: WslServiceFake | null } = { service: null } + return { + order, + handle: vi.fn(), + resolveEnvironment: vi.fn(), + removeEnvironment: vi.fn(), + retire: vi.fn(), + closeRequest: vi.fn(), + getStatus: vi.fn(), + clearDecline: vi.fn(), + wsl + } +}) + +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) +vi.mock('../../shared/runtime-environment-store', () => ({ + addEnvironmentFromPairingCode: vi.fn(), + listEnvironments: vi.fn(() => []), + removeEnvironment: mocks.removeEnvironment, + resolveEnvironment: mocks.resolveEnvironment +})) +vi.mock('./runtime-environment-removal-cleanup', () => ({ + retireRemovedRuntimeEnvironment: mocks.retire +})) +vi.mock('./runtime-environment-request-connections', async (importOriginal) => ({ + ...(await importOriginal>()), + closeRemoteRuntimeRequestConnection: mocks.closeRequest +})) +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: vi.fn(), + getRuntimeEnvironmentStatus: mocks.getStatus +})) +vi.mock('../ssh/ssh-credential-decline', () => ({ + clearSshCredentialDeclinedForEnvironment: mocks.clearDecline +})) +vi.mock('../wsl/wsl-host-service-instance', () => ({ getWslHostService: () => mocks.wsl.service })) + +import { registerRuntimeEnvironmentConnectivityHandlers } from './runtime-environment-connectivity-handlers' +import { WSL_HOSTS_GATED_OFF_MESSAGE } from '../wsl/wsl-host-gate' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' + +const endpoints = [ + { + id: 'ws-1', + kind: 'websocket', + label: 'WebSocket', + endpoint: 'ws://h', + deviceToken: 't', + publicKeyB64: 'k' + } +] +const HOSTS = { + paired: { id: 'paired-1', name: 'Box', runtimeId: 'r', endpoints }, + sshManagedLegacy: { + id: 'ssh-legacy', + name: 'Legacy', + runtimeId: 'r', + endpoints, + orcadDeployment: { + sshTargetId: 't1', + sshTargetGeneration: 1, + localPort: 4100, + remotePort: 6768 + } + }, + sshManaged: { + id: 'ssh-1', + name: 'Managed', + runtimeId: 'r', + endpoints, + orcadDeployment: { + kind: 'ssh', + sshTargetId: 't1', + sshTargetGeneration: 1, + localPort: 4100, + remotePort: 6768 + } + }, + sshAccess: { + id: 'access-1', + name: 'Access', + runtimeId: 'r', + endpoints, + sshAccess: { + sshTargetId: 't2', + sshTargetGeneration: 1, + localPort: 4200, + remotePort: 6768, + endpointId: 'ws-1', + previousPreferredEndpointId: 'ws-0' + } + }, + wsl: { + id: 'wsl:ubuntu', + name: 'Ubuntu (WSL)', + runtimeId: 'r', + endpoints, + orcadDeployment: { kind: 'wsl', distro: 'Ubuntu', localPort: 42000, remotePort: 6768 } + } +} as const +type HostName = keyof typeof HOSTS +const NETWORK_HOSTS: HostName[] = ['paired', 'sshManagedLegacy', 'sshManaged', 'sshAccess'] + +const redacted = (name: HostName) => ({ + ...HOSTS[name], + endpoints: [{ id: 'ws-1', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://h' }] +}) + +function handler(channel: string): (event: unknown, args: Record) => unknown { + const call = mocks.handle.mock.calls.find(([name]) => name === channel) + expect(call).toBeTruthy() + return call![1] +} + +function register(activeRuntimeEnvironmentId?: string): void { + registerRuntimeEnvironmentConnectivityHandlers({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: connect and remove read only settings. + store: { getSettings: () => ({ activeRuntimeEnvironmentId }) } as never, + getUserDataPath: () => '/u', + invalidateTransport: vi.fn() + }) +} + +function useHost(name: HostName): void { + mocks.resolveEnvironment.mockReturnValue(HOSTS[name]) + mocks.removeEnvironment.mockReturnValue(HOSTS[name]) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.order.length = 0 + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + mocks.wsl.service = { + // Why a macrotask: a dropped await would let getStatus run first and reorder the log. + connect: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + mocks.order.push('wsl.connect') + }), + remove: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + mocks.order.push('wsl.remove') + }) + } + mocks.clearDecline.mockImplementation(() => mocks.order.push('clearDecline')) + mocks.getStatus.mockImplementation(async () => { + mocks.order.push('getStatus') + return { id: 'status.get', ok: true, result: {}, _meta: { runtimeId: 'r' } } + }) + register() +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('runtimeEnvironments:connect by host kind', () => { + it('connects a WSL host through its service before asking for status', async () => { + useHost('wsl') + await handler('runtimeEnvironments:connect')(null, { selector: 'wsl:ubuntu', timeoutMs: 5 }) + expect(mocks.wsl.service!.connect).toHaveBeenCalledWith('wsl:ubuntu') + expect(mocks.order).toEqual(['clearDecline', 'wsl.connect', 'getStatus']) + expect(mocks.getStatus).toHaveBeenCalledWith('/u', 'wsl:ubuntu', 5, { reconnect: true }) + }) + + it('still asks a WSL host for status when no service exists', async () => { + useHost('wsl') + mocks.wsl.service = null + await handler('runtimeEnvironments:connect')(null, { selector: 'wsl:ubuntu' }) + expect(mocks.order).toEqual(['clearDecline', 'getStatus']) + }) + + it('refuses a WSL host with the gate closed before touching anything', async () => { + useHost('wsl') + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + await expect( + handler('runtimeEnvironments:connect')(null, { selector: 'wsl:ubuntu' }) + ).rejects.toThrow(WSL_HOSTS_GATED_OFF_MESSAGE) + expect(mocks.order).toEqual([]) + }) + + it.each(NETWORK_HOSTS)('only asks a %s host for status', async (name) => { + useHost(name) + await handler('runtimeEnvironments:connect')(null, { selector: HOSTS[name].id }) + expect(mocks.wsl.service!.connect).not.toHaveBeenCalled() + expect(mocks.clearDecline).toHaveBeenCalledWith(HOSTS[name]) + expect(mocks.order).toEqual(['clearDecline', 'getStatus']) + }) +}) + +describe('runtimeEnvironments:remove by host kind', () => { + it('hands a WSL host to its service and reports the resolved record', async () => { + useHost('wsl') + const result = handler('runtimeEnvironments:remove')(null, { selector: 'wsl:ubuntu' }) + expect(result).toBeInstanceOf(Promise) + await expect(result).resolves.toEqual({ removed: redacted('wsl') }) + expect(mocks.wsl.service!.remove).toHaveBeenCalledWith('wsl:ubuntu') + expect(mocks.removeEnvironment).not.toHaveBeenCalled() + expect(mocks.retire).not.toHaveBeenCalled() + }) + + it('removes a WSL host generically when no service exists', () => { + useHost('wsl') + mocks.wsl.service = null + expect(handler('runtimeEnvironments:remove')(null, { selector: 'Ubuntu (WSL)' })).toEqual({ + removed: redacted('wsl') + }) + expect(mocks.removeEnvironment).toHaveBeenCalledWith('/u', 'Ubuntu (WSL)') + expect(mocks.closeRequest).toHaveBeenCalledWith('Ubuntu (WSL)') + }) + + it('refuses a WSL host with the gate closed', () => { + useHost('wsl') + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + expect(() => handler('runtimeEnvironments:remove')(null, { selector: 'wsl:ubuntu' })).toThrow( + WSL_HOSTS_GATED_OFF_MESSAGE + ) + expect(mocks.wsl.service!.remove).not.toHaveBeenCalled() + expect(mocks.removeEnvironment).not.toHaveBeenCalled() + }) + + it('refuses the default host before the gate check', () => { + mocks.handle.mockClear() + register('wsl:ubuntu') + useHost('wsl') + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + expect(() => handler('runtimeEnvironments:remove')(null, { selector: 'wsl:ubuntu' })).toThrow( + 'This server is the default host for new projects. Choose another one in Advanced before removing it.' + ) + }) + + it.each(NETWORK_HOSTS)('removes a %s host generically and synchronously', (name) => { + useHost(name) + const id = HOSTS[name].id + expect(handler('runtimeEnvironments:remove')(null, { selector: id })).toEqual({ + removed: redacted(name) + }) + expect(mocks.wsl.service!.remove).not.toHaveBeenCalled() + expect(mocks.removeEnvironment).toHaveBeenCalledWith('/u', id) + expect(mocks.retire).toHaveBeenCalledWith(id, expect.any(Function), expect.anything()) + // Removed by its own id, so no legacy-selector transport is closed. + expect(mocks.closeRequest).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/runtime-environment-host-service-ops.ts b/src/main/ipc/runtime-environment-host-service-ops.ts new file mode 100644 index 00000000000..3c9117cbbe2 --- /dev/null +++ b/src/main/ipc/runtime-environment-host-service-ops.ts @@ -0,0 +1,32 @@ +/** Connect and remove for hosts a desktop service owns (WSL); network hosts need neither. */ +import { classifyRuntimeEnvironmentHost } from '../../shared/runtime-environment-host-kind' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import { getWslHostService } from '../wsl/wsl-host-service-instance' + +/** An explicit user connect; may boot a WSL host's distro. */ +export async function connectThroughOwningService( + environment: KnownRuntimeEnvironment +): Promise { + const host = classifyRuntimeEnvironmentHost(environment) + switch (host.kind) { + case 'wsl': + await getWslHostService()?.connect(environment.id) + break + case 'network': + break + } +} + +/** Null when no service owns the host, which leaves removal to the generic store path. */ +export function removeThroughOwningService( + environment: KnownRuntimeEnvironment +): Promise | null { + const host = classifyRuntimeEnvironmentHost(environment) + switch (host.kind) { + case 'wsl': + // Also releases the distro's holder and drops the WSL record. + return getWslHostService()?.remove(environment.id) ?? null + case 'network': + return null + } +} diff --git a/src/main/ipc/runtime-environment-managed-tunnel.test.ts b/src/main/ipc/runtime-environment-managed-tunnel.test.ts index 09fa3257602..5254f50f901 100644 --- a/src/main/ipc/runtime-environment-managed-tunnel.test.ts +++ b/src/main/ipc/runtime-environment-managed-tunnel.test.ts @@ -1,18 +1,35 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - ensureTunnel: vi.fn(async () => {}), - rebuildTunnel: vi.fn(async () => {}), - verify: vi.fn(), - restoreUpdate: vi.fn(() => null), - redeploy: vi.fn(async () => true) -})) +const mocks = vi.hoisted(() => { + const environments: Record> = {} + const reads: Record = {} + return { + environments, + reads, + ensureForRuntimeCall: vi.fn(async () => {}), + hasWslService: true, + ensureTunnel: vi.fn(async () => {}), + rebuildTunnel: vi.fn(async () => {}), + verify: vi.fn(), + restoreUpdate: vi.fn(() => null), + redeploy: vi.fn(async () => true) + } +}) vi.mock('../../shared/runtime-environment-store', () => ({ - resolveEnvironment: (_userData: string, id: string) => ({ - id, - orcadDeployment: { sshTargetId: 'ssh-1' } - }) + resolveEnvironment: (_userData: string, id: string) => { + const environment = mocks.environments[id] + if (!environment) { + return { id, orcadDeployment: { sshTargetId: 'ssh-1' } } + } + mocks.reads[id] = (mocks.reads[id] ?? 0) + 1 + // A fresh object per read, so a test can tell the re-read after bring-up from the first. + return { ...environment, read: mocks.reads[id] } + } +})) +vi.mock('../wsl/wsl-host-service-instance', () => ({ + getWslHostService: () => + mocks.hasWslService ? { ensureForRuntimeCall: mocks.ensureForRuntimeCall } : null })) vi.mock('../ssh/managed-server-update-deps', () => ({ managedServerUpdateDeps: () => ({}) })) vi.mock('../ssh/orcad-managed-tunnel', () => ({ @@ -33,6 +50,8 @@ vi.mock('./ssh-renderer-broadcast', () => ({ broadcastSshState: vi.fn() })) import { MANAGED_ORCAD_NOT_ACTIVATED_DETAIL } from '../ssh/orcad-managed-serving' import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' +import { WSL_HOSTS_GATED_OFF_MESSAGE } from '../wsl/wsl-host-gate' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' describe('resolving a managed runtime for a call', () => { beforeEach(() => vi.clearAllMocks()) @@ -57,3 +76,61 @@ describe('resolving a managed runtime for a call', () => { expect(mocks.restoreUpdate).toHaveBeenCalledWith('env-1', expect.any(Function)) }) }) + +describe('resolving a runtime for a call, by host kind', () => { + const wsl = { + id: 'wsl:ubuntu', + orcadDeployment: { kind: 'wsl', distro: 'Ubuntu', localPort: 42000, remotePort: 6768 } + } + beforeEach(() => { + vi.clearAllMocks() + mocks.hasWslService = true + for (const id of Object.keys(mocks.reads)) { + delete mocks.reads[id] + } + mocks.environments = { 'wsl:ubuntu': wsl, 'paired-1': { id: 'paired-1' } } + mocks.verify.mockResolvedValue({ state: 'serving' }) + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + }) + afterEach(() => vi.unstubAllEnvs()) + + it('brings a WSL host up through its service, never through an SSH tunnel', async () => { + // The record re-read after bring-up, which may have moved the bridge port. + await expect(resolveManagedRuntimeEnvironment('/u', 'wsl:ubuntu')).resolves.toEqual({ + ...wsl, + read: 2 + }) + expect(mocks.ensureForRuntimeCall).toHaveBeenCalledWith('wsl:ubuntu') + expect(mocks.ensureTunnel).not.toHaveBeenCalled() + expect(mocks.verify).not.toHaveBeenCalled() + expect(mocks.restoreUpdate).not.toHaveBeenCalled() + }) + + it('resolves a WSL host with no service without bringing anything up', async () => { + mocks.hasWslService = false + await expect(resolveManagedRuntimeEnvironment('/u', 'wsl:ubuntu')).resolves.toEqual({ + ...wsl, + read: 2 + }) + expect(mocks.ensureTunnel).not.toHaveBeenCalled() + }) + + it('refuses a WSL host with the gate closed', async () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + await expect(resolveManagedRuntimeEnvironment('/u', 'wsl:ubuntu')).rejects.toThrow( + WSL_HOSTS_GATED_OFF_MESSAGE + ) + expect(mocks.ensureForRuntimeCall).not.toHaveBeenCalled() + }) + + it('runs the managed tunnel path for a paired server too', async () => { + await expect(resolveManagedRuntimeEnvironment('/u', 'paired-1')).resolves.toEqual({ + id: 'paired-1', + read: 2 + }) + expect(mocks.ensureTunnel).toHaveBeenCalledWith('/u', 'paired-1') + expect(mocks.verify).toHaveBeenCalledWith('/u', 'paired-1') + expect(mocks.restoreUpdate).toHaveBeenCalledWith('paired-1', expect.any(Function)) + expect(mocks.ensureForRuntimeCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/runtime-environment-managed-tunnel.ts b/src/main/ipc/runtime-environment-managed-tunnel.ts index d2a31cc6071..e596a723110 100644 --- a/src/main/ipc/runtime-environment-managed-tunnel.ts +++ b/src/main/ipc/runtime-environment-managed-tunnel.ts @@ -1,8 +1,9 @@ import { resolveEnvironment } from '../../shared/runtime-environment-store' import { getOrcadSshDeployment, - isWslHostRuntimeEnvironment + type KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import { classifyRuntimeEnvironmentHost } from '../../shared/runtime-environment-host-kind' import type { SshManagedServerStatus, SshManagedServerUpdateNote, @@ -34,11 +35,23 @@ export async function resolveManagedRuntimeEnvironment( const environment = resolveEnvironment(userDataPath, selector) // Every call, subscription and status probe passes here, so a gated-off WSL host is never reached. assertWslHostReachable(environment) - if (isWslHostRuntimeEnvironment(environment)) { - // Reached over the wsl.exe bridge; connects only when an open project or terminal needs it. - await getWslHostService()?.ensureForRuntimeCall(environment.id) - return resolveEnvironment(userDataPath, environment.id) + const host = classifyRuntimeEnvironmentHost(environment) + switch (host.kind) { + case 'wsl': + // Reached over the wsl.exe bridge; connects only when an open project or terminal needs it. + await getWslHostService()?.ensureForRuntimeCall(environment.id) + break + case 'network': + await prepareNetworkServerForCall(userDataPath, environment) + break } + return resolveEnvironment(userDataPath, environment.id) +} + +async function prepareNetworkServerForCall( + userDataPath: string, + environment: KnownRuntimeEnvironment +): Promise { await ensureOrcadManagedTunnel(userDataPath, environment.id) // Why: a server that stopped (idle, killed, host rebooted) starts before the call that needs it. const serving = await verifyOrcadManagedServing(userDataPath, environment.id) @@ -71,7 +84,6 @@ export async function resolveManagedRuntimeEnvironment( .catch(() => {}) } } - return resolveEnvironment(userDataPath, environment.id) } function isStoppedElsewhere(serving: OrcadManagedServing): boolean { diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts index ace93ac104c..2882fe00f6b 100644 --- a/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts @@ -68,6 +68,27 @@ describe('runtime environment mobile relay hosts', () => { expect(mocks.resolveManaged).not.toHaveBeenCalled() }) + it('relays SSH-managed servers, with and without the persisted deployment kind', async () => { + const tunnel = { sshTargetId: 't', sshTargetGeneration: 1, localPort: 1, remotePort: 2 } + mocks.environments = [ + { id: 'legacy', name: 'A', createdAt: 1, runtimeId: null, orcadDeployment: tunnel }, + { + id: 'managed', + name: 'B', + createdAt: 2, + runtimeId: null, + orcadDeployment: { kind: 'ssh', ...tunnel } + } + ] + const hosts = createRuntimeEnvironmentMobileRelayHosts() + expect(hosts.list().environments.map((environment) => environment.id)).toEqual([ + 'legacy', + 'managed' + ]) + await hosts.resolve('managed') + expect(mocks.resolveManaged).toHaveBeenCalledWith('/user-data', 'managed') + }) + it("lists configured servers with the desktop's own status, ignoring another pairing's snapshot", () => { mocks.environments = [ { id: 'env-1', name: 'VM', createdAt: 1, pairingRevision: 7, runtimeId: 'runtime-a' }, diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts index b04b630fcbe..50b39ad2dfd 100644 --- a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts @@ -3,9 +3,9 @@ import { pairedRuntimeBrowserClientHostsPage } from '../browser/paired-runtime-b import { listEnvironments } from '../../shared/runtime-environment-store' import { getPreferredPairingOffer, - isWslHostRuntimeEnvironment, type KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import { classifyRuntimeEnvironmentHost } from '../../shared/runtime-environment-host-kind' import { runtimeEnvironmentStatusFromSnapshot, type RuntimeEnvironmentStatus @@ -31,9 +31,17 @@ export function retireMobileDesktopRelayEnvironment(environmentId: string): void * distros, connected only for work open here, so a phone must not wake or route to them. */ function listRelayableEnvironments(userDataPath: string) { - return listEnvironments(userDataPath).filter( - (environment) => !isWslHostRuntimeEnvironment(environment) - ) + return listEnvironments(userDataPath).filter(isRelayableToPhones) +} + +function isRelayableToPhones(environment: KnownRuntimeEnvironment): boolean { + const host = classifyRuntimeEnvironmentHost(environment) + switch (host.kind) { + case 'network': + return true + case 'wsl': + return false + } } /** The desktop's configured servers, addressed only by exact environment id. */ diff --git a/src/main/ipc/runtime-environment-unreachable-hint.test.ts b/src/main/ipc/runtime-environment-unreachable-hint.test.ts index 4f00e67d7f6..42bbeb475ef 100644 --- a/src/main/ipc/runtime-environment-unreachable-hint.test.ts +++ b/src/main/ipc/runtime-environment-unreachable-hint.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { withRuntimeUnreachableHint } from './runtime-environment-unreachable-hint' +import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' const UNREACHABLE = 'Could not connect to the remote Orca runtime at ws://127.0.0.1:42000.' @@ -26,4 +27,40 @@ describe('withRuntimeUnreachableHint', () => { } expect(withRuntimeUnreachableHint(wsl, 'Unauthorized', null)).toBe('Unauthorized') }) + + it.each([ + ['a paired server', {}], + [ + 'a legacy SSH-managed server', + { orcadDeployment: { sshTargetId: 't', sshTargetGeneration: 1, localPort: 1, remotePort: 2 } } + ], + [ + 'an SSH-managed server', + { + orcadDeployment: { + kind: 'ssh' as const, + sshTargetId: 't', + sshTargetGeneration: 1, + localPort: 1, + remotePort: 2 + } + } + ] + ])('gives %s exactly the Tailscale hint', (_name, environment) => { + expect(withRuntimeUnreachableHint(environment, UNREACHABLE, 'ws://203.0.113.4:6768')).toBe( + withRemoteRuntimeTailscaleHint(UNREACHABLE, 'ws://203.0.113.4:6768') + ) + }) + + it('pins the full WSL hint text', () => { + expect( + withRuntimeUnreachableHint( + { orcadDeployment: { kind: 'wsl', distro: 'Debian', localPort: 1, remotePort: 2 } }, + 'Timed out waiting for the remote Orca runtime.', + null + ) + ).toBe( + 'Timed out waiting for the remote Orca runtime. The Orca server in the WSL distro Debian isn’t answering; connect it again in Settings → Remote Orca Servers → WSL hosts.' + ) + }) }) diff --git a/src/main/ipc/runtime-environment-unreachable-hint.ts b/src/main/ipc/runtime-environment-unreachable-hint.ts index d2cd1f1aacb..671a03b0839 100644 --- a/src/main/ipc/runtime-environment-unreachable-hint.ts +++ b/src/main/ipc/runtime-environment-unreachable-hint.ts @@ -1,9 +1,7 @@ /** What to suggest when a server cannot be reached, chosen by the kind of host that owns it. */ import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' -import { - isWslHostRuntimeEnvironment, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' +import { classifyRuntimeEnvironmentHost } from '../../shared/runtime-environment-host-kind' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' const UNREACHABLE_RE = /could not connect to the remote orca runtime|remote orca runtime closed the connection|timed out (?:waiting for|while connecting to) the remote orca runtime/i @@ -13,11 +11,14 @@ export function withRuntimeUnreachableHint( message: string, endpoint: string | null | undefined ): string { - if (!isWslHostRuntimeEnvironment(environment)) { - return withRemoteRuntimeTailscaleHint(message, endpoint) + const host = classifyRuntimeEnvironmentHost(environment) + switch (host.kind) { + case 'network': + return withRemoteRuntimeTailscaleHint(message, endpoint) + case 'wsl': + // Why not Tailscale: a WSL host is a distro on this computer, reached over wsl.exe. + return UNREACHABLE_RE.test(message) + ? `${message} The Orca server in the WSL distro ${host.deployment.distro} isn’t answering; connect it again in Settings → Remote Orca Servers → WSL hosts.` + : message } - // Why not Tailscale: a WSL host is a distro on this computer, reached over wsl.exe. - return UNREACHABLE_RE.test(message) - ? `${message} The Orca server in the WSL distro ${environment.orcadDeployment.distro} isn’t answering; connect it again in Settings → Remote Orca Servers → WSL hosts.` - : message } diff --git a/src/shared/runtime-environment-host-kind.test.ts b/src/shared/runtime-environment-host-kind.test.ts new file mode 100644 index 00000000000..6af547eef62 --- /dev/null +++ b/src/shared/runtime-environment-host-kind.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { classifyRuntimeEnvironmentHost } from './runtime-environment-host-kind' + +const tunnel = { sshTargetId: 't', sshTargetGeneration: 1, localPort: 1, remotePort: 2 } +const wsl = { kind: 'wsl' as const, distro: 'Ubuntu', localPort: 1, remotePort: 2 } + +describe('classifyRuntimeEnvironmentHost', () => { + it.each([ + ['no deployment', {}], + ['a null deployment', { orcadDeployment: null }], + ['an SSH link saved without its kind', { orcadDeployment: tunnel }], + ['an SSH link', { orcadDeployment: { kind: 'ssh' as const, ...tunnel } }] + ])('treats %s as a network host', (_name, environment) => { + expect(classifyRuntimeEnvironmentHost(environment)).toEqual({ kind: 'network', environment }) + }) + + it('treats a WSL deployment as a WSL host and hands back its link', () => { + const environment = { orcadDeployment: wsl } + const host = classifyRuntimeEnvironmentHost(environment) + expect(host).toEqual({ kind: 'wsl', environment, deployment: wsl }) + expect(host.environment).toBe(environment) + }) +}) diff --git a/src/shared/runtime-environment-host-kind.ts b/src/shared/runtime-environment-host-kind.ts new file mode 100644 index 00000000000..04d53e10f15 --- /dev/null +++ b/src/shared/runtime-environment-host-kind.ts @@ -0,0 +1,29 @@ +import type { OrcadDeploymentLink } from './runtime-environments' +import type { OrcadWslDeploymentLink } from './wsl-host-deployment-link' + +/** + * What kind of host serves a runtime environment. Switch on `kind` (lint requires the switch to be + * exhaustive), so a new kind fails the build at every switch on it. + * `network`: reached over the network or an SSH tunnel (paired, SSH access, SSH-managed). + * `wsl`: a distro on this computer, reached over the wsl.exe bridge. + */ +export type RuntimeEnvironmentHost = + | { kind: 'network'; environment: T } + | { kind: 'wsl'; environment: T; deployment: OrcadWslDeploymentLink } + +export function classifyRuntimeEnvironmentHost< + T extends { orcadDeployment?: OrcadDeploymentLink | null } +>(environment: T): RuntimeEnvironmentHost { + const deployment = environment.orcadDeployment + if (!deployment) { + return { kind: 'network', environment } + } + switch (deployment.kind) { + // Why undefined: SSH deployment links are persisted without their kind. + case undefined: + case 'ssh': + return { kind: 'network', environment } + case 'wsl': + return { kind: 'wsl', environment, deployment } + } +}