Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/main/orcad/orcad-managed-stop-request.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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')
Expand Down
20 changes: 18 additions & 2 deletions src/main/orcad/orcad-managed-stop.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
19 changes: 14 additions & 5 deletions src/main/ssh/orcad-managed-remote-stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -51,10 +54,16 @@ export async function readRemoteOrcadManagedStopTarget(
options: OrcadSlotOptions,
version: string
): Promise<OrcadManagedStopTarget> {
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(
Expand Down
50 changes: 46 additions & 4 deletions src/main/ssh/orcad-remote-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,8 +55,6 @@ export type OrcadGcOptions = {
}

export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise<void> {
// 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.')
Expand All @@ -71,6 +71,11 @@ export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise<void>
...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(
Expand Down Expand Up @@ -101,3 +106,40 @@ export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise<void>
})
}
}

/** 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<readonly string[] | null> {
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')
}
10 changes: 5 additions & 5 deletions src/main/ssh/orcad-remote-host-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
* 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'

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'
}
Expand Down
4 changes: 1 addition & 3 deletions src/main/ssh/orcad-remote-stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -68,7 +67,6 @@ function censusRefusal(census: OrcadTerminalCensus): Refusal | null {
export async function decommissionRemoteOrcad(
options: OrcadDecommissionOptions
): Promise<OrcadDecommissionResult> {
assertPosixOrcadHost(options.host)
const now = options.now ?? ((): Date => new Date())
return withOrcadActivationLock(options, async (lock) => {
if (await readOrcadActivationTransaction(options)) {
Expand Down
169 changes: 169 additions & 0 deletions src/main/ssh/orcad-remote-windows-decommission.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof InstallLock>()),
acquireInstallLock: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./orcad-remote-record-file', async (importOriginal) => ({
...(await importOriginal<typeof RecordFile>()),
writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<readonly string[] | null> {
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()
})
})
Loading
Loading