mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(wsl-host): Remove keeps a WSL host's server that another Orca on this computer shares (R39) (#34699)
* fix(wsl-host): Remove keeps a WSL host's server that another Orca on this computer shares A WSL host's server lives in the Linux login's ~/.orca, so two Orca installs on one Windows account (stable and dev), or a desktop reaching the distro over SSH, share one server. Remove used to close every terminal there and decommission it, ending the other desktop's sessions and agents. The server now answers host.peerDesktops (capability orcad.peer-desktops.v1): the other desktops paired with it, whether each is connected now, and when each was last seen. Before tearing anything down, Remove asks. If another desktop is connected or was seen in the last 30 days, it only closes this desktop's bridge and reports the server kept. If the server can't answer, the teardown is refused. An older server without the capability gets today's teardown, plus a warning. * fix(wsl-host): a shared Remove releases this Orca from the host, and says the host keeps running - On a shared server, Remove now revokes this desktop's own device (and its phones) on the host, through the new host.releaseDesktop, before letting go. Otherwise its recent row would make a peer's later Remove keep the server forever. Peers also skip rows of the caller's own installation (same caller fingerprint), such as an enrollment from before a re-add. - The renderer says when a removal kept the host for another Orca, and the Remove confirmation no longer promises to end terminals unconditionally. - Remove-path files now route CI to the fake transport e2e spec, which exercises Remove. * i18n(wsl-host): Russian and Traditional Chinese for the shared-host Remove copy * fix(wsl-host): tell desktops apart by device key, and release this Orca on every Remove - Peers are no longer hidden by caller fingerprint. Every device-key fork of one pairing token row inherits that row's fingerprint, so two installations that paired through one code hid each other, and one's Remove could tear the server down under the other. Only a shared device public key now marks the same installation, as with a re-add under a new token. - This desktop's own device is released as the last call over the bridge on every Remove that proceeds (no terminals left), not only a shared one. A later Add then Remove never meets a stale row of ours. - When the release fails on a shared Remove, the toast says the host may keep running for a while. The Russian confirmation copy is corrected. * fix(wsl-host): move undo refuses a removed host whose server another Orca kept A Remove that kept a WSL host's server for another Orca (R39) now records that. Before, undo of a move onto that host read the missing environment as "its terminals and automations stopped with it", and would have restored the project here while the host's copies kept running, duplicating its agents and automations. Undo now reads such a host as unreachable and refuses, as it does for any host it can't reach. A later Remove that proves the server stopped clears the record. If the record can't be saved, the host stays listed as pending instead. * i18n(wsl-host): Turkish for the shared-host Remove copy * fix(wsl-host): each WSL desktop splits off its own row from orcad's startup pairing orcad publishes one startup pairing offer and never rotates it, so every desktop that enrolled through it shared one token row and device id. Stable and dev Orca on one distro were invisible to each other as peers, and releasing that shared row broke the next re-add and pairing repair. - New host.forkDesktop (same orcad.peer-desktops.v1 capability). A runtime device on a shared desktop token mints its own token row, forked from it and named by the caller. Forks share the device-key fork cap, and a released fork frees its slot. - The WSL desktop forks right after enrolling through the startup offer, or after a repair lands back on it, and keeps the forked pairing (named after its userData folder, e.g. Orca or Orca Dev). On an older orcad, or if the fork fails, it keeps the shared pairing and warns. - A token row that desktops forked from counts as a peer only while something is connected on it. One that nobody forked from keeps the last-seen rule. * fix(wsl-host): keep host.forkDesktop to the startup offer's holder, over E2EE, with a clean label host.forkDesktop mints a credential, so: - only the holder of the desktop pairing offer this server advertised last (orcad's startup offer) may fork; an older offer, a fork, a phone or any other row is refused; - the fork takes exactly the caller's grants and reach; params are just a label (strict, so a grant request is refused); - it runs only on an authenticated E2EE socket; the token goes back only in that reply and is never logged; - the label is display-only: control, zero-width and bidi characters stripped, whitespace collapsed, at most 64 characters; an empty result is refused. * refactor(wsl-host): count only Orcas connected right now as WSL host peers; drop fork and release Review found the token fork and self-release add more identity state than R39 warrants: orphan forks, a desktop left on the shared token releasing it, and a changed caller fingerprint. So this drops host.forkDesktop, host.releaseDesktop and the 30-day last-seen rule, and restores the restart test. A peer is now another Orca installation with a control socket open on the server right now. The desktop declares its installation (a hash of its userData path) with host.declareClient on its shared-control socket after every ready, including reconnects. The server tags that socket and drops the tag when it closes. host.peerDesktops takes the caller's installation id, so its own other sockets (multiplex, one-shot) never count, and two desktops sharing one startup token stay distinct. Undeclared sockets, phones and browser sessions are ignored. Peer names go through sanitizeDeviceLabel, and a startup-offer "CLI <date>" name shows as "another Orca". An older orcad lacks the method: nothing is declared, and Remove keeps today's teardown with a warning. Limit: an Orca that's closed while its terminals keep running isn't protected. * fix(wsl-host): keep a reconnecting Orca as a WSL host peer for 60 seconds Lost contact isn't proof an Orca left. When an installation's last declared control socket closes (a bridge exit, an orcad restart, resume from sleep, reconnect backoff), the server keeps counting it as a peer for 60 seconds, or until it re-declares. A Remove inside that gap no longer tears down its terminals. * fix(wsl-host): say an open Orca keeps the host, and name each peer once - The Remove confirmation now reads "Unless another Orca that's open on this computer is using it", since only a connected Orca keeps the server. Updated in all 10 locales. - The kept-host toast dedupes peer names, so several unnamed peers read "another Orca" once. * test(wsl-host): pin the reconnect grace to the last socket's close, and to the server's own forget - The unit test advances the clock between the two closes and checks that the peer still counts when the first close's grace would have run out. - The integration test waits until the server has dropped the closed socket before reading peers, so it fails without the grace (checked by setting the grace to zero). * fix(wsl-host): sweep expired reconnect graces on every close, on a monotonic clock Expired graces are dropped at the start of forget() as well as peers(), so the map stays bounded even when nobody asks for peers. The default clock is performance.now(), so a wall-clock jump can't stretch or cut the grace. * i18n(wsl-host): German for the shared-host Remove copy
This commit is contained in:
@@ -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'])
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -18,7 +18,11 @@ const host = vi.hoisted(() => ({
|
||||
listed: true,
|
||||
unreadable: false,
|
||||
removalPending: false,
|
||||
connect: vi.fn(async (..._args: unknown[]): Promise<boolean> => true)
|
||||
connect: vi.fn(async (..._args: unknown[]): Promise<boolean> => 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", () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<DeviceEntry>[]) {
|
||||
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\nDev' },
|
||||
{ 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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, Declaration>()
|
||||
// Installations whose last declared socket closed recently, by id, until their grace ends.
|
||||
private readonly reconnecting = new Map<string, { deviceId: string; until: number }>()
|
||||
|
||||
// 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<string, string>()
|
||||
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 <date>", 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
|
||||
}
|
||||
@@ -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> | void)[] = []
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) {
|
||||
await cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
async function startServer(): Promise<OrcaRuntimeRpcServer> {
|
||||
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<T>(offer: PairingOffer, method: string, params?: unknown) {
|
||||
return sendRemoteRuntimeRequest<T>(
|
||||
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<string[]> {
|
||||
const answer = await oneShot<OrcadPeerDesktopsResult>(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<RuntimeStatus>(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 <date>" 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'])
|
||||
})
|
||||
})
|
||||
@@ -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<MobileRelayHostWorktreesResult>
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<unknown>) | null = null
|
||||
protected mobileRelayPairingOfferQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<RuntimeRpcResponse<unknown>>
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string>(), pending: new Array<string>() }))
|
||||
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<WslHostConnector> 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'])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<unknown>,
|
||||
clientInstanceId = wslHostClientInstanceId()
|
||||
): Promise<WslHostPeerDesktops> {
|
||||
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' }
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<WslHostServerStopSteps> = {}) {
|
||||
const order: string[] = []
|
||||
const all: WslHostServerStopSteps = {
|
||||
peerDesktops: vi.fn(async (): Promise<WslHostPeerDesktops> => ({ 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<WslHostPeerDesktops> => ({
|
||||
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<WslHostPeerDesktops> => ({ 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 () => [
|
||||
|
||||
@@ -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<WslHostPeerDesktops>
|
||||
/** Closes every terminal orcad runs; returns the ones that refused instead of stopping. */
|
||||
endTerminals: () => Promise<WslHostTerminalLeftover[]>
|
||||
census: () => Promise<OrcadTerminalCensus>
|
||||
@@ -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<WslHostServerStopSteps, 'endTerminals' | 'census' | 'servingRuntimeId'> {
|
||||
): Pick<WslHostServerStopSteps, 'peerDesktops' | 'endTerminals' | 'census' | 'servingRuntimeId'> {
|
||||
return {
|
||||
peerDesktops: () =>
|
||||
readWslHostPeerDesktops((method, params) => access.call(environmentId, method, params)),
|
||||
endTerminals: async () => {
|
||||
const list = TerminalHandlesSchema.parse(
|
||||
await access.call(environmentId, 'terminal.list', { limit: 1000 })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -27,6 +27,19 @@ async function removeWslHostImpl(environmentId: string): Promise<void> {
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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}} 再試行するにはもう一度削除し、残す場合は接続してください。"
|
||||
},
|
||||
|
||||
@@ -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}} 다시 시도하려면 다시 제거하고, 유지하려면 연결하세요."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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}} Повторите удаление или подключитесь, чтобы оставить его."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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}}。請再次移除以重試,或連線至此主機以保留它。"
|
||||
},
|
||||
|
||||
@@ -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}} 可再次移除以重试,或连接以保留它。"
|
||||
},
|
||||
|
||||
@@ -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<typeof OrcadPeerDesktopsResultSchema>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user