fix(runtime): add single-instance lock + owned-metadata clear (#1312) (#1326)

* fix(runtime): add single-instance lock + owned-metadata clear to prevent orca-runtime.json corruption

Closes #1312.

Every AppImage/.app relaunch was booting a fresh Electron main that clobbered
`<userData>/orca-runtime.json` and `agent-hooks/endpoint.env`. When the newest
instance quit, metadata pointed at a dead pid and `orca status` reported
`stale_bootstrap` even though the original Orca was still running. SIGKILL'd
predecessors also left orphaned `o-<pid>-*.sock` files in userData.

Three surgical changes:

1. `app.requestSingleInstanceLock()` in a new
   `src/main/startup/single-instance-lock.ts` helper, wired into
   `src/main/index.ts` after `configureDevUserDataPath(is.dev)` so dev and
   packaged runs lock in separate namespaces. Losing instances focus the
   primary's window via `second-instance` and quit without touching userData.

2. `clearRuntimeMetadataIfOwned(userData, pid, runtimeId)` in
   `runtime-metadata.ts` — compares both pid AND runtimeId against the
   current file before clearing, so the auto-updater handoff window never
   erases the replacement process's fresh bootstrap. Called from a rewritten
   `will-quit` handler that folds `runtimeRpc.stop()` + owned-clear into the
   same `Promise.allSettled([disconnectDaemon, …]).then(app.quit)` chain
   (inside the `!daemonDisconnectDone` guard so the second-pass re-entry
   can't re-invoke stop+clear).

3. `sweepOrphanedRuntimeSockets()` in `runtime-rpc.ts` runs at the top of
   `start()` on POSIX, using `process.kill(pid, 0)` to probe liveness and
   remove `o-<dead-pid>-*.sock` orphans left by SIGKILL/OOM-kill.

Tests (37 new/updated):
- `single-instance-lock.test.ts` (3): lock-failed does not register listener;
  lock-acquired registers exactly one; callback dispatches correctly.
- `runtime-metadata.test.ts` (+4): clearRuntimeMetadataIfOwned matched /
  pid-mismatch / runtimeId-mismatch / no-file branches.
- `runtime-socket-sweep.test.ts` (4): own-pid-skip / alive-retain /
  dead-sweep / regex-miss separated via synthetic ownPid=1; two
  regex-invariant tests assert the sweep regex matches the real
  `createRuntimeTransportMetadata` output (including the 'rt' fallback).

Design doc: `docs/fix-missing-single-instance-lock.md`.

Co-authored-by: Orca <help@stably.ai>

* fix(runtime): focus hidden windows on second-instance event

focus() alone is a silent no-op when the primary window is hidden
(close-to-tray on macOS via Cmd+W, or on a different macOS Space) or
behind other apps on Windows. Call show() before focus() so a second
launch attempt reliably surfaces the existing window regardless of
state.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-02 23:05:14 -07:00 committed by GitHub
parent 46a26252bb
commit 827b84d27a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 883 additions and 20 deletions

View File

@ -0,0 +1,443 @@
# Fix: Missing single-instance lock corrupts `orca-runtime.json` + `endpoint.env` on every relaunch
**Issue:** [#1312](https://github.com/stablyai/orca/issues/1312)
**Status:** Proposed fix
**Approach:** Add `app.requestSingleInstanceLock()`, clear owned metadata on clean exit, and sweep orphaned sockets on startup.
## Problem
Orca v1.3.24 does not call `app.requestSingleInstanceLock()`. Every launch of the AppImage / `.app` bundle starts a new Electron main process that unconditionally:
1. Opens a fresh Unix socket `o-<NEW_PID>-<runtimeId-prefix>.sock`.
2. Atomically rewrites `<userData>/orca-runtime.json` with the new pid / socket / runtimeId / authToken, clobbering the previously-running instance's metadata.
3. Picks a new random port and rewrites `<userData>/agent-hooks/endpoint.env` with the new port + token.
The earlier Orca keeps its socket + hook-port alive, but the canonical metadata files no longer point at it. When the most-recent instance quits, the socket is `rmSync`'d in `OrcaRuntimeRpcServer.stop()` but the metadata file is left pointing at the dead pid. `orca status` then returns `runtime.state = 'stale_bootstrap'` even though earlier Orca instances are still running healthily.
### Root cause
Three distinct gaps in the main-process lifecycle:
1. **No single-instance lock.** `grep -rn requestSingleInstanceLock src/` returns zero matches. Every launch boots a full second Electron process instead of focusing the existing window.
2. **No metadata clear on clean exit.** `src/main/runtime/runtime-metadata.ts:29` exports `clearRuntimeMetadata()` but it is never called. The `will-quit` handler in `src/main/index.ts:516` stops the RPC server (removes the socket) but leaves `orca-runtime.json` on disk pointing at a dead pid + missing socket. The safety comment in `src/main/runtime/runtime-rpc.ts:132-136` explicitly declines to clear metadata, citing the risk of erasing another live runtime's bootstrap during restarts / updates / dev overlap — that concern is real but is the *symptom* of missing single-instance, not a principled design.
3. **No startup sweep of orphaned sockets.** A process killed by SIGKILL / OOM-kill skips `OrcaRuntimeRpcServer.stop()` entirely, leaving `o-<dead-pid>-*.sock` files in `<userData>/` with no cleanup path. The reporter observed three such orphans on a single live system.
### Symptom (from the issue)
Healthy state with one orca running (pid 50926):
```json
{
"app": { "running": true, "pid": 50926 },
"runtime": { "state": "ready", "reachable": true, "runtimeId": "2ad0..." },
"graph": { "state": "ready" }
}
```
After launching a second instance and quitting it while the first stays open:
```json
{
"app": { "running": false, "pid": null },
"runtime": { "state": "stale_bootstrap", "reachable": false, "runtimeId": null },
"graph": { "state": "not_running" }
}
```
The `stale_bootstrap` branch is distinguished at `src/cli/runtime/status.ts:17-20` — "metadata file exists but pid not running" vs. "no metadata at all".
### Affected code
- `src/main/index.ts` — app lifecycle entry; `app.whenReady` at line 315, `will-quit` at line 516.
- `src/main/runtime/runtime-rpc.ts``OrcaRuntimeRpcServer.start()` line 51, `stop()` line 112 (with the deferred-cleanup comment at lines 132-136).
- `src/main/runtime/runtime-metadata.ts``writeRuntimeMetadata` / `readRuntimeMetadata` / `clearRuntimeMetadata`.
- `src/main/agent-hooks/server.ts``writeEndpointFile` line 1299, `stop()` line 1223 (with a parallel deferred-cleanup comment at lines 1230-1237).
- `src/shared/runtime-bootstrap.ts``RuntimeMetadata` shape and `getRuntimeMetadataPath()`.
- `src/main/startup/configure-process.ts``configureDevUserDataPath(isDev)` redirects dev runs to `orca-dev` userData.
- `src/cli/runtime/status.ts` — CLI reader, never writes metadata.
## Architecture
### Current (buggy) state
Every launch boots a new Electron main. All live instances race on the same `orca-runtime.json` / `endpoint.env`, and SIGKILL'd predecessors leave orphaned sockets behind.
```
<userData>/ (e.g. ~/.config/orca)
┌────────────────────────────────────────────┐
│ orca-runtime.json ← clobbered on every │
│ launch (last writer │
│ wins, pid may be │
│ dead) │
│ agent-hooks/endpoint.env ← same race │
│ o-<pid-A>-*.sock (live, owned by A) │
│ o-<pid-B>-*.sock (orphan — B SIGKILL'd) │
│ o-<pid-C>-*.sock (orphan — C OOM-killed)│
└────────────────────────────────────────────┘
▲ ▲ ▲
│ writes │ writes │ writes
│ │ │
┌───────┴──┐ ┌──────┴───┐ ┌─────┴────┐
│ Electron │ │ Electron │ │ Electron │
#1 │ │ #2 │ │ #3
│ (live, │ │ (live, │ │ (quit; │
│ hook │ │ hook │ │ left │
│ HTTP) │ │ HTTP) │ │ stale │
└──────────┘ └──────────┘ │ meta) │
└──────────┘
│ reads orca-runtime.json
│ → sees wrong/dead pid
┌───────┴───────┐
│ CLI (status) │ reports 'stale_bootstrap'
└───────────────┘
```
### Post-fix state
A single Electron owns the userData. Second launches fire `second-instance` and exit. The ownership guard at `clearRuntimeMetadataIfOwned()` protects the auto-updater handoff. Sweep runs at startup.
```
<userData>/
┌────────────────────────────────────────────┐
│ orca-runtime.json ← one writer (pid A) │
│ agent-hooks/endpoint.env │
│ o-<pid-A>-*.sock (live, owned by A) │
│ (orphans swept on next start) │
└────────────────────────────────────────────┘
│ writes
┌───────┴──────────────┐
│ Electron #1 (holds │──── hook HTTP ──►
│ single-instance lock)│
└──────────▲───────────┘
│ 'second-instance' event
┌──────────┴───────────┐
│ Electron #2 (boots, │
│ lock fails, focuses │
#1's window, quits) │── transient, no writes
└──────────────────────┘
│ reads orca-runtime.json
┌───────┴───────┐
│ CLI (status) │ reports 'ready'
└───────────────┘
Ownership guard site: src/main/runtime/runtime-metadata.ts
→ clearRuntimeMetadataIfOwned(userData, ownedPid, ownedRuntimeId)
```
### Data flow
**Happy path — single instance steady state**
```
[user action] → Electron #1 main loop
→ Electron #1 RPC server (Unix socket) handles IPC
→ hook HTTP server answers localhost requests
→ orca-runtime.json unchanged (only written at start)
→ CLI reads orca-runtime.json → 'ready'
```
**First-launch cold start**
```
[launch] → configureDevUserDataPath(is.dev) (startup/configure-process.ts)
→ app.requestSingleInstanceLock() → true (main/index.ts)
→ sweepOrphanedRuntimeSockets(userData) (runtime-rpc.ts start())
→ OrcaRuntimeRpcServer.start() binds o-<pid>-*.sock
→ writeRuntimeMetadata({pid, runtimeId, ...}) (runtime-metadata.ts)
→ agent-hooks server writes endpoint.env
→ openMainWindow()
```
**Second-launch rejected**
```
[launch] → configureDevUserDataPath(is.dev)
→ app.requestSingleInstanceLock() → false (lock held by #1)
→ app.quit() (transient process exits)
→ Electron #1 receives 'second-instance' event
→ Electron #1 restores + focuses mainWindow
→ no writes to orca-runtime.json or endpoint.env
```
**Auto-updater handoff (two orderings, both safe)**
```
Ordering X: old clears first
old.runtimeRpc.stop() rmSync's o-<oldPid>-*.sock
old.clearRuntimeMetadataIfOwned() — current.pid == oldPid → clear
new.writeRuntimeMetadata() — fresh file, no conflict
Ordering Y: new writes first
new.writeRuntimeMetadata() — file now points at newPid/newRuntimeId
old.clearRuntimeMetadataIfOwned() — current.pid != oldPid → SUPPRESSED
new continues undisturbed
Invariant: the ownership guard (pid + runtimeId match) is what makes
both orderings safe. Without it, Ordering Y would erase the new
process's just-written metadata.
```
## Proposed fix
Three surgical changes, in decreasing order of importance:
### 1. Single-instance lock (primary — kills ~90% of the bug surface)
In `src/main/index.ts`, immediately **after** `configureDevUserDataPath(is.dev)` (line 89) and **before** any handler registration or `app.whenReady(...)`, gate the rest of the module on a new helper `acquireSingleInstanceLock()` (see "Helper extraction" in the testing section):
```ts
function focusExistingWindow(): void {
// Why: focus the existing window instead of spawning a parallel Electron
// process that would clobber orca-runtime.json and endpoint.env.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
// Pre-window case: the active instance is still booting and will call
// openMainWindow() from whenReady(). No action needed here.
}
if (!acquireSingleInstanceLock(app, focusExistingWindow)) {
if (is.dev) {
console.log(
'Another Orca instance is already running against this userData path — focusing existing window.'
)
}
app.quit()
// Why: early-return is what prevents the service constructors and handler
// registrations below (whenReady, before-quit, will-quit, etc.) from
// running in a process that is already losing the lock race.
return
}
// All existing app lifecycle setup below.
```
**Placement rationale (from codex review):** the lock must come *after* `configureDevUserDataPath(is.dev)` so dev (`orca-dev` userData) and packaged (`orca` userData) instances lock in separate namespaces — Electron derives the lock identity from the `userData` path. Placing it before would force dev and packaged builds to serialize against each other, which is the opposite of the current dev/prod isolation contract.
**Dev-mode behavior:** kept unconditional. `configureDevUserDataPath` already isolates `pnpm dev` from packaged runs, so devs who want two instances already get the answer by running both a packaged build and a dev build. Allowing multi-instance against the same userData reopens the exact corruption this fix targets.
**Dev-mode ergonomics:** when the lock fails in dev mode, write a single `console.log('Another Orca instance is already running against this userData path — focusing existing window.')` immediately before `app.quit()`. This makes the behavior discoverable in the `pnpm dev` console so internal devs don't mistake a silent exit for a broken launcher. Packaged runs skip the log — there's no attached console.
### 2. Clear owned metadata on clean exit (secondary — hardens `stale_bootstrap` reporting)
Add a guarded clear in `src/main/runtime/runtime-metadata.ts`:
```ts
/**
* Why: clearing metadata unconditionally would race with a sibling Orca
* process during auto-updater handoff or dev/prod overlap — see the comment
* in runtime-rpc.ts:132-136. The ownership guard preserves the original
* safety invariant while still letting us report 'not_running' (not
* 'stale_bootstrap') after a clean exit.
*/
export function clearRuntimeMetadataIfOwned(
userDataPath: string,
ownedPid: number,
ownedRuntimeId: string
): void {
const current = readRuntimeMetadata(userDataPath)
if (!current) return
if (current.pid !== ownedPid) return
if (current.runtimeId !== ownedRuntimeId) return
clearRuntimeMetadata(userDataPath)
}
```
In `src/main/index.ts` `will-quit` handler (line 516-548), the clear must be **awaited before Electron exits**, not fire-and-forget. The existing handler uses a two-pass `preventDefault()` pattern with `disconnectDaemon().finally(app.quit())`. The existing `void runtimeRpc.stop().catch(...)` at line 530-534 currently fires independently of that chain; Electron may exit before `runtimeRpc.stop()` resolves, and a `.then(clear)` appended to it would race the second-pass `app.quit()`. Fold both into the awaited chain instead — and do the work **inside** the `!daemonDisconnectDone` guard so `runtimeRpc.stop()` fires exactly once, on the first pass:
```ts
if (!daemonDisconnectDone) {
e.preventDefault()
// Why: capture ownership synchronously (before any await) so the guard
// still has the right pid/runtimeId to compare against if shutdown
// partially clears global state. Evaluating these inside .then() would
// let a later teardown path null them out mid-chain.
const ownedPid = process.pid
const ownedRuntimeId = runtime?.getRuntimeId()
// Why: the construction AND the allSettled() must both live inside the
// `!daemonDisconnectDone` guard. The will-quit handler re-fires after
// app.quit() below; without this guard, the second pass would re-invoke
// runtimeRpc.stop() (redundant rmSync on an already-removed socket) and
// re-run the ownership-guarded clear against a metadata file that may
// now belong to the auto-updater's replacement process.
const rpcStopAndClear = runtimeRpc
? runtimeRpc
.stop()
.then(() => {
if (ownedRuntimeId) {
clearRuntimeMetadataIfOwned(app.getPath('userData'), ownedPid, ownedRuntimeId)
}
})
.catch((error) => {
console.error('[runtime] Failed to stop local RPC transport:', error)
})
: Promise.resolve()
// Why: Promise.allSettled — we need BOTH the daemon disconnect and the RPC
// stop + owned-metadata clear to complete before Electron exits. Using
// allSettled (not all) preserves the existing fail-open posture: if
// disconnectDaemon rejects, we still quit instead of hanging the app.
Promise.allSettled([disconnectDaemon(), rpcStopAndClear]).then(() => {
daemonDisconnectDone = true
app.quit()
})
}
```
Remove the standalone `void runtimeRpc.stop().catch(...)` at line 530-534 — it is folded into `rpcStopAndClear` above.
**Why compare-before-clear is not over-engineering:** `autoUpdater.quitAndInstall()` quits the old process immediately and relaunches. The new process may already be starting (writing its own metadata) while the old `will-quit` is running. An unconditional clear would delete the new process's fresh metadata. Codex confirmed this is the correct posture.
### 3. Orphaned-socket sweep on startup (tertiary — hygiene)
In `OrcaRuntimeRpcServer.start()` (`src/main/runtime/runtime-rpc.ts:51`), before creating the new listener, enumerate `<userData>/o-*.sock` and remove any whose pid component is dead:
```ts
// Why: processes killed by SIGKILL / OOM-kill skip stop() and leave behind
// o-<pid>-*.sock files. Sweep dead-pid sockets on startup so the userData
// directory does not accumulate orphans over the app's lifetime.
function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void {
let entries: string[]
try {
entries = readdirSync(userDataPath)
} catch {
return
}
for (const entry of entries) {
const match = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/.exec(entry)
if (!match) continue
const pid = Number(match[1])
if (!Number.isFinite(pid) || pid === ownPid) continue
try {
process.kill(pid, 0)
// Pid is alive — leave its socket alone. Another Orca instance owns it.
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
try {
rmSync(join(userDataPath, entry), { force: true })
} catch {
// Best-effort sweep; a permission error is fine to ignore.
}
}
}
}
}
```
**Constraints:** only sweep sockets with the exact `o-<digits>-<base64url-ish>.sock` shape; skip anything else. Never touch the current process's own pid even though the stricter `start()` flow already rmSync's it.
**Windows note:** this sweep is POSIX-only by construction — on Windows the transport is a named pipe (`\\.\pipe\orca-<pid>-<suffix>`) and named pipes don't leave filesystem entries in `userData`. No sweep needed.
**Socket-name invariant:** the sweep regex `^o-(\d+)-[A-Za-z0-9_-]+\.sock$` must stay in lockstep with `createRuntimeTransportMetadata()` in `runtime-rpc.ts`, which emits `o-${pid}-${endpointSuffix}.sock`. The cheap guard — and what this doc recommends — is a unit test that constructs a transport name via `createRuntimeTransportMetadata()` and asserts the sweep regex matches it; any future change to the socket-name shape trips the test. Longer-term, if the transport name grows more fields, the preferred answer is to export a shared constant or factory from `runtime-rpc.ts` that both the creator and the sweep regex consume, so the two can never drift by construction. Deferred here to keep the diff surgical.
## Deliberately not in scope
- **No CLI-side reader fallback.** Scanning for any live `o-*.sock` in `getCliStatus()` would require a per-socket sidecar metadata file (authToken, runtimeId) since today only one shared `orca-runtime.json` exists. That is a multi-instance-by-design change, not a bug fix. The fix above assumes the intended model is one Orca per userData.
- **No per-instance userData.** The env-var override `ORCA_USER_DATA_PATH` at `src/cli/runtime/metadata.js` already exists for power users who genuinely want parallel runtimes. No change.
- **No unlink of `endpoint.env` on stop.** The deferred-cleanup rationale at `src/main/agent-hooks/server.ts:1230-1237` (TOCTOU with a concurrent writer) is still correct under the single-instance lock: auto-update handoff still briefly has two processes sharing the file. Leaving the file unchanged on quit matches the existing fail-open policy and no user symptom motivates changing it.
## Testing strategy
### Unit tests (`src/main/runtime/runtime-metadata.test.ts`, extend)
- `clearRuntimeMetadataIfOwned` with matching `{pid, runtimeId}` → file removed.
- `clearRuntimeMetadataIfOwned` with mismatched pid → file retained.
- `clearRuntimeMetadataIfOwned` with mismatched runtimeId → file retained (simulates another instance having overwritten while we were still alive).
- `clearRuntimeMetadataIfOwned` with no file → no-op, no throw.
### Unit tests (new `src/main/runtime/runtime-socket-sweep.test.ts`)
Seed a temp userData directory with four entries so the three retention branches are distinct:
- `o-1-aaaa.sock` → own-pid-skipped branch (test passes `ownPid: 1`, so this is skipped via the `pid === ownPid` early-exit).
- `o-<process.pid>-bbbb.sock` → alive-but-not-own branch (`process.kill(pid, 0)` succeeds without `ESRCH` → retained).
- `o-99999999-cccc.sock` → dead-pid branch (`process.kill(pid, 0)` throws `ESRCH` → swept).
- `foo.sock` → non-matching-shape branch (regex miss → retained untouched).
Run sweep with `ownPid = 1` and assert: the `o-99999999-*.sock` file is gone; the other three remain. Using a synthetic `ownPid` (init pid `1`, effectively always alive on POSIX and never the test runner's own pid) means each of the three retained-entries covers a *distinct* code path, instead of collapsing "own-pid-skipped" and "alive-non-own-pid-retained" into the same observation.
### Helper extraction for the single-instance lock
Extract the lock acquisition into `src/main/startup/single-instance-lock.ts`:
```ts
export function acquireSingleInstanceLock(
app: Electron.App,
onSecondInstance: () => void
): boolean {
if (!app.requestSingleInstanceLock()) {
return false
}
app.on('second-instance', onSecondInstance)
return true
}
```
`src/main/index.ts` wires it up inline after `configureDevUserDataPath(is.dev)`. Because the module's top-level cannot `return`, gate the file-writing init (`initDataPath` / `initStatsPath` / `initClaudeUsagePath` / `initCodexUsagePath` / `enableMainProcessGpuFeatures` / `installDevParentWatchdog` / `installDevParentDisconnectQuit`) behind an `if (hasSingleInstanceLock)` block and let the lifecycle handler registrations fire unconditionally — `app.quit()` prevents `whenReady` from ever dispatching, so `runtime` / `runtimeRpc` / `store` / `stats` stay `null` and every shutdown handler short-circuits via optional chaining:
```ts
const hasSingleInstanceLock = acquireSingleInstanceLock(app, focusExistingWindow)
if (!hasSingleInstanceLock) {
if (is.dev) {
console.log(
'[single-instance] Another Orca instance is already running against this userData path — focusing existing window.'
)
}
app.quit()
}
if (hasSingleInstanceLock) {
installDevParentDisconnectQuit(is.dev)
installDevParentWatchdog(is.dev)
initDataPath()
initStatsPath()
initClaudeUsagePath()
initCodexUsagePath()
enableMainProcessGpuFeatures()
}
```
### Unit test of the helper (`src/main/startup/single-instance-lock.test.ts`)
With a fake `app` (stub `requestSingleInstanceLock()` + `on()`):
- When `requestSingleInstanceLock()` returns `false` → helper returns `false` and does NOT register `'second-instance'`.
- When it returns `true` → helper returns `true` and registers exactly one `'second-instance'` listener that invokes the callback.
### Integration assertion (narrow)
Do NOT try to assert "downstream service constructors were not invoked" by re-importing `src/main/index.ts`. Module-level side effects at lines 67-101 (dev watchdog, shell path hydration, userData path configuration) run before the lock check and would fire regardless, either failing the test or requiring heavy mocking.
Instead, the integration surface the fix cares about is already covered by: (a) the helper unit test above, and (b) the manual repro below. If deeper coverage is wanted later, the right move is to refactor `src/main/index.ts` so the `whenReady`-guarded setup is exported as a testable function — that is a separate refactor and deliberately out of scope for this fix.
### Manual repro (matches the issue's steps)
1. Launch packaged Orca. `cp ~/.config/orca/orca-runtime.json /tmp/before.json`.
2. Launch the AppImage / `.app` a second time. Verify the existing window receives focus (no new window appears) and `stat` shows `orca-runtime.json` mtime is unchanged.
3. Quit Orca. Verify `~/.config/orca/orca-runtime.json` is gone and `orca status --json` reports `runtime.state = 'not_running'` (not `'stale_bootstrap'`).
4. Kill a running Orca with `SIGKILL`. Verify that the next Orca launch removes the orphaned `o-<killed-pid>-*.sock` during `OrcaRuntimeRpcServer.start()`.
### Regression watch — auto-updater
`autoUpdater.quitAndInstall()` in `src/main/updater.ts:194` triggers the relaunch flow. The handoff is short (~1s) but non-atomic, so the design relies on the ownership guard to keep both interleavings safe:
- **Socket is removed before the new process binds.** The old process's `runtimeRpc.stop()` `rmSync`'s `o-<oldPid>-*.sock` as part of teardown. The new process then binds its own `o-<newPid>-*.sock`. The brief window of inconsistency is "metadata absent" + "no socket", never "metadata points at a live-but-wrong pid with a still-mounted old socket."
- **New writes before old clears (Ordering Y).** If the new process calls `writeRuntimeMetadata()` before the old process's `clearRuntimeMetadataIfOwned()` runs, the guard sees `current.pid !== ownedPid` (and `current.runtimeId !== ownedRuntimeId`) and suppresses the clear. The new metadata survives.
- **Old clears before new writes (Ordering X).** The old process finds its own pid + runtimeId in the file, clears it, and exits. The new process then writes its fresh metadata onto an empty slot. Same end state.
- **CLI behavior during the ~1s window.** `orca status` transitions `ready``not_running` (clean, not `stale_bootstrap`) → `starting``ready`. It never reports a wrong-pid reading, because the only two observable file states are "file for old pid" (pre-teardown) and "file for new pid" (post-write); the guard prevents a mixed state.
A future regression report that catches the transient `not_running` during an auto-update should be recognized as intended behavior.
### Cross-platform coverage
- **macOS / Linux / Windows:** single-instance lock path is identical (Electron handles the OS-level plumbing).
- **Windows:** named-pipe transport means no socket-sweep work; assert the sweep early-returns on `platform === 'win32'` (or simply does nothing because no matching entries exist).
- **Dev mode:** lock acquired against `orca-dev` userData; asserted by running `pnpm dev` twice and seeing the second exit immediately.
## Confidence
High. The diagnosis matches the reported symptom and filesystem evidence one-for-one; the fix adds only calls that Electron and the existing metadata module already expose. Second opinion from codex flagged the `configureDevUserDataPath` ordering constraint and the `clearRuntimeMetadataIfOwned` ownership guard, both incorporated above.
Concretely, the fix closes the reported data-integrity scenarios: double-click relaunch, `gtk-launch` relaunch, clean-quit of a second instance while the first stays open, and orphaned `o-<pid>-*.sock` files left by SIGKILL / OOM-kill. It deliberately leaves two narrow scenarios open: (1) the auto-updater handoff, where `orca status` may transiently report `not_running` for ~1s between the old process's clear and the new process's write — covered in the regression-watch section above; and (2) deliberate multi-instance launches via `ORCA_USER_DATA_PATH` / `ORCA_DEV_USER_DATA_PATH`, which remain an explicit power-user escape hatch with isolated userData per instance and are not in scope for this fix.

View File

@ -18,6 +18,7 @@ import { registerCoreHandlers } from './ipc/register-core-handlers'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { registerAppMenu, rebuildAppMenu } from './menu/register-app-menu'
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
import {
@ -29,6 +30,7 @@ import {
patchPackagedProcessPath
} from './startup/configure-process'
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
import { acquireSingleInstanceLock } from './startup/single-instance-lock'
import { RateLimitService } from './rate-limits/service'
import { attachMainWindowServices } from './window/attach-main-window-services'
import { createMainWindow } from './window/createMainWindow'
@ -87,18 +89,67 @@ if (app.isPackaged && process.platform !== 'win32') {
})
}
configureDevUserDataPath(is.dev)
installDevParentDisconnectQuit(is.dev)
installDevParentWatchdog(is.dev)
// Why: must run after configureDevUserDataPath (which redirects userData to
// orca-dev in dev mode) but before app.setName('Orca') inside whenReady
// (which would change the resolved path on case-sensitive filesystems).
initDataPath()
// Why: same timing constraint as initDataPath — capture the userData path
// before app.setName changes it. See persistence.ts:20-28.
initStatsPath()
initClaudeUsagePath()
initCodexUsagePath()
enableMainProcessGpuFeatures()
function focusExistingWindow(): void {
// Why: the second-instance event fires on the *primary* Electron process
// after another launch tries (and fails) to acquire the lock. Bring the
// existing window forward so the user sees the same focus behaviour as
// re-clicking the dock/taskbar icon, rather than a silent no-op.
//
// Why show() as well as restore() + focus(): isMinimized() only covers the
// dock-minimised case. A hidden window (close-to-tray on macOS via Cmd+W,
// or a window on a different macOS Space) is NOT minimised, so focus()
// alone is a silent no-op. show() handles those plus Windows taskbar
// focus-steal, which focus() alone does not reliably trigger.
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
if (!mainWindow.isVisible()) {
mainWindow.show()
}
mainWindow.focus()
}
// Pre-window case: the primary is still booting and will call
// openMainWindow() from whenReady(). No action needed here.
}
// Why: the lock must be acquired AFTER configureDevUserDataPath — Electron
// derives the lock identity from the `userData` path, so this placement lets
// dev (`orca-dev`) and packaged (`orca`) runs lock in separate namespaces
// instead of serialising against each other.
const hasSingleInstanceLock = acquireSingleInstanceLock(app, focusExistingWindow)
if (!hasSingleInstanceLock) {
if (is.dev) {
// Why: packaged runs have no attached console, but dev runs do. Emit a
// single line so a `pnpm dev` operator does not mistake a silent exit
// for a broken launcher.
console.log(
'[single-instance] Another Orca instance is already running against this userData path — focusing existing window.'
)
}
app.quit()
}
// Why: when the lock is held by another process, we've already called
// app.quit() above. Skip every remaining file-writing side effect so this
// transient process never touches userData, and let handler registration
// below happen — those handlers only fire after whenReady, which app.quit()
// prevents from ever dispatching.
if (hasSingleInstanceLock) {
installDevParentDisconnectQuit(is.dev)
installDevParentWatchdog(is.dev)
// Why: must run after configureDevUserDataPath (which redirects userData to
// orca-dev in dev mode) but before app.setName('Orca') inside whenReady
// (which would change the resolved path on case-sensitive filesystems).
initDataPath()
// Why: same timing constraint as initDataPath — capture the userData path
// before app.setName changes it. See persistence.ts:20-28.
initStatsPath()
initClaudeUsagePath()
initCodexUsagePath()
enableMainProcessGpuFeatures()
}
function openMainWindow(): BrowserWindow {
if (!store) {
@ -527,11 +578,6 @@ app.on('will-quit', (e) => {
runtime?.getAgentBrowserBridge()?.destroyAllSessions()
killAllPty()
void closeAllWatchers()
if (runtimeRpc) {
void runtimeRpc.stop().catch((error) => {
console.error('[runtime] Failed to stop local RPC transport:', error)
})
}
store?.flush()
// Why: disconnectDaemon writes final checkpoints via async getSnapshot RPCs.
@ -540,7 +586,35 @@ app.on('will-quit', (e) => {
// app.quit() re-fires will-quit, but the second pass skips straight through.
if (!daemonDisconnectDone) {
e.preventDefault()
disconnectDaemon().finally(() => {
// Why: capture ownership synchronously (before any await) so the guard
// still has the right pid/runtimeId to compare against if shutdown
// partially clears global state. Evaluating these inside .then() would
// let a later teardown path null them out mid-chain.
const ownedPid = process.pid
const ownedRuntimeId = runtime?.getRuntimeId()
// Why: the construction of rpcStopAndClear AND the allSettled() below must
// both live inside the `!daemonDisconnectDone` guard. will-quit re-fires
// after app.quit() below; without this guard, the second pass would
// re-invoke runtimeRpc.stop() (redundant rmSync on an already-removed
// socket) and re-run the ownership-guarded clear against a metadata file
// that may now belong to the auto-updater's replacement process.
const rpcStopAndClear = runtimeRpc
? runtimeRpc
.stop()
.then(() => {
if (ownedRuntimeId) {
clearRuntimeMetadataIfOwned(app.getPath('userData'), ownedPid, ownedRuntimeId)
}
})
.catch((error) => {
console.error('[runtime] Failed to stop local RPC transport:', error)
})
: Promise.resolve()
// Why: Promise.allSettled — we need BOTH the daemon disconnect and the
// RPC stop + owned-metadata clear to complete before Electron exits.
// Using allSettled (not all) preserves the existing fail-open posture:
// if disconnectDaemon rejects, we still quit instead of hanging the app.
Promise.allSettled([disconnectDaemon(), rpcStopAndClear]).then(() => {
daemonDisconnectDone = true
app.quit()
})

View File

@ -3,7 +3,12 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap'
import { clearRuntimeMetadata, readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata'
import {
clearRuntimeMetadata,
clearRuntimeMetadataIfOwned,
readRuntimeMetadata,
writeRuntimeMetadata
} from './runtime-metadata'
const tempDirs: string[] = []
@ -59,6 +64,73 @@ describe('runtime metadata', () => {
expect(getRuntimeMetadataPath(userDataPath)).toContain('orca-runtime.json')
})
describe('clearRuntimeMetadataIfOwned', () => {
it('clears metadata when pid and runtimeId both match', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-'))
tempDirs.push(userDataPath)
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_owner',
pid: 42,
transport: null,
authToken: null,
startedAt: 100
})
clearRuntimeMetadataIfOwned(userDataPath, 42, 'rt_owner')
expect(readRuntimeMetadata(userDataPath)).toBeNull()
})
it('retains metadata when the pid does not match (simulates auto-update handoff)', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-'))
tempDirs.push(userDataPath)
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_replacement',
pid: 999,
transport: null,
authToken: null,
startedAt: 200
})
clearRuntimeMetadataIfOwned(userDataPath, 42, 'rt_owner')
expect(readRuntimeMetadata(userDataPath)).toMatchObject({
pid: 999,
runtimeId: 'rt_replacement'
})
})
it('retains metadata when only the runtimeId differs', () => {
// Why: pid reuse is possible across an auto-update (fork+exec keeps the
// old pid if the OS reassigns it quickly). The runtimeId check is the
// second-level guard that catches this even when pid collides.
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-'))
tempDirs.push(userDataPath)
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_replacement',
pid: 42,
transport: null,
authToken: null,
startedAt: 200
})
clearRuntimeMetadataIfOwned(userDataPath, 42, 'rt_owner')
expect(readRuntimeMetadata(userDataPath)).toMatchObject({
pid: 42,
runtimeId: 'rt_replacement'
})
})
it('is a no-op when no metadata exists', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-metadata-'))
tempDirs.push(userDataPath)
expect(() => clearRuntimeMetadataIfOwned(userDataPath, 42, 'rt_owner')).not.toThrow()
expect(readRuntimeMetadata(userDataPath)).toBeNull()
})
})
it.runIf(process.platform !== 'win32')(
'restricts runtime metadata permissions to the current user on Unix',
() => {

View File

@ -30,6 +30,38 @@ export function clearRuntimeMetadata(userDataPath: string): void {
rmSync(getRuntimeMetadataPath(userDataPath), { force: true })
}
/**
* Why: clearing metadata unconditionally on quit would race with a sibling
* Orca process during auto-updater handoff (the new process may already
* have written its own metadata before the old process finishes tearing
* down). The ownership guard pid + runtimeId must both match the values
* the caller recorded at its own startup keeps the clean-exit case honest
* ('not_running' instead of 'stale_bootstrap') while refusing to erase the
* replacement process's fresh bootstrap.
*
* Callers MUST capture `ownedPid` and `ownedRuntimeId` synchronously at
* startup (or at least before any shutdown await) so the comparison below
* reflects the process that actually wrote the file, not whatever state
* globals happen to hold mid-teardown.
*/
export function clearRuntimeMetadataIfOwned(
userDataPath: string,
ownedPid: number,
ownedRuntimeId: string
): void {
const current = readRuntimeMetadata(userDataPath)
if (!current) {
return
}
if (current.pid !== ownedPid) {
return
}
if (current.runtimeId !== ownedRuntimeId) {
return
}
clearRuntimeMetadata(userDataPath)
}
function writeMetadataFile(path: string, metadata: RuntimeMetadata): void {
const dir = dirname(path)
if (!existsSync(dir)) {

View File

@ -5,7 +5,7 @@
// stays easy to audit in one sitting.
import { randomBytes } from 'crypto'
import { createServer, type Server, type Socket } from 'net'
import { chmodSync, existsSync, rmSync } from 'fs'
import { chmodSync, existsSync, readdirSync, rmSync } from 'fs'
import { join } from 'path'
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
import type { OrcaRuntimeService } from './orca-runtime'
@ -53,6 +53,16 @@ export class OrcaRuntimeRpcServer {
return
}
// Why: processes killed by SIGKILL / OOM-kill / forced-shutdown skip
// stop() and leave behind `o-<pid>-*.sock` files in userData. Sweeping
// dead-pid sockets at startup keeps the directory from accumulating
// orphans over the app's lifetime. Named-pipe transports on Windows do
// not leave filesystem entries in userData, so the sweep is a no-op
// there.
if (this.platform !== 'win32') {
sweepOrphanedRuntimeSockets(this.userDataPath, this.pid)
}
const transport = createRuntimeTransportMetadata(
this.userDataPath,
this.pid,
@ -213,6 +223,61 @@ export class OrcaRuntimeRpcServer {
}
}
/**
* Why: the regex MUST stay in lockstep with createRuntimeTransportMetadata()
* below, which emits `o-${pid}-${endpointSuffix}.sock` where endpointSuffix
* is `[A-Za-z0-9_-]{1,4}` (derived from a sanitised runtimeId prefix, or
* `'rt'` as the fallback). The invariant is covered by a unit test so any
* future change to the transport-name shape trips CI.
*/
export const RUNTIME_SOCKET_NAME_REGEX = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/
export function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void {
let entries: string[]
try {
entries = readdirSync(userDataPath)
} catch {
// Why: first-launch userData may not exist yet; the cold-start path
// below will create it. Nothing to sweep in that case.
return
}
for (const entry of entries) {
const match = RUNTIME_SOCKET_NAME_REGEX.exec(entry)
if (!match) {
continue
}
const pid = Number(match[1])
if (!Number.isFinite(pid)) {
continue
}
// Why: never touch the current process's socket. start() already
// rmSync's it if it exists, but belt-and-braces — a bug in the own-pid
// path here would rmSync a socket we're about to bind to.
if (pid === ownPid) {
continue
}
try {
// Why: signal 0 is the POSIX liveness probe — it delivers no signal
// but returns success iff the pid resolves AND the caller has
// permission to signal it. ESRCH = no such process; EPERM = pid
// exists but owned by another user, which is extremely unusual on a
// desktop app's userData dir but we conservatively leave those
// sockets alone.
process.kill(pid, 0)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
try {
rmSync(join(userDataPath, entry), { force: true })
} catch {
// Why: best-effort sweep — a permission error on unlink is fine
// to ignore; the socket will be cleaned by a later start() or
// by the OS on reboot.
}
}
}
}
}
export function createRuntimeTransportMetadata(
userDataPath: string,
pid: number,

View File

@ -0,0 +1,83 @@
import { existsSync, mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import {
createRuntimeTransportMetadata,
RUNTIME_SOCKET_NAME_REGEX,
sweepOrphanedRuntimeSockets
} from './runtime-rpc'
describe('sweepOrphanedRuntimeSockets', () => {
// Why: a pid we know is always alive and is never the test runner's own
// pid — init on POSIX, so process.kill(1, 0) resolves without ESRCH. Using
// this synthetic pid for ownPid cleanly separates three retention branches
// (own-pid-skipped / alive-non-own-retained / dead-swept) into distinct
// observations.
const SYNTHETIC_OWN_PID = 1
const KNOWN_DEAD_PID = 99999999
it.runIf(process.platform !== 'win32')(
'sweeps dead-pid sockets while retaining own, alive, and non-matching entries',
() => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-sweep-'))
const ownPidSocket = join(userDataPath, `o-${SYNTHETIC_OWN_PID}-aaaa.sock`)
const aliveSocket = join(userDataPath, `o-${process.pid}-bbbb.sock`)
const deadSocket = join(userDataPath, `o-${KNOWN_DEAD_PID}-cccc.sock`)
const unrelatedFile = join(userDataPath, 'foo.sock')
writeFileSync(ownPidSocket, '')
writeFileSync(aliveSocket, '')
writeFileSync(deadSocket, '')
writeFileSync(unrelatedFile, '')
sweepOrphanedRuntimeSockets(userDataPath, SYNTHETIC_OWN_PID)
// Why: own pid (1) is skipped by the ownPid === pid early-exit.
expect(existsSync(ownPidSocket)).toBe(true)
// Why: alive non-own pid — process.kill(pid, 0) succeeds without
// throwing, so the sweep leaves it alone.
expect(existsSync(aliveSocket)).toBe(true)
// Why: dead pid — process.kill throws ESRCH, sweep removes it.
expect(existsSync(deadSocket)).toBe(false)
// Why: regex miss — not `o-<digits>-<suffix>.sock` shape, so the
// sweep never touches it.
expect(existsSync(unrelatedFile)).toBe(true)
}
)
it('tolerates a non-existent userData directory', () => {
const userDataPath = join(tmpdir(), `orca-sweep-missing-${Date.now()}`)
expect(() => sweepOrphanedRuntimeSockets(userDataPath, SYNTHETIC_OWN_PID)).not.toThrow()
})
it('regex invariant: matches sockets produced by createRuntimeTransportMetadata', () => {
// Why: if the socket-name factory ever changes shape (e.g. adds a new
// separator or allows different characters), the sweep will silently
// stop matching real sockets. Assert the two stay in lockstep.
const userDataPath = '/tmp'
const transport = createRuntimeTransportMetadata(userDataPath, 12345, 'linux', 'rt_abcdef')
expect(transport.kind).toBe('unix')
if (transport.kind !== 'unix') {
throw new Error('expected unix transport')
}
const basename = transport.endpoint.slice(transport.endpoint.lastIndexOf('/') + 1)
expect(RUNTIME_SOCKET_NAME_REGEX.test(basename)).toBe(true)
})
it('regex invariant: also matches the fallback runtimeId suffix ("rt")', () => {
// Why: createRuntimeTransportMetadata falls back to the literal 'rt'
// suffix when the runtimeId contains no allowed characters; the sweep
// regex must still match that shape.
const userDataPath = '/tmp'
const transport = createRuntimeTransportMetadata(userDataPath, 99, 'darwin', '!!!!')
if (transport.kind !== 'unix') {
throw new Error('expected unix transport')
}
const basename = transport.endpoint.slice(transport.endpoint.lastIndexOf('/') + 1)
expect(RUNTIME_SOCKET_NAME_REGEX.test(basename)).toBe(true)
expect(basename).toBe('o-99-rt.sock')
})
})

View File

@ -0,0 +1,67 @@
import type { App } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import { acquireSingleInstanceLock } from './single-instance-lock'
type Listener = (...args: unknown[]) => void
function makeFakeApp(lockResult: boolean): {
app: App
requestSingleInstanceLock: ReturnType<typeof vi.fn>
on: ReturnType<typeof vi.fn>
listeners: Record<string, Listener[]>
} {
const listeners: Record<string, Listener[]> = {}
const requestSingleInstanceLock = vi.fn(() => lockResult)
const on = vi.fn((event: string, cb: Listener) => {
listeners[event] = listeners[event] ?? []
listeners[event].push(cb)
})
const app = {
requestSingleInstanceLock,
on
} as unknown as App
return { app, requestSingleInstanceLock, on, listeners }
}
describe('acquireSingleInstanceLock', () => {
it('returns false and does NOT register second-instance when the lock is held', () => {
const onSecondInstance = vi.fn()
const fake = makeFakeApp(false)
const acquired = acquireSingleInstanceLock(fake.app, onSecondInstance)
expect(acquired).toBe(false)
expect(fake.requestSingleInstanceLock).toHaveBeenCalledTimes(1)
// Why: if we registered the listener on a losing process, focusing the
// existing window would become our job even though the primary owns
// that UX surface. Verify no listener was added.
expect(fake.on).not.toHaveBeenCalled()
expect(fake.listeners['second-instance']).toBeUndefined()
})
it('returns true and registers exactly one second-instance listener when the lock is acquired', () => {
const onSecondInstance = vi.fn()
const fake = makeFakeApp(true)
const acquired = acquireSingleInstanceLock(fake.app, onSecondInstance)
expect(acquired).toBe(true)
expect(fake.requestSingleInstanceLock).toHaveBeenCalledTimes(1)
expect(fake.on).toHaveBeenCalledTimes(1)
expect(fake.on).toHaveBeenCalledWith('second-instance', onSecondInstance)
expect(fake.listeners['second-instance']).toHaveLength(1)
})
it('fires the registered callback when second-instance dispatches', () => {
const onSecondInstance = vi.fn()
const fake = makeFakeApp(true)
acquireSingleInstanceLock(fake.app, onSecondInstance)
const [registered] = fake.listeners['second-instance'] ?? []
expect(registered).toBeDefined()
registered?.()
expect(onSecondInstance).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,27 @@
import type { App } from 'electron'
/**
* Why: Orca writes two canonical discovery files into `<userData>/`:
* `orca-runtime.json` (RPC endpoint + authToken for the bundled CLI) and
* `agent-hooks/endpoint.env` (hook port + token for cursor-agent/claude/codex
* scripts). Without a single-instance lock, every AppImage/.app double-click
* boots a fresh Electron main that clobbers both files. When the most recent
* instance quits, metadata points at a dead pid and `orca status` reports
* `stale_bootstrap` even though the original process is still running.
*
* This helper centralises the lock gate so it is testable in isolation and
* so `src/main/index.ts` has one clean call site rather than two spread-out
* Electron calls.
*
* Electron derives the lock identity from the current `userData` path, so
* callers MUST invoke this AFTER `configureDevUserDataPath(is.dev)` that
* way dev (`orca-dev` userData) and packaged (`orca` userData) runs lock in
* separate namespaces instead of serialising against each other.
*/
export function acquireSingleInstanceLock(app: App, onSecondInstance: () => void): boolean {
if (!app.requestSingleInstanceLock()) {
return false
}
app.on('second-instance', onSecondInstance)
return true
}