diff --git a/config/scripts/pr-e2e-route-contracts.test.mjs b/config/scripts/pr-e2e-route-contracts.test.mjs index 117600fba1c..554ba60e2a5 100644 --- a/config/scripts/pr-e2e-route-contracts.test.mjs +++ b/config/scripts/pr-e2e-route-contracts.test.mjs @@ -503,6 +503,21 @@ function selectsManagedSshLane(file) { return selectPrE2eSpecs([file]).some((spec) => spec.startsWith(MANAGED_SSH_E2E_SPEC_PREFIX)) } +describe('wsl-host remove routing', () => { + it('runs the fake transport spec, which covers Remove, for a remove-path change', () => { + for (const file of [ + 'src/main/wsl/wsl-host-server-stop.ts', + 'src/main/wsl/wsl-host-peer-desktops.ts', + 'src/main/wsl/wsl-host-removal.ts' + ]) { + expect(selectPrE2eSpecs([file]), file).toContain('tests/e2e/wsl-host-fake-transport.spec.ts') + } + expect(selectPrE2eSpecs(['src/main/wsl/wsl-host-server-stop.test.ts'])).not.toContain( + 'tests/e2e/wsl-host-fake-transport.spec.ts' + ) + }) +}) + describe('wsl-host fake connector routing', () => { it('runs both WSL host specs for a fake connector change', () => { const specs = selectPrE2eSpecs(['src/main/wsl/wsl-host-e2e-fake-connector.ts']) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index ab191df0ab0..6c3c1f50f4e 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -397,6 +397,15 @@ export const PR_E2E_SOURCE_ROUTES = [ specs: ['tests/e2e/wsl-host-fake-transport.spec.ts', 'tests/e2e/wsl-host-chat.spec.ts'], matches: (file) => file === 'src/main/wsl/wsl-host-e2e-fake-connector.ts' }, + { + // Remove runs through the fake transport spec; its peer check asks the server who else uses it. + id: 'wsl-host.remove', + specs: ['tests/e2e/wsl-host-fake-transport.spec.ts'], + matches: (file) => + /^src\/main\/wsl\/(?:wsl-host-server-stop|wsl-host-peer-desktops|wsl-host-removal)\.ts$/.test( + file + ) + }, { id: 'terminal.windows-wsl-launch-and-paste', specs: [ diff --git a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts index 7b40f92d25c..37490c151cd 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts @@ -27,6 +27,7 @@ import { advanceRuntimeEnvironmentTransportGeneration, getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' +import { declareWslHostClient } from '../../wsl/wsl-host-client-declaration' type CachedRuntimeConnection = { pairingKey: string @@ -269,13 +270,16 @@ function getSharedControlConnection( cached?.connection.close() const transportGeneration = getRuntimeEnvironmentTransportGeneration(environmentId) let answeredAt: number | null = null - cached = { - pairingKey, - answeredAt: () => answeredAt, - connection: new RemoteRuntimeSharedControlConnection(pairedConnection, { + const connection: RemoteRuntimeSharedControlConnection = + new RemoteRuntimeSharedControlConnection(pairedConnection, { environmentId, clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, ...sharedControlEnvironmentPolicy(environmentId), + // R39: every ready socket, including after a reconnect, re-declares this installation. + onReady: () => + declareWslHostClient(environmentId, (method, params, timeoutMs) => + connection.request(method, params, timeoutMs) + ), // Wall-clock time includes suspend, expiring pre-sleep answers. onAnswered: () => { answeredAt = Date.now() @@ -301,7 +305,7 @@ function getSharedControlConnection( ) } }) - } + cached = { pairingKey, answeredAt: () => answeredAt, connection } sharedControlConnections.set(environmentId, cached) const entry = cached // Ready requires this socket's own server frame, which stamps its answer time. diff --git a/src/main/ipc/wsl-move-undo-wiring.test.ts b/src/main/ipc/wsl-move-undo-wiring.test.ts index b992ff8416a..c5656dbad67 100644 --- a/src/main/ipc/wsl-move-undo-wiring.test.ts +++ b/src/main/ipc/wsl-move-undo-wiring.test.ts @@ -18,7 +18,11 @@ const host = vi.hoisted(() => ({ listed: true, unreadable: false, removalPending: false, - connect: vi.fn(async (..._args: unknown[]): Promise => true) + connect: vi.fn(async (..._args: unknown[]): Promise => true), + keptForPeers: false +})) +vi.mock('../wsl/wsl-host-kept-for-peers', () => ({ + wasWslHostKeptForPeers: () => host.keptForPeers })) vi.mock('../../shared/runtime-environment-store', () => ({ listEnvironments: () => { @@ -149,6 +153,22 @@ describe("undo's host automation port", () => { ).toBe('stopped') expect(host.disabled).toEqual([]) }) + + // R39: another Orca on this computer kept the removed host's server, so the move's copies may run. + it('refuses a removed host whose server was kept for another Orca', async () => { + host.disabled = [] + host.listed = false + host.keptForPeers = true + try { + const { store } = storeWith() + const ports = createWslMoveUndoPorts(store, '/user-data') + expect((await ports.stopHostAutomations?.(move))?.outcome).toBe('unreachable') + expect(await ports.stopHostCopies(move)).toBe('unreachable') + expect(host.disabled).toEqual([]) + } finally { + host.keptForPeers = false + } + }) }) describe("undo's host automation port, refusing", () => { diff --git a/src/main/ipc/wsl-move-undo-wiring.ts b/src/main/ipc/wsl-move-undo-wiring.ts index 27ede2a57b5..77d7efafad1 100644 --- a/src/main/ipc/wsl-move-undo-wiring.ts +++ b/src/main/ipc/wsl-move-undo-wiring.ts @@ -16,6 +16,7 @@ import { wslMoveReleasedSessionFocusPatch } from '../persistence/moving-wsl-proj import { applyWslMoveSessionFocus } from '../persistence/moving-wsl-projects/wsl-move-main-rewrite-stores' import { listEnvironments } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment } from './runtime-environments/runtime-environment-transport-routing' +import { wasWslHostKeptForPeers } from '../wsl/wsl-host-kept-for-peers' import { stopWslMoveHostAutomations, wslMoveMovedAutomationIds, @@ -79,9 +80,12 @@ export function createWslMoveUndoPorts( ): WslMoveUndoPorts { return { stopHostCopies: async (move) => { - // A host the user removed ran its terminals down with it; nothing of the project runs there. + // A host the user removed ran its terminals down with it, unless its Remove kept the server + // running for another Orca (R39): then its copies may still run, and nothing here reaches them. if (!listEnvironments(userDataPath).some((env) => env.id === move.destinationEnvironmentId)) { - return 'stopped' + return wasWslHostKeptForPeers(userDataPath, move.destinationEnvironmentId) + ? 'unreachable' + : 'stopped' } const scope = wslMoveRewriteScope(wslMoveDesktopRewriteContext(userDataPath, move)) const local = store.getWorkspaceSession(LOCAL_EXECUTION_HOST_ID) @@ -148,11 +152,13 @@ export function createWslMoveUndoPorts( stopHostAutomations: async (move) => { // Inside a try: a throw here would otherwise escape past undo's spawn fence. try { - // A removed host took its automations with it. + // A removed host took its automations with it, unless another Orca keeps its server (R39). if ( !listEnvironments(userDataPath).some((env) => env.id === move.destinationEnvironmentId) ) { - return { outcome: 'stopped', restore: async () => {}, applyHere: () => () => {} } + return wasWslHostKeptForPeers(userDataPath, move.destinationEnvironmentId) + ? { outcome: 'unreachable' } + : { outcome: 'stopped', restore: async () => {}, applyHere: () => () => {} } } // E11: a Remove that could not prove the server stopped; the host is not to be touched. if (wslHostRemovalPending(userDataPath, move.manifest.source.distro)) { diff --git a/src/main/runtime/declared-desktop-clients.test.ts b/src/main/runtime/declared-desktop-clients.test.ts new file mode 100644 index 00000000000..0d5c0041f50 --- /dev/null +++ b/src/main/runtime/declared-desktop-clients.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { DeviceEntry } from './device-entry' +import { + DECLARED_CLIENT_RECONNECT_GRACE_MS, + DeclaredDesktopClients +} from './declared-desktop-clients' + +function devices(entries: Partial[]) { + const rows = entries.map((entry, index) => ({ + deviceId: `d${index}`, + name: `Desk ${index}`, + scope: 'runtime' as const, + pairedAt: 1, + lastSeenAt: 1, + ...entry + })) + return (deviceId: string) => rows.find((row) => row.deviceId === deviceId) ?? null +} + +describe('DeclaredDesktopClients', () => { + it('counts each other installation once, never the caller’s own sockets', () => { + let now = 1_000 + const clients = new DeclaredDesktopClients(() => now) + clients.declare('c-stable', 'd0', 'stable') + clients.declare('c-dev-1', 'd0', 'dev') + clients.declare('c-dev-2', 'd0', 'dev') + const getDevice = devices([{ name: 'Orca' }]) + expect( + clients.peers({ connectionId: 'c-other', clientInstanceId: 'stable' }, getDevice) + ).toEqual({ peers: [{ name: 'Orca' }] }) + // Without an id, the caller's own declared socket still identifies it. + expect(clients.peers({ connectionId: 'c-stable' }, getDevice).peers).toHaveLength(1) + clients.forget('c-dev-1') + clients.forget('c-dev-2') + now += DECLARED_CLIENT_RECONNECT_GRACE_MS + expect(clients.peers({ connectionId: 'c-stable' }, getDevice).peers).toEqual([]) + }) + + it('sanitizes names, hides startup-offer names, and skips rows that are no desktop', () => { + const clients = new DeclaredDesktopClients() + clients.declare('a', 'd0', 'one') + clients.declare('b', 'd1', 'two') + clients.declare('c', 'd2', 'three') + clients.declare('d', 'gone', 'four') + const getDevice = devices([ + { name: 'Orca\n‮Dev⁦' }, + { name: 'CLI 10/28/2026' }, + { name: 'Phone', scope: 'mobile' } + ]) + expect(clients.peers({ connectionId: 'me', clientInstanceId: 'me' }, getDevice)).toEqual({ + peers: [{ name: 'OrcaDev' }, { name: 'another Orca' }] + }) + }) + + it('keeps the Orca of a closed control socket for a reconnect grace, then drops it', () => { + let now = 1_000 + const clients = new DeclaredDesktopClients(() => now) + const getDevice = devices([{ name: 'Orca Dev' }]) + clients.declare('dev-socket', 'd0', 'dev') + clients.forget('dev-socket') + expect(clients.peers({ connectionId: 'me', clientInstanceId: 'me' }, getDevice).peers).toEqual([ + { name: 'Orca Dev' } + ]) + // A re-declare on a new socket takes over; closing it starts a fresh grace. + now += DECLARED_CLIENT_RECONNECT_GRACE_MS - 1 + clients.declare('dev-socket-2', 'd0', 'dev') + clients.forget('dev-socket-2') + now += DECLARED_CLIENT_RECONNECT_GRACE_MS - 1 + expect( + clients.peers({ connectionId: 'me', clientInstanceId: 'me' }, getDevice).peers + ).toHaveLength(1) + now += 1 + expect(clients.peers({ connectionId: 'me', clientInstanceId: 'me' }, getDevice).peers).toEqual( + [] + ) + }) + + it('starts the grace only when an Orca’s last declared socket closes', () => { + let now = 1_000 + const clients = new DeclaredDesktopClients(() => now) + const getDevice = devices([{ name: 'Orca Dev' }]) + const peers = () => + clients.peers({ connectionId: 'me', clientInstanceId: 'me' }, getDevice).peers + clients.declare('a', 'd0', 'dev') + clients.declare('b', 'd0', 'dev') + const aClosed = now + clients.forget('a') + now += 30_000 + const bClosed = now + clients.forget('b') + // Counted from b's close, not a's: still listed when a's grace would have run out. + now = aClosed + DECLARED_CLIENT_RECONNECT_GRACE_MS + expect(peers()).toHaveLength(1) + now = bClosed + DECLARED_CLIENT_RECONNECT_GRACE_MS + expect(peers()).toEqual([]) + }) + + it('sweeps expired graces on every close, even if nobody asks for peers', () => { + let now = 1_000 + const clients = new DeclaredDesktopClients(() => now) + for (let i = 0; i < 50; i += 1) { + clients.declare(`socket-${i}`, 'd0', `install-${i}`) + clients.forget(`socket-${i}`) + } + expect(clients.reconnectingCount).toBe(50) + now += DECLARED_CLIENT_RECONNECT_GRACE_MS + clients.declare('last', 'd0', 'install-last') + clients.forget('last') + expect(clients.reconnectingCount).toBe(1) + }) +}) diff --git a/src/main/runtime/declared-desktop-clients.ts b/src/main/runtime/declared-desktop-clients.ts new file mode 100644 index 00000000000..34fe1832c92 --- /dev/null +++ b/src/main/runtime/declared-desktop-clients.ts @@ -0,0 +1,98 @@ +import type { OrcadPeerDesktopsResult } from '../../shared/orcad-peer-desktops-contract' +import { sanitizeDeviceLabel } from '../../shared/device-key-proof' +import type { DeviceEntry } from './device-entry' + +const UNNAMED_DESKTOP = 'another Orca' +// Lost contact isn't proof an Orca left: a closed control socket still counts while it reconnects. +export const DECLARED_CLIENT_RECONNECT_GRACE_MS = 60_000 + +type Declaration = { clientInstanceId: string; deviceId: string } + +/** + * R39: which desktop installation each authenticated control socket belongs to. A desktop declares + * on its control socket after auth (and after every reconnect); the tag goes with that socket. + * Desktops sharing one token row stay distinct, and a desktop's own other sockets never count. + */ +export class DeclaredDesktopClients { + private readonly byConnection = new Map() + // Installations whose last declared socket closed recently, by id, until their grace ends. + private readonly reconnecting = new Map() + + // Monotonic: a wall-clock jump can't stretch or cut the grace. + constructor(private readonly now: () => number = () => performance.now()) {} + + declare(connectionId: string, deviceId: string, clientInstanceId: string): void { + this.byConnection.set(connectionId, { clientInstanceId, deviceId }) + this.reconnecting.delete(clientInstanceId) + } + + forget(connectionId: string): void { + this.sweepExpired() + const declaration = this.byConnection.get(connectionId) + this.byConnection.delete(connectionId) + if ( + declaration && + ![...this.byConnection.values()].some( + (other) => other.clientInstanceId === declaration.clientInstanceId + ) + ) { + this.reconnecting.set(declaration.clientInstanceId, { + deviceId: declaration.deviceId, + until: this.now() + DECLARED_CLIENT_RECONNECT_GRACE_MS + }) + } + } + + peers( + caller: { connectionId: string; clientInstanceId?: string }, + getDevice: (deviceId: string) => DeviceEntry | null + ): OrcadPeerDesktopsResult { + const callerInstance = + caller.clientInstanceId ?? this.byConnection.get(caller.connectionId)?.clientInstanceId + const names = new Map() + this.sweepExpired() + const candidates: [string | null, Declaration][] = [ + ...this.byConnection, + ...[...this.reconnecting].map(([clientInstanceId, { deviceId }]): [null, Declaration] => [ + null, + { clientInstanceId, deviceId } + ]) + ] + for (const [connectionId, declaration] of candidates) { + if ( + connectionId === caller.connectionId || + declaration.clientInstanceId === callerInstance || + names.has(declaration.clientInstanceId) + ) { + continue + } + const device = getDevice(declaration.deviceId) + // A revoked, phone or browser-session row is no desktop. + if (device?.scope !== 'runtime' || device.ephemeral || device.parentDeviceId !== undefined) { + continue + } + names.set(declaration.clientInstanceId, displayName(device.name)) + } + return { peers: [...names.values()].map((name) => ({ name })) } + } + + /** Bounded even if nothing asks for peers: every close drops expired graces first. */ + get reconnectingCount(): number { + return this.reconnecting.size + } + + private sweepExpired(): void { + const now = this.now() + for (const [clientInstanceId, { until }] of this.reconnecting) { + if (until <= now) { + this.reconnecting.delete(clientInstanceId) + } + } + } +} + +// Startup offers are named "CLI ", which says nothing to a person. +function displayName(raw: string): string { + const name = sanitizeDeviceLabel(raw, UNNAMED_DESKTOP) + return /^CLI \d/.test(name) ? UNNAMED_DESKTOP : name +} diff --git a/src/main/runtime/peer-desktops.integration.test.ts b/src/main/runtime/peer-desktops.integration.test.ts new file mode 100644 index 00000000000..9a3b869463f --- /dev/null +++ b/src/main/runtime/peer-desktops.integration.test.ts @@ -0,0 +1,161 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' +import { encodePairingOffer, parsePairingCode, type PairingOffer } from '../../shared/pairing' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime/remote-runtime-client' +import { + ORCAD_DECLARE_CLIENT_METHOD, + ORCAD_PEER_DESKTOPS_METHOD, + type OrcadPeerDesktopsResult +} from '../../shared/orcad-peer-desktops-contract' +import { ORCAD_PEER_DESKTOPS_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { + authenticateMobileWsSession, + createEncryptedWsResponseReader, + sendEncryptedWsRequest, + waitForWsClose, + type AuthenticatedMobileWs +} from './runtime-rpc-mobile-ws-test-harness' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' +import { partialRuntimeStore } from './runtime-store-partial-fixture' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +const STABLE = 'stable-installation-id' +const DEV = 'dev-installation-id-01' + +describe(ORCAD_PEER_DESKTOPS_METHOD, () => { + const cleanups: (() => Promise | void)[] = [] + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup() + } + }) + + async function startServer(): Promise { + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(partialRuntimeStore(makeStore())), + userDataPath: mkdtempSync(join(tmpdir(), 'orca-peer-desktops-')), + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + cleanups.push(() => server.stop()) + return server + } + + function pair(server: OrcaRuntimeRpcServer, name: string, scope: 'runtime' | 'mobile') { + const offer = server.createPairingOffer({ address: '127.0.0.1', name, scope }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + return parsePairingCode(offer.pairingUrl)! + } + + /** A one-shot socket, as the desktop's Remove uses. */ + function oneShot(offer: PairingOffer, method: string, params?: unknown) { + return sendRemoteRuntimeRequest( + pairedRuntimeConnectionFromOffer(offer), + method, + params, + 5_000 + ) + } + + /** A long-lived socket; a control socket declares its installation on it. */ + async function socket(offer: PairingOffer) { + const session: AuthenticatedMobileWs = await authenticateMobileWsSession( + encodePairingOffer(offer) + ) + const reader = createEncryptedWsResponseReader(session) + cleanups.push(() => { + reader.dispose() + session.ws.close() + }) + let next = 0 + const request = async (method: string, params?: unknown) => { + const id = `r${(next += 1)}` + sendEncryptedWsRequest(session, { id, method, params }) + return reader.next(id) + } + return { session, request } + } + + async function peerNames(offer: PairingOffer, clientInstanceId: string): Promise { + const answer = await oneShot(offer, ORCAD_PEER_DESKTOPS_METHOD, { + clientInstanceId + }) + if (!answer.ok) { + throw new Error(answer.error.message) + } + return answer.result.peers.map((peer) => peer.name) + } + + it('advertises the check, and a desktop with control, multiplex and one-shot sockets sees no peers', async () => { + const server = await startServer() + const startup = pair(server, 'CLI 10/28/2026', 'runtime') + const status = await oneShot(startup, 'status.get') + expect(status.ok && status.result.capabilities).toContain( + ORCAD_PEER_DESKTOPS_RUNTIME_CAPABILITY + ) + const control = await socket(startup) + expect( + await control.request(ORCAD_DECLARE_CLIENT_METHOD, { clientInstanceId: STABLE }) + ).toMatchObject({ ok: true }) + // An undeclared second socket (as a terminal multiplex) and one-shot calls are this same desktop. + await (await socket(startup)).request('status.get') + expect(await peerNames(startup, STABLE)).toEqual([]) + }) + + it('tells two desktops sharing one startup token apart, and keeps one through a reconnect', async () => { + const server = await startServer() + const startup = pair(server, 'CLI 10/28/2026', 'runtime') + const stable = await socket(startup) + await stable.request(ORCAD_DECLARE_CLIENT_METHOD, { clientInstanceId: STABLE }) + const dev = await socket(startup) + await dev.request(ORCAD_DECLARE_CLIENT_METHOD, { clientInstanceId: DEV }) + // The startup row's "CLI " name says nothing to a person. + expect(await peerNames(startup, STABLE)).toEqual(['another Orca']) + expect(await peerNames(startup, DEV)).toEqual(['another Orca']) + + // Dev's control socket closes: lost contact isn't proof it left, so it still counts while it + // reconnects (the grace's expiry is unit-tested with a clock). + // Read only after the server itself dropped the socket, so this proves the grace, not a race. + const declared = () => server['declaredDesktopClients']['byConnection'].size + expect(declared()).toBe(2) + const closed = waitForWsClose(dev.session.ws) + dev.session.ws.close() + await closed + await expect.poll(declared).toBe(1) + expect(await peerNames(startup, STABLE)).toEqual(['another Orca']) + + // A reconnect declares again. + const devAgain = await socket(startup) + await devAgain.request(ORCAD_DECLARE_CLIENT_METHOD, { clientInstanceId: DEV }) + expect(await peerNames(startup, STABLE)).toEqual(['another Orca']) + }) + + it('sanitizes a peer name and ignores phones', async () => { + const server = await startServer() + const stable = pair(server, 'Orca', 'runtime') + await (await socket(stable)).request(ORCAD_DECLARE_CLIENT_METHOD, { clientInstanceId: STABLE }) + const dev = pair(server, 'Orca‮\nDev​', 'runtime') + await (await socket(dev)).request(ORCAD_DECLARE_CLIENT_METHOD, { clientInstanceId: DEV }) + expect(await peerNames(stable, STABLE)).toEqual(['OrcaDev']) + + const phone = await socket(pair(server, 'Phone', 'mobile')) + const refused = await phone.request(ORCAD_DECLARE_CLIENT_METHOD, { + clientInstanceId: 'phone-installation' + }) + expect(refused.ok).not.toBe(true) + expect(await peerNames(stable, STABLE)).toEqual(['OrcaDev']) + }) +}) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index f09e866f7ba..87124c250bb 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -20,6 +20,7 @@ import type { import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import type { RpcCallerIdentity } from './rpc-caller-identity' +import type { OrcadPeerDesktopsResult } from '../../../shared/orcad-peer-desktops-contract' import type { RpcBinarySender } from './rpc-binary-sender' import type { RpcMethodPermission } from './rpc-method-permission' import type { @@ -143,6 +144,12 @@ export type RpcContext = { list(): MobileRelayHostsListResult worktrees(hostId: string): Promise } + // Authenticated runtime devices see which other desktops share this server. + peerDesktops?: { + list(callerClientInstanceId?: string): OrcadPeerDesktopsResult + /** Tags this socket with the caller's installation until it closes. */ + declare(clientInstanceId: string): void + } // Authenticated runtime devices may manage only their own delegated phones. delegatedMobileDevices?: { sync(phones: readonly DelegatedPhone[]): DelegatedMobileDeviceSyncResult diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index b79d87f9512..bf454638ae1 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -20,6 +20,7 @@ export type RpcDispatchStreamingOptions = Pick< | 'pairing' | 'deviceSelf' | 'mobileRelayHosts' + | 'peerDesktops' | 'delegatedMobileDevices' | 'sendBinary' | 'registerBinaryStreamHandler' @@ -60,6 +61,7 @@ export function rpcContextFromTransport( pairing: options?.pairing, deviceSelf: options?.deviceSelf, mobileRelayHosts: options?.mobileRelayHosts, + peerDesktops: options?.peerDesktops, delegatedMobileDevices: options?.delegatedMobileDevices, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts index cd96c5c02bd..10b8982d644 100644 --- a/src/main/runtime/rpc/methods/pairing.ts +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -3,6 +3,12 @@ import { DELEGATED_MOBILE_DEVICE_SYNC_METHOD, DelegatedMobileDeviceSyncParamsSchema } from '../../../../shared/delegated-mobile-device-contract' +import { + ORCAD_DECLARE_CLIENT_METHOD, + ORCAD_PEER_DESKTOPS_METHOD, + OrcadDeclareClientParamsSchema, + OrcadPeerDesktopsParamsSchema +} from '../../../../shared/orcad-peer-desktops-contract' import { RegisterDeviceKeyParamsSchema } from '../../../../shared/device-key-registration-contract' import { REGISTER_DEVICE_KEY_METHOD } from '../../../../shared/device-key-capability' import { @@ -44,6 +50,30 @@ export const PAIRING_METHODS = [ return ctx.deviceSelf.registerDeviceKey(params) } }), + defineMethod({ + name: ORCAD_PEER_DESKTOPS_METHOD, + // Paired desktops lack pairing-admin; they learn only other desktops' names, never credentials. + permission: 'workspace', + params: OrcadPeerDesktopsParamsSchema, + handler: (params, ctx) => { + if (!ctx.peerDesktops) { + throw new Error('runtime_device_required') + } + return ctx.peerDesktops.list(params.clientInstanceId) + } + }), + defineMethod({ + name: ORCAD_DECLARE_CLIENT_METHOD, + permission: 'workspace', + params: OrcadDeclareClientParamsSchema, + handler: (params, ctx) => { + if (!ctx.peerDesktops) { + throw new Error('runtime_device_required') + } + ctx.peerDesktops.declare(params.clientInstanceId) + return {} + } + }), defineMethod({ name: DELEGATED_MOBILE_DEVICE_SYNC_METHOD, // Paired desktops lack pairing-admin but must manage their children within rpc-caller-scope. diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index df140e110c2..df8e1bbca04 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -271,6 +271,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { this.runtime.cleanupSubscriptionsForConnection(socket.connectionId) this.runtime.cancelMobileDictationForConnection(socket.connectionId) this.binaryMessageRouter.deleteConnection(socket.connectionId) + this.declaredDesktopClients.forget(socket.connectionId) if (!hasOtherConnections) { this.runtime.onClientDisconnected(socket.device.clientId) } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index 2cddce4666b..7a4c960ed80 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -31,6 +31,7 @@ import type { PairingOfferUnavailable } from './runtime-rpc-pairing-types' import { DEFAULT_WS_PORT } from './runtime-rpc-pairing-types' +import { DeclaredDesktopClients } from '../declared-desktop-clients' export class RuntimeRpcState { protected readonly runtime: OrcaRuntimeService @@ -76,6 +77,7 @@ export class RuntimeRpcState { protected mobileDesktopRelay: MobileDesktopRelay | null = null protected mobileRelayHostCatalog: MobileRelayHostCatalog | null = null protected detachPairedServerRows: (() => void) | null = null + protected readonly declaredDesktopClients = new DeclaredDesktopClients() // Why: lets an automatic mint install a provider whose launch-time construction failed. protected mobileRelayPairingProviderInstaller: (() => Promise) | null = null protected mobileRelayPairingOfferQueue: Promise = Promise.resolve() diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 887fbd9221e..44b631f6d3b 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -254,6 +254,23 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices { } : undefined, mobileRelayHosts: this.mobileRelayHostCatalog ?? undefined, + // Only an authenticated desktop socket can say which installation it is. + peerDesktops: + device.scope === 'runtime' && authenticatedSocket + ? { + list: (clientInstanceId) => + this.declaredDesktopClients.peers( + { connectionId: authenticatedSocket.connectionId, clientInstanceId }, + (deviceId) => this.deviceRegistry?.getDevice(deviceId) ?? null + ), + declare: (clientInstanceId) => + this.declaredDesktopClients.declare( + authenticatedSocket.connectionId, + device.deviceId, + clientInstanceId + ) + } + : undefined, delegatedMobileDevices: device.scope === 'runtime' ? { sync: (phones) => this.syncDelegatedMobileDevices(device.deviceId, phones) } diff --git a/src/main/wsl/wsl-host-client-declaration.test.ts b/src/main/wsl/wsl-host-client-declaration.test.ts new file mode 100644 index 00000000000..c05144fc7ea --- /dev/null +++ b/src/main/wsl/wsl-host-client-declaration.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./wsl-host-client-instance', () => ({ + wslHostClientInstanceId: () => 'this-installation' +})) + +import { ORCAD_DECLARE_CLIENT_METHOD } from '../../shared/orcad-peer-desktops-contract' +import { declareWslHostClient } from './wsl-host-client-declaration' + +describe('declareWslHostClient', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('declares this installation on a WSL host’s control socket', async () => { + const request = vi.fn(async () => ({ + id: 'x', + ok: true as const, + result: {}, + _meta: { runtimeId: 'r' } + })) + declareWslHostClient('wsl:ubuntu', request, () => true) + await vi.waitFor(() => + expect(request).toHaveBeenCalledWith( + ORCAD_DECLARE_CLIENT_METHOD, + { clientInstanceId: 'this-installation' }, + expect.any(Number) + ) + ) + }) + + it('declares nothing to any other server', () => { + const request = vi.fn() + declareWslHostClient('ssh-managed', request, () => false) + expect(request).not.toHaveBeenCalled() + }) + + it('takes an older server’s missing method quietly', async () => { + const request = vi.fn(async () => ({ + id: 'x', + ok: false as const, + error: { code: 'method_not_found', message: 'nope' }, + _meta: { runtimeId: 'r' } + })) + declareWslHostClient('wsl:ubuntu', request, () => true) + await vi.waitFor(() => expect(request).toHaveBeenCalled()) + expect(console.warn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl/wsl-host-client-declaration.ts b/src/main/wsl/wsl-host-client-declaration.ts new file mode 100644 index 00000000000..72a23a17af4 --- /dev/null +++ b/src/main/wsl/wsl-host-client-declaration.ts @@ -0,0 +1,48 @@ +/** R39: each ready control socket to a WSL host says which Orca installation it belongs to. */ +import { getAppEnvironment } from '../../shared/app-environment' +import { ORCAD_DECLARE_CLIENT_METHOD } from '../../shared/orcad-peer-desktops-contract' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { wslHostClientInstanceId } from './wsl-host-client-instance' + +const DECLARE_TIMEOUT_MS = 10_000 + +type ControlRequest = ( + method: string, + params: unknown, + timeoutMs: number +) => Promise> + +/** Sent on the control socket itself, so the server ties the tag to exactly that socket. */ +export function declareWslHostClient( + environmentId: string, + request: ControlRequest, + isWslHost: (environmentId: string) => boolean = isWslHostEnvironment +): void { + if (!isWslHost(environmentId)) { + return + } + void request( + ORCAD_DECLARE_CLIENT_METHOD, + { clientInstanceId: wslHostClientInstanceId() }, + DECLARE_TIMEOUT_MS + ) + .then((response) => { + // An older server lacks the method; it then has no peer check, so nothing reads the tag. + if (!response.ok && response.error.code !== 'method_not_found') { + console.warn('[wsl-hosts] The WSL host did not note this Orca:', response.error.message) + } + }) + .catch(() => { + // A socket that dropped before the answer declares again on its next ready. + }) +} + +function isWslHostEnvironment(environmentId: string): boolean { + try { + const environment = resolveEnvironment(getAppEnvironment().getPath('userData'), environmentId) + return environment.orcadDeployment?.kind === 'wsl' + } catch { + return false + } +} diff --git a/src/main/wsl/wsl-host-client-instance.ts b/src/main/wsl/wsl-host-client-instance.ts new file mode 100644 index 00000000000..46ae2fed25c --- /dev/null +++ b/src/main/wsl/wsl-host-client-instance.ts @@ -0,0 +1,14 @@ +/** R39: names this Orca installation to a WSL host's server, so desktops sharing one server tell apart. */ +import { createHash } from 'node:crypto' +import { getAppEnvironment } from '../../shared/app-environment' + +let cached: string | null = null + +/** Stable per userData folder, so stable and dev Orca on one account differ; reveals no path. */ +export function wslHostClientInstanceId(): string { + cached ??= createHash('sha256') + .update(getAppEnvironment().getPath('userData')) + .digest('base64url') + .slice(0, 32) + return cached +} diff --git a/src/main/wsl/wsl-host-kept-for-peers.test.ts b/src/main/wsl/wsl-host-kept-for-peers.test.ts new file mode 100644 index 00000000000..c7958154b3a --- /dev/null +++ b/src/main/wsl/wsl-host-kept-for-peers.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store' +import type { WslHostServerStop } from '../../shared/wsl-host-types' + +const store = vi.hoisted(() => ({ removed: new Array(), pending: new Array() })) +vi.mock('../../shared/runtime-environment-wsl-host-store', () => ({ + removeWslHostEnvironment: (_userData: string, environmentId: string) => { + store.removed.push(environmentId) + }, + setWslHostPendingStop: (_userData: string, environmentId: string) => { + store.pending.push(environmentId) + } +})) +vi.mock('./wsl-host-user-actions', () => ({ noteWslHostUserAction: () => {} })) + +import { + forgetWslHostKeptForPeers, + recordWslHostKeptForPeers, + wasWslHostKeptForPeers +} from './wsl-host-kept-for-peers' +import { removeWslHostServer } from './wsl-host-removal' +import type { WslHostConnector } from './wsl-host-service' + +const RECORD: WslHostDeploymentRecord = { + kind: 'wsl', + environmentId: 'wsl:ubuntu', + distro: 'Ubuntu', + distroId: '00000000-0000-4000-8000-000000000001', + localPort: 42000, + remotePort: 6768, + createdAt: 1 +} + +function connector(serverStop: WslHostServerStop): WslHostConnector { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: removeWslHostServer calls only these four members. + return { + resolveDistroName: async () => 'Ubuntu', + stopServer: async () => serverStop, + disconnect: async () => {}, + releaseHolder: async () => {} + } as Partial as WslHostConnector +} + +describe('a WSL host kept for another Orca', () => { + let userData: string + beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-wsl-kept-')) + store.removed = [] + store.pending = [] + }) + afterEach(() => rmSync(userData, { recursive: true, force: true })) + + it('is remembered until a later Remove proves its server stopped', () => { + expect(wasWslHostKeptForPeers(userData, 'wsl:ubuntu')).toBe(false) + recordWslHostKeptForPeers(userData, 'wsl:ubuntu') + recordWslHostKeptForPeers(userData, 'wsl:ubuntu') + expect(wasWslHostKeptForPeers(userData, 'wsl:ubuntu')).toBe(true) + forgetWslHostKeptForPeers(userData, 'wsl:ubuntu') + expect(wasWslHostKeptForPeers(userData, 'wsl:ubuntu')).toBe(false) + }) + + it('is recorded by a Remove that kept the server, and cleared by one that stopped it', async () => { + await removeWslHostServer( + userData, + connector({ stopped: true, keptForOtherDesktops: ['Orca Dev'] }), + RECORD + ) + expect(store.removed).toEqual(['wsl:ubuntu']) + expect(wasWslHostKeptForPeers(userData, 'wsl:ubuntu')).toBe(true) + + await removeWslHostServer(userData, connector({ stopped: true }), RECORD) + expect(wasWslHostKeptForPeers(userData, 'wsl:ubuntu')).toBe(false) + }) + + it('keeps the host listed when the record cannot be saved', async () => { + // A directory where the file should be: the write fails. + const blocked = join(userData, 'blocked') + rmSync(blocked, { force: true }) + const { mkdirSync } = await import('node:fs') + mkdirSync(join(blocked, 'orca-wsl-hosts-kept-for-peers.json'), { recursive: true }) + const removal = await removeWslHostServer( + blocked, + connector({ stopped: true, keptForOtherDesktops: ['Orca Dev'] }), + RECORD + ) + expect(removal).toMatchObject({ + removed: false, + serverStop: { stopped: false, verdict: 'live' } + }) + expect(store.pending).toEqual(['wsl:ubuntu']) + }) +}) diff --git a/src/main/wsl/wsl-host-kept-for-peers.ts b/src/main/wsl/wsl-host-kept-for-peers.ts new file mode 100644 index 00000000000..296327f906b --- /dev/null +++ b/src/main/wsl/wsl-host-kept-for-peers.ts @@ -0,0 +1,61 @@ +/** + * R39: a Remove that kept a WSL host's server for another Orca leaves that server, and anything a + * move put on it, running. Undo of such a move must not read the removed host as stopped. + */ +import { join } from 'node:path' +import { z } from 'zod' +import { writeSecureJsonFileWithinLimit } from '../../shared/bounded-secure-json-file' +import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' + +const KEPT_FILE = 'orca-wsl-hosts-kept-for-peers.json' +const MAX_KEPT_FILE_BYTES = 64 * 1024 + +const KeptListSchema = z.object({ + version: z.literal(1), + environmentIds: z.array(z.string().min(1).max(256)) +}) + +function keptFile(userDataPath: string): string { + return join(userDataPath, KEPT_FILE) +} + +function readKeptList(userDataPath: string): string[] { + try { + const { buffer } = readNodeFileSyncWithinLimit(keptFile(userDataPath), MAX_KEPT_FILE_BYTES) + return KeptListSchema.parse(JSON.parse(buffer.toString('utf8'))).environmentIds + } catch { + return [] + } +} + +/** Throws when it can't be saved, so the caller doesn't report a plain stop. */ +export function recordWslHostKeptForPeers(userDataPath: string, environmentId: string): void { + const current = readKeptList(userDataPath) + if (current.includes(environmentId)) { + return + } + writeSecureJsonFileWithinLimit( + keptFile(userDataPath), + { version: 1, environmentIds: [...current, environmentId] }, + MAX_KEPT_FILE_BYTES, + { durable: true } + ) +} + +export function wasWslHostKeptForPeers(userDataPath: string, environmentId: string): boolean { + return readKeptList(userDataPath).includes(environmentId) +} + +/** A later Remove proved the server stopped: nothing of the host runs on. */ +export function forgetWslHostKeptForPeers(userDataPath: string, environmentId: string): void { + const current = readKeptList(userDataPath) + if (!current.includes(environmentId)) { + return + } + writeSecureJsonFileWithinLimit( + keptFile(userDataPath), + { version: 1, environmentIds: current.filter((id) => id !== environmentId) }, + MAX_KEPT_FILE_BYTES, + { durable: true } + ) +} diff --git a/src/main/wsl/wsl-host-peer-desktops.test.ts b/src/main/wsl/wsl-host-peer-desktops.test.ts new file mode 100644 index 00000000000..b52c3c3ced4 --- /dev/null +++ b/src/main/wsl/wsl-host-peer-desktops.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest' +import { ORCAD_PEER_DESKTOPS_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities' +import { ORCAD_PEER_DESKTOPS_METHOD } from '../../shared/orcad-peer-desktops-contract' +import { readWslHostPeerDesktops } from './wsl-host-peer-desktops' + +function server(peers: unknown, capabilities = [ORCAD_PEER_DESKTOPS_RUNTIME_CAPABILITY]) { + return vi.fn(async (method: string) => { + if (method === 'status.get') { + return { runtimeId: 'r', capabilities } + } + if (method === ORCAD_PEER_DESKTOPS_METHOD) { + return { peers } + } + throw new Error(`unexpected ${method}`) + }) +} + +describe('readWslHostPeerDesktops', () => { + it('names the other Orcas connected now, asking as this installation', async () => { + const call = server([{ name: 'Orca Dev' }]) + await expect(readWslHostPeerDesktops(call, 'this-installation')).resolves.toEqual({ + kind: 'shared', + names: ['Orca Dev'] + }) + expect(call).toHaveBeenCalledWith(ORCAD_PEER_DESKTOPS_METHOD, { + clientInstanceId: 'this-installation' + }) + }) + + it('lets the teardown proceed when no other Orca is connected', async () => { + await expect(readWslHostPeerDesktops(server([]), 'this-installation')).resolves.toEqual({ + kind: 'none' + }) + }) + + it('reports an older server without asking it a method it lacks', async () => { + const call = server([], []) + await expect(readWslHostPeerDesktops(call, 'this-installation')).resolves.toEqual({ + kind: 'unsupported' + }) + expect(call).toHaveBeenCalledTimes(1) + }) + + it('throws on an answer it cannot read, so the teardown is refused', async () => { + await expect(readWslHostPeerDesktops(server('nope'), 'this-installation')).rejects.toThrow() + const failing = vi.fn(async () => { + throw new Error('timeout') + }) + await expect(readWslHostPeerDesktops(failing, 'this-installation')).rejects.toThrow('timeout') + }) +}) diff --git a/src/main/wsl/wsl-host-peer-desktops.ts b/src/main/wsl/wsl-host-peer-desktops.ts new file mode 100644 index 00000000000..4d920008aa6 --- /dev/null +++ b/src/main/wsl/wsl-host-peer-desktops.ts @@ -0,0 +1,36 @@ +/** R39: a WSL host's server lives in the Linux login's ~/.orca, so another Orca on this computer may share it. */ +import { z } from 'zod' +import { ORCAD_PEER_DESKTOPS_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities' +import { + ORCAD_PEER_DESKTOPS_METHOD, + OrcadPeerDesktopsResultSchema +} from '../../shared/orcad-peer-desktops-contract' +import { wslHostClientInstanceId } from './wsl-host-client-instance' + +export type WslHostPeerDesktops = + | { kind: 'none' } + | { kind: 'shared'; names: string[] } + /** An older server can't say: Remove keeps today's teardown. */ + | { kind: 'unsupported' } + +const StatusCapabilitiesSchema = z.looseObject({ capabilities: z.array(z.string()).optional() }) + +/** + * Another Orca counts only while its control socket is open on the server right now; a closed one + * isn't protected. Throws when the server can't answer, so the caller refuses the teardown. + */ +export async function readWslHostPeerDesktops( + call: (method: string, params: unknown) => Promise, + clientInstanceId = wslHostClientInstanceId() +): Promise { + const status = StatusCapabilitiesSchema.parse(await call('status.get', undefined)) + if (!status.capabilities?.includes(ORCAD_PEER_DESKTOPS_RUNTIME_CAPABILITY)) { + return { kind: 'unsupported' } + } + const { peers } = OrcadPeerDesktopsResultSchema.parse( + await call(ORCAD_PEER_DESKTOPS_METHOD, { clientInstanceId }) + ) + return peers.length > 0 + ? { kind: 'shared', names: peers.map((peer) => peer.name) } + : { kind: 'none' } +} diff --git a/src/main/wsl/wsl-host-removal.ts b/src/main/wsl/wsl-host-removal.ts index 4c5061e0fd1..1b44b291d36 100644 --- a/src/main/wsl/wsl-host-removal.ts +++ b/src/main/wsl/wsl-host-removal.ts @@ -14,6 +14,7 @@ import { admitWslHostDistro, wslHostRemovalPendingMessage } from './wsl-host-add import type { WslHostDistroCandidate } from './wsl-host-distro-list' import type { WslHostConnector } from './wsl-host-service' import { noteWslHostUserAction } from './wsl-host-user-actions' +import { forgetWslHostKeptForPeers, recordWslHostKeptForPeers } from './wsl-host-kept-for-peers' export type WslHostRemoval = { serverStop: WslHostServerStop; removed: boolean } @@ -43,6 +44,7 @@ export async function removeWslHostServer( // Without its holder the distro idles out, pending or not. await connector.releaseHolder(record.distro) } + serverStop = noteServerKeptForPeers(userDataPath, record.environmentId, serverStop) if (serverStop.stopped) { removeWslHostEnvironment(userDataPath, record.environmentId) // Every Remove (sidebar or Settings) is the user's: the move orchestrator never removes a @@ -58,6 +60,32 @@ export async function removeWslHostServer( return { serverStop: { ...serverStop, kept: true }, removed: false } } +/** R39: undo of a move onto this host must not read a server kept for another Orca as stopped. */ +function noteServerKeptForPeers( + userDataPath: string, + environmentId: string, + serverStop: WslHostServerStop +): WslHostServerStop { + if (!serverStop.stopped) { + return serverStop + } + try { + if (serverStop.keptForOtherDesktops) { + recordWslHostKeptForPeers(userDataPath, environmentId) + } else { + forgetWslHostKeptForPeers(userDataPath, environmentId) + } + return serverStop + } catch (error) { + // Unrecorded, a later undo would take the kept server for stopped; keep the host listed instead. + return { + stopped: false, + verdict: serverStop.keptForOtherDesktops ? 'live' : 'unverifiable', + reason: `Orca couldn’t record what this Remove left running: ${errorMessage(error)}` + } + } +} + /** Adding a pending host again keeps it, under the same stopped-distro rule as any add. */ export async function readdPendingWslHost( args: { distro: string; startIfStopped?: boolean }, diff --git a/src/main/wsl/wsl-host-server-stop.test.ts b/src/main/wsl/wsl-host-server-stop.test.ts index 412f4060fb1..53cffa835dc 100644 --- a/src/main/wsl/wsl-host-server-stop.test.ts +++ b/src/main/wsl/wsl-host-server-stop.test.ts @@ -7,6 +7,7 @@ import { type WslHostRuntimeAccess, type WslHostServerStopSteps } from './wsl-host-server-stop' +import type { WslHostPeerDesktops } from './wsl-host-peer-desktops' function census(liveSessions: number | null): OrcadTerminalCensus { return { liveSessions, startedSinceActivation: liveSessions, daemonProtocolVersion: 1 } @@ -21,6 +22,7 @@ const RETIRED: OrcadDecommissionResult = { function steps(overrides: Partial = {}) { const order: string[] = [] const all: WslHostServerStopSteps = { + peerDesktops: vi.fn(async (): Promise => ({ kind: 'none' })), endTerminals: vi.fn(async () => { order.push('endTerminals') return [] @@ -60,6 +62,45 @@ describe('stopWslHostServer', () => { expect(all.decommission).toHaveBeenCalledWith(census(0), 'runtime-a') }) + it('keeps a server another Orca shares: only this desktop lets go, nothing ends', async () => { + const { all, order } = steps({ + peerDesktops: vi.fn(async (): Promise => ({ + kind: 'shared', + names: ['Orca Dev'] + })) + }) + await expect(stopWslHostServer(all)).resolves.toEqual({ + stopped: true, + keptForOtherDesktops: ['Orca Dev'] + }) + expect(order).toEqual(['closeBridge']) + }) + + it('refuses the teardown when the server cannot say who else uses it', async () => { + const { all, order } = steps({ + peerDesktops: vi.fn(async () => { + throw new Error('runtime_device_required') + }) + }) + await expect(stopWslHostServer(all)).resolves.toMatchObject({ + stopped: false, + verdict: 'unverifiable', + reason: expect.stringContaining('another Orca on this computer') + }) + expect(order).toEqual(['closeBridge']) + }) + + it("keeps today's teardown, with a warning, for a server that predates the check", async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { all, order } = steps({ + peerDesktops: vi.fn(async (): Promise => ({ kind: 'unsupported' })) + }) + await expect(stopWslHostServer(all)).resolves.toEqual({ stopped: true }) + expect(order).toContain('decommission') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('predates the shared-host check')) + warn.mockRestore() + }) + it('names each terminal that refused, and still asks the server to stop', async () => { const { all } = steps({ endTerminals: vi.fn(async () => [ diff --git a/src/main/wsl/wsl-host-server-stop.ts b/src/main/wsl/wsl-host-server-stop.ts index ef4e0abf0b7..e5c013e75bb 100644 --- a/src/main/wsl/wsl-host-server-stop.ts +++ b/src/main/wsl/wsl-host-server-stop.ts @@ -12,6 +12,7 @@ import { managedOrcadSlot } from '../managed-server/lifecycle/orcad-managed-runt import { resolveOrcadRemoteContext } from '../managed-server/install/orcad-remote-context' import { decommissionRemoteOrcad } from '../managed-server/install/orcad-remote-decommission' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' +import { readWslHostPeerDesktops, type WslHostPeerDesktops } from './wsl-host-peer-desktops' const CENSUS_ATTEMPTS = 20 const CENSUS_INTERVAL_MS = 250 @@ -20,6 +21,8 @@ const CENSUS_INTERVAL_MS = 250 export type WslHostTerminalLeftover = { handle: string; worktreeId: string; reason: string } export type WslHostServerStopSteps = { + /** Which other desktops share this server; throws when it can't say. */ + peerDesktops: () => Promise /** Closes every terminal orcad runs; returns the ones that refused instead of stopping. */ endTerminals: () => Promise census: () => Promise @@ -45,6 +48,27 @@ export async function stopWslHostServer(steps: WslHostServerStopSteps): Promise< const problems: string[] = [] // Anything proven still running makes the stop `live`; only doubt leaves it `unverifiable`. let provenLive = false + // Another Orca using this server keeps it: only this desktop lets go (R39). + let peers: WslHostPeerDesktops + try { + peers = await steps.peerDesktops() + } catch (error) { + await steps.closeBridge() + return { + stopped: false, + verdict: 'unverifiable', + reason: `Orca couldn’t ask the WSL host whether another Orca on this computer still uses it: ${errorMessage(error)}` + } + } + if (peers.kind === 'shared') { + await steps.closeBridge() + return { stopped: true, keptForOtherDesktops: peers.names } + } + if (peers.kind === 'unsupported') { + console.warn( + '[wsl-hosts] This WSL host’s server predates the shared-host check; removing it ends its terminals for every Orca using it.' + ) + } let leftovers: WslHostTerminalLeftover[] = [] try { leftovers = await steps.endTerminals() @@ -111,8 +135,10 @@ const RuntimeIdSchema = z.object({ runtimeId: z.string().min(1) }) export function wslHostRuntimeStopSteps( access: WslHostRuntimeAccess, environmentId: string -): Pick { +): Pick { return { + peerDesktops: () => + readWslHostPeerDesktops((method, params) => access.call(environmentId, method, params)), endTerminals: async () => { const list = TerminalHandlesSchema.parse( await access.call(environmentId, 'terminal.list', { limit: 1000 }) diff --git a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx index b735d323962..77524cf5608 100644 --- a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx +++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx @@ -182,8 +182,8 @@ export function HostRemoveDialog({ const description = target.kind === 'wsl' ? translate( - 'auto.components.sidebar.HostRemoveDialog.wslHostEndsTerminals', - 'Removes {{value0}} from Orca, ends its open terminals and stops Orca’s server in that distro. Files in the distro are untouched.', + 'auto.components.sidebar.HostRemoveDialog.wslHostEndsTerminalsUnlessShared', + 'Removes {{value0}} from Orca. Unless another Orca that’s open on this computer is using it, this also ends its open terminals and stops Orca’s server in that distro. Files in the distro are untouched.', { value0: label } ) : target.kind === 'runtime' diff --git a/src/renderer/src/components/sidebar/wsl-host-removal.test.ts b/src/renderer/src/components/sidebar/wsl-host-removal.test.ts index f6bb1ca6f06..e7a0d05ade6 100644 --- a/src/renderer/src/components/sidebar/wsl-host-removal.test.ts +++ b/src/renderer/src/components/sidebar/wsl-host-removal.test.ts @@ -7,13 +7,14 @@ const mocks = vi.hoisted(() => { } return { warning: vi.fn(), + info: vi.fn(), setRuntimeEnvironments: vi.fn(), setActiveWorktree: vi.fn(), workspace } }) -vi.mock('sonner', () => ({ toast: { warning: mocks.warning } })) +vi.mock('sonner', () => ({ toast: { warning: mocks.warning, info: mocks.info } })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ @@ -44,12 +45,30 @@ afterEach(() => { describe('removeWslHost', () => { beforeEach(() => { mocks.warning.mockReset() + mocks.info.mockReset() mocks.setRuntimeEnvironments.mockReset() mocks.setActiveWorktree.mockReset() mocks.workspace.activeWorktreeId = null mocks.workspace.ownerHostId = null }) + it('says the host keeps running when another Orca on this computer shares it', async () => { + stubApi({ stopped: true, keptForOtherDesktops: ['Orca Dev'] }) + await removeWslHost('wsl:ubuntu') + expect(mocks.info).toHaveBeenCalledWith( + 'Removed from this Orca. The host keeps running for Orca Dev.' + ) + expect(mocks.warning).not.toHaveBeenCalled() + }) + + it('names several unnamed peers once', async () => { + stubApi({ stopped: true, keptForOtherDesktops: ['another Orca', 'another Orca', 'Orca Dev'] }) + await removeWslHost('wsl:ubuntu') + expect(mocks.info).toHaveBeenCalledWith( + 'Removed from this Orca. The host keeps running for another Orca, Orca Dev.' + ) + }) + it('removes quietly when the server, its daemon and terminals all ended', async () => { const remove = stubApi({ stopped: true }) await removeWslHost('wsl:ubuntu') diff --git a/src/renderer/src/components/sidebar/wsl-host-removal.ts b/src/renderer/src/components/sidebar/wsl-host-removal.ts index 468b620e7ce..d22290a5d81 100644 --- a/src/renderer/src/components/sidebar/wsl-host-removal.ts +++ b/src/renderer/src/components/sidebar/wsl-host-removal.ts @@ -27,6 +27,19 @@ async function removeWslHostImpl(environmentId: string): Promise { /** Says what a WSL host's Remove left behind; true when it warned. Shared by every Remove entry. */ export function warnWslHostServerStop(serverStop: WslHostServerStop | undefined): boolean { + if (serverStop?.stopped === true && serverStop.keptForOtherDesktops?.length) { + // R39: another Orca on this computer shares the server, so only this Orca let go of it. + // Several unnamed peers all read "another Orca"; say it once. + const names = [...new Set(serverStop.keptForOtherDesktops)].join(', ') + toast.info( + translate( + 'auto.components.sidebar.wslHostRemoval.keptForOthers', + 'Removed from this Orca. The host keeps running for {{value0}}.', + { value0: names } + ) + ) + return true + } if (serverStop?.stopped !== false) { return false } diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 1f6e523c539..df6906caf07 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -6217,7 +6217,7 @@ "advanced": "Erweitert", "alsoDeleteRemoteHint": "Löscht die entfernten Git-Worktrees und ihre Branches dauerhaft. Kann nicht rückgängig gemacht werden.", "alsoForgetLocalHint": "Entfernt sie nur aus Orca. Entfernte Dateien, Worktrees und Branches bleiben unberührt.", - "wslHostEndsTerminals": "Entfernt {{value0}} aus Orca, beendet die zugehörigen offenen Terminals und stoppt den Orca-Server in dieser Distribution. Dateien in der Distribution bleiben unberührt." + "wslHostEndsTerminalsUnlessShared": "Entfernt {{value0}} aus Orca. Sofern kein anderes auf diesem Computer geöffnetes Orca es verwendet, werden außerdem die offenen Terminals beendet und der Orca-Server in dieser Distribution gestoppt. Dateien in der Distribution bleiben unverändert." }, "HostRenameDialog": { "1a2b3c4d5e": "Host umbenennen", @@ -6574,6 +6574,7 @@ "removalPending": "Entfernen nicht abgeschlossen" }, "wslHostRemoval": { + "keptForOthers": "Aus diesem Orca entfernt. Der Host läuft für {{value0}} weiter.", "partial": "Der Host wurde entfernt, aber in der Distribution läuft noch etwas: {{value0}}", "pending": "Orca hat den Host noch nicht entfernt: {{value0}} Zum Wiederholen erneut entfernen oder zum Beibehalten verbinden." }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6a33c2f2aab..d84532fd3ce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6233,7 +6233,7 @@ "advanced": "Advanced", "alsoDeleteRemoteHint": "Permanently deletes the remote Git worktrees and their branches. Cannot be undone.", "alsoForgetLocalHint": "Clears them from Orca only. Remote files, worktrees, and branches are left untouched.", - "wslHostEndsTerminals": "Removes {{value0}} from Orca, ends its open terminals and stops Orca’s server in that distro. Files in the distro are untouched." + "wslHostEndsTerminalsUnlessShared": "Removes {{value0}} from Orca. Unless another Orca that’s open on this computer is using it, this also ends its open terminals and stops Orca’s server in that distro. Files in the distro are untouched." }, "HostRenameDialog": { "1a2b3c4d5e": "Rename host", @@ -6593,6 +6593,7 @@ "pausedShort": "Adding WSL distros is paused in this build." }, "wslHostRemoval": { + "keptForOthers": "Removed from this Orca. The host keeps running for {{value0}}.", "partial": "The host was removed, but something in the distro is still running: {{value0}}", "pending": "Orca didn’t remove the host yet: {{value0}} Remove it again to retry, or connect it to keep it." }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 29db65ca171..b092bcec0b7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5935,7 +5935,7 @@ "advanced": "Avanzado", "alsoDeleteRemoteHint": "Elimina permanentemente los espacios de trabajo Git remotos y sus ramas. Esta acción no se puede deshacer.", "alsoForgetLocalHint": "Solo limpia de Orca. Los archivos, espacios de trabajo y ramas remotas permanecen intactos.", - "wslHostEndsTerminals": "Quita {{value0}} de Orca, cierra sus terminales abiertas y detiene el servidor de Orca en esa distro. Los archivos de la distro no se tocan." + "wslHostEndsTerminalsUnlessShared": "Quita {{value0}} de Orca. Salvo que otro Orca abierto en este equipo lo esté usando, también cierra sus terminales abiertas y detiene el servidor de Orca en esa distro. Los archivos de la distro no se tocan." }, "HostRenameDialog": { "1a2b3c4d5e": "Renombrar host", @@ -6237,6 +6237,7 @@ "removalPending": "La eliminación no terminó" }, "wslHostRemoval": { + "keptForOthers": "Se quitó de este Orca. El host sigue en marcha para {{value0}}.", "partial": "Se quitó el host, pero algo en la distro sigue en ejecución: {{value0}}", "pending": "Orca aún no eliminó el host: {{value0}} Vuelve a eliminarlo para reintentarlo o conéctalo para conservarlo." }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6b132dd577e..3edcb29cea2 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -6057,7 +6057,7 @@ "advanced": "Avancé", "alsoDeleteRemoteHint": "Supprime définitivement les worktrees Git distants et leurs branches. Irréversible.", "alsoForgetLocalHint": "Les retire uniquement d'Orca. Les fichiers, worktrees et branches distants restent intacts.", - "wslHostEndsTerminals": "Retire {{value0}} d'Orca, ferme ses terminaux ouverts et arrête le serveur d'Orca dans cette distro. Les fichiers de la distro restent intacts." + "wslHostEndsTerminalsUnlessShared": "Retire {{value0}} d'Orca. Sauf si un autre Orca ouvert sur cet ordinateur l'utilise, cela ferme aussi ses terminaux ouverts et arrête le serveur d'Orca dans cette distro. Les fichiers de la distro restent intacts." }, "HostRenameDialog": { "1a2b3c4d5e": "Renommer l'hôte", @@ -6414,6 +6414,7 @@ "removalPending": "La suppression n'a pas abouti" }, "wslHostRemoval": { + "keptForOthers": "Retiré de cet Orca. L'hôte reste actif pour {{value0}}.", "partial": "L'hôte a été retiré, mais quelque chose dans la distro est encore en cours d'exécution : {{value0}}", "pending": "Orca n'a pas encore supprimé l'hôte : {{value0}} Supprimez-le à nouveau pour réessayer, ou connectez-le pour le conserver." }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 475a3216d36..f29b17b4a72 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5933,7 +5933,7 @@ "advanced": "詳細設定", "alsoDeleteRemoteHint": "リモートの Git ワークツリーとそのブランチを完全に削除します。この操作は取り消せません。", "alsoForgetLocalHint": "Orca からのみ削除します。リモートのファイル、ワークツリー、ブランチはそのまま残ります。", - "wslHostEndsTerminals": "Orca から {{value0}} を削除し、開いているターミナルを終了して、そのディストリビューション内の Orca サーバーを停止します。ディストリビューション内のファイルは変更されません。" + "wslHostEndsTerminalsUnlessShared": "Orca から {{value0}} を削除します。このコンピューターで開いている別の Orca が使用していない場合は、開いているターミナルも終了し、そのディストリビューション内の Orca サーバーを停止します。ディストリビューション内のファイルは変更されません。" }, "HostRenameDialog": { "1a2b3c4d5e": "ホスト名を変更", @@ -6235,6 +6235,7 @@ "removalPending": "削除が完了しませんでした" }, "wslHostRemoval": { + "keptForOthers": "この Orca から削除しました。ホストは {{value0}} のために動作し続けます。", "partial": "ホストは削除されましたが、ディストリビューション内でまだ実行中のものがあります: {{value0}}", "pending": "Orca はまだホストを削除していません: {{value0}} 再試行するにはもう一度削除し、残す場合は接続してください。" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4720a3221a8..20b0f037cb0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5933,7 +5933,7 @@ "advanced": "고급", "alsoDeleteRemoteHint": "원격 Git 워크트리와 해당 브랜치를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", "alsoForgetLocalHint": "Orca에서만 제거합니다. 원격 파일, 워크트리, 브랜치는 그대로 남아 있습니다.", - "wslHostEndsTerminals": "Orca에서 {{value0}}을(를) 제거하고, 열려 있는 터미널을 종료하며, 해당 배포판에서 Orca 서버를 중지합니다. 배포판의 파일은 변경되지 않습니다." + "wslHostEndsTerminalsUnlessShared": "Orca에서 {{value0}}을(를) 제거합니다. 이 컴퓨터에서 열려 있는 다른 Orca가 사용 중이지 않다면 열린 터미널도 종료하고 해당 배포판의 Orca 서버를 중지합니다. 배포판의 파일은 변경되지 않습니다." }, "HostRenameDialog": { "1a2b3c4d5e": "호스트 이름 바꾸기", @@ -6235,6 +6235,7 @@ "removalPending": "제거가 완료되지 않음" }, "wslHostRemoval": { + "keptForOthers": "이 Orca에서 제거했습니다. 호스트는 {{value0}}을(를) 위해 계속 실행됩니다.", "partial": "호스트는 제거되었지만 배포판에서 아직 실행 중인 항목이 있습니다: {{value0}}", "pending": "Orca가 아직 호스트를 제거하지 않았습니다: {{value0}} 다시 시도하려면 다시 제거하고, 유지하려면 연결하세요." }, diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 309caf60bb4..872f7c44526 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -10625,7 +10625,7 @@ "advanced": "Avançado", "alsoDeleteRemoteHint": "Exclui permanentemente as worktrees Git remotas e suas branches. Não pode ser desfeito.", "alsoForgetLocalHint": "Limpa-os apenas do Orca. Os arquivos, worktrees e branches remotos permanecem intocados.", - "wslHostEndsTerminals": "Remove {{value0}} do Orca, encerra os terminais abertos e interrompe o servidor Orca nessa distribuição. Os arquivos da distribuição não são alterados." + "wslHostEndsTerminalsUnlessShared": "Remove {{value0}} do Orca. A menos que outro Orca aberto neste computador o esteja usando, também encerra seus terminais abertos e para o servidor do Orca nessa distro. Os arquivos da distro não são alterados." }, "AddRepoSteps": { "569326d9cc": "Escolher pasta", @@ -11365,6 +11365,7 @@ "removalPending": "A remoção não foi concluída" }, "wslHostRemoval": { + "keptForOthers": "Removido deste Orca. O host continua em execução para {{value0}}.", "partial": "O host foi removido, mas algo na distribuição ainda está em execução: {{value0}}", "pending": "O Orca ainda não removeu o host: {{value0}} Remova-o novamente para tentar de novo, ou conecte-o para mantê-lo." }, diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 4d0b5ff35d6..9ef68cd8179 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -6211,7 +6211,7 @@ "advanced": "Дополнительно", "alsoDeleteRemoteHint": "Навсегда удаляет удалённые рабочие деревья Git и их ветки. Это действие нельзя отменить.", "alsoForgetLocalHint": "Только убирает их из Orca. Удалённые файлы, рабочие деревья и ветки сохранятся.", - "wslHostEndsTerminals": "Убирает {{value0}} из Orca, завершает открытые терминалы и останавливает сервер Orca в этом дистрибутиве. Файлы дистрибутива сохранятся." + "wslHostEndsTerminalsUnlessShared": "Удаляет {{value0}} из Orca. Если только его не использует другой Orca, открытый на этом компьютере, также закрывает его открытые терминалы и останавливает сервер Orca в этом дистрибутиве. Файлы в дистрибутиве не затрагиваются." }, "HostRenameDialog": { "1a2b3c4d5e": "Переименовать компьютер", @@ -6580,6 +6580,7 @@ "removalPending": "Удаление не завершено" }, "wslHostRemoval": { + "keptForOthers": "Удалено из этого Orca. Хост продолжает работать для {{value0}}.", "partial": "Компьютер убран, но в дистрибутиве что-то ещё работает: {{value0}}", "pending": "Orca пока не удалила хост: {{value0}} Повторите удаление или подключитесь, чтобы оставить его." }, diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index dc687ef8a62..aa030bcd4b0 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -6183,7 +6183,7 @@ "advanced": "Gelişmiş", "alsoDeleteRemoteHint": "Uzak Git worktree'lerini ve branch'lerini kalıcı olarak siler. Geri alınamaz.", "alsoForgetLocalHint": "Yalnızca Orca'dan temizler. Uzak dosyalar, worktree'ler ve branch'ler dokunulmadan bırakılır.", - "wslHostEndsTerminals": "{{value0}} öğesini Orca'dan kaldırır, açık terminallerini sonlandırır ve bu dağıtımdaki Orca sunucusunu durdurur. Dağıtımdaki dosyalar değiştirilmez." + "wslHostEndsTerminalsUnlessShared": "{{value0}} öğesini Orca'dan kaldırır. Bu bilgisayarda açık olan başka bir Orca onu kullanmıyorsa açık terminallerini de sonlandırır ve bu dağıtımdaki Orca sunucusunu durdurur. Dağıtımdaki dosyalar değiştirilmez." }, "HostRenameDialog": { "1a2b3c4d5e": "Host'u yeniden adlandır", @@ -6540,6 +6540,7 @@ "removalPending": "Kaldırma tamamlanamadı" }, "wslHostRemoval": { + "keptForOthers": "Bu Orca’dan kaldırıldı. Ana bilgisayar {{value0}} için çalışmaya devam ediyor.", "partial": "Ana makine kaldırıldı ancak dağıtım içinde hâlâ çalışan bir şey var: {{value0}}", "pending": "Orca ana makineyi henüz kaldırmadı: {{value0}} Tekrar denemek için yeniden kaldırın veya tutmak için bağlanın." }, diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index f978f556044..4fe9bd98734 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -6172,7 +6172,7 @@ "advanced": "進階", "alsoDeleteRemoteHint": "永久刪除遠端 Git 工作樹及其分支。此操作不可復原。", "alsoForgetLocalHint": "僅從 Orca 中清除。遠端檔案、工作樹和分支不受影響。", - "wslHostEndsTerminals": "從 Orca 中移除 {{value0}},結束其開啟的終端機,並停止該發行版中的 Orca 伺服器。發行版中的檔案不受影響。" + "wslHostEndsTerminalsUnlessShared": "從 Orca 移除 {{value0}}。除非此電腦上開啟的另一個 Orca 正在使用它,否則也會結束其開啟的終端機,並停止該發行版中的 Orca 伺服器。發行版中的檔案不受影響。" }, "HostRenameDialog": { "1a2b3c4d5e": "重新命名主機", @@ -6529,6 +6529,7 @@ "removalPending": "尚未完成移除" }, "wslHostRemoval": { + "keptForOthers": "已從此 Orca 移除。該主機將繼續為 {{value0}} 執行。", "partial": "主機已被移除,但發行版中仍有內容在執行:{{value0}}", "pending": "Orca 尚未移除此主機:{{value0}}。請再次移除以重試,或連線至此主機以保留它。" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b9a2f4c0ec4..bcddf5a47a8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5933,7 +5933,7 @@ "advanced": "高级", "alsoDeleteRemoteHint": "永久删除远程 Git 工作树及其分支。此操作不可撤销。", "alsoForgetLocalHint": "仅从 Orca 中清除。远程文件、工作树和分支均不受影响。", - "wslHostEndsTerminals": "从 Orca 中移除 {{value0}},结束其打开的终端,并停止该发行版中的 Orca 服务器。发行版中的文件不受影响。" + "wslHostEndsTerminalsUnlessShared": "从 Orca 中移除 {{value0}}。除非此计算机上打开的另一个 Orca 正在使用它,否则还会结束其打开的终端,并停止该发行版中的 Orca 服务器。发行版中的文件不受影响。" }, "HostRenameDialog": { "1a2b3c4d5e": "重命名主机", @@ -6235,6 +6235,7 @@ "removalPending": "移除未完成" }, "wslHostRemoval": { + "keptForOthers": "已从此 Orca 中移除。该主机将继续为 {{value0}} 运行。", "partial": "主机已被移除,但发行版中仍有内容在运行:{{value0}}", "pending": "Orca 尚未移除该主机:{{value0}} 可再次移除以重试,或连接以保留它。" }, diff --git a/src/shared/orcad-peer-desktops-contract.ts b/src/shared/orcad-peer-desktops-contract.ts new file mode 100644 index 00000000000..62cf1763a13 --- /dev/null +++ b/src/shared/orcad-peer-desktops-contract.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' + +// Why: a WSL host's server is one per Linux login, so other Orca desktops may share it. +export const ORCAD_PEER_DESKTOPS_METHOD = 'host.peerDesktops' +// A desktop names its installation on its long-lived control socket, so peers tell installations apart. +export const ORCAD_DECLARE_CLIENT_METHOD = 'host.declareClient' + +const ClientInstanceIdSchema = z.string().regex(/^[A-Za-z0-9_-]{8,128}$/) + +export const OrcadDeclareClientParamsSchema = z + .object({ clientInstanceId: ClientInstanceIdSchema }) + .strict() + +/** The caller's own installation, so none of its sockets count as another desktop. */ +export const OrcadPeerDesktopsParamsSchema = z + .object({ clientInstanceId: ClientInstanceIdSchema.optional() }) + .strict() + +/** Other desktop installations with a declared control socket open on this server right now. */ +export const OrcadPeerDesktopsResultSchema = z.object({ + peers: z.array(z.object({ name: z.string() })) +}) + +export type OrcadPeerDesktopsResult = z.infer diff --git a/src/shared/orcad-runtime-capabilities.ts b/src/shared/orcad-runtime-capabilities.ts index 5cf2280339d..31852d08a0d 100644 --- a/src/shared/orcad-runtime-capabilities.ts +++ b/src/shared/orcad-runtime-capabilities.ts @@ -22,10 +22,14 @@ export const HOST_ACCOUNT_LAUNCH_ENV_RUNTIME_CAPABILITY = 'accounts.host-launch- // Older orcads reject --reclaim-stale-lock; without this capability offer manual instructions. export const ORCAD_STALE_LOCK_RECLAIM_RUNTIME_CAPABILITY = 'orcad.stale-lock-reclaim.v1' as const +// A WSL host's Remove asks host.peerDesktops before ending a server other desktops may share. +export const ORCAD_PEER_DESKTOPS_RUNTIME_CAPABILITY = 'orcad.peer-desktops.v1' as const + export const ORCAD_RUNTIME_CAPABILITIES = [ ORCAD_STALE_LOCK_RECLAIM_RUNTIME_CAPABILITY, ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY, ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY, ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY, - HOST_ACCOUNT_LAUNCH_ENV_RUNTIME_CAPABILITY + HOST_ACCOUNT_LAUNCH_ENV_RUNTIME_CAPABILITY, + ORCAD_PEER_DESKTOPS_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts index a62abd82afa..eacdc647f7b 100644 --- a/src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts @@ -252,6 +252,19 @@ describe('RemoteRuntimeSharedControlConnection', () => { connection.close() }) + it('calls onReady for every socket that becomes ready, including after a reconnect', async () => { + const server = await createServer() + const onReady = vi.fn() + const connection = new RemoteRuntimeSharedControlConnection(server.pairing, { onReady }) + await connection.request('worktree.ps', undefined, 1000) + expect(onReady).toHaveBeenCalledTimes(1) + server.closeClients() + await vi.waitFor(() => expect(server.connectionCount()).toBe(2)) + await connection.request('worktree.ps', undefined, 2000) + expect(onReady).toHaveBeenCalledTimes(2) + connection.close() + }) + it('cleans up an id-scoped subscription closed before its ready response', async () => { const server = await createServer({ delaySubscriptionReady: true }) const connection = new RemoteRuntimeSharedControlConnection(server.pairing) diff --git a/src/shared/remote-runtime/control/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime/control/remote-runtime-shared-control-connection.ts index 9f324747ec9..c8742bfbcf4 100644 --- a/src/shared/remote-runtime/control/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime/control/remote-runtime-shared-control-connection.ts @@ -223,6 +223,7 @@ export class RemoteRuntimeSharedControlConnection { getSocket: () => this.ws, markReady: () => { this.diagnostics.markReady() + this.options.onReady?.() // Clear predecessor-attempt diagnostics on recovery so Connected cannot retain stale failure details. this.publishDiagnostics() this.readyStableReset.schedule({ diff --git a/src/shared/remote-runtime/control/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime/control/remote-runtime-shared-control-types.ts index 58a41126e48..cd5309ef0a8 100644 --- a/src/shared/remote-runtime/control/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime/control/remote-runtime-shared-control-types.ts @@ -114,6 +114,8 @@ export type RemoteRuntimeSharedControlConnectionOptions = { onDiagnosticsChanged?: (diagnostics: RemoteRuntimeSharedConnectionDiagnostics) => void /** Any inbound frame, ping or pong on the current socket. */ onAnswered?: () => void + /** Each socket that becomes ready, including after every reconnect. */ + onReady?: () => void reconnectStableResetMs?: number /** Every socket close with its cause, e.g. for the diagnostics trace. */ onSocketClosed?: (record: SharedControlSocketCloseRecord) => void diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 6f40a5363cc..60983d5136d 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -37,6 +37,10 @@ import { MobileRelayHostWorktreesParamsSchema } from '../mobile-relay-hosts-cont import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract' import { OrcadMigrationBlobChunkRequestSchema } from '../orcad-migration/orcad-migration-blob-transfer' import { OrcadMigrationSnapshotChunkRequestSchema } from '../orcad-migration/orcad-migration-scrollback' +import { + OrcadDeclareClientParamsSchema, + OrcadPeerDesktopsParamsSchema +} from '../orcad-peer-desktops-contract' import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census' import { pluginConsentRequestSchema } from '../plugins/plugin-consent-request' import { @@ -1089,7 +1093,9 @@ export const RPC_PARAMS_BY_METHOD = { 'gitlab.updateMRState': UpdateMrState, 'gitlab.workItemByPath': WorkItemByPath, 'gitlab.workItemDetails': WorkItemDetails, + 'host.declareClient': OrcadDeclareClientParamsSchema, 'host.gitBash.isAvailable': null, + 'host.peerDesktops': OrcadPeerDesktopsParamsSchema, 'host.platform': null, 'host.pwsh.isAvailable': null, 'host.wsl.isAvailable': null, diff --git a/src/shared/wsl-host-types.ts b/src/shared/wsl-host-types.ts index c90da4c3302..2c95c873ee7 100644 --- a/src/shared/wsl-host-types.ts +++ b/src/shared/wsl-host-types.ts @@ -63,7 +63,8 @@ export type WslHostCapability = { * listed with its stop pending, so Remove can retry; older mains removed it anyway. */ export type WslHostServerStop = - | { stopped: true } + /** `keptForOtherDesktops`: another Orca shares the server, so only this desktop let go of it. */ + | { stopped: true; keptForOtherDesktops?: string[] } | { stopped: false; reason: string; verdict?: 'live' | 'unverifiable'; kept?: boolean } export type WslHostAddFailureKind =