Files
orca/tests/e2e/helpers/daemon-shell-process-identity.ts
T
NeilandOrca e4033f9994 test(terminal): add discriminating oracles for the daemon, skew and multi-host journeys
Daemon: replaces a spec that modelled only a client restart and never
crossed the daemon boundary, whose successor generation owned nothing so
"the live successor is neither killed nor replaced" was vacuous. The PTY
leader is now a real login shell reporting `$$` back through the
production write path, resolved to a kernel start time. Two mutations
each redden exactly one of the three clauses, on macOS and Linux:
reverting three-valued `hasPty` reddens only the unknown-not-dead
clause; widening the sole-provider fallback reddens only the stale
generation clause.

Skew: reverting the restore-required publication to expiry reddens 4 of
5 new tests while the legacy control stays green — the regression this
branch fixed is now caught if reintroduced.

Multi-host: restoring `mux.dispose('connection_lost')` reddens sibling
isolation on one host. It does NOT redden across hosts, and that is
recorded rather than glossed: a mux belongs to one relay session per
target, so its dispose cannot cross a host boundary. Journey 4's
cross-host clause rests on isolation-by-construction, not on a mutation.

No production code changes.

Co-authored-by: Orca <help@stably.ai>
2026-08-08 19:11:54 -07:00

152 lines
5.4 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { DaemonPtyAdapter } from '../../../src/main/daemon/daemon-pty-adapter'
import type { IPtyProvider } from '../../../src/main/providers/pty-provider-contract'
import { DAEMON_GENERATION_WORKTREE_ID } from '../fixtures/daemon-generation-fixture-contract'
import {
recordProcessIdentity,
recordProcessTree,
terminateRecordedTree,
waitForCondition,
type RecordedProcessIdentity
} from './daemon-generation-processes'
import type { DaemonGeneration, DaemonGenerationRuntime } from './daemon-generation-safety-fixtures'
const MAX_CAPTURED_CHARS = 65_536
const PROBE_WIDTH_COLS = 200
const PROBE_HEIGHT_ROWS = 30
export type DaemonShellSession = {
generation: DaemonGeneration
sessionId: string
adapter: DaemonPtyAdapter
/** The PID the daemon reported for the PTY leader at spawn time. */
spawnPid: number
rootIdentity: RecordedProcessIdentity
treeIdentities: RecordedProcessIdentity[]
incarnationId: string | undefined
output(): string
}
/** Buffers one session's stream off any provider that fans out `onData`. */
export function recordSessionOutput(
provider: Pick<IPtyProvider, 'onData'>,
sessionId: string
): { text(): string; stop(): void } {
let text = ''
const unsubscribe = provider.onData((event) => {
if (event.id === sessionId) {
text = `${text}${event.data}`.slice(-MAX_CAPTURED_CHARS)
}
})
return { text: () => text, stop: unsubscribe }
}
/**
* A real login shell under the daemon — not a fixture script. Journey 2 has to
* compare the *shell* the user is typing into across the restart boundary, so
* the PTY leader must be the shell itself.
*/
export async function spawnDaemonShellSession(options: {
runtime: DaemonGenerationRuntime
generation: DaemonGeneration
role: string
worktreeId?: string
}): Promise<DaemonShellSession> {
const { runtime, generation, role, worktreeId = DAEMON_GENERATION_WORKTREE_ID } = options
// Why the durable prefix: daemon inventory infers ownership from it, so a bare
// id would resolve to unknown for reasons unrelated to what is under test.
const sessionId = `${worktreeId}@@orca-j2-${generation.label}-${role}-${randomUUID().slice(0, 8)}`
const adapter = new DaemonPtyAdapter({
socketPath: generation.socketPath,
tokenPath: generation.tokenPath,
protocolVersion: generation.protocolVersion
})
const recorder = recordSessionOutput(adapter, sessionId)
const result = await adapter.spawn({
sessionId,
isNewSession: true,
cols: PROBE_WIDTH_COLS,
rows: PROBE_HEIGHT_ROWS,
cwd: runtime.rootDir,
...(process.platform === 'win32' ? { shellOverride: 'powershell.exe' } : {})
})
if (!result.pid) {
throw new Error(`${generation.label}-${role} shell did not expose its PTY leader PID`)
}
const rootIdentity = await recordProcessIdentity(result.pid)
return {
generation,
sessionId,
adapter,
spawnPid: result.pid,
rootIdentity,
treeIdentities: await recordProcessTree(rootIdentity),
incarnationId: result.incarnationId,
output: recorder.text
}
}
/** Disposable fixture teardown: exact-identity kills only, never a bare PID. */
export async function disposeDaemonShellSessions(
shells: readonly DaemonShellSession[]
): Promise<void> {
for (const shell of shells) {
shell.adapter.dispose()
}
if (shells.length > 0) {
await terminateRecordedTree(shells.flatMap((shell) => shell.treeIdentities))
}
}
/**
* Ask the live shell for its own PID over the production write path, then read
* the kernel's start time for that PID.
*
* Both halves are load-bearing. The write path proves *this* connection reaches
* a shell that is still executing; the kernel start time proves the PID was not
* recycled by a replacement shell. A same-id/new-process respawn passes every
* weaker check and fails this one.
*/
export async function readShellProcessIdentity(options: {
phase: string
write: (data: string) => void
readOutput: () => string
timeoutMs?: number
}): Promise<RecordedProcessIdentity> {
const { phase, write, readOutput, timeoutMs = 30_000 } = options
// Why a per-phase nonce: reattach replays scrollback, so an earlier phase's
// answer is sitting in the stream and would otherwise satisfy this probe.
const marker = `ORCA_J2_SHELL_PID_${phase}_${randomUUID().replaceAll('-', '')}`
// Why match the *expanded* value: the echoed command line carries the literal
// `$$`/`$PID`, so only the shell's own reply contains digits.
const reported = new RegExp(`${marker}=(\\d+)`)
const command = process.platform === 'win32' ? `echo ${marker}=$PID\r` : `echo ${marker}=$$\r`
let pid: number | null = null
const deadline = Date.now() + timeoutMs
// Why re-issue: a freshly spawned shell may still be sourcing its rc files and
// will discard the first line. Repeats are idempotent for `echo`.
while (Date.now() <= deadline && pid === null) {
write(command)
try {
await waitForCondition(
`${phase} shell PID reply`,
() => {
const match = reported.exec(readOutput())
pid = match ? Number(match[1]) : null
return pid !== null
},
3_000
)
} catch {
// Fall through and re-issue until the outer deadline.
}
}
if (pid === null) {
throw new Error(
`Shell never reported its PID for phase ${phase}. Tail: ${readOutput().slice(-500)}`
)
}
return await recordProcessIdentity(pid)
}