Files
orca/src/main/ssh/ssh-relay-versioned-install.test.ts
T
OrcaWinandm4air 9ddcc9b9f0 fix(ssh): collect relay versions only when provably exited; runtimes/ store GC (#24130)
* fix(ssh): relay version GC deletes only on an exited verdict and keeps the previous build

The relay records .relay-pid in its version dir once it owns its socket. GC calls a
relay version dir exited only when that PID is provably dead and every relay-*.sock
refuses a connection; a dir without a PID file keeps the test -S rule. The most
recently completed other relay build is pinned like orcad's rollback target.
Design D5 GC liveness.

* feat(ssh): collect the shared runtimes/ Node store and give it its own owner

runtimes/ gets its own owner in the install model, so no version-dir GC (new or old
clients, whose listings are prefix-scoped) can list or delete it. A store pass
removes node-<sha> only when no retained dir references it, it is neither a current
pin nor the newest other verified runtime, and a ps or /proc check ran and found no
process using it. Legacy relay-*/orcad-* dirs are read for references and reported
as diagnostics only (design D10 two-step). Wired behind orcad GC's nodeRuntimePins.

* fix(ssh): runtime store process check holds runtimes reached through a symlinked home

/proc exe resolves symlinks and argv keeps whatever spelling launched the runtime, so
filtering on the exact $root path missed in-use runtimes on hosts like /home -> /var/home.
Filter on the store segment instead; the parser already attributes holds root-agnostically.

* test(ssh): wait for the holder process to spawn instead of a fixed delay

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-10-01 01:10:47 -07:00

929 lines
34 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DeployHelpers from './ssh-relay-deploy-helpers'
vi.mock('fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn()
}))
vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({
...(await importOriginal<typeof DeployHelpers>()),
execCommand: vi.fn()
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
}))
// The previous-build pin has its own tests; here it names a build that is never a candidate.
vi.mock('./remote-install-previous-version', () => ({
findPreviousRemoteInstall: vi.fn().mockResolvedValue({ state: 'ok', dirName: 'relay-0.1.0+fff' })
}))
import { existsSync, readFileSync } from 'node:fs'
import {
readLocalFullVersion,
computeRemoteRelayDir,
isRelayAlreadyInstalled,
finalizeInstall,
abandonInstall,
gcOldRelayVersions
} from './ssh-relay-versioned-install'
import { acquireInstallLock } from './ssh-relay-install-lock'
import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock'
import {
isRelayGcClaimed,
relayGcClaimPath,
releaseRelayGcClaim,
tryAcquireRelayGcClaim,
waitForRelayGcClaimRelease
} from './ssh-relay-gc-claim'
import { execCommand } from './ssh-relay-deploy-helpers'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import type { SshConnection } from './ssh-connection'
const conn = {} as SshConnection
const mockExec = vi.mocked(execCommand)
const mockExists = vi.mocked(existsSync)
const mockRead = vi.mocked(readFileSync)
function decodePowerShellCommand(command: string): string {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
return match ? Buffer.from(match[1], 'base64').toString('utf16le') : ''
}
describe('readLocalFullVersion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns trimmed contents of the .version file', () => {
mockExists.mockReturnValue(true)
mockRead.mockReturnValue('0.1.0+deadbeef\n')
expect(readLocalFullVersion('/local/relay')).toBe('0.1.0+deadbeef')
})
it('throws an actionable error when the .version file is missing', () => {
mockExists.mockReturnValue(false)
expect(() => readLocalFullVersion('/local/relay')).toThrow(/missing its version marker/)
})
it('throws when the .version file is empty', () => {
mockExists.mockReturnValue(true)
mockRead.mockReturnValue(' \n')
expect(() => readLocalFullVersion('/local/relay')).toThrow(/is empty/)
})
})
describe('computeRemoteRelayDir', () => {
it('joins remoteHome with .orca-remote and the version-keyed dir name', () => {
expect(computeRemoteRelayDir('/home/u', '0.1.0+abc')).toBe(
'/home/u/.orca-remote/relay-0.1.0+abc'
)
})
})
describe('isRelayAlreadyInstalled', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns true only when the OK probe succeeds', async () => {
mockExec.mockResolvedValueOnce('OK')
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(true)
})
it('returns false when the probe reports MISSING', async () => {
mockExec.mockResolvedValueOnce('MISSING')
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false)
})
it('returns false on exec error', async () => {
mockExec.mockRejectedValueOnce(new Error('boom'))
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false)
})
it('does not convert an aborted install probe into a missing install', async () => {
const controller = new AbortController()
mockExec.mockImplementationOnce(async () => {
controller.abort()
throw Object.assign(new Error('cancelled'), { name: 'AbortError' })
})
await expect(
isRelayAlreadyInstalled(conn, '/r', undefined, { signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
})
it('keeps default probe failures as not installed for SSH session-limit-shaped errors', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
mockExec.mockRejectedValueOnce(sessionLimitError)
await expect(isRelayAlreadyInstalled(conn, '/r')).resolves.toBe(false)
})
it('rethrows SSH session-limit errors in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
mockExec.mockRejectedValueOnce(sessionLimitError)
await expect(
isRelayAlreadyInstalled(conn, '/r', undefined, { rethrowSessionLimitErrors: true })
).rejects.toBe(sessionLimitError)
})
it('checks every relay runtime artifact and .install-complete', async () => {
mockExec.mockResolvedValueOnce('OK')
await isRelayAlreadyInstalled(conn, '/r')
const cmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(cmd).toContain('relay.js')
expect(cmd).toContain('relay-watcher.js')
expect(cmd).toContain('relay-ai-vault-service.js')
expect(cmd).toContain('managed-hook-runtime.js')
expect(cmd).toContain('.install-complete')
})
})
describe('acquireInstallLock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns when mkdir reports OK', async () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('OPEN')
await acquireInstallLock(conn, '/r')
expect(mockExec).toHaveBeenCalledTimes(4)
})
it('recovers a stale sibling GC claim before first-install lock acquisition', async () => {
mockExec
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('RELEASED')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('OPEN')
await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined()
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands[1]).toContain('lock_tombstone')
expect(commands[4]).toBe("mkdir -p '/r'")
})
it('returns immediately without deleting a live repair lock', async () => {
// Why: repair can run npm install plus rebuild under the same lock; a
// second reconnect must launch degraded rather than corrupt node_modules.
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('0')
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('busy')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true)
expect(commands.filter((command) => command.startsWith('rm -rf'))).toHaveLength(0)
})
it('does not report stale or indeterminate contention as a launch fence', async () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce(`${21 * 60}`)
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error')
mockExec.mockReset().mockRejectedValueOnce(new Error('claim probe failed'))
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error')
})
it('recovers a stale best-effort repair lock without polling', async () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('OPEN')
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('acquired')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true)
expect(commands.filter((command) => command.includes('lock_tombstone'))).toHaveLength(1)
})
it('backs out when GC claims the sibling path during repair lock acquisition', async () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('')
await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('gc')
const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(lastCommand).toBe("rm -rf '/r/.install-lock'")
})
it('propagates cancellation through best-effort repair lock commands', async () => {
const abortController = new AbortController()
const abortError = Object.assign(new Error('cancelled'), { name: 'AbortError' })
mockExec.mockRejectedValueOnce(abortError)
const promise = tryAcquireRelayRepairLock(conn, '/r', undefined, {
signal: abortController.signal
})
abortController.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
expect(mockExec.mock.calls[0]?.[2]?.signal).toBe(abortController.signal)
})
it('polls until the lock becomes available (concurrent installer wins, then we acquire)', async () => {
vi.useFakeTimers()
try {
let createAttempts = 0
mockExec.mockImplementation(async (_conn: unknown, command: string) => {
if (command.includes('.gc-claim')) {
return 'OPEN'
}
if (command.startsWith('mkdir -p')) {
return ''
}
if (command.includes('lock_tombstone')) {
return 'BUSY'
}
if (command.includes('.install-lock')) {
createAttempts++
return createAttempts >= 3 ? 'OK' : 'BUSY'
}
return ''
})
const promise = acquireInstallLock(conn, '/r')
// Drive the polling loop: each iteration awaits a 1s timer.
for (let i = 0; i < 5; i++) {
await vi.advanceTimersByTimeAsync(1_000)
}
await promise
const cmds = mockExec.mock.calls.map(([, c]) => c)
const mkdirAttempts = cmds.filter((c) => c.includes('mkdir') && c.includes('.install-lock'))
expect(mkdirAttempts.length).toBeGreaterThanOrEqual(3)
} finally {
vi.useRealTimers()
}
})
it('immediately tries to steal an already-stale lock', async () => {
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('BUSY')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('OPEN')
await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined()
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds).toHaveLength(5)
expect(cmds[3]).toContain('lock_tombstone')
})
it('retries stale takeover when a fresh lock ages out during the wait', async () => {
vi.useFakeTimers({ now: 1_700_000_000_000 })
try {
const recoverableAt = Date.now() + 60_000
mockExec.mockImplementation(async (_conn: unknown, cmd: string) => {
if (cmd.includes('.gc-claim')) {
return 'OPEN'
}
if (cmd.startsWith('mkdir -p')) {
return ''
}
if (cmd.includes('lock_tombstone')) {
return Date.now() >= recoverableAt ? 'OK' : 'BUSY'
}
if (cmd.includes('mkdir') && cmd.includes('.install-lock')) {
return 'BUSY'
}
return ''
})
const promise = acquireInstallLock(conn, '/r')
await vi.advanceTimersByTimeAsync(61_000)
await expect(promise).resolves.toBeUndefined()
expect(mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone'))).toHaveLength(
2
)
} finally {
vi.useRealTimers()
}
})
it('throws if the timeout elapses and the lock is fresh', async () => {
vi.useFakeTimers({ now: 1_700_000_000_000 })
try {
mockExec.mockImplementation(async (_conn: unknown, cmd: string) => {
if (cmd.includes('.gc-claim')) {
return 'OPEN'
}
if (cmd.startsWith('mkdir -p')) {
return ''
}
if (cmd.includes('lock_tombstone')) {
return 'BUSY'
}
if (cmd.includes('mkdir') && cmd.includes('.install-lock')) {
return 'BUSY'
}
if (cmd.includes('stat')) {
return '0\n'
}
return ''
})
const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow(
/another install is still in progress/i
)
await vi.advanceTimersByTimeAsync(905_000)
await rejection
} finally {
vi.useRealTimers()
}
})
it('stops polling when the caller aborts the lock wait', async () => {
vi.useFakeTimers()
try {
const abortController = new AbortController()
mockExec.mockImplementation(async (_conn: unknown, cmd: string) =>
cmd.includes('.gc-claim') ? 'OPEN' : 'BUSY'
)
const promise = acquireInstallLock(conn, '/r', undefined, {
signal: abortController.signal
})
await vi.advanceTimersByTimeAsync(1_000)
abortController.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
const callCountAfterAbort = mockExec.mock.calls.length
await vi.advanceTimersByTimeAsync(10_000)
expect(mockExec).toHaveBeenCalledTimes(callCountAfterAbort)
} finally {
vi.useRealTimers()
}
})
it('keeps waiting while a slow first installer is within the deploy bound', async () => {
vi.useFakeTimers({ now: 1_700_000_000_000 })
try {
const availableAt = Date.now() + 500_000
mockExec.mockImplementation(async (_conn: unknown, cmd: string) => {
if (cmd.includes('.gc-claim')) {
return 'OPEN'
}
if (cmd.startsWith('mkdir -p')) {
return ''
}
if (cmd.includes('mkdir') && cmd.includes('.install-lock')) {
return Date.now() >= availableAt ? 'OK' : 'BUSY'
}
return ''
})
const promise = acquireInstallLock(conn, '/r')
await vi.advanceTimersByTimeAsync(501_000)
await expect(promise).resolves.toBeUndefined()
expect(
mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone')).length
).toBeGreaterThan(1)
} finally {
vi.useRealTimers()
}
})
it('finalizeInstall writes .install-complete then removes the lock', async () => {
mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('')
await finalizeInstall(conn, '/r')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds[0]).toContain('touch')
expect(cmds[0]).toContain('.install-complete')
expect(cmds[1]).toContain('rm -rf')
expect(cmds[1]).toContain('.install-lock')
})
it('abandonInstall removes the lock without writing the sentinel', async () => {
mockExec.mockResolvedValueOnce('')
await abandonInstall(conn, '/r')
const cmd = mockExec.mock.calls[0]?.[1] ?? ''
expect(cmd).toContain('rm -rf')
expect(cmd).toContain('.install-lock')
expect(cmd).not.toContain('.install-complete')
})
})
describe('relay GC claim', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExec.mockReset()
})
it('parses explicit claim markers through remote startup noise', async () => {
mockExec.mockResolvedValueOnce('Welcome to host\nLOCKED\n')
await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(true)
mockExec.mockResolvedValueOnce('Last login: today\nOPEN\n')
await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(false)
})
it('rejects missing or conflicting claim markers', async () => {
mockExec.mockResolvedValueOnce('Welcome to host\n')
await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive')
mockExec.mockResolvedValueOnce('LOCKED\nOPEN\n')
await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive')
})
it('uses a stable sibling path outside the recursively deleted install', async () => {
mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('')
await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toEqual(
expect.any(String)
)
expect(relayGcClaimPath('/relay/version')).toBe('/relay/version.gc-claim')
expect(mockExec.mock.calls[0]?.[1]).toContain("mkdir '/relay/version.gc-claim'")
})
it('recovers and removes a stale sibling claim before retrying deploy', async () => {
mockExec
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('RELEASED')
await waitForRelayGcClaimRelease(conn, '/relay/version')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands[1]).toContain('lock_tombstone')
expect(commands[3]).toContain("rm -rf '/relay/version.gc-claim'")
})
it('keeps waiting after losing a recovered claim until the claim is actually gone', async () => {
mockExec
.mockResolvedValueOnce('LOCKED')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('LOST')
.mockResolvedValueOnce('OPEN')
await waitForRelayGcClaimRelease(conn, '/relay/version')
expect(mockExec).toHaveBeenCalledTimes(5)
})
it('writes and conditionally releases the Windows sibling claim owner token', async () => {
const windows = getRemoteHostPlatform('win32-x64')
mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('').mockResolvedValueOnce('RELEASED')
const token = await tryAcquireRelayGcClaim(conn, 'C:/relay/version', windows)
expect(token).toEqual(expect.any(String))
await expect(releaseRelayGcClaim(conn, 'C:/relay/version', token!, windows)).resolves.toBe(
'released'
)
const ownerScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '')
const releaseScript = decodePowerShellCommand(mockExec.mock.calls[2]?.[1] ?? '')
expect(ownerScript).toContain('Set-Content -LiteralPath')
expect(ownerScript).toContain('.gc-claim/.gc-owner')
expect(releaseScript).toContain('Get-Content -LiteralPath')
expect(releaseScript).toContain('-cne')
expect(releaseScript).toContain('Remove-Item -LiteralPath')
expect(releaseScript).toContain("'RELEASED'")
expect(releaseScript).toContain("'LOST'")
expect(releaseScript).toContain("'UNKNOWN'")
expect(releaseScript).not.toContain('}; elseif')
expect(releaseScript).not.toContain('{;')
})
it('conditionally releases a claim when the owner write reply is lost', async () => {
mockExec
.mockResolvedValueOnce('OK')
.mockRejectedValueOnce(new Error('owner write reply lost'))
.mockResolvedValueOnce('RELEASED')
await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toBeNull()
const ownerCommand = mockExec.mock.calls[1]?.[1] ?? ''
const releaseCommand = mockExec.mock.calls[2]?.[1] ?? ''
const token = ownerCommand.match(/printf %s '([^']+)'/)?.[1]
expect(token).toBeTruthy()
expect(releaseCommand).toContain(`!= '${token}'`)
})
})
describe('gcOldRelayVersions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExec.mockReset()
})
it('removes a sibling that is complete, unlocked, and has no live socket', async () => {
// ls listing
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+bbb\n')
// For sibling "aaa": safety probes pass, then GC claims the install lock before removal.
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockResolvedValueOnce('MOVED')
.mockResolvedValueOnce('RELEASED')
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(lastCmd).toContain('rm -rf')
expect(lastCmd).toContain('relay-0.1.0+aaa.gc-tombstone')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(
commands.some((command) => command === "rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'")
).toBe(false)
})
it('cleans only strict POSIX orphan tombstones even with no relay candidates', async () => {
mockExec
.mockResolvedValueOnce(
[
'relay-0.1.0+abc.gc-tombstone.123.456',
'relay-0.1.0+abc.gc-tombstone.bad.456',
'xrelay-0.1.0+abc.gc-tombstone.123.456',
'relay-0.1.0+abc.gc-tombstone.123.456.extra',
'relay-0.1.0+abc.gc-tombstone.123.456/child',
'logs'
].join('\n')
)
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const removeCommands = mockExec.mock.calls
.map(([, command]) => command)
.filter((command) => command.startsWith('rm -rf'))
expect(removeCommands).toEqual([
"rm -rf '/home/u/.orca-remote/relay-0.1.0+abc.gc-tombstone.123.456'"
])
})
it('cleans only strict Windows orphan tombstones even with no relay candidates', async () => {
const windows = getRemoteHostPlatform('win32-x64')
mockExec
.mockResolvedValueOnce(
'relay-v0.1.0.gc-tombstone.123.456\nrelay-v0.1.0.gc-tombstone.latest.456\n'
)
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, 'C:/Users/u', 'C:/Users/u/.orca-remote/relay-0.1.0+bbb', windows)
const removeScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '')
expect(removeScript).toContain('relay-v0.1.0.gc-tombstone.123.456')
expect(removeScript).not.toContain('relay-v0.1.0.gc-tombstone.latest.456')
})
it('retries orphan tombstone cleanup on a later GC pass', async () => {
const tombstone = 'relay-0.1.0+abc.gc-tombstone.123.456'
mockExec
.mockResolvedValueOnce(tombstone)
.mockRejectedValueOnce(new Error('remove failed'))
.mockResolvedValueOnce(tombstone)
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const removeCommands = mockExec.mock.calls
.map(([, command]) => command)
.filter((command) => command.startsWith('rm -rf'))
expect(removeCommands).toHaveLength(2)
})
it('retries only an unknown claim release and stops after observing a lost generation', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockResolvedValueOnce('MOVED')
.mockResolvedValueOnce('UNKNOWN')
.mockResolvedValueOnce('LOST')
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const releaseCommands = mockExec.mock.calls
.map(([, command]) => command)
.filter((command) => command.includes('.gc-owner') && command.includes('echo RELEASED'))
expect(releaseCommands).toHaveLength(2)
})
it('skips siblings that are missing .install-complete (mid-install or partial)', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec
.mockResolvedValueOnce('OPEN') // not locked
.mockResolvedValueOnce('PARTIAL') // missing .install-complete
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
})
it('skips siblings whose .install-lock is held and fresh', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec.mockResolvedValueOnce('LOCKED')
// isLockStale: age ~now → not stale.
mockExec.mockResolvedValueOnce('0\n')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
})
it('removes a sibling with a stale lock + .install-complete (rm-lock failed mid-finalize)', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec.mockResolvedValueOnce('LOCKED')
// isLockStale: age well above the stale window → stale.
mockExec.mockResolvedValueOnce(`${21 * 60}\n`)
mockExec.mockResolvedValueOnce('COMPLETE') // .install-complete present
mockExec.mockResolvedValueOnce('DEAD') // socket probe
mockExec.mockResolvedValueOnce('OK') // stable sibling GC claim
mockExec.mockResolvedValueOnce('') // write claim ownership token
mockExec.mockResolvedValueOnce('LOCKED')
mockExec.mockResolvedValueOnce(`${21 * 60}\n`)
mockExec.mockResolvedValueOnce('COMPLETE')
mockExec.mockResolvedValueOnce('DEAD')
mockExec.mockResolvedValueOnce('OWNED')
mockExec.mockResolvedValueOnce('MOVED')
mockExec.mockResolvedValueOnce('RELEASED') // release sibling claim
mockExec.mockResolvedValueOnce('') // remove tombstone
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(lastCmd).toContain('rm -rf')
expect(lastCmd).toContain('relay-0.1.0+aaa')
})
it('GCs a legacy relay-v0.1.0 dir whose daemon is dead (no .install-complete required)', async () => {
mockExec.mockResolvedValueOnce('relay-v0.1.0\n')
mockExec.mockResolvedValueOnce('OPEN') // not locked
mockExec.mockResolvedValueOnce('DEAD') // socket probe (no completeProbe — legacy)
mockExec.mockResolvedValueOnce('OK') // GC claims candidate
mockExec.mockResolvedValueOnce('')
mockExec.mockResolvedValueOnce('OPEN')
mockExec.mockResolvedValueOnce('DEAD')
mockExec.mockResolvedValueOnce('OWNED')
mockExec.mockResolvedValueOnce('MOVED')
mockExec.mockResolvedValueOnce('RELEASED')
mockExec.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf') && c.includes('relay-v0.1.0'))).toBe(true)
// critically: no .install-complete probe on legacy dirs
expect(cmds.some((c) => c.includes('.install-complete'))).toBe(false)
})
it('keeps a legacy relay-v0.1.0 dir whose daemon is still serving', async () => {
mockExec.mockResolvedValueOnce('relay-v0.1.0\n')
mockExec.mockResolvedValueOnce('OPEN')
mockExec.mockResolvedValueOnce('ALIVE') // socket alive → keep
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
})
it('skips siblings with a live relay-*.sock', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('ALIVE')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
})
it('keeps a sibling when the install-lock probe fails or returns unexpected output', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n')
.mockRejectedValueOnce(new Error('lock probe failed'))
.mockResolvedValueOnce('INCONCLUSIVE')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
expect(mockExec).toHaveBeenCalledTimes(3)
})
it('keeps a sibling when the relay-liveness probe fails or returns unexpected output', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockRejectedValueOnce(new Error('liveness probe failed'))
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('INCONCLUSIVE')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
expect(mockExec).toHaveBeenCalledTimes(7)
})
it('probes Windows GC liveness by connecting to named pipes, not process command lines', async () => {
const windows = getRemoteHostPlatform('win32-x64')
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('WAITING')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('WAITING')
.mockResolvedValueOnce('OWNED')
.mockResolvedValueOnce('MOVED')
.mockResolvedValueOnce('RELEASED')
.mockResolvedValueOnce('')
await gcOldRelayVersions(
conn,
'C:/Users/u',
'C:/Users/u/.orca-remote/relay-0.1.0+bbb',
windows,
{
windowsNodePath: 'C:/Program Files/nodejs/node.exe',
windowsSockNames: ['relay-target.sock']
}
)
const livenessCommand = mockExec.mock.calls[3]?.[1] ?? ''
const script = decodePowerShellCommand(livenessCommand ?? '')
expect(script).toContain('net.connect(pipe)')
expect(script).toContain('.windows-active-pipe-')
expect(script).toContain('\\\\.\\pipe\\orca-relay-')
expect(script).not.toContain('Win32_Process')
})
it('does not consider the current dir as a GC candidate', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+aaa')
expect(mockExec.mock.calls.length).toBe(1) // only the listing
})
it('ignores entries that do not match the relay version dir regex (allowlist)', async () => {
mockExec.mockResolvedValueOnce('logs\nbackup\nrelay-0.1.0+aaa\n')
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockResolvedValueOnce('MOVED')
.mockResolvedValueOnce('RELEASED')
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
const rmCmds = cmds.filter((c) => c.startsWith('rm') && c.includes('gc-tombstone'))
expect(rmCmds).toHaveLength(1)
expect(rmCmds[0]).toContain('relay-0.1.0+aaa')
expect(rmCmds[0]).not.toContain('logs')
expect(rmCmds[0]).not.toContain('backup')
})
it('does not remove a candidate claimed by repair after the initial lock probe', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD') // socket probe
.mockResolvedValueOnce('OK') // GC sibling claim
.mockResolvedValueOnce('') // write claim ownership token
.mockResolvedValueOnce('LOCKED') // repair won before the safety recheck
.mockResolvedValueOnce('0')
.mockResolvedValueOnce('RELEASED') // release GC sibling claim
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(
commands.some(
(command) =>
command.startsWith("rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") &&
!command.includes('.install-lock')
)
).toBe(false)
})
it('does not move a candidate after losing the sibling claim generation', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('LOST')
.mockResolvedValueOnce('LOST')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const commands = mockExec.mock.calls.map(([, command]) => command)
expect(commands.some((command) => command.startsWith('mv '))).toBe(false)
})
it('releases the GC claim after a confirmed move failure', async () => {
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockRejectedValueOnce(new Error('move failed'))
.mockResolvedValueOnce('RELEASED')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(lastCommand).toContain('rm -rf')
expect(lastCommand).toContain('relay-0.1.0+aaa.gc-claim')
})
it('keeps the GC claim when remote move termination is unconfirmed', async () => {
const unconfirmed = Object.assign(new Error('move timed out'), {
sshChannelCloseConfirmed: false
})
mockExec
.mockResolvedValueOnce('relay-0.1.0+aaa\n')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OK')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('DEAD')
.mockResolvedValueOnce('OWNED')
.mockRejectedValueOnce(unconfirmed)
await expect(
gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
).rejects.toBe(unconfirmed)
const releaseCommands = mockExec.mock.calls
.map(([, command]) => command)
.filter((command) => command.includes('relay-0.1.0+aaa.gc-claim') && command.startsWith('rm'))
expect(releaseCommands).toHaveLength(0)
})
})