* 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:
parent
46a26252bb
commit
827b84d27a
|
|
@ -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.
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue