mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
sim: merge PR #17164
This commit is contained in:
+9
-1
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ export type {
|
||||
} from './runtime-capability-degradation'
|
||||
export type {
|
||||
CliRuntimeState,
|
||||
CliRuntimeUnreachableCode,
|
||||
CliRuntimeUnreachableReason,
|
||||
CliStatusResult,
|
||||
DeviceScope,
|
||||
RuntimeBrowserDriverState,
|
||||
|
||||
Reference in New Issue
Block a user