mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
fix(serve): decide a duplicate or doomed Electron serve before exec (#27472)
On macOS a second Electron main aborts inside _RegisterApplication before any Orca JavaScript can apply the one-Orca-per-profile rule, so supervisors and agents retrying 'orca serve' loop on SIGABRT (#14541). The CLI now checks the profile before it execs Electron serve. A runtime that answers, or whose socket accepts, is live; one whose socket neither accepts nor refuses, or that refuses this CLI's credentials, is unverifiable; both refuse with exit code 3, the single-instance code systemd units already key off. Only a definitive refusal (ENOENT or ECONNREFUSED) lets it start. A fresh profile keeps the old synchronous launch. A macOS startup abort is remembered per environment, so a later separate 'orca serve' from the same launchd session refuses for 30 minutes with exit 78 instead of aborting again; --force overrides, and a serve that reaches readiness clears it. Adapted from #14768; the Launch Services diagnostic wording and the exit code 3 contract come from #14542. Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: thiagomsoares <5190162+thiagomsoares@users.noreply.github.com>
This commit is contained in:
co-authored by
m4air
Jinwoo-H
thiagomsoares
parent
d3417b257f
commit
8bc4cef855
@@ -104,6 +104,7 @@ export const CORE_HANDLERS: Record<string, CommandHandler> = {
|
||||
const mobilePairing = flags.get('mobile-pairing') === true
|
||||
const grantDesktopControl = flags.get('grant-desktop-control') === true
|
||||
const recipeJson = flags.get('recipe-json') === true
|
||||
const force = flags.get('force') === true
|
||||
const validationError = getServeOptionValidationError({
|
||||
noPairing,
|
||||
mobilePairing,
|
||||
@@ -124,7 +125,8 @@ export const CORE_HANDLERS: Record<string, CommandHandler> = {
|
||||
mobilePairing,
|
||||
grantDesktopControl,
|
||||
recipeJson,
|
||||
projectRoot
|
||||
projectRoot,
|
||||
...(force ? { force: true } : {})
|
||||
})
|
||||
process.exitCode = exitCode
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MacServeStartupAbortError } from './serve-signal-exit-diagnostic'
|
||||
|
||||
const { spawnMock, preflightMock, recordAbortMock, superviseMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
preflightMock: vi.fn(),
|
||||
recordAbortMock: vi.fn(),
|
||||
superviseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: vi.fn() }))
|
||||
vi.mock('./serve-electron-preflight', () => ({
|
||||
preflightElectronServe: preflightMock,
|
||||
recordElectronServeStartupAbort: recordAbortMock
|
||||
}))
|
||||
vi.mock('./serve-update-supervisor', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
superviseForegroundServe: superviseMock
|
||||
}))
|
||||
|
||||
import { serveOrcaApp } from './launch'
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
stdout = new EventEmitter()
|
||||
kill = vi.fn()
|
||||
unref = vi.fn()
|
||||
pid = 4101
|
||||
}
|
||||
|
||||
const PROFILE = resolve('/profiles/serve-preflight')
|
||||
|
||||
describe('serveOrcaApp: Electron serve preflight', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
spawnMock.mockReturnValue(new FakeChildProcess())
|
||||
process.env.ORCA_APP_EXECUTABLE = '/opt/orca/orca'
|
||||
process.env.ORCA_SERVE_RUNTIME = 'electron'
|
||||
process.env.ORCA_USER_DATA_PATH = PROFILE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ORCA_APP_EXECUTABLE
|
||||
delete process.env.ORCA_SERVE_RUNTIME
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
})
|
||||
|
||||
it('never execs a second Electron main once the preflight refuses', async () => {
|
||||
preflightMock.mockReturnValue(Promise.resolve(3))
|
||||
|
||||
await expect(serveOrcaApp({ json: true })).resolves.toBe(3)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(preflightMock).toHaveBeenCalledWith({ json: true }, PROFILE)
|
||||
})
|
||||
|
||||
it('execs once the preflight finds the profile free', async () => {
|
||||
preflightMock.mockReturnValue(Promise.resolve(null))
|
||||
superviseMock.mockResolvedValue(0)
|
||||
|
||||
await expect(serveOrcaApp({})).resolves.toBe(0)
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('remembers a prompt macOS startup abort for the next separate run', async () => {
|
||||
preflightMock.mockReturnValue(null)
|
||||
const abort = new MacServeStartupAbortError('runtime_serve_failed', 'aborted at startup')
|
||||
superviseMock.mockRejectedValue(abort)
|
||||
|
||||
await expect(serveOrcaApp({})).rejects.toBe(abort)
|
||||
expect(recordAbortMock).toHaveBeenCalledWith(PROFILE, 'aborted at startup')
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,11 @@ const { spawnMock, resolveLocalServeRuntimeMock, serveWithOrcadMock } = vi.hoist
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: vi.fn() }))
|
||||
// Why: these cover host selection; the pre-exec profile check would read this machine's own profile.
|
||||
vi.mock('./serve-electron-preflight', () => ({
|
||||
preflightElectronServe: () => null,
|
||||
recordElectronServeStartupAbort: vi.fn()
|
||||
}))
|
||||
vi.mock('./serve-orcad-launch', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ServeOrcadLaunch>()),
|
||||
resolveLocalServeRuntime: resolveLocalServeRuntimeMock,
|
||||
|
||||
@@ -29,8 +29,11 @@ import {
|
||||
type ServeOrcaAppArgs
|
||||
} from './serve-orcad-launch'
|
||||
import { waitForRecipeJson } from './serve-recipe-json'
|
||||
import { preflightElectronServe, recordElectronServeStartupAbort } from './serve-electron-preflight'
|
||||
import { MacServeStartupAbortError } from './serve-signal-exit-diagnostic'
|
||||
|
||||
const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000
|
||||
const STARTUP_ABORT_MS = 30_000
|
||||
|
||||
export function launchOrcaApp(): void {
|
||||
const overrideCommand = process.env.ORCA_OPEN_COMMAND
|
||||
@@ -143,6 +146,20 @@ function serveWithElectron(
|
||||
executable: string,
|
||||
args: ServeOrcaAppArgs,
|
||||
userDataPath: string
|
||||
): Promise<number> {
|
||||
const preflight = preflightElectronServe(args, userDataPath)
|
||||
if (preflight === null) {
|
||||
return spawnElectronServe(executable, args, userDataPath)
|
||||
}
|
||||
return Promise.resolve(preflight).then(
|
||||
(refusal) => refusal ?? spawnElectronServe(executable, args, userDataPath)
|
||||
)
|
||||
}
|
||||
|
||||
function spawnElectronServe(
|
||||
executable: string,
|
||||
args: ServeOrcaAppArgs,
|
||||
userDataPath: string
|
||||
): Promise<number> {
|
||||
const pinned = pinServeUserDataPath(
|
||||
getExecutableAppArgs(executable),
|
||||
@@ -187,6 +204,7 @@ function serveWithElectron(
|
||||
})
|
||||
}
|
||||
const child = spawnProcess(executable, childArgs, spawnOptions)
|
||||
const spawnedAt = Date.now()
|
||||
|
||||
if (args.recipeJson) {
|
||||
return waitForRecipeJson(child)
|
||||
@@ -199,6 +217,12 @@ function serveWithElectron(
|
||||
child,
|
||||
handoffPath,
|
||||
expectedHandoff: null
|
||||
}).catch((error: unknown) => {
|
||||
// Why only a prompt abort: the registration abort kills the child before any JS runs.
|
||||
if (error instanceof MacServeStartupAbortError && Date.now() - spawnedAt < STARTUP_ABORT_MS) {
|
||||
recordElectronServeStartupAbort(userDataPath, error.message)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, mkdtempSync } 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, vi } from 'vitest'
|
||||
import type { RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import { probeRuntimeListener, RUNTIME_LISTENER_PROBE_TIMEOUT_MS } from './runtime-listener-probe'
|
||||
|
||||
const servers = new Set<Server>()
|
||||
const sockets = new Set<Socket>()
|
||||
const lockedDirs = new Set<string>()
|
||||
let endpointCount = 0
|
||||
|
||||
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()
|
||||
for (const dir of lockedDirs) {
|
||||
chmodSync(dir, 0o700)
|
||||
}
|
||||
lockedDirs.clear()
|
||||
})
|
||||
|
||||
function metadataFor(endpoint: string): RuntimeMetadata {
|
||||
return {
|
||||
runtimeId: 'runtime-1',
|
||||
pid: process.pid,
|
||||
transports: [
|
||||
{ kind: process.platform === 'win32' ? 'named-pipe' : 'unix', endpoint },
|
||||
// Why: the websocket entry must not be picked — it is reachable from
|
||||
// anywhere on the network and proves nothing about this profile's owner.
|
||||
{ kind: 'websocket', endpoint: 'ws://127.0.0.1:1' }
|
||||
],
|
||||
authToken: 'token',
|
||||
startedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
function nextEndpoint(): string {
|
||||
endpointCount += 1
|
||||
return process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\orca-probe-${process.pid}-${endpointCount}`
|
||||
: join(mkdtempSync(join(tmpdir(), 'orca-probe-')), 'runtime.sock')
|
||||
}
|
||||
|
||||
describe('probeRuntimeListener', () => {
|
||||
it('accepts a runtime that is listening but silent', async () => {
|
||||
// Why: this is the case the pid could never distinguish — the owner is alive
|
||||
// and holding the profile, it just has not answered RPC yet.
|
||||
const endpoint = nextEndpoint()
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
|
||||
await expect(probeRuntimeListener(metadataFor(endpoint))).resolves.toBe('accepting')
|
||||
})
|
||||
|
||||
it('frees a profile whose endpoint was never bound', async () => {
|
||||
// Why: a runtime unlinks its socket on shutdown. Reading that as an owner is
|
||||
// what makes a stale profile refuse serve forever.
|
||||
await expect(probeRuntimeListener(metadataFor(nextEndpoint()))).resolves.toBe('not-listening')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'frees a profile whose owner was killed with its socket file still on disk',
|
||||
async () => {
|
||||
// Why: this is the STA-4336 crash shape — SIGKILL skips the unlink, so the path
|
||||
// survives and only its refusal distinguishes it from a live owner.
|
||||
const endpoint = nextEndpoint()
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
`require('net').createServer(()=>{}).listen(${JSON.stringify(endpoint)},()=>console.log('up'))`
|
||||
])
|
||||
await new Promise((resolve) => child.stdout.once('data', resolve))
|
||||
child.kill('SIGKILL')
|
||||
await new Promise((resolve) => child.once('exit', resolve))
|
||||
|
||||
await expect(probeRuntimeListener(metadataFor(endpoint))).resolves.toBe('not-listening')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refuses to call a profile free when the endpoint could not be reached at all',
|
||||
async () => {
|
||||
// Why: only ENOENT/ECONNREFUSED disown an endpoint. Anything else — here an
|
||||
// unreadable directory — leaves an owner possible, and guessing "free" is
|
||||
// what spawns the second main that aborts pre-JS.
|
||||
const dir = join(mkdtempSync(join(tmpdir(), 'orca-probe-')), 'locked')
|
||||
mkdirSync(dir)
|
||||
chmodSync(dir, 0o000)
|
||||
lockedDirs.add(dir)
|
||||
|
||||
await expect(probeRuntimeListener(metadataFor(join(dir, 'runtime.sock')))).resolves.toBe(
|
||||
'unproven'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses to call a profile free when a connect neither lands nor is refused', async () => {
|
||||
// Why: the cap keeps this off the launch path's critical time, but a connect that
|
||||
// ran out of time proved nothing. Fake timers fire the cap before the real connect
|
||||
// can settle, which is the only way to reach that branch deterministically.
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const probe = probeRuntimeListener(metadataFor(nextEndpoint()))
|
||||
vi.advanceTimersByTime(RUNTIME_LISTENER_PROBE_TIMEOUT_MS)
|
||||
|
||||
await expect(probe).resolves.toBe('unproven')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing endpoint', undefined],
|
||||
['a blank endpoint', ' '],
|
||||
['a numeric endpoint', 42]
|
||||
])('frees a profile whose metadata carries %s instead of dialling it', async (_label, value) => {
|
||||
// Why: the metadata file is unvalidated JSON. A missing or blank endpoint throws
|
||||
// out of createConnection and would fail serve outright, and a number is read as a
|
||||
// TCP port — 127.0.0.1:42 could be answered by something that is not Orca at all.
|
||||
const metadata = metadataFor(nextEndpoint())
|
||||
const transport = { ...metadata.transports[0]! }
|
||||
// The file on disk is untyped JSON, so the probe must survive a non-string endpoint.
|
||||
Object.assign(transport, { endpoint: value })
|
||||
|
||||
await expect(probeRuntimeListener({ ...metadata, transports: [transport] })).resolves.toBe(
|
||||
'not-listening'
|
||||
)
|
||||
})
|
||||
|
||||
it('frees a profile whose metadata published no local transport to probe', async () => {
|
||||
const metadata = metadataFor(nextEndpoint())
|
||||
|
||||
await expect(
|
||||
probeRuntimeListener({
|
||||
...metadata,
|
||||
transports: metadata.transports.filter((transport) => transport.kind === 'websocket')
|
||||
})
|
||||
).resolves.toBe('not-listening')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createConnection } from 'node:net'
|
||||
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
|
||||
export const RUNTIME_LISTENER_PROBE_TIMEOUT_MS = 250
|
||||
|
||||
/** What a connect to the published endpoint proved — `unproven` is not an absence. */
|
||||
export type RuntimeListenerProbe = 'accepting' | 'not-listening' | 'unproven'
|
||||
|
||||
/** Why: measured on macOS — a stopped runtime unlinks its socket (ENOENT) and a SIGKILLed one leaves a path that refuses (ECONNREFUSED). Both land in <1ms, so these are the only outcomes that prove nobody is home. */
|
||||
const NOT_LISTENING_CODES = new Set(['ENOENT', 'ECONNREFUSED'])
|
||||
|
||||
/**
|
||||
* Why: a recorded pid is weak evidence — the OS recycles pids. The runtime binds its
|
||||
* endpoints before publishing metadata, so an accepted connect proves a live owner
|
||||
* even when it is too busy for RPC; a crash leaves a path with nothing accepting.
|
||||
*/
|
||||
export function probeRuntimeListener(
|
||||
metadata: RuntimeMetadata,
|
||||
timeoutMs: number = RUNTIME_LISTENER_PROBE_TIMEOUT_MS
|
||||
): Promise<RuntimeListenerProbe> {
|
||||
const transport = findTransport(metadata, 'unix', 'named-pipe')
|
||||
const endpoint = transport?.endpoint
|
||||
// Why: metadata is unvalidated JSON from disk. A missing or blank endpoint makes
|
||||
// createConnection throw out of serve, and a numeric one is read as a TCP port —
|
||||
// it would dial 127.0.0.1 and could be answered by something that is not Orca.
|
||||
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
|
||||
return Promise.resolve('not-listening')
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const socket = createConnection(endpoint)
|
||||
let settled = false
|
||||
const settle = (result: RuntimeListenerProbe): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
resolve(result)
|
||||
}
|
||||
// Why: a connect that neither lands nor is refused means something still holds the
|
||||
// endpoint, so it is no proof the profile is free. Capped because this is the launch path.
|
||||
const timer = setTimeout(() => settle('unproven'), timeoutMs)
|
||||
socket.once('connect', () => settle('accepting'))
|
||||
// Why: `on`, not `once` — a socket destroyed mid-connect can emit again, and a
|
||||
// second error with no listener left would take down the CLI.
|
||||
socket.on('error', (error: NodeJS.ErrnoException) => {
|
||||
settle(NOT_LISTENING_CODES.has(error.code ?? '') ? 'not-listening' : 'unproven')
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import { readServeAbortPark, writeServeAbortPark } from '../../shared/serve-abort-park'
|
||||
import { preflightElectronServe, recordElectronServeStartupAbort } from './serve-electron-preflight'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
const METADATA: RuntimeMetadata = {
|
||||
runtimeId: 'runtime-1',
|
||||
pid: 4242,
|
||||
transports: [{ kind: 'unix', endpoint: '/tmp/orca-test.sock' }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
}
|
||||
|
||||
function status(reachable: boolean, pid: number | null = 4242) {
|
||||
return {
|
||||
id: 'local',
|
||||
ok: true as const,
|
||||
result: {
|
||||
app: { running: reachable, pid },
|
||||
runtime: { state: reachable ? 'ready' : 'stale_bootstrap', reachable, runtimeId: null },
|
||||
graph: { state: 'not_running' }
|
||||
},
|
||||
_meta: { runtimeId: null }
|
||||
}
|
||||
}
|
||||
|
||||
describe('preflightElectronServe', () => {
|
||||
let userData: string
|
||||
let stderr: ReturnType<typeof vi.spyOn>
|
||||
let stdout: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
userData = mkdtempSync(join(tmpdir(), 'orca-serve-preflight-'))
|
||||
stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function deps(
|
||||
overrides: {
|
||||
metadata?: RuntimeMetadata | null
|
||||
reachable?: boolean
|
||||
probe?: 'accepting' | 'not-listening' | 'unproven'
|
||||
statusError?: Error
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
readMetadata: () => (overrides.metadata === undefined ? METADATA : overrides.metadata),
|
||||
readStatus: vi.fn(async () => {
|
||||
if (overrides.statusError) {
|
||||
throw overrides.statusError
|
||||
}
|
||||
return status(overrides.reachable ?? false)
|
||||
}),
|
||||
probeListener: vi.fn(async () => overrides.probe ?? 'not-listening')
|
||||
}
|
||||
}
|
||||
|
||||
it('starts at once, synchronously, when nothing has published this profile', () => {
|
||||
expect(
|
||||
preflightElectronServe({}, userData, { platform: 'linux', deps: deps({ metadata: null }) })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses with exit 3 when a live Orca answers for the profile', async () => {
|
||||
await expect(
|
||||
preflightElectronServe({}, userData, { platform: 'linux', deps: deps({ reachable: true }) })
|
||||
).resolves.toBe(3)
|
||||
expect(String(stderr.mock.calls[0]?.[0])).toContain(
|
||||
'Orca is already running for this userData profile as pid 4242; not starting a second process.'
|
||||
)
|
||||
})
|
||||
|
||||
it('prints the refusal as a JSON envelope for --json callers', async () => {
|
||||
await preflightElectronServe({ json: true }, userData, {
|
||||
platform: 'linux',
|
||||
deps: deps({ reachable: true })
|
||||
})
|
||||
const envelope = JSON.parse(String(stdout.mock.calls[0]?.[0]))
|
||||
expect(envelope).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'runtime_serve_already_running', data: { verdict: 'live', pid: 4242 } }
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an accepting socket as live and an unanswered one as unverifiable, both refused', async () => {
|
||||
await expect(
|
||||
preflightElectronServe({}, userData, {
|
||||
platform: 'linux',
|
||||
deps: deps({ probe: 'accepting' })
|
||||
})
|
||||
).resolves.toBe(3)
|
||||
await expect(
|
||||
preflightElectronServe({}, userData, { platform: 'linux', deps: deps({ probe: 'unproven' }) })
|
||||
).resolves.toBe(3)
|
||||
expect(String(stderr.mock.calls[1]?.[0])).toContain('cannot be shown to be free')
|
||||
})
|
||||
|
||||
it('treats a denied status check as unverifiable, not as stopped', async () => {
|
||||
const denied = new RuntimeClientError('runtime_access_denied', 'denied')
|
||||
await expect(
|
||||
preflightElectronServe({}, userData, {
|
||||
platform: 'linux',
|
||||
deps: deps({ statusError: denied })
|
||||
})
|
||||
).resolves.toBe(3)
|
||||
expect(String(stderr.mock.calls[0]?.[0])).toContain("refused this CLI's credentials")
|
||||
})
|
||||
|
||||
it('starts when the published endpoint definitively refuses (the old runtime is gone)', async () => {
|
||||
await expect(
|
||||
preflightElectronServe({}, userData, {
|
||||
platform: 'linux',
|
||||
deps: deps({ probe: 'not-listening' })
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
describe('remembered macOS startup abort', () => {
|
||||
const fingerprint = () => 'Background|ssh=1|sandbox=0'
|
||||
|
||||
it('refuses a later run from the same environment with exit 78 before anything else', () => {
|
||||
recordElectronServeStartupAbort(userData, 'Orca serve aborted with SIGABRT on macOS.', {
|
||||
now: 1_000,
|
||||
fingerprint
|
||||
})
|
||||
const result = preflightElectronServe({}, userData, {
|
||||
platform: 'darwin',
|
||||
now: 1_000 + 5 * 60_000,
|
||||
fingerprint,
|
||||
deps: deps({ metadata: null })
|
||||
})
|
||||
expect(result).toBe(78)
|
||||
expect(String(stderr.mock.calls[0]?.[0])).toContain('--force')
|
||||
})
|
||||
|
||||
it('lets a different environment, an expired park, or --force through', () => {
|
||||
recordElectronServeStartupAbort(userData, 'aborted', { now: 1_000, fingerprint })
|
||||
const run = (args: { force?: boolean }, now: number, print = fingerprint) =>
|
||||
preflightElectronServe(args, userData, {
|
||||
platform: 'darwin',
|
||||
now,
|
||||
fingerprint: print,
|
||||
deps: deps({ metadata: null })
|
||||
})
|
||||
expect(run({}, 2_000, () => 'Aqua|ssh=0|sandbox=0')).toBeNull()
|
||||
expect(run({}, 1_000 + 31 * 60_000)).toBeNull()
|
||||
expect(run({ force: true }, 2_000)).toBeNull()
|
||||
// --force also clears it for the next run.
|
||||
expect(readServeAbortPark(userData)).toBeNull()
|
||||
})
|
||||
|
||||
it('never parks off macOS', () => {
|
||||
writeServeAbortPark(userData, {
|
||||
schemaVersion: 1,
|
||||
verdict: 'environment_abort',
|
||||
at: 1_000,
|
||||
envFingerprint: fingerprint(),
|
||||
diagnostic: 'aborted'
|
||||
})
|
||||
expect(
|
||||
preflightElectronServe({}, userData, {
|
||||
platform: 'linux',
|
||||
now: 2_000,
|
||||
fingerprint,
|
||||
deps: deps({ metadata: null })
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
import type { RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope'
|
||||
import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from '../../shared/single-instance-exit-code'
|
||||
import {
|
||||
clearServeAbortPark,
|
||||
isServeAbortParkActive,
|
||||
readServeAbortPark,
|
||||
SERVE_ABORT_PARK_EXIT_CODE,
|
||||
writeServeAbortPark
|
||||
} from '../../shared/serve-abort-park'
|
||||
import { tryReadMetadata } from './metadata'
|
||||
import { probeRuntimeListener, type RuntimeListenerProbe } from './runtime-listener-probe'
|
||||
import { getCliStatus } from './status'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
/** Who holds this profile, in the execution-boundary vocabulary: only `absent` lets serve start. */
|
||||
export type ServeProfileVerdict =
|
||||
| { verdict: 'live'; evidence: 'rpc' | 'listening'; pid: number | null }
|
||||
| { verdict: 'unverifiable'; reason: 'endpoint-held' | 'access-denied'; pid: number | null }
|
||||
| { verdict: 'absent' }
|
||||
|
||||
/** The part of `orca status` the verdict reads. */
|
||||
type ServeProfileStatus = {
|
||||
result: { app: { pid?: number | null }; runtime: { reachable: boolean } }
|
||||
}
|
||||
|
||||
type PreflightDeps = {
|
||||
readMetadata: (userDataPath: string) => RuntimeMetadata | null
|
||||
readStatus: (userDataPath: string) => Promise<ServeProfileStatus>
|
||||
probeListener: (metadata: RuntimeMetadata) => Promise<RuntimeListenerProbe>
|
||||
}
|
||||
|
||||
const DEFAULT_DEPS: PreflightDeps = {
|
||||
readMetadata: tryReadMetadata,
|
||||
readStatus: getCliStatus,
|
||||
probeListener: probeRuntimeListener
|
||||
}
|
||||
|
||||
/**
|
||||
* Why before the exec: macOS aborts a second Electron main inside `_RegisterApplication`, before
|
||||
* any Orca JavaScript can apply the one-Orca-per-profile rule, so the CLI must decide first.
|
||||
* Only a definitive refusal frees the profile; a recorded pid alone proves nothing (pids recycle).
|
||||
*/
|
||||
export async function decideServeProfileVerdict(
|
||||
userDataPath: string,
|
||||
metadata: RuntimeMetadata,
|
||||
deps: PreflightDeps = DEFAULT_DEPS
|
||||
): Promise<ServeProfileVerdict> {
|
||||
let status: ServeProfileStatus
|
||||
try {
|
||||
status = await deps.readStatus(userDataPath)
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeClientError && error.code === 'runtime_access_denied') {
|
||||
return { verdict: 'unverifiable', reason: 'access-denied', pid: metadata.pid ?? null }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const pid = status.result.app.pid ?? metadata.pid ?? null
|
||||
if (status.result.runtime.reachable) {
|
||||
return { verdict: 'live', evidence: 'rpc', pid }
|
||||
}
|
||||
const probe = await deps.probeListener(metadata)
|
||||
if (probe === 'accepting') {
|
||||
return { verdict: 'live', evidence: 'listening', pid }
|
||||
}
|
||||
return probe === 'not-listening'
|
||||
? { verdict: 'absent' }
|
||||
: { verdict: 'unverifiable', reason: 'endpoint-held', pid }
|
||||
}
|
||||
|
||||
export function serveProfileRefusalMessage(
|
||||
verdict: Exclude<ServeProfileVerdict, { verdict: 'absent' }>
|
||||
): string {
|
||||
const who = verdict.pid === null ? 'another process' : `pid ${verdict.pid}`
|
||||
const lead = `[serve] Orca is already running for this userData profile as ${who}`
|
||||
if (verdict.verdict === 'live') {
|
||||
return verdict.evidence === 'rpc'
|
||||
? `${lead}; not starting a second process. Run \`orca status\` to inspect it, or stop it before serving again.`
|
||||
: `${lead}: its socket accepts connections but it is not answering \`orca status\` yet; not starting a second process. Wait for it to finish starting, or stop it before serving again.`
|
||||
}
|
||||
if (verdict.reason === 'access-denied') {
|
||||
return `${lead}: it refused this CLI's credentials, so it cannot be shown to be stopped; not starting a second process. Stop it, or run this command as the user that started it.`
|
||||
}
|
||||
return `${lead}: its socket neither accepted nor refused a connection, so this profile cannot be shown to be free; not starting a second process. Stop that process, or delete orca-runtime.json from this profile if nothing owns it.`
|
||||
}
|
||||
|
||||
function refusalEnvelope(code: string, message: string, data: unknown): RuntimeRpcFailure {
|
||||
return { id: 'local', ok: false, error: { code, message, data }, _meta: { runtimeId: null } }
|
||||
}
|
||||
|
||||
/** Why: `--json` callers parse stdout; recipe stdout is a strict schema channel, so it gets stderr. */
|
||||
function writeRefusal(
|
||||
args: { json?: boolean; recipeJson?: boolean },
|
||||
envelope: RuntimeRpcFailure
|
||||
): void {
|
||||
if (args.json === true && args.recipeJson !== true) {
|
||||
process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`)
|
||||
} else {
|
||||
process.stderr.write(`${envelope.error.message}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
/** What separates "this environment" from another: launchd session type, SSH, app sandbox. */
|
||||
export function serveEnvironmentFingerprint(env: NodeJS.ProcessEnv = process.env): string {
|
||||
let manager = 'unknown'
|
||||
try {
|
||||
const result = runProcessSync({
|
||||
program: 'launchctl',
|
||||
args: ['managername'],
|
||||
timeoutMs: 2_000
|
||||
})
|
||||
manager = result.code === 0 ? result.stdout.trim() || 'unknown' : 'unknown'
|
||||
} catch {
|
||||
manager = 'unknown'
|
||||
}
|
||||
return `${manager}|ssh=${env.SSH_CONNECTION ? 1 : 0}|sandbox=${env.APP_SANDBOX_CONTAINER_ID ? 1 : 0}`
|
||||
}
|
||||
|
||||
export type ElectronServePreflightArgs = {
|
||||
json?: boolean
|
||||
recipeJson?: boolean
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Null means start now. A number is the exit code of a refusal already reported. Synchronous
|
||||
* when nothing has ever published this profile, so a fresh launch keeps its old timing.
|
||||
*/
|
||||
export function preflightElectronServe(
|
||||
args: ElectronServePreflightArgs,
|
||||
userDataPath: string,
|
||||
options: {
|
||||
platform?: NodeJS.Platform
|
||||
now?: number
|
||||
fingerprint?: () => string
|
||||
deps?: PreflightDeps
|
||||
} = {}
|
||||
): number | null | Promise<number | null> {
|
||||
const platform = options.platform ?? process.platform
|
||||
if (platform === 'darwin') {
|
||||
if (args.force) {
|
||||
clearServeAbortPark(userDataPath)
|
||||
} else {
|
||||
const park = readServeAbortPark(userDataPath)
|
||||
const fingerprint = options.fingerprint ?? serveEnvironmentFingerprint
|
||||
if (park && isServeAbortParkActive(park, fingerprint(), options.now ?? Date.now())) {
|
||||
const minutes = Math.max(1, Math.round(((options.now ?? Date.now()) - park.at) / 60_000))
|
||||
const message = `[serve] Not starting: \`orca serve\` aborted at macOS startup in this environment ${minutes} min ago, and retrying here cannot help. ${park.diagnostic} Run it with --force to try anyway.`
|
||||
writeRefusal(args, refusalEnvelope('runtime_serve_parked', message, { at: park.at }))
|
||||
return SERVE_ABORT_PARK_EXIT_CODE
|
||||
}
|
||||
}
|
||||
}
|
||||
const deps = options.deps ?? DEFAULT_DEPS
|
||||
const metadata = deps.readMetadata(userDataPath)
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
return decideServeProfileVerdict(userDataPath, metadata, deps).then((verdict) => {
|
||||
if (verdict.verdict === 'absent') {
|
||||
return null
|
||||
}
|
||||
const message = serveProfileRefusalMessage(verdict)
|
||||
writeRefusal(
|
||||
args,
|
||||
refusalEnvelope('runtime_serve_already_running', message, {
|
||||
verdict: verdict.verdict,
|
||||
pid: verdict.pid
|
||||
})
|
||||
)
|
||||
return SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE
|
||||
})
|
||||
}
|
||||
|
||||
/** Remembers a macOS startup abort so a later, separate `orca serve` here refuses before exec. */
|
||||
export function recordElectronServeStartupAbort(
|
||||
userDataPath: string,
|
||||
diagnostic: string,
|
||||
options: { now?: number; fingerprint?: () => string } = {}
|
||||
): void {
|
||||
writeServeAbortPark(userDataPath, {
|
||||
schemaVersion: 1,
|
||||
verdict: 'environment_abort',
|
||||
at: options.now ?? Date.now(),
|
||||
envFingerprint: (options.fingerprint ?? serveEnvironmentFingerprint)(),
|
||||
diagnostic
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export type ServeOrcaAppArgs = {
|
||||
grantDesktopControl?: boolean
|
||||
recipeJson?: boolean
|
||||
projectRoot?: string | null
|
||||
/** Starts Electron serve despite a remembered macOS startup abort in this environment. */
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
/** A first run may download and verify the pinned Node; bound it well past that. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { serveSignalExitError } from './serve-signal-exit-diagnostic'
|
||||
import { MacServeStartupAbortError, serveSignalExitError } from './serve-signal-exit-diagnostic'
|
||||
import {
|
||||
SERVE_CHILD_FORCE_KILL_GRACE_MS,
|
||||
SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS,
|
||||
@@ -52,16 +52,18 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('serveSignalExitError', () => {
|
||||
it('explains the macOS window-server abort on darwin SIGABRT', () => {
|
||||
it('explains the Launch Services startup abort on darwin SIGABRT', () => {
|
||||
const error = serveSignalExitError('SIGABRT', 'darwin')
|
||||
|
||||
expect(error).toBeInstanceOf(MacServeStartupAbortError)
|
||||
expect(error).toBeInstanceOf(RuntimeClientError)
|
||||
expect(error.code).toBe('runtime_serve_failed')
|
||||
expect(error.message).toContain('aborted with SIGABRT on macOS')
|
||||
expect(error.message).toContain('macOS window server')
|
||||
expect(error.message).toContain('_RegisterApplication')
|
||||
expect(error.data).toMatchObject({
|
||||
nextSteps: [
|
||||
expect.stringContaining('macOS desktop login'),
|
||||
expect.stringContaining('desktop Orca is already running'),
|
||||
expect.stringContaining('refuse for 30 minutes'),
|
||||
expect.stringContaining('~/Library/Logs/DiagnosticReports/Orca-*.ips')
|
||||
]
|
||||
})
|
||||
|
||||
@@ -2,6 +2,9 @@ import { RuntimeClientError } from './types'
|
||||
|
||||
export const MAC_CRASH_REPORT_GLOB = '~/Library/Logs/DiagnosticReports/Orca-*.ips'
|
||||
|
||||
/** A darwin SIGABRT of the serve child: the startup-registration abort a retry cannot fix. */
|
||||
export class MacServeStartupAbortError extends RuntimeClientError {}
|
||||
|
||||
export function serveSignalExitError(
|
||||
signal: NodeJS.Signals | null,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
@@ -18,13 +21,14 @@ export function serveSignalExitError(
|
||||
// Why: the startup abort happens inside +[NSApplication sharedApplication], before any of our JS
|
||||
// runs, so the parent CLI is the only place it can be explained. We only see the signal, never the
|
||||
// phase, so the cause is offered as the likely one rather than asserted.
|
||||
return new RuntimeClientError(
|
||||
return new MacServeStartupAbortError(
|
||||
'runtime_serve_failed',
|
||||
'Orca serve aborted with SIGABRT on macOS. This most often happens at application startup, when the process cannot register with the macOS window server, which is common in restricted or sandboxed environments, SSH sessions without a GUI login, and CI.',
|
||||
'Orca serve aborted with SIGABRT on macOS. This most often happens at application startup, when the process cannot register with Launch Services (_RegisterApplication / +[NSApplication sharedApplication]) before any Orca JavaScript runs. That is common in sandboxed agent environments, SSH sessions without a GUI login, and CI. Confirm the crashing frame in the crash report; this is not the Electron UTF-8 write abort (electron/electron#51871).',
|
||||
{
|
||||
nextSteps: [
|
||||
'Re-run `orca serve` outside a sandboxed or restricted environment, with a macOS desktop login active.',
|
||||
`Look for a crash report at ${MAC_CRASH_REPORT_GLOB}.`
|
||||
'If a desktop Orca is already running, use `orca status` / `orca` against that instance instead of `orca serve`.',
|
||||
'Re-run `orca serve` outside a sandboxed or restricted environment, with a macOS desktop login active. Later `orca serve` runs from this same environment refuse for 30 minutes; pass --force to try anyway.',
|
||||
`Look for a crash report at ${MAC_CRASH_REPORT_GLOB}. If the crashing frame is _RegisterApplication, this is the startup-registration abort.`
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,7 +10,8 @@ import { SERVE_COMMAND_SPECS } from './specs/serve'
|
||||
// Global flags with no `--serve-*` counterpart:
|
||||
// - help: a help launch is refused outright, not translated (see serve-mode-argv's HELP_FLAGS).
|
||||
// - pairing-code / environment: nothing in src/main reads them, so they ride through untouched.
|
||||
const UNTRANSLATED_GLOBAL_FLAGS = new Set(['help', 'pairing-code', 'environment'])
|
||||
// - force: overrides the CLI's own pre-exec check; a direct `<binary> serve` launch has none to override.
|
||||
const UNTRANSLATED_GLOBAL_FLAGS = new Set(['help', 'pairing-code', 'environment', 'force'])
|
||||
|
||||
const serveSpec = SERVE_COMMAND_SPECS.find((spec) => spec.path.join(' ') === 'serve')
|
||||
const translatedFlags = [...new Set(serveSpec?.allowedFlags ?? [])].filter(
|
||||
|
||||
@@ -6,7 +6,7 @@ export const SERVE_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['serve'],
|
||||
summary: 'Start an Orca runtime server without opening a desktop window',
|
||||
usage:
|
||||
'orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--grant-desktop-control] [--project-root <path>] [--recipe-json] [--json]',
|
||||
'orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--grant-desktop-control] [--project-root <path>] [--recipe-json] [--force] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'port',
|
||||
@@ -15,7 +15,8 @@ export const SERVE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'no-pairing',
|
||||
'grant-desktop-control',
|
||||
'project-root',
|
||||
'recipe-json'
|
||||
'recipe-json',
|
||||
'force'
|
||||
],
|
||||
notes: [
|
||||
'Runs in the foreground and prints the bound endpoint, advertised endpoint, and pairing status. Stop it with Ctrl+C.',
|
||||
@@ -23,7 +24,9 @@ export const SERVE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'Use --recipe-json with --project-root from VM recipes to print the recipe result JSON and leave the server running.',
|
||||
'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.',
|
||||
"A paired client cannot drive this machine's desktop (orca computer) unless the offer was created with --grant-desktop-control.",
|
||||
'When the web client bundle is available, the server also prints a browser URL with the pairing data embedded.'
|
||||
'When the web client bundle is available, the server also prints a browser URL with the pairing data embedded.',
|
||||
'Exits with code 3, without starting anything, when another Orca already serves this profile or cannot be shown to have stopped.',
|
||||
'On macOS, after Orca serve aborts at startup (it could not register with Launch Services), later runs from the same environment refuse for 30 minutes with exit code 78. Use --force to try anyway.'
|
||||
],
|
||||
examples: [
|
||||
'orca serve',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { notifyServeSupervisorReady } from '../serve-update-handoff'
|
||||
import { clearServeAbortPark } from '../../shared/serve-abort-park'
|
||||
import { buildServePairingReadiness, servePublishMode } from '../server/serve-pairing-readiness'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
import { getServeOptions, type ServeOptions } from './serve-options'
|
||||
@@ -36,4 +37,6 @@ export async function printServeReady(options: ServeOptions): Promise<void> {
|
||||
output
|
||||
)
|
||||
notifyServeSupervisorReady(runtime.getRuntimeId())
|
||||
// Why: a serve that got this far proves this environment can launch it after all.
|
||||
clearServeAbortPark(app.getPath('userData'))
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ export const SINGLE_INSTANCE_LOCK_BYPASS_ENV = 'ORCA_BYPASS_SINGLE_INSTANCE_LOCK
|
||||
export const SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV = 'ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK'
|
||||
export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE =
|
||||
'[single-instance] ORCA_BYPASS_SINGLE_INSTANCE_LOCK=1 is set; bypassing the packaged macOS single-instance lock for diagnostics. Do not use this with another Orca instance running for the same profile.'
|
||||
// Why: stable "another process owns this profile" contract that systemd RestartPreventExitStatus= keys off; changing it silently un-fixes #11935.
|
||||
export const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = 3
|
||||
export { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from '../../shared/single-instance-exit-code'
|
||||
|
||||
// Why: a duplicate `orca serve` is a supervisor artifact, not a user asking for a window; fail open when argv is unavailable.
|
||||
// Why not `argv.includes('--serve')`: the documented systemd unit runs `<binary> serve --port …`, so a
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/** How long a macOS startup abort keeps later `orca serve` runs from the same environment out. */
|
||||
export const SERVE_ABORT_PARK_MS = 30 * 60_000
|
||||
|
||||
/** The exit code a parked `orca serve` refuses with (EX_CONFIG): retrying cannot help. */
|
||||
export const SERVE_ABORT_PARK_EXIT_CODE = 78
|
||||
|
||||
export type ServeAbortPark = {
|
||||
schemaVersion: 1
|
||||
verdict: 'environment_abort'
|
||||
at: number
|
||||
envFingerprint: string
|
||||
diagnostic: string
|
||||
}
|
||||
|
||||
export function serveAbortParkPath(userDataPath: string): string {
|
||||
return join(userDataPath, 'serve-supervisor-journal.json')
|
||||
}
|
||||
|
||||
function parseServeAbortPark(raw: string): ServeAbortPark | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw)
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'schemaVersion' in value &&
|
||||
value.schemaVersion === 1 &&
|
||||
'verdict' in value &&
|
||||
value.verdict === 'environment_abort' &&
|
||||
'at' in value &&
|
||||
typeof value.at === 'number' &&
|
||||
'envFingerprint' in value &&
|
||||
typeof value.envFingerprint === 'string' &&
|
||||
'diagnostic' in value &&
|
||||
typeof value.diagnostic === 'string'
|
||||
) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
verdict: 'environment_abort',
|
||||
at: value.at,
|
||||
envFingerprint: value.envFingerprint,
|
||||
diagnostic: value.diagnostic
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// An unreadable journal parks nothing.
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function readServeAbortPark(userDataPath: string): ServeAbortPark | null {
|
||||
try {
|
||||
return parseServeAbortPark(readFileSync(serveAbortParkPath(userDataPath), 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** A park applies only to the environment that aborted, and only for a while. */
|
||||
export function isServeAbortParkActive(
|
||||
park: ServeAbortPark | null,
|
||||
envFingerprint: string,
|
||||
now: number
|
||||
): park is ServeAbortPark {
|
||||
return (
|
||||
park !== null &&
|
||||
park.envFingerprint === envFingerprint &&
|
||||
now >= park.at &&
|
||||
now - park.at < SERVE_ABORT_PARK_MS
|
||||
)
|
||||
}
|
||||
|
||||
export function writeServeAbortPark(userDataPath: string, park: ServeAbortPark): void {
|
||||
try {
|
||||
writeFileSync(serveAbortParkPath(userDataPath), JSON.stringify(park), { mode: 0o600 })
|
||||
} catch {
|
||||
// Fail open: a park that cannot be recorded only costs the next run one more abort.
|
||||
}
|
||||
}
|
||||
|
||||
/** A serve that reached readiness, or an explicit --force, proves the park no longer applies. */
|
||||
export function clearServeAbortPark(userDataPath: string): void {
|
||||
try {
|
||||
rmSync(serveAbortParkPath(userDataPath), { force: true })
|
||||
} catch {
|
||||
// Nothing to clear, or the profile is read-only; either way nothing is parked by us.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Why shared: the CLI refuses a duplicate `orca serve` before exec with the same code the Electron
|
||||
// main uses, and systemd's RestartPreventExitStatus= keys off it; changing it un-fixes #11935.
|
||||
export const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = 3
|
||||
Reference in New Issue
Block a user