Persist profile state in SQLite with background writes (#22612)

Migrate profile state to SQLite and move writes and backups into a background worker. Acknowledge terminal, SSH and automation changes only after durable saves. Preserve JSON import, recovery, rollback and compatibility exports.

Validate migration, worker failures, maintenance, cross-profile moves and terminal lifetime races with unit, integration and end-to-end coverage.
This commit is contained in:
OrcaWin
2026-09-25 22:47:33 -07:00
committed by GitHub
parent f0a3610928
commit 82412dab8b
490 changed files with 41290 additions and 3485 deletions
+68
View File
@@ -0,0 +1,68 @@
import type { SshConnection } from './ssh-connection'
import { execCommand } from './ssh-relay-deploy-helpers'
import { ORCAD_INSTALL_MODEL } from './remote-install-model'
import { computeRemoteInstallDir } from './ssh-relay-versioned-install'
import {
parseOrcadStopOutcome,
stopOrcadCommand,
type OrcadStopOutcome
} from './orcad-remote-process-control'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { compareOrcadStateSnapshotCommand, orcadSnapshotIsUnchanged } from './orcad-state-snapshot'
export type OrcadOutgoingStopOptions = {
conn: SshConnection
host: RemoteHostPlatform
remoteHome: string
nodePath: string
signal?: AbortSignal
}
/** Stop the outgoing runtime and return its execution-host verdict. */
export async function stopOutgoingOrcad(
options: OrcadOutgoingStopOptions,
outgoingVersion: string
): Promise<OrcadStopOutcome> {
const outgoingDir = computeRemoteInstallDir(
ORCAD_INSTALL_MODEL,
options.remoteHome,
outgoingVersion
)
const output = await execCommand(
options.conn,
stopOrcadCommand(options.host, outgoingDir, { waitSeconds: 20, nodePath: options.nodePath }),
{
wrapCommand: options.host.commandDialect !== 'powershell',
signal: options.signal
}
)
return parseOrcadStopOutcome(output)
}
/** The caller must confirm candidate exit before inspecting its shared state. */
export async function rejectedOrcadStateRecoveryRefusal(
options: OrcadOutgoingStopOptions & { userDataDir: string },
incumbentVersion: string,
snapshotDir: string | undefined
): Promise<string | undefined> {
const unchanged = snapshotDir
? orcadSnapshotIsUnchanged(
await execCommand(
options.conn,
compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir),
{ wrapCommand: options.host.commandDialect !== 'powershell', signal: options.signal }
).catch(() => '')
)
: false
if (unchanged) {
return undefined
}
// RPC was already exposed; a prelaunch census cannot authorize discarding candidate writes.
const retainedSnapshot = snapshotDir ? ` at ${snapshotDir}.` : ', which is unavailable.'
return (
'The candidate is stopped, but profile state changed or could not be verified. ' +
`orcad ${incumbentVersion} was not restarted against potentially incompatible state. ` +
'Current state and daemon terminals are preserved; recovery requires a fresh host ' +
`terminal census before restoring the prelaunch snapshot${retainedSnapshot}`
)
}
+81 -4
View File
@@ -82,6 +82,9 @@ type HostScript = {
/** Readiness content per version dir, keyed by the version in the path. */
readiness: Record<string, string>
log: string[]
snapshotResult?: string
comparisonResult?: string
candidateStopResult?: string
}
function scriptHost(script: HostScript): void {
@@ -100,11 +103,15 @@ function scriptHost(script: HostScript): void {
}
if (text.includes('kill -TERM')) {
script.log.push(`stop:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`)
return 'STOPPED'
return text.includes(NEW_VERSION) ? (script.candidateStopResult ?? 'STOPPED') : 'STOPPED'
}
if (text.includes('tar -C') && text.includes('-cf')) {
script.log.push('snapshot')
return 'CAPTURED'
return script.snapshotResult ?? 'CAPTURED'
}
if (text.includes('verdict=UNCHANGED')) {
script.log.push('compare-state')
return script.comparisonResult ?? 'UNCHANGED'
}
return ''
})
@@ -218,7 +225,7 @@ describe('deployOrcad', () => {
})
})
it('snapshots the shared data root before the candidate ever runs', async () => {
it('stops the incumbent before snapshotting, so SQLite WAL files are quiescent', async () => {
const script: HostScript = {
activationRecord: ACTIVE_OLD,
readiness: { [NEW_VERSION]: readyLine({}) },
@@ -228,6 +235,7 @@ describe('deployOrcad', () => {
await deployOrcad(options())
expect(script.log.indexOf('snapshot')).toBeGreaterThan(-1)
expect(script.log.indexOf('snapshot')).toBeLessThan(script.log.indexOf(`launch:${NEW_VERSION}`))
expect(script.log.indexOf(`stop:${OLD_VERSION}`)).toBeLessThan(script.log.indexOf('snapshot'))
})
it('installs but does not activate when terminals are running', async () => {
@@ -278,10 +286,11 @@ describe('deployOrcad', () => {
const result = await deployOrcad(options())
expect(result).toMatchObject({ outcome: 'installed-not-activated' })
expect(script.log).toEqual([
'snapshot',
`stop:${OLD_VERSION}`,
'snapshot',
`launch:${NEW_VERSION}`,
`stop:${NEW_VERSION}`,
'compare-state',
`launch:${OLD_VERSION}`
])
expect(result.outcome === 'installed-not-activated' && result.reason).toContain(
@@ -289,6 +298,74 @@ describe('deployOrcad', () => {
)
})
it.each(['CHANGED', 'UNKNOWN', ''])(
'preserves rejected candidate state when comparison is %s',
async (comparisonResult) => {
const script: HostScript = {
activationRecord: ACTIVE_OLD,
readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }) },
log: [],
comparisonResult
}
scriptHost(script)
const result = await deployOrcad(options())
expect(result).toMatchObject({ outcome: 'installed-not-activated' })
expect(script.log).toEqual([
`stop:${OLD_VERSION}`,
'snapshot',
`launch:${NEW_VERSION}`,
`stop:${NEW_VERSION}`,
'compare-state'
])
expect(result.outcome === 'installed-not-activated' && result.reason).toContain(
'recovery requires a fresh host terminal census'
)
expect(result.outcome === 'installed-not-activated' && result.reason).toContain(
'/home/u/.orca-remote/orcad-state-snapshots/'
)
expect(mockExec.mock.calls.some(([, command]) => command.includes('echo RESTORED'))).toBe(
false
)
}
)
it('restarts the incumbent when a quiescent snapshot cannot be captured', async () => {
const script: HostScript = {
activationRecord: ACTIVE_OLD,
readiness: { [OLD_VERSION]: readyLine({}) },
log: [],
snapshotResult: 'tar: write failed'
}
scriptHost(script)
await expect(deployOrcad(options())).rejects.toThrow('incumbent was stopped')
expect(script.log).toEqual([`stop:${OLD_VERSION}`, 'snapshot', `launch:${OLD_VERSION}`])
})
it.each(['NO_PID', 'STILL_RUNNING', 'SIGNAL_FAILED', ''])(
'does not inspect or replace state without confirmed candidate exit: %s',
async (candidateStopResult) => {
const script: HostScript = {
activationRecord: ACTIVE_OLD,
readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }) },
log: [],
candidateStopResult
}
scriptHost(script)
await deployOrcad(options())
expect(script.log).toEqual([
`stop:${OLD_VERSION}`,
'snapshot',
`launch:${NEW_VERSION}`,
`stop:${NEW_VERSION}`
])
}
)
it('refuses to activate when a different build answered the port', async () => {
const script: HostScript = {
activationRecord: ACTIVE_OLD,
+70 -87
View File
@@ -1,16 +1,7 @@
/**
* Installing orcad on a host and, only if it proves itself, making it the active one.
*
* The install half is the relay's transaction, parameterized: the same per-version lock,
* staged SFTP write, `.install-complete` sentinel and stale-lock recovery, under
* `orcad-<version>/` instead of `relay-<version>/`. That is what §02 marks reusable.
*
* The activation half has no relay equivalent, because the relay has no notion of a version
* being *selected*. Bytes landing in a versioned directory neither picks a version nor rolls
* one back; the activation record does, and it is written only after the candidate publishes
* a health payload that survives `evaluateOrcadActivation`. A rejected candidate leaves the
* previous version running and its own bytes on disk — nothing is lost, and a retry costs no
* upload.
* Activate installed bytes only after the candidate proves healthy. A rejected candidate
* allows restarting the incumbent only when profile state is provably unchanged; otherwise
* preserve current state and the prelaunch snapshot for explicit recovery.
*/
import type { SshConnection } from './ssh-connection'
import { execCommand } from './ssh-relay-deploy-helpers'
@@ -40,9 +31,9 @@ import {
ORCAD_LOG_FILENAME,
orcadLaunchCommand,
parseOrcadReadinessOutput,
readOrcadReadinessCommand,
type OrcadLaunchSpec
readOrcadReadinessCommand
} from './orcad-remote-launch'
import { rejectedOrcadStateRecoveryRefusal, stopOutgoingOrcad } from './orcad-remote-deploy-stop'
import {
captureOrcadStateSnapshotCommand,
orcadSnapshotDirName,
@@ -88,14 +79,10 @@ const DEFAULT_READINESS_TIMEOUT_MS = 90_000
const READINESS_POLL_MS = 500
const STOP_WAIT_SECONDS = 20
function exec(
options: OrcadDeployOptions,
command: string,
signal = options.signal
): Promise<string> {
function exec(options: OrcadDeployOptions, command: string): Promise<string> {
return execCommand(options.conn, command, {
wrapCommand: options.host.commandDialect !== 'powershell',
signal
signal: options.signal
})
}
@@ -156,6 +143,8 @@ async function captureSnapshot(
outgoingVersion: string | null,
takenAt: Date
): Promise<OrcadStateSnapshot | null> {
// The caller has already stopped the outgoing runtime. This is required once profile state
// includes SQLite: a tar of a live WAL, main database, and SHM file is not a SQLite backup.
const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime())
const snapshotDir = joinRemotePath(
options.host,
@@ -176,9 +165,8 @@ async function captureSnapshot(
'way back. Refusing to activate.'
)
}
// Empty profiles need no rollback snapshot.
if (capture === 'empty') {
// Nothing on the host to lose: a first deployment. Rollback will correctly report that
// it has no snapshot, rather than restoring an archive of nothing over a populated root.
return null
}
return {
@@ -191,16 +179,20 @@ async function captureSnapshot(
async function launchAndAwaitReadiness(
options: OrcadDeployOptions,
spec: OrcadLaunchSpec
remoteInstallDir: string,
fullVersion: string
): Promise<ReturnType<typeof parseOrcadReadinessOutput>> {
await exec(options, orcadLaunchCommand(options.host, spec))
await exec(
options,
orcadLaunchCommand(options.host, { ...options, remoteInstallDir, fullVersion })
)
const deadline = Date.now() + (options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS)
const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)))
let last = parseOrcadReadinessOutput('')
while (Date.now() < deadline) {
options.signal?.throwIfAborted()
last = parseOrcadReadinessOutput(
await exec(options, readOrcadReadinessCommand(options.host, spec.remoteInstallDir))
await exec(options, readOrcadReadinessCommand(options.host, remoteInstallDir))
)
if (last.state !== 'pending') {
return last
@@ -210,57 +202,51 @@ async function launchAndAwaitReadiness(
return last
}
/**
* Put the previous version back after a rejected candidate.
*
* Why this exists at all: activating means swapping which process owns the data root and the
* port, so the incumbent has to stop before the candidate can start. A gate that rejected
* and returned would leave the host with nothing running — a careful deploy causing the
* outage it was being careful about. The returned sentence goes into the caller's reason so
* the operator learns the host's actual state, not just why the candidate failed.
*/
/** Restart the incumbent only when the candidate left shared state unchanged. */
async function restoreIncumbent(
options: OrcadDeployOptions,
record: OrcadActivationRecord,
candidateDir: string
candidateDir?: string,
snapshot?: OrcadStateSnapshot | null
): Promise<string> {
const stopped = parseOrcadStopOutcome(
await exec(
options,
stopOrcadCommand(options.host, candidateDir, { waitSeconds: STOP_WAIT_SECONDS })
if (candidateDir) {
const stopped = parseOrcadStopOutcome(
await exec(
options,
stopOrcadCommand(options.host, candidateDir, {
waitSeconds: STOP_WAIT_SECONDS,
justLaunched: true
})
)
)
)
if (!orcadStopFreedTheHost(stopped)) {
return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.`
if (!orcadStopFreedTheHost(stopped)) {
return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.`
}
}
if (!record.active) {
return 'No previous version was active, so this host is now serving nothing.'
}
if (candidateDir) {
const snapshotDir = snapshot
? joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, snapshot.dirName)
: undefined
const refusal = await rejectedOrcadStateRecoveryRefusal(options, record.active, snapshotDir)
if (refusal) {
return refusal
}
}
const incumbentDir = computeRemoteInstallDir(
ORCAD_INSTALL_MODEL,
options.remoteHome,
record.active
)
const parsed = await launchAndAwaitReadiness(options, {
remoteInstallDir: incumbentDir,
nodePath: options.nodePath,
fullVersion: record.active,
userDataDir: options.userDataDir,
bindHost: options.bindHost,
port: options.port
})
const parsed = await launchAndAwaitReadiness(options, incumbentDir, record.active)
return parsed.state === 'ready'
? `orcad ${record.active} was restarted and is serving again.`
: `orcad ${record.active} was relaunched but has not published readiness; this host may be down.`
}
/**
* Install, then activate only on a green cross-process health verdict.
*
* Every early return past the install leaves the bytes on disk and the previous version
* serving, which is why they all report `installed-not-activated` rather than throwing: a
* refusal to switch is a successful outcome of a deploy that was asked to be careful.
*/
/** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */
export async function deployOrcad(options: OrcadDeployOptions): Promise<OrcadDeployResult> {
const now = options.now ?? ((): Date => new Date())
const fullVersion = readLocalFullVersion(options.localOrcadDir)
@@ -287,52 +273,49 @@ export async function deployOrcad(options: OrcadDeployOptions): Promise<OrcadDep
}
}
const snapshot = record.active
? await captureSnapshot(options, fullVersion, record.active, now())
: null
if (record.active) {
const outgoingDir = computeRemoteInstallDir(
ORCAD_INSTALL_MODEL,
options.remoteHome,
record.active
)
const stopped = parseOrcadStopOutcome(
await exec(
options,
stopOrcadCommand(options.host, outgoingDir, {
waitSeconds: STOP_WAIT_SECONDS
})
)
)
const stopped = await stopOutgoingOrcad(options, record.active)
if (!orcadStopFreedTheHost(stopped)) {
return {
outcome: 'installed-not-activated',
fullVersion,
code: 'orcad_outgoing_stop_incomplete',
reason:
`orcad ${record.active} did not exit within ${STOP_WAIT_SECONDS}s of SIGTERM ` +
`(${stopped}). It is still holding the data root and the port, so the candidate ` +
'cannot start. Not escalating to SIGKILL: that skips the shutdown that releases ' +
'the instance lock, and the successor would then refuse to start.'
`Could not verify that orcad ${record.active} exited (${stopped}). ` +
'No snapshot was taken and the candidate was not started. Orca requires matching ' +
'runtime readiness before signaling an incumbent and confirmed exit before snapshotting.'
}
}
}
const parsed = await launchAndAwaitReadiness(options, {
remoteInstallDir: remoteDir,
nodePath: options.nodePath,
fullVersion,
userDataDir: options.userDataDir,
bindHost: options.bindHost,
port: options.port
})
// A live SQLite WAL is not a backup boundary: tar can observe the main file, WAL and SHM
// at different points and restore a set SQLite cannot recover. Stop the incumbent first so
// its final durable flush has completed before capturing the pre-activation state.
let snapshot: OrcadStateSnapshot | null = null
if (record.active) {
try {
snapshot = await captureSnapshot(options, fullVersion, record.active, now())
} catch (error) {
const restored = await restoreIncumbent(options, record).catch(
(restartError: unknown) =>
`The incumbent could not be restarted: ${
restartError instanceof Error ? restartError.message : String(restartError)
}`
)
throw new Error(
`${error instanceof Error ? error.message : String(error)} The incumbent was stopped ` +
`before snapshotting; ${restored}`
)
}
}
const parsed = await launchAndAwaitReadiness(options, remoteDir, fullVersion)
const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, {
buildHash: computeLocalOrcadBuildHash(options.localOrcadDir),
fullVersion
})
if (verdict.decision === 'reject') {
const restored = await restoreIncumbent(options, record, remoteDir)
const restored = await restoreIncumbent(options, record, remoteDir, snapshot)
return {
outcome: 'installed-not-activated',
fullVersion,
+10 -2
View File
@@ -42,9 +42,17 @@ export const ORCAD_PID_FILENAME = '.orcad-pid'
* A host without `ps` yields an empty state, which falls through to "alive" — the safe
* direction for both callers.
*/
export function posixProcessAliveShellFunction(): string {
export function posixProcessAliveShellFunction(
options: { refuseUnverifiable?: boolean } = {}
): string {
// Destructive lifecycle steps need explicit absence; permission failures cannot prove exit.
const probe = options.refuseUnverifiable
? 'probe_error=$(LC_ALL=C kill -0 "$1" 2>&1) || { ' +
'case "$probe_error" in *"No such process"*) return 1;; ' +
'*) echo UNKNOWN; exit 0;; esac; }; '
: 'kill -0 "$1" 2>/dev/null || return 1; '
return (
'orcad_alive() { kill -0 "$1" 2>/dev/null || return 1; ' +
`orcad_alive() { ${probe}` +
'case "$(ps -o stat= -p "$1" 2>/dev/null)" in Z*) return 1;; esac; return 0; };'
)
}
+6 -2
View File
@@ -100,7 +100,10 @@ describe('liveness', () => {
describe('stopping a running orcad', () => {
it('sends SIGTERM and never SIGKILL', () => {
const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, { waitSeconds: 20 })
const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, {
waitSeconds: 20,
nodePath: SPEC.nodePath
})
expect(command).toContain('kill -TERM')
for (const kill of ['kill -9', 'kill -KILL', 'kill -SIGKILL', 'pkill']) {
expect(command).not.toContain(kill)
@@ -110,9 +113,10 @@ describe('stopping a running orcad', () => {
it.each([
['STOPPED', 'stopped', true],
['ALREADY_EXITED', 'already-exited', true],
['NO_PID', 'no-pid', true],
['NO_PID', 'no-pid', false],
['STILL_RUNNING', 'still-running', false],
['SIGNAL_FAILED', 'signal-failed', false],
['UNKNOWN', 'unknown', false],
['', 'unknown', false]
])('parses %s and frees the host = %s', (output, expected, frees) => {
expect(parseOrcadStopOutcome(output)).toBe(expected)
+2 -1
View File
@@ -62,7 +62,8 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp
'umask 077 &&',
`ORCA_VERSION=${shellEscape(spec.fullVersion)}`,
`ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`,
`nohup ${shellEscape(spec.nodePath)} ${entry}`,
// Keep $! equal to the runtime PID rather than a waiting shell's PID.
`exec nohup ${shellEscape(spec.nodePath)} ${entry}`,
`--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`,
`> ${readiness} 2>> ${log} < /dev/null &`,
`echo $! > ${pidFile} && cat ${pidFile}`
+23 -5
View File
@@ -10,6 +10,7 @@
*/
import { shellEscape } from './ssh-connection-utils'
import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch'
import {
assertPosixOrcadHost as assertPosixHost,
ORCAD_PID_FILENAME,
@@ -19,20 +20,37 @@ import {
/**
* Signal the orcad recorded in a version dir and wait for it to go.
*
* `escalate` sends the second SIGTERM orcad reads as "exit immediately". Callers use it only
* after the first deadline elapses, so the two signals are never in the same command.
* `justLaunched` is only for this client's fixed exec launcher, including pre-readiness exits.
* Incumbents need their own readiness PID to corroborate the launcher's PID before any signal.
*/
export function stopOrcadCommand(
host: RemoteHostPlatform,
remoteInstallDir: string,
options: { waitSeconds: number }
options: { waitSeconds: number } & (
| { justLaunched: true }
| { justLaunched?: false; nodePath: string }
)
): string {
assertPosixHost(host)
const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME))
const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME))
const readRuntimePid = [
`const r = JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8'));`,
`const pid = r?.type === 'orca_server_ready' ? r.health?.pid : null;`,
`if (!Number.isSafeInteger(pid) || pid <= 1) process.exit(1);`,
`process.stdout.write(String(pid));`
].join(' ')
return [
posixProcessAliveShellFunction(),
posixProcessAliveShellFunction({ refuseUnverifiable: true }),
`pid=$(cat ${pidFile} 2>/dev/null);`,
'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;',
// Older launchers recorded a waiting shell, whose exit does not prove runtime exit.
...(options.justLaunched
? []
: [
`runtime_pid=$(${shellEscape(options.nodePath)} -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`,
'[ "$pid" = "$runtime_pid" ] || { echo UNKNOWN; exit 0; };'
]),
'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };',
'kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };',
`i=0; while [ "$i" -lt ${options.waitSeconds} ]; do`,
@@ -69,5 +87,5 @@ export function parseOrcadStopOutcome(output: string): OrcadStopOutcome {
/** True when the port is free and a successor may bind. */
export function orcadStopFreedTheHost(outcome: OrcadStopOutcome): boolean {
return outcome === 'stopped' || outcome === 'already-exited' || outcome === 'no-pid'
return outcome === 'stopped' || outcome === 'already-exited'
}
+7 -4
View File
@@ -142,7 +142,10 @@ export async function rollbackOrcad(options: OrcadRollbackOptions): Promise<Orca
const stopped = parseOrcadStopOutcome(
await exec(
options,
stopOrcadCommand(options.host, outgoingDir, { waitSeconds: STOP_WAIT_SECONDS })
stopOrcadCommand(options.host, outgoingDir, {
waitSeconds: STOP_WAIT_SECONDS,
nodePath: options.nodePath
})
)
)
if (!orcadStopFreedTheHost(stopped)) {
@@ -150,9 +153,9 @@ export async function rollbackOrcad(options: OrcadRollbackOptions): Promise<Orca
outcome: 'failed',
code: 'orcad_rollback_stop_incomplete',
reason:
`orcad ${options.record.active} did not exit within ${STOP_WAIT_SECONDS}s of SIGTERM ` +
`(${stopped}). Nothing was restored — the store is untouched and the host is still ` +
'serving the version you tried to leave.'
`Could not verify that orcad ${options.record.active} exited (${stopped}). ` +
'Nothing was restored. Orca requires matching runtime readiness before signaling ' +
'an incumbent and confirmed exit before replacing its state.'
}
}
}
@@ -6,20 +6,42 @@
* never matches, a `tar` invocation that silently captures nothing. These run the strings.
*/
import { execFileSync, spawn } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
exportProfileStateJson,
importProfileStateJson
} from '../persistence/profile-state/profile-state-documents'
import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database'
import {
orcadLaunchCommand,
orcadLivenessProbeCommand,
ORCAD_PID_FILENAME,
parseOrcadLiveness
ORCAD_READINESS_FILENAME,
parseOrcadLiveness,
parseOrcadReadinessOutput
} from './orcad-remote-launch'
import { parseOrcadStopOutcome, stopOrcadCommand } from './orcad-remote-process-control'
import {
orcadStopFreedTheHost,
parseOrcadStopOutcome,
stopOrcadCommand
} from './orcad-remote-process-control'
import {
captureOrcadStateSnapshotCommand,
compareOrcadStateSnapshotCommand,
newestStateMtimeCommand,
orcadSnapshotIsUnchanged,
parseNewestStateMtimeSeconds,
parseOrcadSnapshotCapture,
parseOrcadSnapshotRestore,
@@ -33,6 +55,7 @@ let root = ''
let dataDir = ''
let snapshotDir = ''
let versionDir = ''
const launchedPids = new Set<number>()
function sh(command: string): string {
return execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' })
@@ -52,10 +75,141 @@ beforeEach(() => {
})
afterEach(() => {
for (const pid of launchedPids) {
try {
process.kill(pid, 'SIGKILL')
} catch {
// Successful stops have already removed their test processes.
}
}
launchedPids.clear()
rmSync(root, { recursive: true, force: true })
})
async function launchTestRuntime(legacyWrapper = false): Promise<{
runtimePid: number
recordedPid: number
terminatedFile: string
}> {
const terminatedFile = join(versionDir, 'terminated')
writeFileSync(
join(versionDir, 'orcad.js'),
[
`process.on('SIGTERM', () => {`,
` require('node:fs').writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`,
` process.exit(0);`,
`});`,
`console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid}}));`,
`setTimeout(() => process.exit(1), 10_000);`
].join('\n')
)
let command = orcadLaunchCommand(host, {
remoteInstallDir: versionDir,
nodePath: process.execPath,
fullVersion: '0.2.0+bb01',
userDataDir: dataDir,
bindHost: '127.0.0.1',
port: 0
})
if (legacyWrapper) {
// The trailing command retains the old macOS waiting-shell behavior on every POSIX shell.
command = command
.replace('exec nohup ', 'nohup ')
.replace('< /dev/null &', '< /dev/null && : &')
}
execFileSync('/bin/sh', ['-c', command], { stdio: 'ignore', timeout: 5_000 })
const recordedPid = Number(readFileSync(join(versionDir, ORCAD_PID_FILENAME), 'utf8').trim())
expect(recordedPid).toBeGreaterThan(1)
launchedPids.add(recordedPid)
const readRuntimePid = (): number => {
const parsed = parseOrcadReadinessOutput(
readFileSync(join(versionDir, ORCAD_READINESS_FILENAME), 'utf8')
)
return parsed.state === 'ready' ? (parsed.readiness.health?.pid ?? 0) : 0
}
await expect.poll(readRuntimePid, { timeout: 2_000, interval: 20 }).toBeGreaterThan(1)
const runtimePid = readRuntimePid()
launchedPids.add(runtimePid)
return { runtimePid, recordedPid, terminatedFile }
}
function stopTestRuntime(justLaunched = false): ReturnType<typeof parseOrcadStopOutcome> {
return parseOrcadStopOutcome(
execFileSync(
'/bin/sh',
[
'-c',
stopOrcadCommand(host, versionDir, {
waitSeconds: 3,
...(justLaunched ? { justLaunched: true as const } : { nodePath: process.execPath })
})
],
{ encoding: 'utf8', timeout: 5_000 }
)
)
}
describe('state snapshot commands, run for real', () => {
it('detects candidate SQLite migration without modifying current state or the snapshot', () => {
sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))
const archive = readFileSync(join(snapshotDir, 'state.tar'))
const compare = (): boolean =>
orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir)))
expect(compare()).toBe(true)
writeFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'live-daemon-after-launch')
expect(compare()).toBe(true)
const databasePath = join(dataDir, 'profiles', 'p1', 'profile-state.db')
const candidate = openProfileStateDatabase(databasePath, 'p1')
try {
importProfileStateJson(candidate.db, '{"settings":{"theme":"dark"}}')
} finally {
candidate.db.close()
}
const databaseBytes = readFileSync(databasePath)
expect(compare()).toBe(false)
expect(readFileSync(databasePath)).toEqual(databaseBytes)
expect(readFileSync(join(snapshotDir, 'state.tar'))).toEqual(archive)
expect(readFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'utf8')).toBe(
'live-daemon-after-launch'
)
})
it('requires an intact comparison snapshot before an older build can restart', () => {
expect(
orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir)))
).toBe(false)
sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))
writeFileSync(join(snapshotDir, 'state.tar'), 'not an archive')
expect(
orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir)))
).toBe(false)
expect(readFileSync(join(dataDir, 'profiles', 'p1', 'orca-data.json'), 'utf8')).toBe(
'{"repos":"before"}'
)
})
it('rejects symlinked profile state that a tar snapshot does not preserve', () => {
const external = join(root, 'external-profile')
mkdirSync(external)
writeFileSync(join(external, 'orca-data.json'), '{"before":true}')
const profile = join(dataDir, 'profiles', 'linked')
symlinkSync(external, profile)
expect(
parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)))
).toBe('failed')
mkdirSync(snapshotDir, { recursive: true })
execFileSync('tar', ['-C', dataDir, '-cf', join(snapshotDir, 'state.tar'), 'profiles'])
writeFileSync(join(external, 'orca-data.json'), '{"candidate":true}')
expect(
orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir)))
).toBe(false)
expect(readFileSync(join(external, 'orca-data.json'), 'utf8')).toBe('{"candidate":true}')
})
it('captures, then restores state the newer build overwrote', () => {
expect(
parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)))
@@ -74,6 +228,49 @@ describe('state snapshot commands, run for real', () => {
expect(() => readFileSync(join(dataDir, 'profiles', 'p1', 'new-build-only.json'))).toThrow()
})
it('round-trips a quiescent SQLite profile database with its WAL sidecars', () => {
const profileDirectory = join(dataDir, 'profiles', 'p1')
const databasePath = join(profileDirectory, 'profile-state.db')
const opened = openProfileStateDatabase(databasePath, 'p1')
try {
importProfileStateJson(
opened.db,
JSON.stringify({ settings: { theme: 'dark' }, snapshotMarker: 'before' })
)
// The connection remains open, so WAL/SHM are still part of the archive boundary while
// the generated command reads the now-quiescent files.
expect(existsSync(`${databasePath}-wal`)).toBe(true)
expect(
parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)))
).toBe('captured')
} finally {
opened.db.close()
}
const changed = openProfileStateDatabase(databasePath, 'p1')
try {
importProfileStateJson(
changed.db,
JSON.stringify({ settings: { theme: 'light' }, snapshotMarker: 'after' })
)
} finally {
changed.db.close()
}
expect(
parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, dataDir, snapshotDir)))
).toBe('restored')
const restored = openProfileStateDatabase(databasePath, 'p1')
try {
expect(JSON.parse(exportProfileStateJson(restored.db))).toEqual({
settings: { theme: 'dark' },
snapshotMarker: 'before'
})
} finally {
restored.db.close()
}
})
it('leaves the live daemon runtime dir untouched through capture and restore', () => {
sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))
// The daemon is running across the rollback and rewrites its token; a restore that
@@ -115,7 +312,13 @@ describe('state snapshot commands, run for real', () => {
expect(
parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, nasty, snapshotDir)))
).toBe('captured')
expect(
orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, nasty, snapshotDir)))
).toBe(true)
writeFileSync(join(nasty, 'orca-profile-index.json'), '{"v":"changed"}')
expect(
orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, nasty, snapshotDir)))
).toBe(false)
expect(
parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, nasty, snapshotDir)))
).toBe('restored')
@@ -124,6 +327,85 @@ describe('state snapshot commands, run for real', () => {
})
describe('liveness and stop commands, run for real', () => {
it('records the runtime PID and waits for that runtime to exit when stopped', async () => {
const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime()
expect(recordedPid).toBe(runtimePid)
expect(stopTestRuntime()).toBe('stopped')
expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated')
// An unreaped zombie has exited even though kill -0 still succeeds.
expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/)
expect(existsSync(join(versionDir, ORCAD_PID_FILENAME))).toBe(true)
expect(stopTestRuntime()).toBe('already-exited')
})
it('refuses a legacy wrapper PID both before and after its shell exits', async () => {
const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime(true)
expect(recordedPid).not.toBe(runtimePid)
const beforeWrapperExit = stopTestRuntime()
expect(beforeWrapperExit).toBe('unknown')
expect(orcadStopFreedTheHost(beforeWrapperExit)).toBe(false)
expect(() => process.kill(recordedPid, 0)).not.toThrow()
expect(() => process.kill(runtimePid, 0)).not.toThrow()
process.kill(recordedPid, 'SIGTERM')
await expect
.poll(() => sh(`ps -o stat= -p ${recordedPid} || true`).trim(), { timeout: 2_000 })
.toMatch(/^(?:Z.*)?$/)
const afterWrapperExit = stopTestRuntime()
expect(afterWrapperExit).toBe('unknown')
expect(orcadStopFreedTheHost(afterWrapperExit)).toBe(false)
expect(() => process.kill(runtimePid, 0)).not.toThrow()
expect(existsSync(terminatedFile)).toBe(false)
})
it.each(['missing', 'malformed', 'without-health'])(
'refuses an incumbent with %s readiness proof',
async (proof) => {
const { runtimePid, terminatedFile } = await launchTestRuntime()
const readinessFile = join(versionDir, ORCAD_READINESS_FILENAME)
if (proof === 'missing') {
rmSync(readinessFile)
} else {
writeFileSync(readinessFile, proof === 'malformed' ? '{' : '{"type":"orca_server_ready"}')
}
expect(stopTestRuntime()).toBe('unknown')
expect(() => process.kill(runtimePid, 0)).not.toThrow()
expect(existsSync(terminatedFile)).toBe(false)
}
)
it('can stop a candidate just launched with exec without readiness', async () => {
const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime()
expect(recordedPid).toBe(runtimePid)
rmSync(join(versionDir, ORCAD_READINESS_FILENAME))
expect(stopTestRuntime(true)).toBe('stopped')
expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated')
expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/)
})
it.each(['before', 'after'])('refuses a permission-denied probe %s SIGTERM', async (phase) => {
const { runtimePid, terminatedFile } = await launchTestRuntime()
const deniedProbe = [
'term_sent=0; kill() {',
'if [ "$1" = -TERM ]; then term_sent=1; return 0; fi;',
`if [ '${phase}' = before ] || [ "$term_sent" = 1 ]; then`,
'echo "kill: Operation not permitted" >&2; return 1; fi;',
'command kill "$@"; };'
].join(' ')
const outcome = parseOrcadStopOutcome(
sh(
`${deniedProbe} ${stopOrcadCommand(host, versionDir, {
waitSeconds: 1,
nodePath: process.execPath
})}`
)
)
expect(outcome).toBe('unknown')
expect(orcadStopFreedTheHost(outcome)).toBe(false)
expect(() => process.kill(runtimePid, 0)).not.toThrow()
expect(existsSync(terminatedFile)).toBe(false)
})
it('reports UNKNOWN with no pid file, and DEAD for a pid that has exited', () => {
expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('UNKNOWN')
writeFileSync(join(versionDir, ORCAD_PID_FILENAME), 'not-a-pid')
@@ -144,7 +426,9 @@ describe('liveness and stop commands, run for real', () => {
child.once('exit', (_code, signal) => resolve(signal))
)
expect(
parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 })))
parseOrcadStopOutcome(
sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true }))
)
).toBe('stopped')
expect(await exited).toBe('SIGTERM')
expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD')
@@ -166,7 +450,9 @@ describe('liveness and stop commands, run for real', () => {
expect(sh(`kill -0 ${child.pid} 2>/dev/null && echo LIVE || echo DEAD`).trim()).toBe('LIVE')
expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD')
expect(
parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))
parseOrcadStopOutcome(
sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true }))
)
).toBe('already-exited')
} finally {
child.unref()
@@ -176,14 +462,18 @@ describe('liveness and stop commands, run for real', () => {
it('reports ALREADY_EXITED for a stale pid file rather than signalling a stranger', () => {
const exited = Number(sh('sh -c "echo $$"').trim())
writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited))
expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe(
'already-exited'
)
expect(
parseOrcadStopOutcome(
sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true }))
)
).toBe('already-exited')
})
it('reports NO_PID when the version dir was never launched', () => {
expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe(
'no-pid'
)
expect(
parseOrcadStopOutcome(
sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, nodePath: process.execPath }))
)
).toBe('no-pid')
})
})
@@ -4,6 +4,7 @@ import {
ORCAD_SNAPSHOT_EXCLUDED,
ORCAD_SNAPSHOT_MEMBERS,
captureOrcadStateSnapshotCommand,
compareOrcadStateSnapshotCommand,
newestStateMtimeCommand,
orcadSnapshotDirName,
parseNewestStateMtimeSeconds,
@@ -93,6 +94,7 @@ describe('Windows hosts', () => {
it.each([
['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)],
['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)],
['compare', () => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP)],
['mtime', () => newestStateMtimeCommand(windows, ROOT)]
])('refuses %s rather than emitting a POSIX command', (_label, build) => {
expect(build).toThrow('orcad to a Windows host is not implemented')
+51 -3
View File
@@ -27,7 +27,9 @@ export const ORCAD_SNAPSHOT_MEMBERS = [
'orca-profile-index.json',
// Pre-profiles layout; still read as a migration source.
'orca-data.json',
'profiles'
'profiles',
// Cross-profile SQLite moves must survive an orcad rollback too.
'profile-move-intents'
] as const
/** Never captured and never restored — see the module comment. */
@@ -45,6 +47,10 @@ function assertPlainMemberName(member: string): string {
return member
}
function noSymlinkedStateCommand(path: string): string {
return `links=$(find ${path} -type l -print) && [ -z "$links" ]`
}
export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): string {
// Why the version and the timestamp: two activations of one version (a re-deploy after a
// rejected activation) must not overwrite each other's snapshot.
@@ -72,8 +78,14 @@ export function captureOrcadStateSnapshotCommand(
// Why the accumulated name is NOT quoted: `$members` is re-split by the shell before it
// reaches tar, so a quoted name arrives as a literal `'profiles'` that tar cannot stat.
// `assertPlainMemberName` is what makes leaving them bare safe.
(member) =>
`[ -e ${root}/${shellEscape(member)} ] && members="$members ${assertPlainMemberName(member)}";`
(member) => {
const path = `${root}/${shellEscape(member)}`
return (
`if [ -e ${path} ] || [ -L ${path} ]; then ` +
`${noSymlinkedStateCommand(path)} || { echo FAILED; exit 0; }; ` +
`members="$members ${assertPlainMemberName(member)}"; fi;`
)
}
).join(' ')
return [
`members=;`,
@@ -145,6 +157,42 @@ export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore
return value === 'MISSING' ? 'missing' : 'failed'
}
/** Compare after stopping the candidate; a changed root cannot be handed to an older build. */
export function compareOrcadStateSnapshotCommand(
host: RemoteHostPlatform,
userDataDir: string,
snapshotDir: string
): string {
assertPosixHost(host)
const root = shellEscape(userDataDir)
const dir = shellEscape(snapshotDir)
const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar'))
const comparisons = ORCAD_SNAPSHOT_MEMBERS.map((member) => {
const name = shellEscape(member)
return [
`if [ -e ${root}/${name} ] || [ -L ${root}/${name} ]; then`,
`${noSymlinkedStateCommand(`${root}/${name}`)} || { echo UNKNOWN; exit 0; };`,
`diff -r ${root}/${name} "$comparison"/${name} >/dev/null 2>&1 || verdict=CHANGED;`,
`elif [ -e "$comparison"/${name} ] || [ -L "$comparison"/${name} ]; then verdict=CHANGED; fi;`
].join(' ')
}).join(' ')
return [
`test -d ${root} && test -r ${root} && test -x ${root} || { echo UNKNOWN; exit 0; };`,
`test -f ${archive} || { echo UNKNOWN; exit 0; };`,
`comparison=$(mktemp -d ${dir}/compare.XXXXXX) || { echo UNKNOWN; exit 0; };`,
`trap 'rm -rf "$comparison"' EXIT HUP INT TERM;`,
`tar -C "$comparison" -xf ${archive} || { echo UNKNOWN; exit 0; };`,
`${noSymlinkedStateCommand('"$comparison"')} || { echo UNKNOWN; exit 0; };`,
'verdict=UNCHANGED;',
comparisons,
'echo "$verdict"'
].join(' ')
}
export function orcadSnapshotIsUnchanged(output: string): boolean {
return output.trim().split('\n').pop()?.trim() === 'UNCHANGED'
}
/**
* Has the shared store been written since `activatedAt`?
*
@@ -214,7 +214,7 @@ describe('sweepOrphanedRelayPtys', () => {
clearBindingsForTarget: () => {},
clearBindingsForLeases: () => false,
flush: () => {},
flushDurableStateOrThrowAsync: async () => {}
runDurableMutation: async (mutate) => mutate().value
}
// The same pane re-leases under a new relay id; pty-1 is expired, never terminated.
upsertSshRemotePtyLease(operations, {
+1 -4
View File
@@ -96,10 +96,7 @@ export async function removeSshPtyConsumerOwnerRecovery(
clientInstanceId: string,
store: Store
): Promise<void> {
const persisted = store.getSshPtyConsumerRecovery(targetId)
if (persisted?.clientInstanceId === clientInstanceId) {
await store.removeSshPtyConsumerRecovery(targetId)
}
await store.removeSshPtyConsumerRecovery(targetId, clientInstanceId)
}
export function detachSshPtyConsumerRecovery(targetId: string, clientInstanceId: string): void {
@@ -344,7 +344,8 @@ describe('SshRelaySession data delivery', () => {
})
)
session.dispose()
expect(mockStore.removeSshPtyConsumerRecovery).toHaveBeenCalledWith(targetId)
const removeRecovery = mockStore.removeSshPtyConsumerRecovery
expect(removeRecovery).toHaveBeenCalledWith(targetId, 'persisted-client')
})
it('voids checkpoints for a fresh claim without a second owner request', async () => {
@@ -3,7 +3,11 @@ import { randomUUID } from 'node:crypto'
import type * as NodeCrypto from 'node:crypto'
import { SshRelaySession } from './ssh-relay-session'
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
import {
createMockDeps,
mockDeploySuccess,
recordedPtyBindings
} from './ssh-relay-session-test-fixtures'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { SshRemotePtyLease } from '../../shared/ssh-types'
@@ -439,7 +443,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => {
incarnationId
})
expect(runtime.onPtySpawned).not.toHaveBeenCalled()
expect(mockStore.persistPtyBinding).toHaveBeenCalledWith({
expect(recordedPtyBindings(mockStore)).toContainEqual({
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: INCARNATION_LEAF_ID,
@@ -643,7 +647,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => {
leafId: INCARNATION_LEAF_ID,
incarnationId
})
expect(mockStore.persistPtyBinding).toHaveBeenCalledWith(
expect(recordedPtyBindings(mockStore)).toContainEqual(
expect.objectContaining({ tabId: movedTabId, ptyId: APP_PTY_ID, incarnationId })
)
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(
@@ -763,7 +767,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => {
incarnationId: currentIncarnationId
})
expect(setPtyOwnership).toHaveBeenCalledWith(APP_PTY_ID, 'target-1')
expect(mockStore.persistPtyBinding).toHaveBeenCalledWith(
expect(recordedPtyBindings(mockStore)).toContainEqual(
expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId })
)
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', {
@@ -140,17 +140,21 @@ describe('SshRelaySession recovery race fencing', () => {
mockDeploySuccess()
})
function emitSourceFrame(args: {
targetId: string
token: string
clientGeneration: number
ownerGeneration: number
sourceStartSu: number
sourceEndSu: number
}): void {
ptyDataHandlerRef.current?.({
function emitSourceFrame(
args: {
targetId: string
token: string
clientGeneration: number
ownerGeneration: number
sourceStartSu: number
sourceEndSu: number
data?: string
},
sink = ptyDataHandlerRef.current
): void {
sink?.({
id: `ssh:${args.targetId}@@pty-1`,
data: 'late',
data: args.data ?? 'late',
providerGeneration: 23,
ptyIncarnation: 'incarnation-1',
sequenceChars: args.sourceEndSu - args.sourceStartSu,
@@ -227,6 +231,66 @@ describe('SshRelaySession recovery race fencing', () => {
return { session, deps }
}
it('keeps source output contiguous while the recovered pane binding waits for disk', async () => {
const targetId = 'recovery-binding-disk-wait'
const { session, deps } = await prepareRecovery(targetId)
const binding = Promise.withResolvers<boolean>()
vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([
{
targetId,
ptyId: 'pty-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
state: 'detached',
createdAt: 1,
updatedAt: 1
}
])
vi.mocked(deps.mockStore.persistPtyBinding).mockReturnValue(binding.promise)
const emit = (sourceStartSu: number, sink = ptyDataHandlerRef.current): void =>
emitSourceFrame(
{
targetId,
token: 'new-token',
clientGeneration: 2,
ownerGeneration: 2,
sourceStartSu,
sourceEndSu: sourceStartSu + 4
},
sink
)
let recoverySink: typeof ptyDataHandlerRef.current
const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() }
attachForReconnectMock.mockResolvedValue({
incarnationId: 'incarnation-1',
sourceRecovery: pendingRecovery(8),
sourceActivationLease: {
commit: vi.fn(),
rollback: vi.fn(async () => true),
transferToRecovery: (sink: (payload: unknown) => void) => {
recoverySink = sink
emit(4, sink)
completeRecovery({ id: 'pty-1', ...pendingRecovery(8) })
return recoveryActivationLease
}
}
})
const reconnect = session.reconnect(deps.mockConn)
await vi.waitFor(() => expect(deps.mockStore.persistPtyBinding).toHaveBeenCalledOnce())
emit(8, recoverySink)
expect(recoveryActivationLease.commit).not.toHaveBeenCalled()
binding.resolve(true)
await reconnect
emit(12)
expect(
acceptOutputDataMock.mock.calls.map(([payload]) => payload.source.sourceStartSu)
).toEqual([4, 8, 12])
expect(recoveryActivationLease.commit).toHaveBeenCalledOnce()
})
it('publishes held recovery data before an exact exit without waiting for completion', async () => {
const targetId = 'exit-with-complete-private-body'
const { session, deps } = await prepareRecovery(targetId)
@@ -235,22 +299,18 @@ describe('SshRelaySession recovery race fencing', () => {
commit: vi.fn(),
rollback: vi.fn(async () => true),
transferToRecovery: vi.fn((sink: (payload: unknown) => void) => {
sink({
id: `ssh:${targetId}@@pty-1`,
data: 'held',
providerGeneration: 23,
ptyIncarnation: 'incarnation-1',
sequenceChars: 4,
source: {
relayPtyId: 'pty-1',
spanId: 'new-token:4:8',
emitSourceFrame(
{
targetId,
token: 'new-token',
clientGeneration: 2,
ownerGeneration: 2,
deliveryToken: 'new-token',
sourceStartSu: 4,
sourceEndSu: 8
}
})
sourceEndSu: 8,
data: 'held'
},
sink
)
return recoveryActivationLease
})
}
@@ -301,22 +361,18 @@ describe('SshRelaySession recovery race fencing', () => {
commit: vi.fn(),
rollback: vi.fn(async () => true),
transferToRecovery: vi.fn((sink: (payload: unknown) => void) => {
sink({
id: `ssh:${targetId}@@pty-1`,
data: 'partial',
providerGeneration: 23,
ptyIncarnation: 'incarnation-1',
sequenceChars: 2,
source: {
relayPtyId: 'pty-1',
spanId: 'new-token:4:6',
emitSourceFrame(
{
targetId,
token: 'new-token',
clientGeneration: 2,
ownerGeneration: 2,
deliveryToken: 'new-token',
sourceStartSu: 4,
sourceEndSu: 6
}
})
sourceEndSu: 6,
data: 'partial'
},
sink
)
return recoveryActivationLease
})
}
@@ -501,38 +557,23 @@ describe('SshRelaySession recovery race fencing', () => {
commit: vi.fn(),
rollback: vi.fn(),
transferToRecovery: vi.fn((sink: (payload: unknown) => void) => {
sink({
id: `ssh:${targetId}@@pty-1`,
data: 'x'.repeat(2 * 1024 * 1024 + 1),
providerGeneration: 23,
ptyIncarnation: 'incarnation-1',
sequenceChars: 4,
source: {
relayPtyId: 'pty-1',
spanId: 'new-token:4:8',
clientGeneration: 2,
ownerGeneration: 2,
deliveryToken: 'new-token',
sourceStartSu: 4,
sourceEndSu: 8
}
})
sink({
id: `ssh:${targetId}@@pty-1`,
data: 'later',
providerGeneration: 23,
ptyIncarnation: 'incarnation-1',
sequenceChars: 4,
source: {
relayPtyId: 'pty-1',
spanId: 'new-token:8:12',
clientGeneration: 2,
ownerGeneration: 2,
deliveryToken: 'new-token',
sourceStartSu: 8,
sourceEndSu: 12
}
})
for (const [data, sourceStartSu] of [
['x'.repeat(2 * 1024 * 1024 + 1), 4],
['later', 8]
] as const) {
emitSourceFrame(
{
targetId,
token: 'new-token',
clientGeneration: 2,
ownerGeneration: 2,
sourceStartSu,
sourceEndSu: sourceStartSu + 4,
data
},
sink
)
}
return recoveryActivationLease
})
}
@@ -1,6 +1,7 @@
import { vi, type Mock } from 'vitest'
import type { BrowserWindow } from 'electron'
import type { SshConnection } from './ssh-connection'
import type { PersistPtyBindingArgs } from '../persistence/loading-store/pty-binding-persistence'
import type { Store } from '../persistence'
import type { SshPortForwardManager } from './ssh-port-forward'
import { deployAndLaunchRelay } from './ssh-relay-deploy'
@@ -13,8 +14,16 @@ type SshRelaySessionTestDeps = {
mockWindow: BrowserWindow
}
const persistedBindings = new WeakMap<Store, PersistPtyBindingArgs[]>()
export function recordedPtyBindings(store: Store): readonly PersistPtyBindingArgs[] {
return persistedBindings.get(store) ?? []
}
export function createMockDeps(): SshRelaySessionTestDeps {
const bindings: PersistPtyBindingArgs[] = []
const mockConn = {} as SshConnection
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The relay fixture implements the Store methods exercised by session establishment and teardown.
const mockStore = {
getRepos: vi.fn().mockReturnValue([]),
getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null),
@@ -33,8 +42,16 @@ export function createMockDeps(): SshRelaySessionTestDeps {
recordSshRemotePtyKillIntent: vi.fn(),
clearSshRemotePtyKillIntent: vi.fn(),
noteSshRemotePtyKillReplayAttempt: vi.fn(),
persistPtyBinding: vi.fn()
persistPtyBinding: vi.fn(async (input: Parameters<Store['persistPtyBinding']>[0]) => {
const binding = typeof input === 'function' ? input() : input
if (!binding) {
return false
}
bindings.push(binding)
return true
})
} as unknown as Store
persistedBindings.set(mockStore, bindings)
const mockPortForward = {
removeAllForwards: vi.fn()
} as unknown as SshPortForwardManager
+58 -41
View File
@@ -1799,10 +1799,6 @@ export class SshRelaySession {
}
const pending = this.pendingPtyReattaches.get(payload.id)
if (pending && this.activePtyConsumerOwner()?.outputFlowControl) {
if (pending.livePassthrough) {
void this.acceptPtyData(payload).catch(() => {})
return
}
this.quarantineReattachData(pending, payload)
return
}
@@ -2074,6 +2070,12 @@ export class SshRelaySession {
}
private quarantineReattachData(pending: PendingPtyReattach, payload: SshPtyDataPayload): void {
if (pending.livePassthrough) {
if (this.ownsPtyRecoveryAttempt(payload.id, pending)) {
void this.acceptPtyData(payload).catch(() => {})
}
return
}
this.observePrivateRecoveryFrame(pending, payload)
if (pending.restoreRequired) {
return
@@ -2575,11 +2577,15 @@ export class SshRelaySession {
return
}
if (attachResult.incarnationId) {
const restoreResult = this.restoreReattachedPtyRuntime(
const restoreResult = await this.restoreReattachedPtyRuntime(
appPtyId,
attachResult.incarnationId,
activeLease
activeLease,
() => shouldContinue() && this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach)
)
if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach)) {
return
}
if (restoreResult !== 'restored') {
clearProviderPtyState(appPtyId)
deletePtyOwnership(appPtyId)
@@ -2742,45 +2748,56 @@ export class SshRelaySession {
deletePtyOwnership(appPtyId)
}
private restoreReattachedPtyRuntime(
private async restoreReattachedPtyRuntime(
appPtyId: string,
incarnationId: string,
lease: SshPtyLease | undefined
): ReattachedPtyRuntimeRestore {
lease: SshPtyLease | undefined,
shouldContinue: () => boolean
): Promise<ReattachedPtyRuntimeRestore> {
if (lease?.worktreeId && lease.tabId && lease.leafId) {
const session = this.store.getWorkspaceSession?.()
// The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so
// trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that
// merely moved. Leaf is the identity, the tab is only where it currently sits.
// SSH spawns bind panes into `ssh:<target>` while this reattach binds into `local`, so a
// fence that consulted only one partition would read "no pane" for a pane the other holds.
const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId))
const tabId =
findTerminalTabIdForLeaf(session, lease.leafId) ??
findTerminalTabIdForLeaf(hostSession, lease.leafId) ??
lease.tabId
// Absence of the pane only means "the user closed it" once the persisted membership
// speaks for this worktree. Before that it means the renderer has not published its
// layout yet, and refusing there drops a tab the user still has — the regression that
// reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an
// unauthoritative session still gets the creating write.
// Authority is read from `local` because that is the partition this write lands in — it
// is local's absence we would be interpreting. But a pane the other partition still holds
// is not gone, so it keeps its creating write: refusing there would strand a live pane
// behind a binding reattach can no longer reach.
const mayCreate =
!hasHostAuthoritativeTerminalMembership(session, lease.worktreeId) ||
findTerminalTabIdForLeaf(hostSession, lease.leafId) !== undefined
const bound = this.store.persistPtyBinding({
worktreeId: lease.worktreeId,
tabId,
leafId: lease.leafId,
ptyId: appPtyId,
incarnationId,
...(mayCreate ? {} : { mayCreate: false }),
mayReviveRetiredSurface: false,
origin: 'relay_reattach'
const { worktreeId, leafId, tabId: leaseTabId } = lease
let tabId = lease.tabId
const bound = await this.store.persistPtyBinding(() => {
if (!shouldContinue()) {
return null
}
const session = this.store.getWorkspaceSession?.()
// The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so
// trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that
// merely moved. Leaf is the identity, the tab is only where it currently sits.
// SSH spawns bind panes into `ssh:<target>` while this reattach binds into `local`, so a
// fence that consulted only one partition would read "no pane" for a pane the other holds.
const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId))
tabId =
findTerminalTabIdForLeaf(session, leafId) ??
findTerminalTabIdForLeaf(hostSession, leafId) ??
leaseTabId
// Absence of the pane only means "the user closed it" once the persisted membership
// speaks for this worktree. Before that it means the renderer has not published its
// layout yet, and refusing there drops a tab the user still has — the regression that
// reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an
// unauthoritative session still gets the creating write.
// Authority is read from `local` because that is the partition this write lands in — it
// is local's absence we would be interpreting. But a pane the other partition still holds
// is not gone, so it keeps its creating write: refusing there would strand a live pane
// behind a binding reattach can no longer reach.
const mayCreate =
!hasHostAuthoritativeTerminalMembership(session, worktreeId) ||
findTerminalTabIdForLeaf(hostSession, leafId) !== undefined
return {
worktreeId: worktreeId,
tabId,
leafId: leafId,
ptyId: appPtyId,
incarnationId,
...(mayCreate ? {} : { mayCreate: false }),
mayReviveRetiredSurface: false,
origin: 'relay_reattach' as const
}
})
if (!shouldContinue()) {
return 'missing-surface'
}
if (bound === false) {
// Topology absence alone is not authority to kill a process, but neither refusal may
// publish or replay into a missing pane.