diff --git a/src/cli/format.ts b/src/cli/format.ts index 8707ceef092..f72aafdf660 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -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') } diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 2fa6130ce3d..73aad83b6a7 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -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((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((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') diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index f9a855ae929..28bd6e08d82 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -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 ) } } diff --git a/src/cli/runtime/local-runtime-unreachable-reason.test.ts b/src/cli/runtime/local-runtime-unreachable-reason.test.ts new file mode 100644 index 00000000000..83fcc2cd9b1 --- /dev/null +++ b/src/cli/runtime/local-runtime-unreachable-reason.test.ts @@ -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) + }) +}) diff --git a/src/cli/runtime/local-runtime-unreachable-reason.ts b/src/cli/runtime/local-runtime-unreachable-reason.ts new file mode 100644 index 00000000000..d5204d61e6b --- /dev/null +++ b/src/cli/runtime/local-runtime-unreachable-reason.ts @@ -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.` + } +} diff --git a/src/cli/runtime/local-runtime-unreachable-status.test.ts b/src/cli/runtime/local-runtime-unreachable-status.test.ts new file mode 100644 index 00000000000..0d2f219250d --- /dev/null +++ b/src/cli/runtime/local-runtime-unreachable-status.test.ts @@ -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() +const sockets = new Set() + +afterEach(async () => { + for (const socket of sockets) { + socket.destroy() + } + sockets.clear() + await Promise.all( + [...servers].map((server) => new Promise((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 { + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + onConnection(socket) + }) + servers.add(server) + await new Promise((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') + }) +}) diff --git a/src/cli/runtime/runtime-open-timeout-reason.test.ts b/src/cli/runtime/runtime-open-timeout-reason.test.ts new file mode 100644 index 00000000000..c31d485f414 --- /dev/null +++ b/src/cli/runtime/runtime-open-timeout-reason.test.ts @@ -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.') + }) +}) diff --git a/src/cli/runtime/runtime-open-timeout-reason.ts b/src/cli/runtime/runtime-open-timeout-reason.ts new file mode 100644 index 00000000000..29ceee4b8bc --- /dev/null +++ b/src/cli/runtime/runtime-open-timeout-reason.ts @@ -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 +} diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index e4a181a8d51..cbaf9e9c5b2 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -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(metadata, 'status.get', undefined, 1000) + const response = await sendRequest( + 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' } }) } } diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 091ca9da0e0..82a43793f8d 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -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( @@ -66,12 +66,17 @@ export async function sendRequest( } 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( 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' ) }) }) diff --git a/src/cli/runtime/types.ts b/src/cli/runtime/types.ts index a15515acfdb..c38b1d5bd4f 100644 --- a/src/cli/runtime/types.ts +++ b/src/cli/runtime/types.ts @@ -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 diff --git a/src/shared/runtime-client-export-parity.test.ts b/src/shared/runtime-client-export-parity.test.ts index 2b86d2e6e99..bd9db429b34 100644 --- a/src/shared/runtime-client-export-parity.test.ts +++ b/src/shared/runtime-client-export-parity.test.ts @@ -68,6 +68,8 @@ type RuntimeTypeInventory = [ Runtime.BrowserViewportResult, Runtime.BrowserWaitResult, Runtime.CliRuntimeState, + Runtime.CliRuntimeUnreachableCode, + Runtime.CliRuntimeUnreachableReason, Runtime.CliStatusResult, Runtime.ComputerActionMetadata, Runtime.ComputerActionResult, diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 1249870c548..add5e2967df 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -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' } } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 856268217aa..b16d8fd1120 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -109,6 +109,8 @@ export type { } from './runtime-capability-degradation' export type { CliRuntimeState, + CliRuntimeUnreachableCode, + CliRuntimeUnreachableReason, CliStatusResult, DeviceScope, RuntimeBrowserDriverState,