sim: merge PR #17164

This commit is contained in:
Brennan Benson
2026-08-30 15:42:27 -07:00
14 changed files with 811 additions and 21 deletions
+9 -1
View File
@@ -246,7 +246,15 @@ export function formatCliStatus(status: CliStatusResult): string {
`runtimeState: ${status.runtime.state}`,
`runtimeReachable: ${status.runtime.reachable}`,
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
`graphState: ${status.graph.state}`
`graphState: ${status.graph.state}`,
// Why: STA-3969 — an unreachable runtime is only actionable if the plain-text
// output carries the cause, not just the state word.
...(status.runtime.unreachableReason
? [
`unreachableCode: ${status.runtime.unreachableReason.code}`,
`unreachableDetail: ${status.runtime.unreachableReason.message}`
]
: [])
].join('\n')
}
+158 -1
View File
@@ -1,4 +1,4 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer, type Socket } from 'node:net'
@@ -316,6 +316,163 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
expect(launchOrcaApp).toHaveBeenCalledOnce()
})
// STA-3969: this loop only polls getCliStatus, so an unreachable runtime used to
// burn the whole budget and then report a bare timeout — the third symptom in the
// report, and the one that reads as "the app never finished starting".
it('openOrca reports why the runtime was unreachable instead of a bare timeout', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
writeMetadata(userDataPath, join(userDataPath, 'never-listened.sock'), 'token', process.pid)
const client = new RuntimeClient(userDataPath, 100)
await expect(client.openOrca(100)).rejects.toMatchObject({
code: 'runtime_open_timeout',
message: expect.stringContaining('never-listened.sock'),
data: { unreachableReason: { code: 'endpoint_missing' } }
})
})
// STA-3969: the diagnosis is still worth surfacing once metadata disappears, but the newest
// poll no longer observes it -- so it is reported as the last failure seen, not as the live
// one. Presenting it under `unreachableReason` would be the same stale negative in machine form.
it('reports the last unreachable reason as history when later polls have no metadata', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
writeMetadata(userDataPath, join(userDataPath, 'never-listened.sock'), 'token', process.pid)
vi.mocked(launchOrcaApp).mockImplementationOnce(() => {
rmSync(join(userDataPath, 'orca-runtime.json'))
})
const client = new RuntimeClient(userDataPath, 100)
const failure = await client.openOrca(100).then(
() => null,
(error: unknown) => error as { code: string; message: string; data?: unknown }
)
expect(failure?.code).toBe('runtime_open_timeout')
expect(failure?.message).toContain('never-listened.sock')
expect(failure?.message).toContain('The last failure it reported was')
expect(failure?.message).not.toContain('the Orca app process is running')
const data = failure?.data as {
unreachableReason?: unknown
lastObservedUnreachableReason?: { code?: string }
}
expect(data.unreachableReason).toBeUndefined()
expect(data.lastObservedUnreachableReason?.code).toBe('endpoint_missing')
})
// STA-3969: same stale negative one state further along -- the runtime was unreachable, then
// its PROCESS exited. The newest poll reports a dead process and no reason, so quoting the old
// endpoint failure claims a running process the latest status denies.
it('openOrca stops claiming the process is running once it exits mid-wait', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'never-listened.sock')
writeMetadata(userDataPath, endpoint, 'token', process.pid)
vi.mocked(launchOrcaApp).mockImplementationOnce(() => {
writeMetadata(userDataPath, endpoint, 'token', findUnusedPid())
})
const client = new RuntimeClient(userDataPath, 100)
const failure = await client.openOrca(100).then(
() => null,
(error: unknown) => error as { code: string; message: string; data?: unknown }
)
expect(failure?.code).toBe('runtime_open_timeout')
expect(failure?.message).not.toContain('the Orca app process is running')
expect(failure?.message).toContain('no longer running')
expect(
(failure?.data as { unreachableReason?: unknown } | undefined)?.unreachableReason
).toBeUndefined()
})
// STA-3969: `request_rejected` means the runtime ANSWERED and refused. Calling that
// "unreachable" contradicts the very reason being quoted alongside it.
it('openOrca says the runtime refused the request instead of calling it unreachable', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.on('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
socket.write(
`${JSON.stringify({
id: request.id,
ok: false,
error: { code: 'status_refused', message: 'status.get is disabled' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint, 'token', process.pid)
const client = new RuntimeClient(userDataPath, 100)
const failure = await client.openOrca(100).then(
() => null,
(error: unknown) => error as { code: string; message: string; data?: unknown }
)
expect(failure?.code).toBe('runtime_open_timeout')
expect(
(failure?.data as { unreachableReason?: { code?: string } } | undefined)?.unreachableReason
?.code
).toBe('request_rejected')
expect(failure?.message).toContain('refused the status request')
expect(failure?.message).not.toContain('its runtime is unreachable')
})
// STA-3969: the poll carried the earlier reason forward with `?? lastReason`, so a runtime
// that RECOVERED mid-wait was still reported unreachable at the timeout -- a stale negative
// presented as the current diagnosis.
it('openOrca stops reporting a runtime unreachable once it answers again', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
result: {
runtimeId: 'runtime-1',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: 0,
desktopWindowStatus: 'initializing',
liveTabCount: 0,
liveLeafCount: 0
},
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
// Starts pointed at an endpoint nothing serves, then recovers onto the live one.
writeMetadata(userDataPath, join(userDataPath, 'never-listened.sock'), 'token', process.pid)
vi.mocked(launchOrcaApp).mockImplementationOnce(() => {
writeMetadata(userDataPath, endpoint, 'token', process.pid)
})
const client = new RuntimeClient(userDataPath, 100)
const failure = await client.openOrca(1_000).then(
() => null,
(error: unknown) => error as { code: string; message: string; data?: unknown }
)
expect(failure?.code).toBe('runtime_open_timeout')
expect(failure?.message).not.toContain('unreachable')
expect(failure?.data).toBeUndefined()
// The second timeout case: answers all the way through, just no window.
expect(failure?.message).toContain('is responding and still running headlessly')
})
it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
+21 -1
View File
@@ -9,6 +9,7 @@ import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
import { launchOrcaApp } from './launch'
import { getDefaultUserDataPath, readMetadata } from './metadata'
import { getCliStatus, projectRemoteAppStatus } from './status'
import { describeOpenTimeout } from './runtime-open-timeout-reason'
import { sendRequest } from './transport'
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
import { attachMutationRecovery } from './client-error-recovery'
@@ -263,6 +264,13 @@ export class RuntimeClient {
}
const startedAt = Date.now()
// Why (STA-3969): the timeout must describe the NEWEST observation. A reason accumulated
// across polls outlives the poll that saw it and then gets reported as the current
// diagnosis -- first when the runtime recovered, and again when its process exited. So the
// loop keeps the latest status and the reason is read off that; the earlier one survives
// only as history, never as the live verdict.
let latest = initial.result
let lastObservedReason = initial.result.runtime.unreachableReason
while (Date.now() - startedAt < timeoutMs) {
const status = await this.getCliStatus()
if (status.result.app.desktopWindowStatus === 'blocked') {
@@ -271,12 +279,24 @@ export class RuntimeClient {
if (status.result.app.desktopWindowStatus === 'available') {
return status
}
latest = status.result
// A poll that ANSWERED resolves the earlier failure outright, so it is not even history
// any more; only an unresolved failure we can no longer diagnose is worth carrying.
lastObservedReason = latest.runtime.reachable
? undefined
: (latest.runtime.unreachableReason ?? lastObservedReason)
await delay(250)
}
const currentReason = latest.runtime.unreachableReason
throw new RuntimeClientError(
'runtime_open_timeout',
'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.'
`Timed out waiting for an Orca desktop window${describeOpenTimeout(latest, lastObservedReason)}`,
currentReason
? { unreachableReason: currentReason }
: lastObservedReason
? { lastObservedUnreachableReason: lastObservedReason }
: undefined
)
}
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
import { classifyLocalRuntimeUnreachable } from './local-runtime-unreachable-reason'
import { RuntimeClientError, RuntimeRpcFailureError, RuntimeTransportError } from './types'
const PIPE: RuntimeTransportMetadata = {
kind: 'named-pipe',
endpoint: '\\\\.\\pipe\\orca-26272-abcd'
}
const SOCKET: RuntimeTransportMetadata = { kind: 'unix', endpoint: '/tmp/orca/o-1-ab.sock' }
function connectError(osErrorCode: string | null): RuntimeTransportError {
return new RuntimeTransportError('runtime_unavailable', 'generic', 'connect', osErrorCode)
}
describe('classifyLocalRuntimeUnreachable', () => {
it.each([
['ENOENT', 'endpoint_missing'],
['EACCES', 'endpoint_permission_denied'],
['EPERM', 'endpoint_permission_denied'],
['ECONNREFUSED', 'connection_refused'],
['ECONNRESET', 'connection_closed'],
['EPIPE', 'connection_closed']
] as const)('maps a %s connect failure to %s', (osErrorCode, expected) => {
const reason = classifyLocalRuntimeUnreachable(connectError(osErrorCode), PIPE, 1000)
expect(reason.code).toBe(expected)
expect(reason.osErrorCode).toBe(osErrorCode)
})
it('classifies a peer close by phase, not by an errno the OS never reported', () => {
const reason = classifyLocalRuntimeUnreachable(
new RuntimeTransportError('runtime_unavailable', 'closed', 'peer_closed'),
SOCKET,
1000
)
expect(reason.code).toBe('connection_closed')
expect(reason.osErrorCode).toBeUndefined()
})
it('maps a client-side timeout to request_timeout and names the budget', () => {
const reason = classifyLocalRuntimeUnreachable(
new RuntimeClientError('runtime_timeout', 'timed out'),
SOCKET,
1234
)
expect(reason.code).toBe('request_timeout')
expect(reason.message).toContain('1234ms')
})
it('maps an unreadable frame to invalid_response', () => {
const reason = classifyLocalRuntimeUnreachable(
new RuntimeClientError('invalid_runtime_response', 'bad frame'),
SOCKET,
1000
)
expect(reason.code).toBe('invalid_response')
})
// Why: a runtime that answers and declines is reachable. Routing it to a
// transport code would aim sandbox/permission advice at an auth failure.
it('quotes the runtime error when the runtime answers and refuses', () => {
const reason = classifyLocalRuntimeUnreachable(
new RuntimeRpcFailureError({
id: 'req',
ok: false,
error: { code: 'unauthorized', message: 'Auth token is not valid.' }
}),
SOCKET,
1000
)
expect(reason.code).toBe('request_rejected')
expect(reason.message).toContain('unauthorized')
expect(reason.message).toContain('Auth token is not valid.')
})
it('falls back to unknown without inventing a cause', () => {
const reason = classifyLocalRuntimeUnreachable(new Error('boom'), SOCKET, 1000)
expect(reason.code).toBe('unknown')
expect(reason.osErrorCode).toBeUndefined()
})
it('always reports the endpoint it actually tried, with the right noun', () => {
expect(classifyLocalRuntimeUnreachable(connectError('EACCES'), PIPE, 1000)).toMatchObject({
endpoint: PIPE.endpoint,
endpointKind: 'named-pipe'
})
expect(classifyLocalRuntimeUnreachable(connectError('EACCES'), PIPE, 1000).message).toContain(
'named pipe'
)
expect(classifyLocalRuntimeUnreachable(connectError('EACCES'), SOCKET, 1000).message).toContain(
'socket'
)
})
it('does not claim a permission-denied endpoint exists', () => {
const reason = classifyLocalRuntimeUnreachable(connectError('EACCES'), PIPE, 1000)
expect(reason.message).not.toMatch(/\bexists\b/i)
expect(reason.message).toContain('OS denied')
})
// The reported Windows 10 incident: the app process is alive, the pipe is not
// openable from this process. The guidance must name the isolation possibilities
// rather than telling the user to keep waiting.
it('offers session/sandbox guidance for a hidden Windows named pipe', () => {
const reason = classifyLocalRuntimeUnreachable(connectError('ENOENT'), PIPE, 1000)
expect(reason.message).toContain(PIPE.endpoint)
expect(reason.message).toMatch(/sandbox/i)
expect(reason.message).toMatch(/session/i)
})
})
@@ -0,0 +1,100 @@
import type {
CliRuntimeUnreachableCode,
CliRuntimeUnreachableReason
} from '../../shared/runtime-types'
import type { RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
import { RuntimeClientError, RuntimeRpcFailureError, RuntimeTransportError } from './types'
/**
* Why: STA-3969 — `orca status` used to swallow every local RPC failure and report
* `starting`, so a CLI that could not reach the runtime looked identical to one
* waiting on a runtime that was genuinely still coming up. The runtime publishes
* `orca-runtime.json` only *after* its transport is listening (RuntimeRpc.start),
* so once metadata names an endpoint, a failure to talk to that endpoint is never
* evidence of a start still in progress. Name the failure instead.
*/
export function classifyLocalRuntimeUnreachable(
error: unknown,
transport: RuntimeTransportMetadata,
timeoutMs: number
): CliRuntimeUnreachableReason {
const endpoint = transport.endpoint
const endpointKind = transport.kind === 'named-pipe' ? 'named-pipe' : 'unix'
const noun = endpointKind === 'named-pipe' ? 'named pipe' : 'socket'
const osErrorCode = error instanceof RuntimeTransportError ? error.osErrorCode : null
const code = resolveCode(error, osErrorCode)
return {
code,
message:
code === 'request_rejected' && error instanceof RuntimeRpcFailureError
? `The runtime at ${endpoint} answered but refused the status request (${error.response.error.code}): ${error.response.error.message}`
: describe(code, { endpoint, noun, timeoutMs }),
endpoint,
endpointKind,
...(osErrorCode ? { osErrorCode } : {})
}
}
function resolveCode(error: unknown, osErrorCode: string | null): CliRuntimeUnreachableCode {
// Why: a runtime that answers and declines is not unreachable in the transport
// sense — keep it distinct so "fix your sandbox" advice never lands on an auth
// or version rejection.
if (error instanceof RuntimeRpcFailureError) {
return 'request_rejected'
}
if (error instanceof RuntimeTransportError && error.phase === 'peer_closed') {
return 'connection_closed'
}
switch (osErrorCode) {
case 'ENOENT':
return 'endpoint_missing'
case 'EACCES':
case 'EPERM':
return 'endpoint_permission_denied'
case 'ECONNREFUSED':
return 'connection_refused'
case 'EPIPE':
case 'ECONNRESET':
return 'connection_closed'
case null:
default:
break
}
if (error instanceof RuntimeClientError) {
if (error.code === 'runtime_timeout') {
return 'request_timeout'
}
if (error.code === 'invalid_runtime_response') {
return 'invalid_response'
}
}
return 'unknown'
}
// Why: every branch names the endpoint and states only what was observed. The
// sandbox/session wording is a list of possibilities, not a verdict — the CLI
// cannot see why the OS hid the endpoint, and must not claim it can.
function describe(
code: CliRuntimeUnreachableCode,
ctx: { endpoint: string; noun: string; timeoutMs: number }
): string {
const { endpoint, noun, timeoutMs } = ctx
switch (code) {
case 'endpoint_missing':
return `Orca published the ${noun} ${endpoint}, but it does not exist for this process. Either the runtime shut its endpoint down, or this process cannot see it — a sandbox, container, or different user session each hide it this way. Run the CLI as the same user and outside any sandbox, or restart Orca.`
case 'endpoint_permission_denied':
return `The OS denied this process access to the ${noun} ${endpoint}. Run the CLI as the same user that runs Orca, outside any sandbox that restricts ${noun} access.`
case 'connection_refused':
return `The ${noun} ${endpoint} refused the connection. Orca's runtime is most likely shutting down; restart Orca.`
case 'connection_closed':
return `Connected to ${endpoint}, but the runtime closed the connection before replying. It may be shutting down or at its connection limit; restart Orca.`
case 'request_timeout':
return `Connected to ${endpoint}, but the runtime did not reply within ${timeoutMs}ms. It is reachable and not answering, so it is busy or wedged rather than starting.`
case 'request_rejected':
return `The runtime at ${endpoint} answered but refused the status request.`
case 'invalid_response':
return `The runtime at ${endpoint} returned a response this CLI could not read. The CLI and the Orca app are most likely different versions; reinstall the CLI from this Orca build.`
case 'unknown':
return `Could not talk to the Orca runtime at ${endpoint}. The Orca app process is running, so this is not a start still in progress.`
}
}
@@ -0,0 +1,185 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { createServer, type Server, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap'
import { formatCliStatus } from '../format'
import { RuntimeClient } from './client'
const servers = new Set<Server>()
const sockets = new Set<Socket>()
afterEach(async () => {
for (const socket of sockets) {
socket.destroy()
}
sockets.clear()
await Promise.all(
[...servers].map((server) => new Promise<void>((resolve) => server.close(() => resolve())))
)
servers.clear()
})
function seedMetadata(endpoint: string, pid: number): string {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-unreachable-'))
writeFileSync(
getRuntimeMetadataPath(userDataPath),
JSON.stringify({
runtimeId: 'runtime-under-test',
pid,
transports: [{ kind: 'unix', endpoint }],
authToken: 'token',
startedAt: Date.now()
})
)
return userDataPath
}
async function listen(endpoint: string, onConnection: (socket: Socket) => void): Promise<void> {
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
onConnection(socket)
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
}
// Why: Windows publishes named pipes, which these Unix-socket fixtures cannot stand in for.
// The classification itself is platform-independent and covered by the reason unit tests.
describe.skipIf(process.platform === 'win32')('local runtime reachability status', () => {
// STA-3969: the reported incident. The Orca app process is alive and metadata
// names an endpoint, but the CLI cannot open it — reported for weeks as
// `starting`, which told the user to keep waiting for a start that had already
// happened. The runtime writes this metadata only after its transport is
// listening, so `starting` is never a truthful reading here.
it('names the missing endpoint instead of claiming the runtime is still starting', async () => {
const userDataPath = seedMetadata(
join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'gone.sock'),
process.pid
)
const status = await new RuntimeClient(userDataPath).getCliStatus()
expect(status.result.app).toMatchObject({ running: true, pid: process.pid })
expect(status.result.runtime.state).toBe('unreachable')
expect(status.result.runtime.reachable).toBe(false)
expect(status.result.graph.state).toBe('unreachable')
expect(status.result.runtime.unreachableReason).toMatchObject({
code: 'endpoint_missing',
osErrorCode: 'ENOENT'
})
expect(status.result.runtime.unreachableReason?.message).toContain('gone.sock')
})
it('reports a runtime that closes the connection before replying', async () => {
const endpoint = join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'closes.sock')
await listen(endpoint, (socket) => socket.destroy())
const userDataPath = seedMetadata(endpoint, process.pid)
const status = await new RuntimeClient(userDataPath).getCliStatus()
expect(status.result.runtime.state).toBe('unreachable')
expect(status.result.runtime.unreachableReason?.code).toBe('connection_closed')
})
// Why: `socket.destroy()` above surfaces as an ECONNRESET on the client's error
// handler. A clean FIN with no reply takes the separate 'close' path, which has
// no errno at all — it is classified by transport phase, so it needs its own case.
it('reports a runtime that half-closes cleanly without replying', async () => {
const endpoint = join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'fin.sock')
await listen(endpoint, (socket) => {
socket.once('data', () => socket.end())
})
const userDataPath = seedMetadata(endpoint, process.pid)
const status = await new RuntimeClient(userDataPath).getCliStatus()
expect(status.result.runtime.state).toBe('unreachable')
expect(status.result.runtime.unreachableReason).toMatchObject({ code: 'connection_closed' })
expect(status.result.runtime.unreachableReason?.osErrorCode).toBeUndefined()
})
it('reports a runtime that accepts the connection but never answers', async () => {
const endpoint = join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'silent.sock')
await listen(endpoint, () => {})
const userDataPath = seedMetadata(endpoint, process.pid)
const status = await new RuntimeClient(userDataPath).getCliStatus()
expect(status.result.runtime.state).toBe('unreachable')
expect(status.result.runtime.unreachableReason?.code).toBe('request_timeout')
expect(status.result.runtime.unreachableReason?.message).toContain('rather than starting')
})
it('separates a runtime that answers and refuses from one it cannot reach', async () => {
const endpoint = join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'refuses.sock')
await listen(endpoint, (socket) => {
socket.once('data', (data) => {
const request = JSON.parse(String(data).trim()) as { id: string }
socket.write(
`${JSON.stringify({
id: request.id,
ok: false,
error: { code: 'unauthorized', message: 'Auth token is not valid for this runtime.' },
_meta: { runtimeId: 'runtime-under-test' }
})}\n`
)
})
})
const userDataPath = seedMetadata(endpoint, process.pid)
const status = await new RuntimeClient(userDataPath).getCliStatus()
expect(status.result.runtime.unreachableReason?.code).toBe('request_rejected')
expect(status.result.runtime.unreachableReason?.message).toContain(
'Auth token is not valid for this runtime.'
)
})
// Preservation: a dead pid is still a stale bootstrap, not an unreachable runtime.
it('still reports a stale bootstrap when the recorded process is gone', async () => {
const userDataPath = seedMetadata(
join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'dead.sock'),
2 ** 30
)
const status = await new RuntimeClient(userDataPath).getCliStatus()
expect(status.result.runtime.state).toBe('stale_bootstrap')
expect(status.result.runtime.unreachableReason).toBeUndefined()
expect(status.result.app).toMatchObject({ running: false, pid: null })
})
// Why: the default `orca status` output is plain text, so the cause has to reach
// the non-JSON reader too — that is the surface the report was read from.
it('prints the cause in the plain-text status output', async () => {
const userDataPath = seedMetadata(
join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'gone.sock'),
process.pid
)
const text = formatCliStatus((await new RuntimeClient(userDataPath).getCliStatus()).result)
expect(text).toContain('runtimeState: unreachable')
expect(text).toContain('unreachableCode: endpoint_missing')
expect(text).toContain('unreachableDetail: ')
expect(text).toContain('gone.sock')
})
// Constraint: nothing may report a runtime as usable when it is not.
it('never reports a reachable or ready runtime while the endpoint is unusable', async () => {
const userDataPath = seedMetadata(
join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'gone.sock'),
process.pid
)
const status = await new RuntimeClient(userDataPath).getCliStatus()
expect(status.result.runtime.reachable).toBe(false)
expect(status.result.runtime.state).not.toBe('ready')
expect(status.result.runtime.runtimeId).toBeNull()
expect(status._meta?.runtimeId).toBe('none')
})
})
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { describeOpenTimeout } from './runtime-open-timeout-reason'
import type { CliRuntimeUnreachableReason, CliStatusResult } from '../../shared/runtime-types'
// Why: the poll loop can only feed this the states today's local producer happens to emit, so
// driving it through the client leaves whole arms unpinned. Each arm is a promise about what
// the message may claim, so each one is asserted against the status it describes (STA-3969).
function status(overrides: {
appRunning: boolean
reachable: boolean
state: CliStatusResult['runtime']['state']
unreachableReason?: CliRuntimeUnreachableReason
}): CliStatusResult {
return {
app: { running: overrides.appRunning, pid: overrides.appRunning ? 4242 : null },
runtime: {
state: overrides.state,
reachable: overrides.reachable,
runtimeId: overrides.reachable ? 'runtime-1' : null,
...(overrides.unreachableReason ? { unreachableReason: overrides.unreachableReason } : {})
},
graph: { state: overrides.reachable ? 'ready' : 'unreachable' }
}
}
const history: CliRuntimeUnreachableReason = {
code: 'endpoint_missing',
message: 'Orca published the socket /tmp/never-listened.sock, but it does not exist.',
endpoint: '/tmp/never-listened.sock',
endpointKind: 'unix'
}
describe('describeOpenTimeout', () => {
it('reports the current reason as the live diagnosis', () => {
const text = describeOpenTimeout(
status({
appRunning: true,
reachable: false,
state: 'unreachable',
unreachableReason: history
}),
undefined
)
expect(text).toContain('the Orca app process is running but its runtime is unreachable')
expect(text).toContain(history.message)
})
it('calls a rejected request an answer, never unreachable', () => {
const text = describeOpenTimeout(
status({
appRunning: true,
reachable: false,
state: 'unreachable',
unreachableReason: { ...history, code: 'request_rejected', message: 'refused: bad token' }
}),
undefined
)
expect(text).toContain('answered but refused the status request')
expect(text).not.toContain('unreachable')
})
it('says the runtime answered when the newest poll was reachable', () => {
const text = describeOpenTimeout(
status({ appRunning: true, reachable: true, state: 'graph_not_ready' }),
history
)
expect(text).toContain('is responding and still running headlessly')
// A poll that answered resolves the earlier failure, so it is not even history any more.
expect(text).not.toContain(history.message)
})
// Why: this is the arm the client's own producer cannot currently reach, and dropping it makes
// the message claim no process is running while the pid is alive — the exact false report
// STA-3969 exists to stop.
it('never claims the process is gone while the newest status says it is running', () => {
const text = describeOpenTimeout(
status({ appRunning: true, reachable: false, state: 'graph_not_ready' }),
undefined
)
expect(text).toBe('. The runtime may still be running headlessly.')
expect(text).not.toContain('no longer running')
expect(text).not.toContain('No Orca runtime is running')
})
it('reports a dead app process without inheriting the old live verdict', () => {
const text = describeOpenTimeout(
status({ appRunning: false, reachable: false, state: 'stale_bootstrap' }),
history
)
expect(text).toContain('The Orca app process is no longer running.')
expect(text).toContain('The last failure it reported was:')
expect(text).not.toContain('but its runtime is unreachable')
})
it('says no runtime is running when none was ever published, with no history to add', () => {
const text = describeOpenTimeout(
status({ appRunning: false, reachable: false, state: 'not_running' }),
undefined
)
expect(text).toBe('. No Orca runtime is running.')
})
})
@@ -0,0 +1,32 @@
import type { CliRuntimeUnreachableReason, CliStatusResult } from '../../shared/runtime-types'
// Why (STA-3969): the rule this enforces is that every clause names the observation it came
// from. `unreachableReason` is produced in exactly one place -- the branch that checked the pid
// and found the process alive -- so its presence is what licenses saying the process is running,
// and its absence forbids it. A reason from an earlier poll is reported as history, tense marked,
// never folded into the live verdict.
export function describeOpenTimeout(
latest: CliStatusResult,
lastObservedReason: CliRuntimeUnreachableReason | undefined
): string {
const current = latest.runtime.unreachableReason
if (current) {
// A rejected request is an answer, so it cannot also be described as unreachable.
return current.code === 'request_rejected'
? `: the Orca runtime answered but refused the status request. ${current.message}`
: `: the Orca app process is running but its runtime is unreachable. ${current.message}`
}
if (latest.runtime.reachable) {
return '. The Orca runtime is responding and still running headlessly; it did not open a window in time.'
}
if (latest.app.running) {
return '. The runtime may still be running headlessly.'
}
const state =
latest.runtime.state === 'stale_bootstrap'
? '. The Orca app process is no longer running.'
: '. No Orca runtime is running.'
return lastObservedReason
? `${state} The last failure it reported was: ${lastObservedReason.message}`
: state
}
+29 -11
View File
@@ -7,6 +7,9 @@ import {
resolveDesktopWindowStatus
} from '../../shared/cli-app-status-projection'
import { RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
import { classifyLocalRuntimeUnreachable } from './local-runtime-unreachable-reason'
const STATUS_REQUEST_TIMEOUT_MS = 1000
export { projectRemoteAppStatus, resolveDesktopWindowStatus }
@@ -36,7 +39,12 @@ export async function getCliStatus(
}
try {
const response = await sendRequest<RuntimeStatus>(metadata, 'status.get', undefined, 1000)
const response = await sendRequest<RuntimeStatus>(
metadata,
'status.get',
undefined,
STATUS_REQUEST_TIMEOUT_MS
)
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
@@ -63,21 +71,31 @@ export async function getCliStatus(
state: graphState
}
})
} catch {
} catch (error) {
const running = isProcessRunning(metadata.pid)
if (!running) {
return buildCliStatusResponse({
app: { running: false, pid: null },
runtime: { state: 'stale_bootstrap', reachable: false, runtimeId: null },
graph: { state: 'not_running' }
})
}
// Why: STA-3969 — metadata naming this endpoint is only written after the
// runtime's transport is listening, so a live app plus a failed request is a
// reachability failure, never a start in progress. Report the cause.
return buildCliStatusResponse({
app: {
running,
pid: running ? metadata.pid : null
},
app: { running: true, pid: metadata.pid },
runtime: {
state: running ? 'starting' : 'stale_bootstrap',
state: 'unreachable',
reachable: false,
runtimeId: null
runtimeId: null,
unreachableReason: classifyLocalRuntimeUnreachable(
error,
transport,
STATUS_REQUEST_TIMEOUT_MS
)
},
graph: {
state: running ? 'starting' : 'not_running'
}
graph: { state: 'unreachable' }
})
}
}
+12 -6
View File
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema'
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
import { RuntimeClientError, RuntimeTransportError, type RuntimeRpcResponse } from './types'
import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay'
export async function sendRequest<TResult>(
@@ -66,12 +66,17 @@ export async function sendRequest<TResult>(
}
socket.setEncoding('utf8')
socket.once('error', () => {
// Why: STA-3969 — the errno is the whole diagnosis (ENOENT = endpoint gone,
// EACCES = visible but barred), so keep it instead of collapsing every
// connect failure into one message the caller cannot act on.
socket.once('error', (error: NodeJS.ErrnoException) => {
finish({
ok: false,
error: new RuntimeClientError(
error: new RuntimeTransportError(
'runtime_unavailable',
'Could not connect to the running Orca app. Restart Orca and try again.'
'Could not connect to the running Orca app. Restart Orca and try again.',
'connect',
error.code ?? null
)
})
})
@@ -82,9 +87,10 @@ export async function sendRequest<TResult>(
socket.once('close', () => {
finish({
ok: false,
error: new RuntimeClientError(
error: new RuntimeTransportError(
'runtime_unavailable',
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
'The Orca runtime closed the connection before responding. Restart Orca and try again.',
'peer_closed'
)
})
})
+22
View File
@@ -20,6 +20,28 @@ export class RuntimeClientError extends Error {
}
}
// Why: STA-3969 — both of these failures share the `runtime_unavailable` code, and
// the errno is the only thing separating "the endpoint is gone" (ENOENT) from "this
// process may not open it" (EACCES). Carry the phase and the errno so callers can
// classify instead of guessing. osErrorCode stays null when the OS reported none.
export type RuntimeTransportPhase = 'connect' | 'peer_closed'
export class RuntimeTransportError extends RuntimeClientError {
readonly phase: RuntimeTransportPhase
readonly osErrorCode: string | null
constructor(
code: string,
message: string,
phase: RuntimeTransportPhase,
osErrorCode: string | null = null
) {
super(code, message)
this.phase = phase
this.osErrorCode = osErrorCode
}
}
export class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: RuntimeRpcFailure
@@ -68,6 +68,8 @@ type RuntimeTypeInventory = [
Runtime.BrowserViewportResult,
Runtime.BrowserWaitResult,
Runtime.CliRuntimeState,
Runtime.CliRuntimeUnreachableCode,
Runtime.CliRuntimeUnreachableReason,
Runtime.CliStatusResult,
Runtime.ComputerActionMetadata,
Runtime.ComputerActionResult,
+26 -1
View File
@@ -100,6 +100,28 @@ export type CliRuntimeState =
| 'ready'
| 'graph_not_ready'
| 'stale_bootstrap'
// Why: STA-3969 — the CLI reached a definite failure talking to a published
// endpoint. Per docs/reference/ssh-execution-boundary.md a socket that failed
// is not an observation of the runtime, so it may not be reported as 'starting'.
| 'unreachable'
export type CliRuntimeUnreachableCode =
| 'endpoint_missing'
| 'endpoint_permission_denied'
| 'connection_refused'
| 'connection_closed'
| 'request_timeout'
| 'request_rejected'
| 'invalid_response'
| 'unknown'
export type CliRuntimeUnreachableReason = {
code: CliRuntimeUnreachableCode
message: string
endpoint: string
endpointKind: 'unix' | 'named-pipe'
osErrorCode?: string
}
export type CliStatusResult = {
target?: { kind: 'local' } | { kind: 'environment'; environment: string }
@@ -116,9 +138,12 @@ export type CliStatusResult = {
remoteUpdateSupport?: RemoteServerUpdateSupport
capabilities?: RuntimeCapability[]
degradations?: RuntimeDegradation[]
// Why: present exactly when state is 'unreachable' — the cause is the whole
// point of the state, and without it the caller is back to guessing.
unreachableReason?: CliRuntimeUnreachableReason
}
graph: {
state: RuntimeGraphStatus | 'not_running' | 'starting'
state: RuntimeGraphStatus | 'not_running' | 'starting' | 'unreachable'
}
}
+2
View File
@@ -109,6 +109,8 @@ export type {
} from './runtime-capability-degradation'
export type {
CliRuntimeState,
CliRuntimeUnreachableCode,
CliRuntimeUnreachableReason,
CliStatusResult,
DeviceScope,
RuntimeBrowserDriverState,