mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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}`
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; };'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user