fix(orcad): survive a slow first daemon start and close review gaps (#25213)

- Give the terminal daemon 30 s to start on Windows and retry the spawn once before
  falling back, so a cold first deploy is not refused as daemonless.
- Say in the activation refusal that orcad.log holds the daemon's error and that the
  next connect retries.
- Managed stop completion now waits out daemon retirement plus the shutdown deadline.
- Publish the instance lock atomically and reclaim an abandoned torn lock.
- Fix the stop listener closing before it was defined on an already-present request.
- `orca serve`'s cache prune keeps the slot a running local orcad uses.
- A timed-out daemon retirement reopens admission once it is refused; a retirement that
  may have reached the daemon stays fenced.
- A headless host keeps an editor tab's unsaved draft unless the close is forced.
- Unverifiable terminals are attributed by the host's PTY record, like live ones.
- An explicit --user-data-dir is no longer overridden on Windows.
- Read Windows daemon process identity from the process table, not PowerShell.
- Remove the unread data-event incarnationId and daemon health runtime fields, share
  one process-alive and error-code check, and fold the websocket limits file back.

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-04 05:16:36 -07:00
committed by GitHub
co-authored by m4air
parent 37ff957260
commit ce5c58f0d2
54 changed files with 604 additions and 582 deletions
+4 -12
View File
@@ -1,13 +1,5 @@
/** What a `ptySpawnHealth` reply proves about this daemon; every field is optional on the wire. */
export function readDaemonHealthIdentity(): {
coverage: 'pty-spawn' | 'handshake'
runtimeKind: 'node'
runtimeVersion: string
} {
return {
// Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there.
coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn',
runtimeKind: 'node',
runtimeVersion: process.version
}
/** What a `ptySpawnHealth` reply proves on this platform. */
export function ptySpawnHealthPlatformCoverage(): 'pty-spawn' | 'handshake' {
// Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there.
return process.platform === 'win32' ? 'handshake' : 'pty-spawn'
}
+2 -4
View File
@@ -106,11 +106,9 @@ describe('daemon health', () => {
try {
await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy')
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({
await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toEqual({
verdict: 'healthy',
coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn',
runtimeKind: 'node',
runtimeVersion: process.version
coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn'
})
expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(3)
} finally {
+3 -22
View File
@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from 'node:fs'
import { connect, type Socket } from 'node:net'
import { encodeNdjson } from './ndjson'
import { ptySpawnHealthPlatformCoverage } from './daemon-health-identity'
import {
PROTOCOL_VERSION,
type HelloMessage,
@@ -24,24 +25,6 @@ export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-u
export type DaemonHealthCheck = {
verdict: DaemonHealth
coverage: 'pty-spawn' | 'handshake'
/** Optional runtime proof from newer daemons; absent on mixed-version peers. */
runtimeKind?: 'node'
runtimeVersion?: string
}
function readRuntimeIdentity(
payload: unknown
): Pick<DaemonHealthCheck, 'runtimeKind' | 'runtimeVersion'> {
if (typeof payload !== 'object' || payload === null) {
return {}
}
const runtimeKind = 'runtimeKind' in payload ? payload.runtimeKind : undefined
const runtimeVersion = 'runtimeVersion' in payload ? payload.runtimeVersion : undefined
// Why drop other kinds: only a Node daemon reports here; anything else is an unknown peer.
return {
...(runtimeKind === 'node' ? { runtimeKind } : {}),
...(typeof runtimeVersion === 'string' && runtimeVersion.length > 0 ? { runtimeVersion } : {})
}
}
function readPtySpawnHealthCoverage(
@@ -62,7 +45,7 @@ export function checkDaemonHealthWithCoverage(
return new Promise((resolve) => {
// Older Windows daemons answered this RPC without spawning; an absent optional coverage
// field must preserve that weaker meaning during adoption.
const fallbackCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn'
const fallbackCoverage = ptySpawnHealthPlatformCoverage()
const resolveVerdict = (verdict: DaemonHealth): void =>
resolve({ verdict, coverage: fallbackCoverage })
if (process.env[E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV] === '1') {
@@ -148,11 +131,9 @@ export function checkDaemonHealthWithCoverage(
}
if (message.id === 'health-1') {
const identity = readRuntimeIdentity(message.payload)
settle({
verdict: message.ok === true ? 'healthy' : 'pty-spawn-unhealthy',
coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage),
...identity
coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage)
})
return
}
@@ -103,6 +103,22 @@ describe('launchDaemonChild identity', () => {
})
})
describe('launchDaemonChild startup budget', () => {
it('waits for readiness past the default 10 s when the caller allows it', async () => {
vi.useFakeTimers()
const child = fakeDaemonChild(4242)
spawnDaemonChildProcessMock.mockReturnValue(child)
try {
const launch = launchDaemonChild({ ...LAUNCH_OPTIONS, startupTimeoutMs: 30_000 })
await vi.advanceTimersByTimeAsync(20_000)
child.emit('message', { type: 'ready', pid: 4242, startedAtMs: 1_000_000 })
await expect(launch).resolves.toMatchObject({ identity: { pid: 4242 } })
} finally {
vi.useRealTimers()
}
})
})
describe('launchDaemonChild durable-scope fallback', () => {
it('retries once without cgroup isolation when the scoped attempt fails', async () => {
isDurableDaemonScopeSupportedMock.mockReturnValue(true)
@@ -14,6 +14,8 @@ export type DaemonChildSpawnOptions = {
pidPath: string
launchNonce: string
macosLoginSessionWatch: boolean
/** Defaults to 10 s; a cold first exec on Windows can need longer. */
startupTimeoutMs?: number
}
function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] {
+2 -1
View File
@@ -12,6 +12,7 @@ import { unlinkOwnedDaemonPidFile } from './daemon-spawner'
const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000
const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000
const STARTUP_STDERR_MAX_BYTES = 8192
const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 10_000
export class DaemonEndpointUnavailableError extends Error {
constructor(
@@ -187,7 +188,7 @@ async function launchDaemonChildAttempt(
timer = setTimeout(() => {
void fail(new Error('Daemon startup timed out'))
}, 10000)
}, options.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS)
child.on('message', onReadyMessage)
child.on('error', onStartupError)
@@ -52,9 +52,11 @@ function createPreservedDaemonHandle(
return handle
}
export type DaemonLaunchPolicy = { macosLoginSessionWatch?: boolean; startupTimeoutMs?: number }
export function createOutOfProcessLauncher(
runtimeDir: string,
macosLoginSessionWatch = false
{ macosLoginSessionWatch = false, startupTimeoutMs }: DaemonLaunchPolicy = {}
): DaemonLauncher {
return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => {
const entryPath = getDaemonEntryPath()
@@ -132,7 +134,8 @@ export function createOutOfProcessLauncher(
tokenPath,
pidPath,
launchNonce,
macosLoginSessionWatch
macosLoginSessionWatch,
startupTimeoutMs
})
} catch (error) {
if (!(error instanceof DaemonEndpointUnavailableError) || error.reason !== 'occupied') {
@@ -33,41 +33,40 @@ describe('daemon process inspection', () => {
expect(runCommand).not.toHaveBeenCalled()
})
it('asks PowerShell to report a failed CIM query instead of an absent process', async () => {
const runCommand = vi.fn(
async (_file: string, _args: string[], _timeoutMs: number) =>
'{"status":"present","cmd":"daemon","start":1}'
)
it('reads the Windows process from the process table, not a PowerShell spawn', async () => {
const runCommand = vi.fn()
const readProcessTable = vi.fn(async () => [
{ pid: 42, ppid: 1, name: 'node.exe', command: 'daemon', creationTimeMs: 7 }
])
await queryWindowsProcess(42, { runCommand })
const script = runCommand.mock.calls[0]?.[1].at(-1) ?? ''
expect(script).toContain("$ErrorActionPreference = 'Stop'")
expect(script).toMatch(/catch \{[^}]*query_failed/)
await expect(queryWindowsProcess(42, { runCommand, readProcessTable })).resolves.toEqual({
status: 'present',
commandLine: 'daemon',
startedAtMs: 7
})
expect(runCommand).not.toHaveBeenCalled()
})
it('keeps a failed CIM query indeterminate instead of proving the process gone', async () => {
const runCommand = vi.fn(async () => '{"status":"query_failed"}')
it('keeps an unreadable process table indeterminate instead of proving the process gone', async () => {
const readProcessTable = vi.fn(async () => {
throw new Error('windows process table is unreadable')
})
await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({
await expect(queryWindowsProcess(42, { readProcessTable })).resolves.toEqual({
status: 'unavailable'
})
})
it('never reads a probe result without a success marker as proof of absence', async () => {
const runCommand = vi.fn(async () => '{"exists":false}')
it('reports absence only from a table that was read and lacks the PID', async () => {
const readProcessTable = vi.fn(async () => [
{ pid: 7, ppid: 1, name: 'other.exe', command: '' }
])
await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({
status: 'unavailable'
await expect(queryWindowsProcess(42, { readProcessTable })).resolves.toEqual({
status: 'missing'
})
})
it('reports absence only from a CIM query that ran and found nothing', async () => {
const runCommand = vi.fn(async () => '{"status":"missing"}')
await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({ status: 'missing' })
})
it('reads the macOS start time through an async spawn', async () => {
const runCommand = vi.fn(async () => 'Sat Jan 1 00:00:00 2028\n')
@@ -158,14 +157,14 @@ describe('daemon process inspection', () => {
})
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])(
'rejects unsafe Windows pid %s before command interpolation',
'rejects unsafe Windows pid %s before reading the process table',
async (pid) => {
const runCommand = vi.fn()
const readProcessTable = vi.fn(async () => [])
await expect(queryWindowsProcess(pid, { runCommand })).resolves.toEqual({
await expect(queryWindowsProcess(pid, { readProcessTable })).resolves.toEqual({
status: 'unavailable'
})
expect(runCommand).not.toHaveBeenCalled()
expect(readProcessTable).not.toHaveBeenCalled()
}
)
})
+25 -41
View File
@@ -8,6 +8,11 @@ import type {
ProcessSignalEvidence,
WindowsProcessEvidence
} from './daemon-incarnation-evidence-types'
import {
readWindowsProcessCreationTime,
readWindowsProcessTableFresh,
type WindowsProcessRow
} from '../windows/windows-process-table'
const execFileAsync = promisify(execFile)
@@ -16,6 +21,7 @@ type InspectionCommandRunner = (file: string, args: string[], timeoutMs: number)
export type DaemonProcessInspectionDependencies = {
readTextFile?: (path: string) => Promise<string>
runCommand?: InspectionCommandRunner
readProcessTable?: () => Promise<WindowsProcessRow[]>
}
export function inspectProcessSignal(pid: number): ProcessSignalEvidence {
@@ -33,6 +39,12 @@ export function inspectProcessSignal(pid: number): ProcessSignalEvidence {
}
}
/** EPERM counts as alive: it proves some process holds the PID. */
export function isProcessAlive(pid: number): boolean {
const signal = inspectProcessSignal(pid)
return signal === 'occupied' || signal === 'permission_denied'
}
export function inspectProcessLiveness(pid: number): ProcessLivenessVerdict {
const signal = inspectProcessSignal(pid)
switch (signal) {
@@ -94,9 +106,7 @@ export async function readProcessCommandLine(
}
}
// Why: Get-CimInstance errors (Winmgmt down, corrupt WMI repository, access denied) are
// non-terminating and exit 0 with an empty $p, which is indistinguishable from "no such
// process" — so the script reports query failure explicitly instead of asserting absence.
// Only a table that was read and lacks the PID proves absence; the reader rejects a truncated one.
export async function queryWindowsProcess(
pid: number,
dependencies: DaemonProcessInspectionDependencies = {}
@@ -104,45 +114,21 @@ export async function queryWindowsProcess(
if (!Number.isSafeInteger(pid) || pid <= 0) {
return { status: 'unavailable' }
}
const runCommand = dependencies.runCommand ?? runInspectionCommand
let rows: WindowsProcessRow[]
try {
const stdout = await runCommand(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`$ErrorActionPreference = 'Stop'; ` +
`try { $p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" } ` +
`catch { @{ status = 'query_failed' } | ConvertTo-Json -Compress; exit 0 }; ` +
`if (!$p) { @{ status = 'missing' } | ConvertTo-Json -Compress; exit 0 }; ` +
`$start = $null; if ($p.CreationDate) { ` +
`$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` +
`@{ status = 'present'; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress`
],
3_000
)
const parsed = JSON.parse(stdout.trim()) as {
status?: unknown
cmd?: unknown
start?: unknown
}
// Only a query that ran and found nothing proves absence; anything else stays indeterminate.
if (parsed.status === 'missing') {
return { status: 'missing' }
}
if (parsed.status !== 'present') {
return { status: 'unavailable' }
}
return {
status: 'present',
commandLine: typeof parsed.cmd === 'string' && parsed.cmd ? parsed.cmd : null,
startedAtMs:
typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null
}
rows = await (dependencies.readProcessTable ?? readWindowsProcessTableFresh)()
} catch {
return { status: 'unavailable' }
}
const row = rows.find((candidate) => candidate.pid === pid)
if (!row) {
return { status: 'missing' }
}
return {
status: 'present',
commandLine: row.command || null,
startedAtMs: row.creationTimeMs ?? readWindowsProcessCreationTime(pid)
}
}
// Why: the sync procfs helper in daemon-process-start-time spawns getconf per call; CLK_TCK is fixed for
@@ -219,8 +205,6 @@ async function runInspectionCommand(
args: string[],
timeoutMs: number
): Promise<string> {
// powershell.exe is console-subsystem: without this it flashes a conhost and
// steals foreground on every inspection (#10488).
const { stdout } = await execFileAsync(file, args, {
encoding: 'utf8',
timeout: timeoutMs,
@@ -229,6 +213,6 @@ async function runInspectionCommand(
return stdout
}
function hasErrorCode(error: unknown, code: string): boolean {
export function hasErrorCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
}
+4 -3
View File
@@ -19,7 +19,8 @@ import {
} from './daemon-launch-paths'
import {
attributeNextDaemonReplacement,
createOutOfProcessLauncher
createOutOfProcessLauncher,
type DaemonLaunchPolicy
} from './daemon-out-of-process-launcher'
import type { DaemonProvider } from './daemon-provider-routing'
import { installDaemonProvider } from './daemon-provider-state'
@@ -46,7 +47,7 @@ function logDaemonMilestone(event: string, details: Record<string, unknown> = {}
export async function initDaemonPtyProvider(
signal?: AbortSignal,
options: { macosLoginSessionWatch?: boolean } = {}
options: DaemonLaunchPolicy = {}
): Promise<void> {
logDaemonMilestone('daemon-init-start')
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout.
@@ -58,7 +59,7 @@ export async function initDaemonPtyProvider(
const newSpawner = new DaemonSpawner({
runtimeDir,
launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false)
launcher: createOutOfProcessLauncher(runtimeDir, options)
})
// Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner.
@@ -121,6 +121,12 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent
private async finishIdleRetirementRequest(): Promise<DaemonIdleRetirementResult> {
try {
await this.client.ensureConnected()
} catch {
// Nothing was asked of the daemon, so nothing can be retiring.
this.reopenAfterRefusedIdleRetirement()
return { state: 'unverifiable' }
}
try {
const result = await this.client.request<ShutdownIfIdleResult>('shutdownIfIdle', undefined)
if (result.retiring) {
this.idleRetirementState = 'retiring'
@@ -148,7 +154,12 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent
/** Reopens admission an idle-retirement attempt fenced without retiring the daemon. */
releaseIdleRetirementFence(): void {
if (this.idleRetirementState !== 'retiring' && !this.idleRetirementPromise) {
// An unverifiable attempt may have been accepted, so it stays fenced like a retiring one.
if (
this.idleRetirementState !== 'retiring' &&
this.idleRetirementState !== 'unverifiable' &&
!this.idleRetirementPromise
) {
this.reopenAfterRefusedIdleRetirement()
}
}
+7 -5
View File
@@ -26,11 +26,16 @@ import { assertDaemonRecoverySpawnAdmission } from './daemon-recovery-spawn-admi
import { addWslEnvKeys } from '../wsl-env'
export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
// Checked again in doSpawn: retirement can close admission while spawn awaits.
private assertSpawnAdmission(opts: PtySpawnOptions): void {
assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts)
if (this.idleRetirementAdmissionClosed) {
throw new Error('Terminal daemon is decommissioning')
}
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
this.assertSpawnAdmission(opts)
const spawnOpts = this.withHistoryIsolation(opts)
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
const operation: PendingDaemonSpawnOperation = {
@@ -110,10 +115,7 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
operation: PendingDaemonSpawnOperation,
historyRecovery: HistoryRecoveryContext
): Promise<PtySpawnResult> {
assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts)
if (this.idleRetirementAdmissionClosed) {
throw new Error('Terminal daemon is decommissioning')
}
this.assertSpawnAdmission(opts)
if (
opts.agentSessionEnsure &&
this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
+2 -2
View File
@@ -10,7 +10,7 @@ import type { DaemonSessionBackgroundRouting } from './daemon-session-background
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { DaemonTerminalAdmission } from './daemon-terminal-admission'
import { readDaemonHealthIdentity } from './daemon-health-identity'
import { ptySpawnHealthPlatformCoverage } from './daemon-health-identity'
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
import type { TerminalHost } from './terminal-host'
import { SessionNotFoundError, type DaemonRequest } from './types'
@@ -157,7 +157,7 @@ export class DaemonRequestRouter {
return { health: await readCurrentProcessMacSystemResolverHealth() }
case 'ptySpawnHealth':
await this.options.ptySpawnHealthCheck()
return { healthy: true, ...readDaemonHealthIdentity() }
return { healthy: true, coverage: ptySpawnHealthPlatformCoverage() }
case 'shutdown':
return this.shutdown(clientId, request.id, request.payload.killSessions)
}
@@ -118,20 +118,6 @@ describe('DaemonStreamDataBatcher', () => {
})
})
it('preserves PTY incarnation identity through stream serialization', () => {
const { batcher, streamSocket } = createBatcher()
batcher.enqueue('client-1', 'session-1', 'output', {
flushImmediately: true,
incarnationId: 'incarnation-1'
})
expect(JSON.parse(String(streamSocket.write.mock.calls[0]?.[0]))).toMatchObject({
event: 'data',
payload: { data: 'output', incarnationId: 'incarnation-1' }
})
})
it('keeps large pending output batched even when an interactive redraw follows', () => {
vi.useFakeTimers()
try {
@@ -246,8 +246,7 @@ export class DaemonStreamDataBatcher {
this.maxLineBytes,
sliceSequenceChars,
entry.seq,
entry.transformed,
entry.incarnationId
entry.transformed
)
}
this.updateBackpressure(clientId, batch)
+3 -8
View File
@@ -2,7 +2,6 @@ import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
import { writeStreamDataEvents } from './daemon-stream-data-split'
import { encodeNdjson } from './ndjson'
import { accountDaemonStreamEntry } from './daemon-stream-entry-accounting'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DaemonStreamEnqueueOptions = {
flushImmediately?: boolean
@@ -10,7 +9,6 @@ export type DaemonStreamEnqueueOptions = {
rawLength?: number
transformed?: boolean
seq?: number
incarnationId?: PtyIncarnationId
}
export function appendDaemonStreamData(
@@ -25,8 +23,7 @@ export function appendDaemonStreamData(
last?.sessionId === sessionId &&
!last.control &&
!last.transformed &&
options.transformed !== true &&
last.incarnationId === options.incarnationId
options.transformed !== true
) {
last.data += data
const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length
@@ -42,8 +39,7 @@ export function appendDaemonStreamData(
? {}
: { sequenceChars: options.rawLength }),
...(options.transformed ? { transformed: true } : {}),
...(options.seq === undefined ? {} : { seq: options.seq }),
...(options.incarnationId === undefined ? {} : { incarnationId: options.incarnationId })
...(options.seq === undefined ? {} : { seq: options.seq })
})
)
}
@@ -95,8 +91,7 @@ export function flushDaemonStreamSession(
maxLineBytes,
entry.sequenceChars ?? entry.data.length,
entry.seq,
entry.transformed,
entry.incarnationId
entry.transformed
)
}
}
@@ -18,8 +18,7 @@ function write(
rawLength = data.length,
seq?: number,
transformed = false,
sessionId = 'session-1',
incarnationId?: string
sessionId = 'session-1'
): string[] {
const lines: string[] = []
const socket: Pick<Socket, 'write'> = {
@@ -28,16 +27,7 @@ function write(
return true
})
}
writeStreamDataEvents(
socket,
sessionId,
data,
maxLineBytes,
rawLength,
seq,
transformed,
incarnationId
)
writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed)
return lines
}
@@ -94,15 +84,6 @@ describe('writeStreamDataEvents serialization budget', () => {
expect(encodeNdjson).toHaveBeenCalledTimes(1)
})
it('retains incarnation metadata in the single-encode fast path', () => {
const line = encodeStreamDataEvent('session-1', 'é🐙', undefined, undefined, false, 'epoch-1')
vi.mocked(encodeNdjson).mockClear()
expect(
write('é🐙', Buffer.byteLength(line), 3, undefined, false, 'session-1', 'epoch-1')
).toEqual([line])
expect(encodeNdjson).toHaveBeenCalledTimes(1)
})
it('does not add a duplicate full-data sizing probe to oversized writes', () => {
const data = '🐙\x1b[0m'.repeat(100)
const expected = previousWrites(data, 160)
@@ -121,24 +102,6 @@ describe('writeStreamDataEvents serialization budget', () => {
})
describe('writeStreamDataEvents wire parity', () => {
it.each([undefined, 9000])('budgets incarnation metadata on oversized writes (seq=%s)', (seq) => {
const data = '🐙é中\x1b[0m"\\\n'.repeat(100)
const incarnationId = 'epoch-'.repeat(16)
const lines = write(data, 384, data.length, seq, false, 'session-1', incarnationId)
expect(lines.length).toBeGreaterThan(1)
let consumed = 0
const chunks = lines.map((line) => {
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(384)
const { payload } = JSON.parse(line)
expect(payload.incarnationId).toBe(incarnationId)
expect(typeof payload.data).toBe('string')
consumed += payload.data.length
expect(payload.seq).toBe(seq === undefined ? undefined : seq - data.length + consumed)
return payload.data
})
expect(chunks.join('')).toBe(data)
})
it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => {
const payloads = [
'',
+14 -44
View File
@@ -5,15 +5,13 @@
*/
import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan'
import { encodeNdjson } from './ndjson'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export function encodeStreamDataEvent(
sessionId: string,
data: string,
rawLength?: number,
seq?: number,
transformed?: boolean,
incarnationId?: PtyIncarnationId
transformed?: boolean
): string {
return encodeNdjson({
type: 'event',
@@ -21,7 +19,6 @@ export function encodeStreamDataEvent(
sessionId,
payload: {
data,
...(incarnationId === undefined ? {} : { incarnationId }),
...(seq === undefined ? {} : { seq }),
...(rawLength === undefined ? {} : { rawLength }),
...(rawLength === undefined ? {} : { sequenceChars: rawLength }),
@@ -30,16 +27,8 @@ export function encodeStreamDataEvent(
})
}
function streamDataEventLineBytes(
sessionId: string,
data: string,
rawLength?: number,
incarnationId?: PtyIncarnationId
): number {
return Buffer.byteLength(
encodeStreamDataEvent(sessionId, data, rawLength, undefined, false, incarnationId),
'utf8'
)
function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number {
return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8')
}
function isHighSurrogate(value: number): boolean {
@@ -90,28 +79,20 @@ export function splitStreamDataForNdjson(
sessionId: string,
data: string,
maxLineBytes: number,
sequenceChars?: number,
incarnationId?: PtyIncarnationId
sequenceChars?: number
): string[] {
if (streamDataEventLineBytes(sessionId, data, sequenceChars, incarnationId) <= maxLineBytes) {
if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) {
return [data]
}
return splitOversizedStreamDataForNdjson(
sessionId,
data,
maxLineBytes,
sequenceChars,
incarnationId
)
return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars)
}
function splitOversizedStreamDataForNdjson(
sessionId: string,
data: string,
maxLineBytes: number,
sequenceChars?: number,
incarnationId?: PtyIncarnationId
sequenceChars?: number
): string[] {
const chunks: string[] = []
let start = 0
@@ -129,8 +110,7 @@ function splitOversizedStreamDataForNdjson(
}
if (
streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars, incarnationId) <=
maxLineBytes
streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes
) {
best = mid
low = rawMid + 1
@@ -154,36 +134,28 @@ export function writeStreamDataEvents(
maxLineBytes: number,
rawLength = data.length,
seq?: number,
transformed = false,
incarnationId?: PtyIncarnationId
transformed = false
): void {
const explicitRawLength = rawLength === data.length ? undefined : rawLength
if (transformed) {
streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true, incarnationId))
streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true))
return
}
const carriesMetadata = explicitRawLength !== undefined || seq !== undefined
let chunks: string[]
if (!carriesMetadata) {
const line = encodeStreamDataEvent(sessionId, data, undefined, undefined, false, incarnationId)
const line = encodeStreamDataEvent(sessionId, data)
if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) {
streamSocket.write(line)
return
}
chunks = splitOversizedStreamDataForNdjson(
sessionId,
data,
maxLineBytes,
undefined,
incarnationId
)
chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes)
} else {
chunks = splitStreamDataForNdjson(
sessionId,
data,
Math.max(1, maxLineBytes - 96),
explicitRawLength,
incarnationId
explicitRawLength
)
}
let consumed = 0
@@ -191,8 +163,6 @@ export function writeStreamDataEvents(
consumed += chunk.length
const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed)
const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined
streamSocket.write(
encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq, false, incarnationId)
)
streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq))
}
}
@@ -1,59 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { appendDaemonStreamData, flushDaemonStreamSession } from './daemon-stream-data-entry'
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
import { SessionOutputPlane } from './session-output-plane'
function batch(): PendingStreamDataBatch {
return {
timer: null,
queue: [],
queuedChars: 0,
queuedCharsBySession: new Map(),
queuedMetadataBytesBySession: new Map(),
droppableQueuedSessionIds: new Set()
}
}
describe('daemon stream incarnation ids', () => {
it('never merges output from two incarnations into one queued entry', () => {
const pending = batch()
appendDaemonStreamData(pending, 'session-1', 'old', { incarnationId: 'incarnation-1' })
appendDaemonStreamData(pending, 'session-1', 'more', { incarnationId: 'incarnation-1' })
appendDaemonStreamData(pending, 'session-1', 'new', { incarnationId: 'incarnation-2' })
expect(pending.queue.map(({ data, incarnationId }) => ({ data, incarnationId }))).toEqual([
{ data: 'oldmore', incarnationId: 'incarnation-1' },
{ data: 'new', incarnationId: 'incarnation-2' }
])
})
it('writes the id on per-session flushes and omits it when absent', () => {
const pending = batch()
appendDaemonStreamData(pending, 'session-1', 'tagged', { incarnationId: 'incarnation-1' })
appendDaemonStreamData(pending, 'session-1', 'legacy', {})
const lines: string[] = []
flushDaemonStreamSession(pending, 'session-1', 64 * 1024, (line) => lines.push(line))
const payloads = lines.map((line) => JSON.parse(line).payload)
expect(payloads).toEqual([
{ data: 'tagged', incarnationId: 'incarnation-1' },
{ data: 'legacy' }
])
})
it('hands the session incarnation only to identity-aware clients', () => {
const plane = new SessionOutputPlane({ cols: 80, rows: 24, incarnationId: 'incarnation-1' })
const legacy = vi.fn()
const aware = vi.fn()
const unused = vi.fn()
plane.attachClient({ onData: legacy, onExit: vi.fn() })
plane.attachClient({ onData: unused, onDataWithIncarnation: aware, onExit: vi.fn() })
plane.emit({ data: 'hi', rawStartSeq: 0, rawEndSeq: 2, transformed: false })
expect(legacy).toHaveBeenCalledWith('hi')
expect(aware).toHaveBeenCalledWith('hi', undefined, undefined, undefined, 'incarnation-1')
expect(unused).not.toHaveBeenCalled()
plane.disposeEmulator()
})
})
@@ -14,7 +14,6 @@ import {
accountDaemonStreamEntry,
releaseDaemonStreamEntry
} from './daemon-stream-entry-accounting'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
// A control entry carries a whole pre-shaped stream event (background marker,
// data gap, transient fact) that must ride at its exact position in the
@@ -28,7 +27,6 @@ export type StreamQueueEntry = {
sequenceChars?: number
seq?: number
transformed?: boolean
incarnationId?: PtyIncarnationId
control?: DaemonEvent
retainedBytes?: number
}
+16 -25
View File
@@ -173,32 +173,23 @@ export class DaemonTerminalAdmission {
clientId: string,
sessionId: () => string
): CreateOrAttachOptions['streamClient'] {
const onData = (
data: string,
rawLength = data.length,
transformed = false,
seq?: number,
incarnationId?: string
): void => {
const routedSessionId = sessionId()
this.options.transientFactRelay.onSessionData(routedSessionId, data)
const lastInputAt = this.options.attachments.lastInputAt(routedSessionId)
const isInteractiveOutput =
data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS
this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
flushImmediately: isInteractiveOutput,
flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS,
rawLength,
transformed,
seq,
...(incarnationId === undefined ? {} : { incarnationId })
})
}
return {
onData,
onDataWithIncarnation: onData,
onData: (data, rawLength = data.length, transformed = false, seq) => {
const routedSessionId = sessionId()
this.options.transientFactRelay.onSessionData(routedSessionId, data)
const lastInputAt = this.options.attachments.lastInputAt(routedSessionId)
const isInteractiveOutput =
data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS
this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
flushImmediately: isInteractiveOutput,
flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS,
rawLength,
transformed,
seq
})
},
onExit: (code, incarnationId, cause) => {
const routedSessionId = sessionId()
this.options.log.log('session-exited', {
@@ -1,7 +1,6 @@
import { SessionOutputPlane } from './session-output-plane'
import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier'
import type { SubprocessHandle } from './session-subprocess-handle'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
/** The session's ordered output pipeline: the recovery barrier feeding the
* output plane. Built together because the barrier's owner is what the
@@ -12,7 +11,6 @@ export function createSessionOutputPipeline(opts: {
scrollback?: number | undefined
wslDistro?: string | undefined
historySeedChunks?: readonly string[] | undefined
incarnationId?: PtyIncarnationId | undefined
subprocess: SubprocessHandle
isAlive: () => boolean
}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } {
@@ -24,7 +22,6 @@ export function createSessionOutputPipeline(opts: {
scrollback: opts.scrollback,
wslDistro: opts.wslDistro,
historySeedChunks: opts.historySeedChunks,
incarnationId: opts.incarnationId,
getTerminalOwner: () => barrier?.getOwner()
})
const recoveryBarrier = new TerminalShellRecoveryBarrier({
+5 -30
View File
@@ -5,7 +5,6 @@ import { normalizePtySize } from './daemon-pty-size'
import type { PtyIngressEmission } from '../../shared/pty-startup-ingress'
import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types'
import type { TerminalOwner } from '../../shared/terminal-owner'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { SubprocessHandle } from './session-subprocess-handle'
import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint'
@@ -16,14 +15,6 @@ const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
export type AttachedClient = {
token: symbol
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
/** Identity-bearing callback used by mutation-aware consumers; legacy clients keep the old shape. */
onDataWithIncarnation?: (
data: string,
rawLength: number | undefined,
transformed: boolean | undefined,
seq: number | undefined,
incarnationId: PtyIncarnationId
) => void
onExit: (code: number, incarnationId: string, cause?: TerminalExitCause) => void
}
@@ -33,7 +24,6 @@ export type SessionOutputPlaneOptions = {
scrollback?: number | undefined
wslDistro?: string | undefined
historySeedChunks?: readonly string[] | undefined
incarnationId?: PtyIncarnationId | undefined
/** Read from the recovery barrier at snapshot time; the barrier scans bytes
* before this plane receives them, so its owner never lags the emulator. */
getTerminalOwner?: (() => TerminalOwner | undefined) | undefined
@@ -45,7 +35,6 @@ export class SessionOutputPlane {
readonly historySeeded: boolean | undefined
private readonly emulator: HeadlessEmulator
private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined
private readonly incarnationId: PtyIncarnationId | undefined
private attachedClients: AttachedClient[] = []
private pendingOutputRecords: PendingOutputRecord[] = []
private pendingOutputBytes = 0
@@ -75,7 +64,6 @@ export class SessionOutputPlane {
? undefined
: opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk))
this.readTerminalOwner = opts.getTerminalOwner
this.incarnationId = opts.incarnationId
}
get responderParser(): HeadlessEmulator['responderParser'] {
@@ -209,7 +197,9 @@ export class SessionOutputPlane {
return
}
this.record({ kind: 'output', data: pending })
this.broadcastData(pending, 0, true, this._outputSequence)
for (const client of this.attachedClients) {
client.onData(pending, 0, true, this._outputSequence)
}
}
emit(emission: PtyIngressEmission): void {
@@ -226,24 +216,9 @@ export class SessionOutputPlane {
}
// Broadcast to attached clients
if (emission.transformed || rawLength !== data.length) {
this.broadcastData(data, rawLength, true, this._outputSequence)
} else {
this.broadcastData(data)
}
}
private broadcastData(
data: string,
rawLength?: number,
transformed?: boolean,
seq?: number
): void {
for (const client of this.attachedClients) {
if (client.onDataWithIncarnation && this.incarnationId) {
client.onDataWithIncarnation(data, rawLength, transformed, seq, this.incarnationId)
} else if (transformed || rawLength !== undefined || seq !== undefined) {
client.onData(data, rawLength, transformed, seq)
if (emission.transformed || rawLength !== data.length) {
client.onData(data, rawLength, true, this._outputSequence)
} else {
client.onData(data)
}
+12 -8
View File
@@ -12,7 +12,12 @@ import type { TuiAgent } from '../../shared/tui-agent'
import { randomUUID } from 'node:crypto'
import { PtyStartupIngress } from '../../shared/pty-startup-ingress'
import type * as SessionProtocol from './types'
import type {
SessionState,
ShellReadyState,
TakePendingOutputResult,
TerminalSnapshot
} from './types'
import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
@@ -23,7 +28,7 @@ export class Session {
readonly launchAgent: TuiAgent | null
readonly wslDistro: string | null
readonly processNameIsSpawnFile: boolean
private _state: SessionProtocol.SessionState = 'running'
private _state: SessionState = 'running'
private _exitCode: number | null = null
private _disposed = false
private subprocess: SubprocessHandle
@@ -50,8 +55,7 @@ export class Session {
wslDistro: opts.wslDistro,
historySeedChunks: opts.historySeedChunks,
subprocess: this.subprocess,
isAlive: () => !this._disposed && this._state !== 'exited',
incarnationId: this.incarnationId
isAlive: () => !this._disposed && this._state !== 'exited'
})
this.output = pipeline.output
this.recoveryBarrier = pipeline.recoveryBarrier
@@ -91,11 +95,11 @@ export class Session {
this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause))
}
get state(): SessionProtocol.SessionState {
get state(): SessionState {
return this._state
}
get shellState(): SessionProtocol.ShellReadyState {
get shellState(): ShellReadyState {
return this.shellReady.state
}
@@ -212,7 +216,7 @@ export class Session {
this.producerPause.release({ resume: true })
}
getSnapshot(opts: { scrollbackRows?: number } = {}): SessionProtocol.TerminalSnapshot | null {
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
this.startupIngress.snapshotBarrier()
return this.output.getSnapshot(opts)
}
@@ -228,7 +232,7 @@ export class Session {
takePendingOutput(
includeSnapshot: boolean,
opts: { teardownSnapshot?: boolean } = {}
): SessionProtocol.TakePendingOutputResult | null {
): TakePendingOutputResult | null {
if (this._disposed) {
return null
}
@@ -37,13 +37,6 @@ export type CreateOrAttachOptions = {
}
streamClient: {
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
onDataWithIncarnation?: (
data: string,
rawLength: number | undefined,
transformed: boolean | undefined,
seq: number | undefined,
incarnationId: PtyIncarnationId
) => void
onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void
}
/** Lets the daemon route output under the adopted owner's canonical id before
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { ORCAD_LOCK_FILE_NAME } from './orcad-instance-lock'
import {
liveLocalOrcadServeVersion,
pruneDesktopOrcadArtifactCache,
pruneOrcadArtifactCache
} from './orcad-artifact-cache-retention'
@@ -94,4 +95,27 @@ describe('orcad artifact cache retention', () => {
lock('desktop')
expect(liveLocalOrcadServeVersion(userData)).toBeNull()
})
it('keeps both the selected slot and the one a running orcad serve still uses', async () => {
const userData = cacheRoot()
const root = join(userData, 'orcad-artifacts')
const running = slot(root, 'linux-x64-glibc', '0.1.0+old', 90)
const selected = slot(root, 'linux-x64-glibc', '0.4.0+new', 1)
const others = [2, 3, 4].map((age) => slot(root, 'linux-x64-glibc', `v${age}`, age))
writeFileSync(
join(userData, ORCAD_LOCK_FILE_NAME),
JSON.stringify({
pid: process.pid,
startedAtMs: null,
nonce: 'nonce',
identity: 'uid',
version: '0.1.0+old',
acquiredAt: new Date().toISOString(),
role: 'orcad'
})
)
expect(await pruneDesktopOrcadArtifactCache(userData, ['0.4.0+new'])).toEqual([others[2]])
expect(existsSync(running)).toBe(true)
expect(existsSync(selected)).toBe(true)
})
})
@@ -1,7 +1,6 @@
/**
* Bounds the desktop's `<userData>/orcad-artifacts` slot cache, the way VS Code bounds its
* server downloads: per target, keep the most recently used slots and evict the rest at
* startup. The cache survives uninstall (it lives in userData); this is what keeps it small.
* Bounds the desktop's `<userData>/orcad-artifacts` slot cache: per target, keep the most
* recently used slots and evict the rest at startup. The cache survives uninstall (it lives in userData); this is what keeps it small.
*
* Never evicted: a slot this process materialized (an SSH deploy may be reading it) and the
* slot a live local orcad serve runs from, named by the profile's instance lock.
@@ -11,6 +10,7 @@ import { join } from 'node:path'
import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin'
import { ORCAD_LOCK_FILE_NAME, readOrcadInstanceLockRecord } from './orcad-instance-lock'
import { materializedOrcadArtifactVersions } from '../ssh/orcad-artifact-materializer'
import { isProcessAlive } from '../daemon/daemon-process-inspection'
/** The in-use slot plus the two most recent others (the newest three when none is in use). */
export const ORCAD_ARTIFACT_CACHE_KEEP = 3
@@ -68,32 +68,30 @@ export async function pruneOrcadArtifactCache(
return removed
}
/** The desktop's startup pass over its own `<userData>/orcad-artifacts`. */
export function pruneDesktopOrcadArtifactCache(userDataPath: string): Promise<string[]> {
/** A pass over `<userData>/orcad-artifacts` that never evicts a slot something still runs from. */
export function pruneDesktopOrcadArtifactCache(
userDataPath: string,
alsoInUse: readonly string[] = []
): Promise<string[]> {
const live = liveLocalOrcadServeVersion(userDataPath)
return pruneOrcadArtifactCache(join(userDataPath, 'orcad-artifacts'), {
inUseVersions: new Set([...materializedOrcadArtifactVersions(), ...(live ? [live] : [])])
inUseVersions: new Set([
...materializedOrcadArtifactVersions(),
...(live ? [live] : []),
...alsoInUse
])
})
}
/** The slot version a live local orcad serve runs from, or null when none holds the profile. */
export function liveLocalOrcadServeVersion(
userDataPath: string,
isAlive: (pid: number) => boolean = processIsAlive
isAlive: (pid: number) => boolean = isProcessAlive
): string | null {
const record = readOrcadInstanceLockRecord(join(userDataPath, ORCAD_LOCK_FILE_NAME))
return record && record.role !== 'desktop' && isAlive(record.pid) ? record.version : null
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return error instanceof Error && 'code' in error && error.code === 'EPERM'
}
}
async function listNames(directory: string): Promise<string[]> {
try {
return await readdir(directory)
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
import type { z } from 'zod'
import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader'
import { writeDurableSecureJsonFile } from '../../shared/secure-file'
import { hasErrorCode } from '../daemon/daemon-process-inspection'
import {
ORCAD_STOP_RECEIPTS_DIRNAME,
OrcadCompletedStopReceiptSchema,
@@ -40,7 +41,7 @@ export function readOrcadStopReceipt<T extends { request: OrcadManagedStopReques
throw new Error('orcad_stop_receipt_unverifiable')
}
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
if (hasErrorCode(error, 'ENOENT')) {
return null
}
throw error
@@ -69,4 +69,22 @@ describe('best-effort daemon retirement', () => {
})
expect(unanswered.releaseFence).toHaveBeenCalledOnce()
})
it('reopens admission again once a timed-out attempt is refused late', async () => {
let answer: (result: DaemonIdleRetirementResult) => void = () => {}
const late = ports(() => new Promise((resolve) => (answer = resolve)))
await retireOrcadDaemonIfIdle(late)
expect(late.releaseFence).toHaveBeenCalledOnce()
answer({ state: 'busy', liveSessions: 1 })
await vi.waitFor(() => expect(late.releaseFence).toHaveBeenCalledTimes(2))
})
it('keeps the fence when a timed-out attempt turns out to retire the daemon', async () => {
let answer: (result: DaemonIdleRetirementResult) => void = () => {}
const late = ports(() => new Promise((resolve) => (answer = resolve)))
await retireOrcadDaemonIfIdle(late)
answer({ state: 'retiring' })
await new Promise((resolve) => setTimeout(resolve, 10))
expect(late.releaseFence).toHaveBeenCalledOnce()
})
})
+7 -6
View File
@@ -11,8 +11,8 @@ import {
requestIdleDaemonRetirement
} from '../daemon/daemon-init'
import type { OrcadDaemonRetirementVerdict } from '../../shared/orcad-stop-request'
import { ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS } from './orcad-stop-deadlines'
export const ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS = 5_000
export type OrcadDaemonRetirement = {
retirement: OrcadDaemonRetirementVerdict
@@ -48,11 +48,12 @@ export async function retireOrcadDaemonIfIdle(
ports: Partial<RetirementPorts> = {}
): Promise<OrcadDaemonRetirement> {
const { request, releaseFence, countLiveSessions, timeoutMs } = { ...DEFAULT_PORTS, ...ports }
const result = await withTimeout(
request().catch(() => ({ state: 'unverifiable' as const })),
timeoutMs,
{ state: 'timed-out' as const }
)
const attempt = request().catch(() => ({ state: 'unverifiable' as const }))
const result = await withTimeout(attempt, timeoutMs, { state: 'timed-out' as const })
if (result.state === 'timed-out') {
// The fence cannot reopen while the attempt is pending; reopen it once a late refusal lands.
void attempt.then((late) => late.state !== 'retiring' && releaseFence())
}
if (result.state === 'retiring') {
return { retirement: 'retired', liveSessions: 0, reason: null }
}
@@ -56,6 +56,20 @@ describe('startOrcadDaemon', () => {
})
})
it('gives a cold Windows daemon a longer startup budget', async () => {
await startOrcadDaemon('win32')
expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, {
macosLoginSessionWatch: false,
startupTimeoutMs: 30_000
})
})
it('retries the daemon spawn once before falling back to in-process terminals', async () => {
initDaemonPtyProviderMock.mockRejectedValueOnce(new Error('Daemon startup timed out'))
await expect(startOrcadDaemon()).resolves.toEqual({ state: 'live', pid: 4242 })
expect(initDaemonPtyProviderMock).toHaveBeenCalledTimes(2)
})
it('reports degraded when fresh terminals would fall back to the local provider', async () => {
daemonOwnsFreshPersistentPtysMock.mockReturnValue(false)
const result = await startOrcadDaemon()
@@ -71,6 +85,7 @@ describe('startOrcadDaemon', () => {
state: 'unavailable',
reason: 'node-pty is missing'
})
expect(initDaemonPtyProviderMock).toHaveBeenCalledTimes(2)
})
})
+23 -5
View File
@@ -18,6 +18,12 @@ import {
readDaemonPidRecord
} from '../daemon/daemon-init'
const WINDOWS_DAEMON_STARTUP_TIMEOUT_MS = 30_000
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export type OrcadDaemonStartup =
| { state: 'live'; pid: number | null }
| { state: 'degraded'; reason: string }
@@ -31,13 +37,25 @@ export type OrcadDaemonStartup =
* — `daemonOwnsFreshPersistentPtys()` is what the runtime reads for that, and it answers
* false here without any extra bookkeeping.
*/
export async function startOrcadDaemon(): Promise<OrcadDaemonStartup> {
export async function startOrcadDaemon(
platform: NodeJS.Platform = process.platform
): Promise<OrcadDaemonStartup> {
// Why no login-session watch: that retires the daemon when the spawning macOS GUI login
// session dies. An orcad daemon must survive its SSH session ending — that is the point.
const policy = {
macosLoginSessionWatch: false,
// A freshly uploaded node.exe plus conpty can take well over 10 s on its first, AV-scanned exec.
...(platform === 'win32' ? { startupTimeoutMs: WINDOWS_DAEMON_STARTUP_TIMEOUT_MS } : {})
}
try {
// Why no login-session watch: that retires the daemon when the spawning macOS GUI login
// session dies. An orcad daemon must survive its SSH session ending — that is the point.
await initDaemonPtyProvider(undefined, { macosLoginSessionWatch: false })
await initDaemonPtyProvider(undefined, policy).catch((error: unknown) => {
// One warm retry: activation refuses a daemonless candidate, so a cold-start miss would
// otherwise leave a first deploy serving nothing.
console.warn(`[orcad] The terminal daemon did not start (${errorMessage(error)}); retrying`)
return initDaemonPtyProvider(undefined, policy)
})
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
const reason = errorMessage(error)
console.error(
`[orcad] The terminal daemon did not start: ${reason}\n` +
'[orcad] Terminals will run in-process and WILL NOT survive an orcad restart.'
+20 -3
View File
@@ -2,10 +2,12 @@ import {
chmodSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
@@ -146,21 +148,36 @@ describe('acquireOrcadInstanceLock', () => {
expect(JSON.parse(readFileSync(lock.path, 'utf8')).nonce).toBe('successor')
})
it.each([
const garbledLocks = [
['nothing (a torn write)', ''],
['invalid JSON', '{'],
['an incomplete record', JSON.stringify({ pid: 424242, identity: 'uid-1000' })],
['an invalid pid', JSON.stringify(persistedRecord({ pid: -1 }))],
['an invalid start time', JSON.stringify({ ...persistedRecord(), startedAtMs: 'yesterday' })]
])('fails closed when the existing lock contains %s', (_label, contents) => {
]
it.each(garbledLocks)('leaves a lock still being written alone: %s', (_label, contents) => {
const root = makeRoot()
writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), contents)
expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow(
expect.objectContaining({ code: 'orcad_instance_lock_unreadable' })
expect.objectContaining({ code: 'orcad_instance_lock_held' })
)
expect(readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')).toBe(contents)
})
it.each(garbledLocks)('reclaims an abandoned lock containing %s', (_label, contents) => {
const root = makeRoot()
const lockPath = join(root, ORCAD_LOCK_FILE_NAME)
writeFileSync(lockPath, contents)
const longAgo = new Date(Date.now() - 60_000)
utimesSync(lockPath, longAgo, longAgo)
const lock = acquireOrcadInstanceLock(root, hooks())
expect(JSON.parse(readFileSync(lockPath, 'utf8')).nonce).toBe(lock.record.nonce)
expect(readdirSync(root).sort()).toEqual([ORCAD_LOCK_FILE_NAME])
})
it('fails closed when the existing lock is not a regular file', () => {
const root = makeRoot()
mkdirSync(join(root, ORCAD_LOCK_FILE_NAME))
+64 -36
View File
@@ -13,7 +13,7 @@
* it says nothing about the daemon, which is what makes a non-destructive restart possible.
*/
import { randomUUID } from 'node:crypto'
import { linkSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { linkSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
import { userInfo } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
@@ -28,9 +28,12 @@ import {
type OrcadDataRootPrivacyHooks
} from './orcad-data-root-privacy'
import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader'
import { hasErrorCode, isProcessAlive } from '../daemon/daemon-process-inspection'
export const ORCAD_LOCK_FILE_NAME = 'orcad.lock'
const MAX_ORCAD_LOCK_BYTES = 64 * 1024
// A writer finishes in milliseconds; a garbled record older than this has no live writer.
const GARBLED_LOCK_GRACE_MS = 10_000
export { OrcadInstanceLockError, type OrcadInstanceLockCode } from './orcad-data-root-privacy'
@@ -81,19 +84,6 @@ function defaultIdentity(): string {
: String(process.getuid?.() ?? 'unknown')
}
function defaultProcessIsAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return isErrorCode(error, 'EPERM')
}
}
function isErrorCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
}
/** `null` for anything that is not a complete record; never a reason to treat a lock as free. */
export function parseOrcadInstanceLockRecord(content: string): OrcadLockRecord | null {
try {
@@ -115,7 +105,7 @@ export function acquireOrcadInstanceLock(
hooks: OrcadInstanceLockHooks = {}
): OrcadInstanceLock {
const identity = (hooks.identity ?? defaultIdentity)()
const isAlive = hooks.processIsAlive ?? defaultProcessIsAlive
const isAlive = hooks.processIsAlive ?? isProcessAlive
const readStartedAt = hooks.startedAtMs ?? readOrcadProcessStartedAtMs
const matchesStartTime = hooks.startTimeMatches ?? orcadProcessStartTimeMatches
@@ -144,18 +134,23 @@ export function acquireOrcadInstanceLock(
}
const serialized = JSON.stringify(record)
// Staged then hard-linked: the canonical path only ever holds a complete record, never a torn one.
const publish = (): boolean => {
const staged = `${lockPath}.staged-${process.pid}-${randomUUID()}`
try {
writeFileSync(lockPath, serialized, { flag: 'wx', mode: 0o600 })
writeFileSync(staged, serialized, { flag: 'wx', mode: 0o600 })
linkSync(staged, lockPath)
return true
} catch (error) {
if (isErrorCode(error, 'EEXIST')) {
if (hasErrorCode(error, 'EEXIST')) {
return false
}
throw new OrcadInstanceLockError(
'orcad_data_root_unusable',
`Cannot write the orcad instance lock ${lockPath}: ${(error as Error).message}`
)
} finally {
unlinkQuietly(staged)
}
}
@@ -163,16 +158,28 @@ export function acquireOrcadInstanceLock(
return makeLock(lockPath, record)
}
const existing = parseOrcadInstanceLockRecord(readBoundedLockFile(lockPath) ?? '')
if (!existing) {
// Why fail closed: an unreadable record proves nothing about its holder having exited.
const existingContents = readBoundedLockFile(lockPath)
if (existingContents === null) {
// Why fail closed: a record we cannot read proves nothing about its holder having exited.
throw new OrcadInstanceLockError(
'orcad_instance_lock_unreadable',
`The orcad instance lock at ${lockPath} is unreadable, malformed, or larger than ` +
`The orcad instance lock at ${lockPath} is unreadable or larger than ` +
`${MAX_ORCAD_LOCK_BYTES} bytes. Refusing to reclaim it without proof that its holder ` +
'has exited. Stop orcad and remove the stale lock manually.'
)
}
const existing = parseOrcadInstanceLockRecord(existingContents)
if (!existing) {
// Only a torn write (an older build, or a crash mid-write) leaves this; once it has aged
// past any live writer, no owner can be behind it.
if (garbledLockIsAbandoned(lockPath)) {
return reclaimAndPublish(lockPath, dataRoot, existingContents, publish, record)
}
throw new OrcadInstanceLockError(
'orcad_instance_lock_held',
`The orcad instance lock at ${lockPath} is still being written by another process. Retry.`
)
}
if (existing.identity !== identity) {
throw new OrcadInstanceLockError(
'orcad_instance_lock_foreign_identity',
@@ -189,9 +196,21 @@ export function acquireOrcadInstanceLock(
'before starting another, or use a different ORCA_USER_DATA.'
)
}
// Why rename-and-then-publish rather than unlink-and-write: rename claims one exact
// directory entry, so a replacement written between our read and our write stays at the
// canonical path and wins — we never delete a record we did not inspect.
return reclaimAndPublish(lockPath, dataRoot, existingContents, publish, record)
}
/**
* Why rename-and-then-publish rather than unlink-and-write: rename claims one exact directory
* entry, so a replacement written between our read and our write stays at the canonical path
* and wins — we never delete a record we did not inspect.
*/
function reclaimAndPublish(
lockPath: string,
dataRoot: string,
inspectedContents: string,
publish: () => boolean,
record: OrcadLockRecord
): OrcadInstanceLock {
const claimPath = `${lockPath}.stale-${process.pid}-${randomUUID()}`
try {
renameSync(lockPath, claimPath)
@@ -204,33 +223,42 @@ export function acquireOrcadInstanceLock(
}
// A contender may have replaced the entry after the liveness check; never displace its record.
const claimedContents = readBoundedLockFile(claimPath)
if (parseOrcadInstanceLockRecord(claimedContents ?? '')?.nonce !== existing.nonce) {
if (claimedContents !== inspectedContents) {
restoreDisplacedLock(claimPath, lockPath, claimedContents)
throw new OrcadInstanceLockError(
'orcad_instance_lock_held',
`The orcad instance lock at ${lockPath} changed while reclaiming a stale record.`
)
}
if (!publish()) {
const published = publish()
// A uniquely named claim is inert either way.
unlinkQuietly(claimPath)
if (!published) {
// Someone else claimed it first. Their record is authoritative; ours is not.
try {
unlinkSync(claimPath)
} catch {
// A uniquely named claim is inert.
}
throw new OrcadInstanceLockError(
'orcad_instance_lock_held',
`Another orcad took the data root ${dataRoot} while this one was reclaiming a stale lock.`
)
}
try {
unlinkSync(claimPath)
} catch {
// The canonical record is authoritative; the claim is inert.
}
return makeLock(lockPath, record)
}
function garbledLockIsAbandoned(lockPath: string): boolean {
try {
return Date.now() - statSync(lockPath).mtimeMs > GARBLED_LOCK_GRACE_MS
} catch {
return false
}
}
function unlinkQuietly(path: string): void {
try {
unlinkSync(path)
} catch {
// Already gone, or inert under its unique name.
}
}
function describeLockHolder(record: OrcadLockRecord): string {
return record.role === 'desktop' ? 'The Orca desktop app' : 'Another orcad'
}
+2 -1
View File
@@ -5,6 +5,7 @@ import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request'
import { acquireOrcadInstanceLock } from './orcad-instance-lock'
import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime'
import { resolveOrcadExitCode } from './orcad-exit-code'
import { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-stop-deadlines'
import {
acquireProfileStateRuntimeAdmission,
type ProfileStateRuntimeAdmission
@@ -21,7 +22,7 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise<void>): () => Promi
}
}
export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000
export { ORCAD_SHUTDOWN_DEADLINE_MS }
/**
* A launcher and its child can both receive the same process-group or service stop signal.
@@ -10,7 +10,7 @@ import {
ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS
} from '../../shared/orcad-local-serve-selection'
import { selectServeRuntime } from './orcad-local-serve-selection'
import { pruneOrcadArtifactCache } from './orcad-artifact-cache-retention'
import { pruneDesktopOrcadArtifactCache } from './orcad-artifact-cache-retention'
function flagValue(argv: readonly string[], flag: string): string | null {
const index = argv.indexOf(flag)
@@ -34,9 +34,10 @@ async function run(argv: readonly string[]): Promise<void> {
usesMacUpdateHandoff: argv.includes(FLAGS.macUpdateHandoff)
})
// orcad serve never starts the desktop's startup pass, so the slot cache is bounded here too.
await pruneOrcadArtifactCache(join(userDataPath, 'orcad-artifacts'), {
inUseVersions: new Set(selection.kind === 'orcad' ? [selection.version] : [])
}).catch(() => [])
await pruneDesktopOrcadArtifactCache(
userDataPath,
selection.kind === 'orcad' ? [selection.version] : []
).catch(() => [])
process.stdout.write(`${formatServeRuntimeSelection(selection)}\n`, () => process.exit(0))
}
+11 -11
View File
@@ -19,6 +19,7 @@ import {
startTimesWithinTolerance
} from '../daemon/daemon-process-start-time'
import { readOrcadProcessStartedAtMs } from './orcad-process-start-time'
import { hasErrorCode, inspectProcessSignal } from '../daemon/daemon-process-inspection'
import { persistOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt'
import { readOrcadManagedStopDecision } from './orcad-managed-stop-decision'
import {
@@ -26,6 +27,10 @@ import {
orcadManagedStopRequestPath,
readOrcadManagedStopRequest
} from './orcad-managed-stop-request'
import {
ORCAD_STOP_COMPLETION_POLL_ATTEMPTS,
ORCAD_STOP_COMPLETION_POLL_MS
} from './orcad-stop-deadlines'
export type OrcadProcessProbe = (pid: number) => 'alive' | 'missing' | 'unverifiable'
@@ -38,14 +43,9 @@ export type OrcadManagedStopCompletionOptions = {
}
function defaultProbe(pid: number): ReturnType<OrcadProcessProbe> {
try {
process.kill(pid, 0)
return 'alive'
} catch (error) {
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null
// EPERM proves some process holds the PID; it cannot prove ours exited.
return code === 'ESRCH' ? 'missing' : 'unverifiable'
}
const signal = inspectProcessSignal(pid)
// EPERM proves some process holds the PID; it cannot prove ours exited.
return signal === 'occupied' ? 'alive' : signal === 'missing' ? 'missing' : 'unverifiable'
}
function observeInstance(
@@ -75,7 +75,7 @@ export async function completeOrcadManagedStop(
options: OrcadManagedStopCompletionOptions = {}
): Promise<OrcadManagedStopVerdict> {
const request = OrcadManagedStopRequestSchema.parse(input)
const attempts = options.attempts ?? 80
const attempts = options.attempts ?? ORCAD_STOP_COMPLETION_POLL_ATTEMPTS
if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 240) {
throw new Error('orcad_managed_stop_invalid_attempts')
}
@@ -103,7 +103,7 @@ export async function completeOrcadManagedStop(
throw new Error('orcad_managed_stop_request_permissions_unconfirmed')
}
for (let attempt = 0; attempt < attempts; attempt++) {
await (options.sleep ?? (() => delay(250)))()
await (options.sleep ?? (() => delay(ORCAD_STOP_COMPLETION_POLL_MS)))()
verdict = observeInstance(request.instance, options)
if (verdict !== 'live') {
return verdict === 'exited' ? completed() : verdict
@@ -125,6 +125,6 @@ function existingRequestMatches(path: string, request: OrcadManagedStopRequest):
try {
return JSON.stringify(readOrcadManagedStopRequest(path)) === JSON.stringify(request)
} catch (error) {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
return hasErrorCode(error, 'ENOENT')
}
}
@@ -15,13 +15,10 @@ import {
type OrcadManagedStopRequest
} from '../../shared/orcad-stop-request'
import { orcadStopReceiptPath, readOrcadStopReceipt } from './orcad-completed-stop-receipt'
import { hasErrorCode } from '../daemon/daemon-process-inspection'
export type OrcadManagedStopDecisionValue = OrcadManagedStopDecision['decision']
function isAlreadyDecided(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST'
}
export function readOrcadManagedStopDecision(
request: OrcadManagedStopRequest
): OrcadManagedStopDecisionValue | null {
@@ -50,7 +47,7 @@ export function claimOrcadManagedStopDecision(
linkSync(staged, path)
return decision
} catch (error) {
if (!isAlreadyDecided(error)) {
if (!hasErrorCode(error, 'EEXIST')) {
throw error
}
const standing = readOrcadManagedStopDecision(request)
+17
View File
@@ -22,6 +22,11 @@ import {
} from './orcad-completed-stop-receipt'
import { prepareOrcadManagedStop } from './orcad-managed-stop-admission'
import { runOrcadManagedStopCommand } from './orcad-managed-stop-command'
import {
ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS,
ORCAD_SHUTDOWN_DEADLINE_MS,
ORCAD_STOP_COMPLETION_POLL_MS
} from './orcad-stop-deadlines'
const roots: string[] = []
afterEach(() => {
@@ -165,6 +170,18 @@ describe('completing a managed stop', () => {
expect(await completeOrcadManagedStop(request, options())).toBe('live')
})
it('waits out daemon retirement plus the shutdown deadline before answering live', async () => {
const { request } = running()
let polls = 0
const exitAfterMs = 2 * ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS + ORCAD_SHUTDOWN_DEADLINE_MS
const verdict = await completeOrcadManagedStop(request, {
...options({ attempts: undefined }),
sleep: async () => void polls++,
probeProcess: () => (polls * ORCAD_STOP_COMPLETION_POLL_MS >= exitAfterMs ? 'missing' : 'alive')
})
expect(verdict).toBe('exited')
})
it('does not address a live process that no longer holds the lock it published', async () => {
const { lock, request } = running()
writeFileSync(lock.path, JSON.stringify({ ...lock.record, nonce: 'successor' }))
+11
View File
@@ -0,0 +1,11 @@
/** Kept dependency-free: the short-lived stop-completion command imports these too. */
export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000
export const ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS = 5_000
export const ORCAD_STOP_COMPLETION_POLL_MS = 250
// Retirement (request + session count, each bounded) runs before the shutdown deadline starts.
const STOP_COMPLETION_BUDGET_MS =
2 * ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS + ORCAD_SHUTDOWN_DEADLINE_MS + 5_000
export const ORCAD_STOP_COMPLETION_POLL_ATTEMPTS = Math.ceil(
STOP_COMPLETION_BUDGET_MS / ORCAD_STOP_COMPLETION_POLL_MS
)
@@ -112,6 +112,27 @@ describe('orcad stop-request listeners', () => {
expect(existsSync(path)).toBe(true)
})
it('acts once on a managed request written before the listeners were installed', async () => {
const managedStop = managedContext()
writeFileSync(
orcadManagedStopRequestPath(managedStop.instance),
JSON.stringify({
schemaVersion: 1,
transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a',
...managedStop
})
)
const onRequest = vi.fn()
const prepare = vi.fn(async () => {})
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
listen(onRequest, { installRoot: directory(), managedStop, beforeManagedStop: prepare })
await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce())
await new Promise((resolve) => setTimeout(resolve, 50))
expect(prepare).toHaveBeenCalledOnce()
expect(onRequest).toHaveBeenCalledOnce()
expect(report).not.toHaveBeenCalled()
})
it('ignores a managed request for another runtime and reports it once', async () => {
const managedStop = managedContext()
const onRequest = vi.fn()
+25 -23
View File
@@ -17,20 +17,17 @@ import {
validateOrcadManagedStopRequest
} from './orcad-managed-stop-request'
import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision'
import { hasErrorCode } from '../daemon/daemon-process-inspection'
export type OrcadStopRequestListener = { close(): void }
const DEFAULT_POLL_INTERVAL_MS = 1_000
function isMissingFile(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
}
/** `consume` returns true when the request should stop orcad; a missing file throws ENOENT. */
function listenForRequest(
/** `consume` returns the request that should stop orcad, or null; a missing file throws ENOENT. */
function listenForRequest<T>(
requestPath: string,
consume: (path: string) => boolean,
onRequest: () => void,
consume: (path: string) => T | null,
onRequest: (request: T) => void,
pollIntervalMs: number
): OrcadStopRequestListener {
let closed = false
@@ -39,12 +36,11 @@ function listenForRequest(
if (closed) {
return
}
let request: T | null
try {
if (!consume(requestPath)) {
return
}
request = consume(requestPath)
} catch (error) {
if (!isMissingFile(error)) {
if (!hasErrorCode(error, 'ENOENT')) {
// Polling retries every interval; report each distinct refusal once.
const failure = error instanceof Error ? error.message : String(error)
if (failure !== lastFailure) {
@@ -54,9 +50,12 @@ function listenForRequest(
}
return
}
if (request === null) {
return
}
closed = true
stop()
onRequest()
onRequest(request)
}
let watcher: FSWatcher | null = null
try {
@@ -98,17 +97,24 @@ export function installOrcadStopRequestListeners(
}
): OrcadStopRequestListener {
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
const listeners = [
// Declared first: a listener's initial check can consume a request and close before returning.
const listeners: OrcadStopRequestListener[] = []
const close = (): void => {
for (const listener of listeners) {
listener.close()
}
}
listeners.push(
listenForRequest(
join(options.installRoot, ORCAD_STOP_REQUEST_FILENAME),
(path) => {
unlinkSync(path)
return true
},
onRequest,
() => onRequest(),
pollIntervalMs
)
]
)
const managedStop = options.managedStop
if (managedStop) {
const prepare = options.beforeManagedStop ?? (async () => {})
@@ -121,6 +127,9 @@ export function installOrcadStopRequestListeners(
if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') {
throw new Error('orcad_managed_stop_canceled')
}
return request
},
(request) => {
close()
// Preparation is best effort: whatever it reports, the stop proceeds.
void prepare(request)
@@ -128,17 +137,10 @@ export function installOrcadStopRequestListeners(
console.error('[orcad] managed stop preparation failed:', error)
)
.finally(onRequest)
return false
},
onRequest,
pollIntervalMs
)
)
}
const close = (): void => {
for (const listener of listeners) {
listener.close()
}
}
return { close }
}
@@ -60,6 +60,8 @@ function editorTab(
return { ...common, type: 'file', language: file.language, mode: 'edit' }
}
export const HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR = 'editor_tab_has_unsaved_draft'
export function buildHeadlessMobileSessionEditorTabs(
worktreeId: string,
session: WorkspaceSessionState
@@ -73,12 +75,18 @@ export function buildHeadlessMobileSessionEditorTabs(
export function retireHeadlessEditorTab(
session: WorkspaceSessionState,
worktreeId: string,
tab: Pick<HeadlessEditorTab, 'id' | 'filePath'>
tab: Pick<HeadlessEditorTab, 'id' | 'filePath'>,
force = false
): WorkspaceSessionState | null {
const files = session.openFilesByWorktree?.[worktreeId] ?? []
if (!files.some((file) => file.filePath === tab.filePath)) {
const file = files.find((candidate) => candidate.filePath === tab.filePath)
if (!file) {
return null
}
// No window here can prompt to save, and the draft lives nowhere else.
if (file.dirtyDraftContent !== undefined && !force) {
throw new Error(HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR)
}
const unifiedTabId = editorUnifiedTab(session, worktreeId, tab.filePath)?.id ?? tab.id
const withoutTab = (ids: readonly string[] | undefined): string[] | undefined =>
ids?.filter((id) => id !== unifiedTabId)
@@ -127,12 +135,13 @@ export type HeadlessEditorRetirementHost = {
export function retireHeadlessMobileSessionEditorTab(
host: HeadlessEditorRetirementHost,
worktreeId: string,
tab: { id: string; type: string; filePath?: string }
tab: { id: string; type: string; filePath?: string },
force = false
): boolean {
const session = host.getWorkspaceSessionForWorktree(worktreeId)
const next =
session && (tab.type === 'markdown' || tab.type === 'file') && tab.filePath
? retireHeadlessEditorTab(session, worktreeId, { id: tab.id, filePath: tab.filePath })
? retireHeadlessEditorTab(session, worktreeId, { id: tab.id, filePath: tab.filePath }, force)
: null
if (!next) {
return false
@@ -306,14 +306,12 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU
tab,
structuredAgentSessionTabCloseCause(options.reason)
)
} else {
if (!this.notifier?.closeSessionTab) {
// Why: a headless host listed this editor from its own session, so it retires it there.
if (!retireHeadlessMobileSessionEditorTab(this, worktreeId, tab)) {
throw new Error('runtime_unavailable')
}
return finishCommittedClose()
} else if (!this.notifier?.closeSessionTab) {
// Why: a headless host listed this editor from its own session, so it retires it there.
if (!retireHeadlessMobileSessionEditorTab(this, worktreeId, tab, options.force)) {
throw new Error('runtime_unavailable')
}
} else {
await this.notifier.closeSessionTab(tab.id, worktreeId)
}
return finishCommittedClose()
@@ -2,133 +2,117 @@ import type {
OrcadMigrationCatalogAbortResult,
OrcadMigrationCatalogState,
OrcadMigrationImportResult,
OrcadMigrationManifest
} from '../../shared/orcad-migration-manifest'
OrcadMigrationManifest,
} from "../../shared/orcad-migration-manifest";
import type {
OrcadMigrationSnapshotChunkRequest,
OrcadMigrationSnapshotChunkResult
} from '../../shared/orcad-migration-scrollback'
OrcadMigrationSnapshotChunkResult,
} from "../../shared/orcad-migration-scrollback";
import {
abortStagedOrcadMigrationCatalogDurably,
commitStagedOrcadMigrationCatalogDurably,
getOrcadMigrationCatalogState,
importOrcadMigrationCatalogDurably,
stageOrcadMigrationCatalogDurably
} from './orcad-migration-catalog-import'
import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter'
stageOrcadMigrationCatalogDurably,
} from "./orcad-migration-catalog-import";
import { OrcaRuntimeWithResolveWaiter } from "./orca-runtime-resolve-waiter";
import type { RuntimeStore } from "./runtime-store-contract";
import type { Store } from "../persistence";
/** The destination half of a dormant catalog migration; every mutation is flushed before it answers. */
export class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithResolveWaiter {
async importOrcadMigrationCatalog(
manifest: OrcadMigrationManifest,
options: { signal?: AbortSignal } = {}
options: { signal?: AbortSignal } = {},
): Promise<OrcadMigrationImportResult> {
const store = this.store
const importCatalog = store?.importOrcadMigrationCatalog
const flushPending = store?.flushPendingOrThrowAsync
if (!store || !importCatalog || !flushPending) {
throw new Error('runtime_unavailable')
}
return importOrcadMigrationCatalogDurably({
store: {
importOrcadMigrationCatalog: (input, inputOptions) =>
importCatalog.call(store, input, inputOptions),
flushPendingOrThrowAsync: (flushOptions) => flushPending.call(store, flushOptions)
},
store: this.requireMigrationStore(
"importOrcadMigrationCatalog",
"flushPendingOrThrowAsync",
),
manifest,
signal: options.signal,
onDurableImport: () => {
this.invalidateResolvedWorktreeCache()
this.notifyReposChanged()
}
})
this.invalidateResolvedWorktreeCache();
this.notifyReposChanged();
},
});
}
async stageOrcadMigrationCatalog(
manifest: OrcadMigrationManifest,
options: { signal?: AbortSignal } = {}
options: { signal?: AbortSignal } = {},
): Promise<OrcadMigrationCatalogState> {
const store = this.store
const stage = store?.stageOrcadMigrationCatalog
const flush = store?.flushPendingOrThrowAsync
if (!store || !stage || !flush) {
throw new Error('runtime_unavailable')
}
return stageOrcadMigrationCatalogDurably({
store: {
stageOrcadMigrationCatalog: (input, inputOptions) => stage.call(store, input, inputOptions),
flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions)
},
store: this.requireMigrationStore(
"stageOrcadMigrationCatalog",
"flushPendingOrThrowAsync",
),
manifest,
signal: options.signal
})
signal: options.signal,
});
}
async commitStagedOrcadMigrationCatalog(
manifest: OrcadMigrationManifest,
options: { signal?: AbortSignal } = {}
options: { signal?: AbortSignal } = {},
): Promise<OrcadMigrationCatalogState> {
const store = this.store
const commit = store?.commitStagedOrcadMigrationCatalog
const flush = store?.flushPendingOrThrowAsync
if (!store || !commit || !flush) {
throw new Error('runtime_unavailable')
}
return commitStagedOrcadMigrationCatalogDurably({
store: {
commitStagedOrcadMigrationCatalog: (input, inputOptions) =>
commit.call(store, input, inputOptions),
flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions)
},
store: this.requireMigrationStore(
"commitStagedOrcadMigrationCatalog",
"flushPendingOrThrowAsync",
),
manifest,
signal: options.signal,
onDurableCommit: () => {
this.invalidateResolvedWorktreeCache()
this.notifyReposChanged()
}
})
this.invalidateResolvedWorktreeCache();
this.notifyReposChanged();
},
});
}
stageOrcadMigrationSnapshotChunk(
request: OrcadMigrationSnapshotChunkRequest
request: OrcadMigrationSnapshotChunkRequest,
): OrcadMigrationSnapshotChunkResult {
const store = this.store
const stageChunk = store?.stageOrcadMigrationSnapshotChunk
if (!store || !stageChunk) {
throw new Error('runtime_unavailable')
}
return stageChunk.call(store, request)
return this.requireMigrationStore(
"stageOrcadMigrationSnapshotChunk",
).stageOrcadMigrationSnapshotChunk(request);
}
async abortStagedOrcadMigrationCatalog(
manifest: OrcadMigrationManifest,
options: { signal?: AbortSignal } = {}
options: { signal?: AbortSignal } = {},
): Promise<OrcadMigrationCatalogAbortResult> {
const store = this.store
const abort = store?.abortStagedOrcadMigrationCatalog
const flush = store?.flushPendingOrThrowAsync
if (!store || !abort || !flush) {
throw new Error('runtime_unavailable')
}
return abortStagedOrcadMigrationCatalogDurably({
store: {
abortStagedOrcadMigrationCatalog: (input) => abort.call(store, input),
flushPendingOrThrowAsync: (flushOptions) => flush.call(store, flushOptions)
},
store: this.requireMigrationStore(
"abortStagedOrcadMigrationCatalog",
"flushPendingOrThrowAsync",
),
manifest,
signal: options.signal
})
signal: options.signal,
});
}
getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState {
const store = this.store
const readState = store?.getOrcadMigrationCatalogState
if (!store || !readState) {
throw new Error('runtime_unavailable')
}
getOrcadMigrationCatalogState(
manifest: OrcadMigrationManifest,
): OrcadMigrationCatalogState {
return getOrcadMigrationCatalogState({
store: { getOrcadMigrationCatalogState: (input) => readState.call(store, input) },
manifest
})
store: this.requireMigrationStore("getOrcadMigrationCatalogState"),
manifest,
});
}
/** A partial store (tests, headless hosts) lacks some optional methods; refuse rather than half-run. */
private requireMigrationStore<K extends keyof RuntimeStore & keyof Store>(
...methods: K[]
): Pick<Store, K> {
const store = this.store;
if (
!store ||
methods.some((method) => typeof store[method] !== "function")
) {
throw new Error("runtime_unavailable");
}
return this.requireStore();
}
}
@@ -97,4 +97,22 @@ describe('editor tabs a headless host persisted', () => {
expect(getSession().unifiedTabs?.[TEST_WORKTREE_ID]).toEqual([])
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([])
})
it('keeps an unsaved draft unless the close is forced', async () => {
const session = sessionWithMigratedEditor()
const [file] = session.openFilesByWorktree?.[TEST_WORKTREE_ID] ?? []
session.openFilesByWorktree = { [TEST_WORKTREE_ID]: [{ ...file, dirtyDraftContent: 'draft' }] }
const { runtime, getSession } = headlessRuntime(session)
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await expect(
runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme')
).rejects.toThrow('editor_tab_has_unsaved_draft')
expect(getSession().openFilesByWorktree?.[TEST_WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
'draft'
)
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme', { force: true })
expect(getSession().openFilesByWorktree?.[TEST_WORKTREE_ID]).toEqual([])
})
})
@@ -1,5 +0,0 @@
export const WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES = 1024 * 1024
// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs.
export const WEBSOCKET_TRANSPORT_MAX_CONNECTIONS = 128
// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound.
export const WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS = WEBSOCKET_TRANSPORT_MAX_CONNECTIONS * 2
+6 -5
View File
@@ -13,11 +13,12 @@ import {
type WebSocketMessageHandler
} from './node-websocket-lifecycle'
import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat'
import {
WEBSOCKET_TRANSPORT_MAX_CONNECTIONS,
WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES,
WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS
} from './websocket-transport-limits'
const WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES = 1024 * 1024
// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs.
const WEBSOCKET_TRANSPORT_MAX_CONNECTIONS = 128
// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound.
const WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS = WEBSOCKET_TRANSPORT_MAX_CONNECTIONS * 2
const PRE_AUTH_TIMEOUT_MS = 10_000
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import { applyRuntimeWorktreePsUnverifiableTerminals } from './runtime-worktree-ps-unverifiable-terminals'
describe('unverifiable terminal attribution', () => {
it('follows the host PTY record, not a stale pane, as the live pass does', () => {
const summaries = new Map<string, Partial<RuntimeWorktreePsSummary>>([
['wt-old', {}],
['wt-new', {}]
])
applyRuntimeWorktreePsUnverifiableTerminals({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the counters this pass writes are read back.
summaries: summaries as Map<string, RuntimeWorktreePsSummary>,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getSummary below ignores the index.
pathIndex: {} as never,
missingIds: new Set(),
countedPtyIds: new Set(),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId.
leaves: [{ ptyId: 'pty-1', worktreeId: 'wt-old' } as never],
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId.
ptysById: new Map([['pty-1', { ptyId: 'pty-1', worktreeId: 'wt-new' } as never]]),
getLivenessVerdict: () => ({ status: 'unverifiable', reason: 'lost contact' }),
getSummary: (all, _index, _missing, worktreeId) => all.get(worktreeId) ?? null
})
expect(summaries.get('wt-new')?.unverifiableTerminalCount).toBe(1)
expect(summaries.get('wt-old')?.unverifiableTerminalCount).toBeUndefined()
})
})
@@ -23,18 +23,16 @@ export function applyRuntimeWorktreePsUnverifiableTerminals(args: {
worktreeId: string
) => RuntimeWorktreePsSummary | null
}): void {
// The renderer's pane owns the PTY's worktree; the record is the fallback once no pane holds it.
// Like the live pass, the host's PTY record owns the worktree; a pane only fills in a PTY with no record.
const ownerByPtyId = new Map<string, string>()
for (const pty of args.ptysById.values()) {
ownerByPtyId.set(pty.ptyId, pty.worktreeId)
}
for (const leaf of args.leaves) {
if (leaf.ptyId && !ownerByPtyId.has(leaf.ptyId)) {
ownerByPtyId.set(leaf.ptyId, leaf.worktreeId)
}
}
for (const pty of args.ptysById.values()) {
if (!ownerByPtyId.has(pty.ptyId)) {
ownerByPtyId.set(pty.ptyId, pty.worktreeId)
}
}
for (const [ptyId, worktreeId] of ownerByPtyId) {
if (
args.countedPtyIds.has(ptyId) ||
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { evaluateOrcadActivation } from './orcad-activation-gate'
import { classifyOrcadHostUnavailable } from './orcad-host-unavailable'
import type { ServeReadiness } from '../server/serve-readiness'
import type { OrcadHealth, TerminalDaemonHealth } from '../orcad/orcad-health'
@@ -85,6 +86,8 @@ describe('evaluateOrcadActivation', () => {
EXPECTED
)
expect(verdict).toMatchObject({ decision: 'reject', code: 'orcad_activation_daemon_absent' })
// A cold daemon start is transient: the host must stay eligible for the next connect's deploy.
expect(classifyOrcadHostUnavailable({ code: 'orcad_activation_daemon_absent' })).toBeNull()
})
it('refuses a degraded daemon, whose fresh terminals would not survive a restart', () => {
+2 -1
View File
@@ -105,7 +105,8 @@ export function evaluateOrcadActivation(
reason:
'The candidate has no terminal daemon. Every terminal on this host would run in the ' +
'orcad process and die with it, which is the exact regression the daemon exists to ' +
'prevent. Nothing was activated.'
"prevent. Nothing was activated; orcad.log carries the daemon's startup error, and " +
'the next connect retries.'
}
}
if (daemon.state === 'degraded') {
+11 -1
View File
@@ -11,7 +11,10 @@ import {
const ERROR_MESSAGE = /could not find the Windows roaming AppData folder/
function createHost(nativeAppData: string | Error): WindowsAppDataPathHost & {
function createHost(
nativeAppData: string | Error,
switches: string[] = []
): WindowsAppDataPathHost & {
calls: string[]
paths: Map<string, string>
} {
@@ -21,6 +24,7 @@ function createHost(nativeAppData: string | Error): WindowsAppDataPathHost & {
calls,
paths,
getName: () => 'orca',
commandLine: { hasSwitch: (name) => switches.includes(name) },
getPath: (name) => {
calls.push(`get:${name}`)
const overridden = paths.get(name)
@@ -85,6 +89,12 @@ describe('ensureWindowsAppDataPath', () => {
expect(host.paths.get('userData')).toBe('C:\\Users\\me\\AppData\\Roaming\\orca')
})
it('leaves userData to an explicit --user-data-dir switch', () => {
const host = createHost('C:\\Users\\me\\AppData\\Roaming', ['user-data-dir'])
ensureWindowsAppDataPath(host, {}, 'win32')
expect(host.calls).toEqual(['get:appData'])
})
it('creates and sets appData from the environment before userData when the lookup throws', () => {
// Why a real dir: the fallback must mkdir a folder that a profile-less session never created.
const root = mkdtempSync(join(tmpdir(), 'orca-appdata-'))
@@ -5,6 +5,7 @@ export type WindowsAppDataPathHost = {
getPath(name: 'appData'): string
setPath(name: 'appData' | 'userData', value: string): void
getName(): string
commandLine: { hasSwitch(name: string): boolean }
}
/** Derives the roaming AppData folder from the environment when the known-folder lookup fails. */
@@ -50,6 +51,10 @@ export function ensureWindowsAppDataPath(
mkdirSync(appData, { recursive: true })
host.setPath('appData', appData)
}
// Electron already resolved userData from an explicit switch; pinning it would override that.
if (host.commandLine.hasSwitch('user-data-dir')) {
return
}
// Same value Electron's own provider computes; setting it skips that provider entirely.
host.setPath('userData', win32.join(appData, host.getName()))
}