From 570e5fa72aa4d153a58625a7a7021eb07765b6e7 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 00:16:41 -0700 Subject: [PATCH 1/2] feat(orcad): decommission, managed stop and GC on Windows SSH hosts (W4) --- src/main/ssh/orcad-managed-remote-stop.ts | 19 +- src/main/ssh/orcad-remote-gc.ts | 50 +++++- src/main/ssh/orcad-remote-host-support.ts | 10 +- src/main/ssh/orcad-remote-stop.ts | 4 +- .../orcad-remote-windows-decommission.test.ts | 169 ++++++++++++++++++ .../ssh/orcad-remote-windows-scripts.test.ts | 23 +++ src/main/ssh/orcad-windows-host-lane.test.ts | 33 +++- src/main/ssh/orcad-windows-host-script.ts | 12 ++ src/main/ssh/remote-install-gc.ts | 8 +- 9 files changed, 301 insertions(+), 27 deletions(-) create mode 100644 src/main/ssh/orcad-remote-windows-decommission.test.ts diff --git a/src/main/ssh/orcad-managed-remote-stop.ts b/src/main/ssh/orcad-managed-remote-stop.ts index 9cedd78a8497..ecd0b0298866 100644 --- a/src/main/ssh/orcad-managed-remote-stop.ts +++ b/src/main/ssh/orcad-managed-remote-stop.ts @@ -7,9 +7,12 @@ */ import { randomUUID } from 'node:crypto' import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { parseOrcadReadinessOutput, readOrcadReadinessCommand } from './orcad-remote-launch' +import { readOrcadReadinessCommand } from './orcad-remote-launch' +import { + orcadReadinessWaitCommand, + parseOrcadReadinessWaitOutput +} from './orcad-remote-readiness-wait' import { execOrcadRemote } from './orcad-remote-runtime-control' import { readBoundedOrcadRemoteRecord } from './orcad-remote-record-file' import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' @@ -51,10 +54,16 @@ export async function readRemoteOrcadManagedStopTarget( options: OrcadSlotOptions, version: string ): Promise { - assertPosixOrcadHost(options.host) const slotDir = orcadSlotDir(options, version) - const readiness = parseOrcadReadinessOutput( - await execOrcadRemote(options, readOrcadReadinessCommand(options.host, slotDir)) + // Windows reads it through the host script's wait op with no wait; POSIX keeps `head`. + const readiness = parseOrcadReadinessWaitOutput( + options.host, + await execOrcadRemote( + options, + isWindowsRemoteHost(options.host) + ? orcadReadinessWaitCommand(options.host, slotDir, 0) + : readOrcadReadinessCommand(options.host, slotDir) + ) ) if (readiness.state !== 'ready' || !readiness.readiness.health) { return refused( diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts index 556bb35981b4..4e64bcd128d3 100644 --- a/src/main/ssh/orcad-remote-gc.ts +++ b/src/main/ssh/orcad-remote-gc.ts @@ -24,10 +24,12 @@ import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' -import type { RemoteHostPlatform } from './ssh-remote-platform' import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' import { readOrcadGcTransactionPins } from './orcad-gc-transaction-pins' -import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { orcadRemoteBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node' +import { ORCAD_WINDOWS_LIVENESS_MANY_MARKER } from './orcad-windows-host-script' export type OrcadGcOptions = { conn: SshConnection @@ -53,8 +55,6 @@ export type OrcadGcOptions = { } export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise { - // Windows liveness answers now, but GC there (one probe per pass, not per dir) is not built yet. - assertPosixOrcadHost(options.host) const transaction = await readOrcadGcTransactionPins(options) if (transaction.state === 'keep-all') { console.warn('[orcad-gc] An activation transaction is unreadable or unjournaled; skipping GC.') @@ -71,6 +71,11 @@ export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise ...orcadGcPinnedDirNames(options.record, options.liveDaemonVersion), ...transaction.dirNames ], + // Windows screens every candidate in one node.exe; the per-dir probe below rechecks only + // the few that screened dead, under the GC claim. + ...(isWindowsRemoteHost(options.host) + ? { resolveExtraPinnedDirNames: (candidates) => windowsLiveCandidates(options, candidates) } + : {}), isDirLive: async (dir) => { try { const probe = await execCommand( @@ -101,3 +106,40 @@ export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise }) } } + +/** Candidates that are not proven dead, as pins; null (keep everything) when the host cannot say. */ +async function windowsLiveCandidates( + options: OrcadGcOptions, + candidates: readonly string[] +): Promise { + const dirs = candidates.map((name) => + joinRemotePath(options.host, options.remoteHome, RELAY_REMOTE_DIR, name) + ) + let output: string + try { + output = await execCommand( + options.conn, + orcadWindowsHostOpCommand( + options.host, + orcadRemoteBaseDir(options.host, options.remoteHome), + 'liveness-many', + dirs + ), + { wrapCommand: false, signal: options.signal } + ) + } catch (error) { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return null + } + const line = output + .split(/\r?\n/u) + .map((candidate) => candidate.trim()) + .find((candidate) => candidate.startsWith(`${ORCAD_WINDOWS_LIVENESS_MANY_MARKER} `)) + const states = line?.slice(ORCAD_WINDOWS_LIVENESS_MANY_MARKER.length + 1).split(',') ?? [] + if (states.length !== candidates.length) { + return null + } + return candidates.filter((_name, index) => states[index] !== 'DEAD') +} diff --git a/src/main/ssh/orcad-remote-host-support.ts b/src/main/ssh/orcad-remote-host-support.ts index 90206fa5eabf..4517573022fa 100644 --- a/src/main/ssh/orcad-remote-host-support.ts +++ b/src/main/ssh/orcad-remote-host-support.ts @@ -3,8 +3,9 @@ * discovered at runtime. * * The install transaction is host-agnostic — it is the relay's, and the relay runs on - * Windows. Deploy, rollback and their recovery run on Windows through the host script - * (`orcad-windows-host-script.ts`); managed stop, decommission and GC do not yet, and refuse. + * Windows. Every managed-orcad operation runs on Windows through the host script + * (`orcad-windows-host-script.ts`); the guard below remains only on POSIX command builders + * that the Windows paths never call. */ import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' @@ -12,9 +13,8 @@ export class OrcadRemoteLaunchUnsupportedError extends Error { readonly code = 'orcad_remote_launch_unsupported_host' constructor(hostLabel: string) { super( - `Deploying orcad to a ${hostLabel} host is not implemented. The install transaction is ` + - 'host-agnostic, but managed stop, decommission and version GC are POSIX-only. ' + - 'Use the relay for this host.' + `This orcad command does not run on a ${hostLabel} host. The install transaction is ` + + 'host-agnostic, but this step is a POSIX command with a separate Windows path.' ) this.name = 'OrcadRemoteLaunchUnsupportedError' } diff --git a/src/main/ssh/orcad-remote-stop.ts b/src/main/ssh/orcad-remote-stop.ts index dcfbe314314b..ab340f7fcd42 100644 --- a/src/main/ssh/orcad-remote-stop.ts +++ b/src/main/ssh/orcad-remote-stop.ts @@ -5,7 +5,7 @@ * activation does. It refuses while the terminal census says terminals are live or cannot be * counted, and it never signals: the instance-bound request reaches only the orcad it names. * Only proven exit deactivates the record; anything less withdraws the request or keeps the - * fence. POSIX hosts only, like the rest of the orcad deploy core. + * fence. Windows runs the same steps: the request is a staged file and exit proof is orcad's own. */ import { randomUUID } from 'node:crypto' import type { OrcadActivationRecord } from './orcad-activation-record' @@ -26,7 +26,6 @@ import { } from './orcad-decommission-transaction' import { readRemoteOrcadManagedStopTarget } from './orcad-managed-remote-stop' import { settleOrcadDecommissionStop } from './orcad-decommission-stop' -import { assertPosixOrcadHost } from './orcad-remote-host-support' import type { OrcadSlotOptions } from './orcad-recovery-slot' import type { OrcadDecommissionResult } from '../../shared/orcad-decommission' import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' @@ -68,7 +67,6 @@ function censusRefusal(census: OrcadTerminalCensus): Refusal | null { export async function decommissionRemoteOrcad( options: OrcadDecommissionOptions ): Promise { - assertPosixOrcadHost(options.host) const now = options.now ?? ((): Date => new Date()) return withOrcadActivationLock(options, async (lock) => { if (await readOrcadActivationTransaction(options)) { diff --git a/src/main/ssh/orcad-remote-windows-decommission.test.ts b/src/main/ssh/orcad-remote-windows-decommission.test.ts new file mode 100644 index 000000000000..dafad9cb333f --- /dev/null +++ b/src/main/ssh/orcad-remote-windows-decommission.test.ts @@ -0,0 +1,169 @@ +/** + * Decommission and GC against a Windows host whose host ops are answered by a fake: the stop is + * a staged managed request run by the slot's node.exe, and GC screens every candidate in one + * node.exe instead of one per version dir. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RecordFile from './orcad-remote-record-file' +import type * as InstallLock from './ssh-relay-install-lock' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: () => false +})) +vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ + ...(await importOriginal()), + acquireInstallLock: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ + ...(await importOriginal()), + writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ + ...(await importOriginal>()), + gcOldRemoteInstallVersions: vi.fn().mockResolvedValue(undefined) +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' +import { gcOldRemoteInstallVersions } from './ssh-relay-versioned-install' +import { decommissionRemoteOrcad } from './orcad-remote-stop' +import { gcOldOrcadVersions } from './orcad-remote-gc' +import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const mockExec = vi.mocked(execCommand) +const host = getRemoteHostPlatform('win32-x64') +const VERSION = '0.2.0+bb01' +const SLOT_NODE = 'C:\\Users\\u\\.orca-remote\\runtimes\\node-ab\\node.exe' +const record = withActivatedVersion(emptyOrcadActivationRecord(), VERSION, null, new Date(0)) +const encoded = (marker: string, value: string): string => + `${marker} ${Buffer.from(value).toString('base64')}\r\n` +const lock = { + pid: 4242, + startedAtMs: 1_700_000_000_123, + identity: 'u', + version: VERSION, + acquiredAt: '2026-10-01T00:00:00.000Z', + nonce: 'nonce-1' +} + +function conn() { + return Object.assign(Object.create(null), { writeFile: vi.fn().mockResolvedValue(undefined) }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('decommissioning a Windows orcad', () => { + it('stops it by a staged managed request and records that nothing serves', async () => { + const log: string[] = [] + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + log.push(text) + const op = /\.js ([a-z-]+)(?: |$)/u.exec(text)?.[1] ?? '' + if (op === 'record-read') { + if (text.includes('orcad-active.json')) { + return encoded('__ORCAD_RECORD_PRESENT__', JSON.stringify(record)) + } + return text.includes('orcad.lock') + ? encoded('__ORCAD_RECORD_PRESENT__', JSON.stringify(lock)) + : '__ORCAD_RECORD_ABSENT__\r\n' + } + if (op === 'readiness-wait') { + return encoded( + '__ORCAD_READINESS__', + `${JSON.stringify({ type: 'orca_server_ready', runtimeId: 'r1', health: { pid: 4242, stopRequests: 1 } })}\n` + ) + } + if (op === 'slot-runtime') { + return encoded('__ORCAD_RUNTIME__', SLOT_NODE) + } + if (op === 'remove-file' || op === 'remove-tree') { + return '' + } + if (text.includes('--complete-managed-stop')) { + const staged = /--request-file (\S+)/u.exec(text)?.[1] ?? '' + const writes = vi.mocked(options.conn.writeFile).mock.calls + const request = JSON.parse(String(writes.find(([path]) => path === staged)?.[1])) + return `${JSON.stringify({ ...request, kind: 'orcad_managed_stop_completion', verdict: 'exited', receiptPersisted: true, retirement: 'retired' })}\r\n` + } + throw new Error(`unexpected Windows command: ${text}`) + }) + const options = { + conn: conn(), + host, + remoteHome: 'C:/Users/u', + nodePath: 'C:/host/node.exe', + userDataDir: 'C:/Users/u/.orca', + bindHost: '127.0.0.1', + port: 7777, + record, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + now: () => new Date('2026-10-02T00:00:00.000Z') + } + expect(await decommissionRemoteOrcad(options)).toEqual({ + outcome: 'decommissioned', + version: VERSION, + retirement: 'retired' + }) + const written = vi + .mocked(writeAtomicOrcadRemoteRecord) + .mock.calls.find(([, path]) => path.endsWith('orcad-active.json')) + expect(JSON.parse(written?.[2] ?? '{}')).toMatchObject({ active: null, previous: VERSION }) + for (const command of log) { + expect(command).not.toMatch(/EncodedCommand|kill |SIGTERM|taskkill|Stop-Process/u) + expect(command).not.toContain('nonce-1') + } + expect(log.some((command) => command.startsWith(`${SLOT_NODE} `))).toBe(true) + }) +}) + +describe('Windows orcad GC', () => { + const gcOptions = () => ({ + conn: conn(), + host, + remoteHome: 'C:/Users/u', + currentDirAbsPath: 'C:/Users/u/.orca-remote/orcad-0.3.0+cc01', + record + }) + + async function screen(answer: string | Error): Promise { + mockExec.mockImplementation(async (_conn, command: string) => { + if (String(command).includes(' record-read ')) { + return '__ORCAD_RECORD_ABSENT__\r\n' + } + // The activation fence probe is the relay's shared lock check. + if (String(command).startsWith('powershell.exe ')) { + return 'OPEN' + } + if (answer instanceof Error) { + throw answer + } + return answer + }) + await gcOldOrcadVersions(gcOptions()) + const passOptions = vi.mocked(gcOldRemoteInstallVersions).mock.calls[0]?.[5] + return ( + (await passOptions?.resolveExtraPinnedDirNames?.(['orcad-a', 'orcad-b', 'orcad-c'])) ?? null + ) + } + + it('screens every candidate in one node.exe and pins all but the proven dead', async () => { + expect(await screen('__ORCAD_LIVENESS__ LIVE,DEAD,UNKNOWN\r\n')).toEqual(['orcad-a', 'orcad-c']) + const screens = mockExec.mock.calls.filter(([, command]) => + String(command).includes('liveness-many') + ) + expect(screens).toHaveLength(1) + expect(String(screens[0]?.[1])).toContain( + 'C:/Users/u/.orca-remote/orcad-a C:/Users/u/.orca-remote/orcad-b C:/Users/u/.orca-remote/orcad-c' + ) + }) + + it('deletes nothing when the host cannot answer for every candidate', async () => { + expect(await screen('__ORCAD_LIVENESS__ DEAD,DEAD\r\n')).toBeNull() + vi.clearAllMocks() + expect(await screen(new Error('exit 1'))).toBeNull() + }) +}) diff --git a/src/main/ssh/orcad-remote-windows-scripts.test.ts b/src/main/ssh/orcad-remote-windows-scripts.test.ts index c34fa9f474ca..8031cf5f22e9 100644 --- a/src/main/ssh/orcad-remote-windows-scripts.test.ts +++ b/src/main/ssh/orcad-remote-windows-scripts.test.ts @@ -95,6 +95,29 @@ describe('Windows liveness script', () => { expect(await liveness()).toBe('DEAD') }) + it('answers a whole GC pass of dirs from one process, in order', async () => { + const live = join(dir, 'live') + const dead = join(dir, 'dead') + const unknown = join(dir, 'unknown') + for (const [slot, pid] of [ + [live, process.pid], + [dead, DEAD_PID] + ] as const) { + mkdirSync(slot) + writeFileSync(join(slot, ORCAD_WINDOWS_PROCESS_TREE_FILENAME), '') + writeFileSync( + join(slot, `${ORCAD_WINDOWS_PROCESS_TREE_FILENAME}.json`), + JSON.stringify({ [process.pid]: 1000 }) + ) + writeFileSync( + join(slot, ORCAD_WINDOWS_PROCESS_FILENAME), + JSON.stringify({ pid, creationTimeMs: 1000 }) + ) + } + const { stdout } = await runOp('liveness-many', [live, dead, unknown]) + expect(stdout.trim()).toBe('__ORCAD_LIVENESS__ LIVE,DEAD,UNKNOWN') + }) + it('is UNKNOWN when nothing proves identity: no record, no time, no addon', async () => { expect(await liveness()).toBe('UNKNOWN') recordProcess(process.pid, null) diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index bac21721fa20..c00a51333bc7 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -1,6 +1,6 @@ // Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: resolve the context -// (pinned node.exe, host script), deploy and activate, prove readiness and liveness, stop through -// the slot's request file, and prove exit. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +// (pinned node.exe, host script), deploy and activate, prove readiness and liveness, decommission +// through the instance-bound stop request, prove exit, and run a GC pass. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 // provisions the account and runs this file for `orcad-*` cells; ssh-windows-hosts.yml runs that. // // Run: ORCA_RUN_SSH_WINDOWS_HOST=1 ORCA_SSH_WINDOWS_HOST_CELL= pnpm test @@ -18,7 +18,10 @@ import { import { resolveOrcadRemoteContext } from './orcad-remote-context' import { deployOrcad } from './orcad-remote-deploy' import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' -import { orcadSlotDir, stopOrcadSlot, type OrcadSlotOptions } from './orcad-recovery-slot' +import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' +import { decommissionRemoteOrcad } from './orcad-remote-stop' +import { readOrcadActivationRecord } from './orcad-activation-record-store' +import { gcOldOrcadVersions } from './orcad-remote-gc' import { execOrcadRemote } from './orcad-remote-runtime-control' import { isWindowsOrcadCellId, @@ -46,7 +49,7 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { }) it( - 'deploys, serves, stops by request and exits', + 'deploys, serves, decommissions by request and exits', async () => { const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '') if (!isWindowsOrcadCellId(descriptor.cell)) { @@ -86,8 +89,26 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, slotDir)) ) expect(await liveness()).toBe('LIVE') - receipt.stop = await stopOrcadSlot(options, slotDir, false) - expect(receipt.stop).toBe('stopped') + // Decommission stops it by the instance-bound request orcad itself completes and proves. + const record = await readOrcadActivationRecord(options) + receipt.decommission = await decommissionRemoteOrcad({ + ...options, + record, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } + }) + expect(receipt.decommission).toMatchObject({ + outcome: 'decommissioned', + version: deployed.fullVersion + }) + expect(await liveness()).toBe('DEAD') + // A GC pass after decommission must leave the recorded previous slot in place. + await gcOldOrcadVersions({ + conn, + host: options.host, + remoteHome: options.remoteHome, + currentDirAbsPath: slotDir, + record: await readOrcadActivationRecord(options) + }) expect(await liveness()).toBe('DEAD') const commands = exec.mock.calls.map(([command]) => String(command)) diff --git a/src/main/ssh/orcad-windows-host-script.ts b/src/main/ssh/orcad-windows-host-script.ts index 92f529f88bb5..cedc73597d9c 100644 --- a/src/main/ssh/orcad-windows-host-script.ts +++ b/src/main/ssh/orcad-windows-host-script.ts @@ -38,6 +38,7 @@ export const ORCAD_RECORD_PRESENT_MARKER = '__ORCAD_RECORD_PRESENT__' export const ORCAD_BUILD_HASH_MARKER = '__ORCAD_BUILD_HASH__' export const ORCAD_WINDOWS_READINESS_MARKER = '__ORCAD_READINESS__' export const ORCAD_WINDOWS_RUNTIME_MARKER = '__ORCAD_RUNTIME__' +export const ORCAD_WINDOWS_LIVENESS_MANY_MARKER = '__ORCAD_LIVENESS__' /** A slot whose marker names no usable runtime; the POSIX selector exits the same way. */ export const ORCAD_WINDOWS_RUNTIME_MISSING_EXIT = 78 /** The record exists but cannot be read within its bound. */ @@ -45,6 +46,7 @@ export const ORCAD_WINDOWS_RECORD_UNREADABLE_EXIT = 65 export type OrcadWindowsHostOp = | 'liveness' + | 'liveness-many' | 'stop' | 'readiness-wait' | 'build-hash' @@ -107,6 +109,16 @@ const ops = { answer(state === 'alive' ? 'LIVE' : state === 'dead' ? 'DEAD' : 'UNKNOWN') }, + // One process for a whole GC pass: a node.exe per version dir is the burst EDR scores. + 'liveness-many'(...dirs) { + const states = dirs.map((dir) => { + const record = orcadRecord(dir) + const state = record ? orcadState(dir, record) : 'unknown' + return state === 'alive' ? 'LIVE' : state === 'dead' ? 'DEAD' : 'UNKNOWN' + }) + answer(${text(`${ORCAD_WINDOWS_LIVENESS_MANY_MARKER} `)} + states.join(',') + '\\n') + }, + // Never a signal: on Windows that is TerminateProcess, which skips the durable shutdown. stop(dir, waitArg, launchedArg) { const record = orcadRecord(dir) diff --git a/src/main/ssh/remote-install-gc.ts b/src/main/ssh/remote-install-gc.ts index 532a350681aa..848b13876d8e 100644 --- a/src/main/ssh/remote-install-gc.ts +++ b/src/main/ssh/remote-install-gc.ts @@ -74,10 +74,10 @@ export type RemoteInstallGcOptions = { */ pinnedDirNames?: readonly string[] /** - * More pins, resolved only once a candidate exists. Null means the host could not say which - * directories to keep, so this pass deletes nothing. + * More pins, resolved only once a candidate exists, given those candidates. Null means the + * host could not say which directories to keep, so this pass deletes nothing. */ - resolveExtraPinnedDirNames?: () => Promise + resolveExtraPinnedDirNames?: (candidates: readonly string[]) => Promise } /** @@ -133,7 +133,7 @@ export async function gcOldRemoteInstallVersions( return } const extraPins = options.resolveExtraPinnedDirNames - ? await options.resolveExtraPinnedDirNames() + ? await options.resolveExtraPinnedDirNames(candidates) : [] if (!extraPins) { return From 1e02eab935e2c6ff6f181325a1fa56c9d5661df8 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 05:10:13 -0700 Subject: [PATCH 2/2] fix(orcad): accept a managed stop request whose lock path is spelled with Windows client separators --- src/main/orcad/orcad-managed-stop-request.ts | 5 +++-- src/main/orcad/orcad-managed-stop.test.ts | 20 ++++++++++++++++++-- src/main/ssh/orcad-windows-host-lane.test.ts | 3 ++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/orcad/orcad-managed-stop-request.ts b/src/main/orcad/orcad-managed-stop-request.ts index ffd7560cae0d..f7211dd7d7c2 100644 --- a/src/main/orcad/orcad-managed-stop-request.ts +++ b/src/main/orcad/orcad-managed-stop-request.ts @@ -1,7 +1,7 @@ /** Validating an instance-bound stop request before the running orcad acts on it. */ import { createHash } from 'node:crypto' import { lstatSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' import { ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES, @@ -54,7 +54,8 @@ export function validateOrcadManagedStopRequest( if ( request.version !== context.version || request.runtimeId !== context.runtimeId || - request.instance.lockPath !== context.instance.lockPath || + // Resolved: a Windows client names the lock with `/`, orcad's own path.join with `\`. + resolve(request.instance.lockPath) !== resolve(context.instance.lockPath) || !sameOrcadManagedStopInstance(request.instance, context.instance) ) { throw new Error('orcad_managed_stop_request_identity_mismatch') diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts index a9b6f848cc0f..24ebce56a72d 100644 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ b/src/main/orcad/orcad-managed-stop.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, dirname, join, sep } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcadManagedStopCompletionSchema, @@ -88,7 +88,11 @@ describe('managed stop requests', () => { for (const mismatch of [ { ...request, version: '0.9.0' }, { ...request, runtimeId: 'runtime-2' }, - { ...request, instance: { ...request.instance, pid: request.instance.pid + 1 } } + { ...request, instance: { ...request.instance, pid: request.instance.pid + 1 } }, + { + ...request, + instance: { ...request.instance, lockPath: `${request.instance.lockPath}.old` } + } ]) { writeFileSync(path, JSON.stringify(mismatch)) expect(() => validateOrcadManagedStopRequest(context(request), path)).toThrow( @@ -97,6 +101,18 @@ describe('managed stop requests', () => { } }) + it('accepts the same lock path spelled differently, as a Windows client sends it', () => { + const { request } = running() + const path = orcadManagedStopRequestPath(request.instance) + const { lockPath } = request.instance + // `/` separators and a `.` segment: the same file, not the same string. + const respelled = `${dirname(lockPath)}/./${basename(lockPath)}`.replaceAll(sep, '/') + expect(respelled).not.toBe(lockPath) + const sent = { ...request, instance: { ...request.instance, lockPath: respelled } } + writeFileSync(path, JSON.stringify(sent)) + expect(validateOrcadManagedStopRequest(context(request), path)).toEqual(sent) + }) + it('refuses a request once the instance lock names another holder', () => { const { lock, request } = running() const path = orcadManagedStopRequestPath(request.instance) diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index c00a51333bc7..4af12d71dae0 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -96,7 +96,8 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { record, census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } }) - expect(receipt.decommission).toMatchObject({ + // The message carries the refusal code and reason, which toMatchObject's diff omits. + expect(receipt.decommission, JSON.stringify(receipt.decommission)).toMatchObject({ outcome: 'decommissioned', version: deployed.fullVersion })