Skip to content

fix(cli): decide duplicate-serve before macOS aborts pre-JS (STA-4336) - #14768

Open
Jinwoo-H wants to merge 15 commits into
mainfrom
aug14-p0-sta4336-macos-serve
Open

fix(cli): decide duplicate-serve before macOS aborts pre-JS (STA-4336)#14768
Jinwoo-H wants to merge 15 commits into
mainfrom
aug14-p0-sta4336-macos-serve

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Aug 15, 2026

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 8 +893 −16 +877
Prod 12 +1142 −53 +1089

Fixes STA-4336. Closes #14541.

The bug

On macOS the Electron main constructs NSApplication before any JavaScript runs, and HIServices _RegisterApplication calls abort() when Launch Services is unreachable (no GUI login session, an SSH/sandbox-exec context, a wedged lsd). Orca's "one Orca per userData profile" rule lives inside that main, so a duplicate orca serve never reaches it — the process SIGABRTs before the V8 isolate exists and a supervisor that retries loops on it. Upstream: electron/electron#52815.

Each abort also leaves Launch Services unable to register the next GUI app for ~45s, machine-wide (measured on macOS 25.5), so the retry loop degrades launches for everything else on the machine — not just Orca.

PR #12212 is already merged, and latest main still reproduces: it fixed the in-process path, but the decision has to move before the exec to help.

The fix

Move the duplicate decision into the CLI, ahead of the launch:

  • serve preflights the profile and returns the existing exit code 3 without spawning, printing the RPC failure envelope for --json (recipe stdout is a strict schema channel, so its refusal goes to stderr and exit 3 carries the signal).
  • Ownership is decided by what the published endpoint proves, never by the recorded pid — pid recycling would otherwise let one crashed runtime refuse serve on that profile forever, so the pid only names the owner in the message. The runtime binds its transports before publishing metadata, so an accepted connect proves a live owner even while it is too busy for RPC.
  • A profile is called free only on a definitive refusal: ENOENT (a runtime that unlinked its socket on shutdown) or ECONNREFUSED (a path a killed runtime left behind). Measured on macOS, both land in under 1ms, so every way an owner really goes away is immediate and unambiguous. Every other outcome — the 250ms cap expiring, EACCES, an unexpected errno — is treated as an owner, because it proves nothing and guessing "free" is what spawns the second main that aborts pre-JS. That refusal names the one manual escape hatch (delete orca-runtime.json) since it is the only one a user may have to clear by hand.
  • open watches the detached launch and reports a classified runtime_open_failed at the first failed exit instead of waiting out its 15s "no window" timeout.
  • orca status treats EPERM as alive (another user's serve on a shared host is still running) but no longer reads a pid the OS rejects outright as one.
  • A detached launch that never starts is reported as a launch failure instead of being discarded — Node emits error and no exit there, so orca open used to wait out its full window and blame a missing window for a process that was never created.
  • The macOS signal diagnostic names _RegisterApplication and states that retrying cannot help.

Exit 3 is the existing SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE the Electron main already returns and that the documented systemd unit keys RestartPreventExitStatus= off, so a supervisor now stops instead of looping. The constant moved to src/shared/ so the CLI can reuse it without importing a main-process module.

Falsifiable invariant

When macOS Launch Services is unreachable, every CLI entrypoint that would start a GUI Electron main either reuses the runtime that already owns the userData profile, refuses with the single-instance exit code before spawning, or fails exactly once with a classified diagnostic naming _RegisterApplication — never a bare pre-JS SIGABRT a supervisor can retry.

Evidence

config/scripts/run-macos-launch-abort-oracle.mjs denies exactly the four Launch Services mach services with a sandbox-exec seatbelt profile that is otherwise (allow default), so a difference in outcome can only come from Launch Services being unreachable. It grades six entrypoints and is byte-identical across arms — point --electron/--cli at any build.

Arm Result
Packaged 1.4.177 (signed + notarized, shipped) 4/6 red
Latest main 92fb276040 (already contains #12212) 4/6 red
Main c0a775454a = this branch with the fix removed 4/6 red
This branch 6/6 green

The third arm is a fresh build of c0a775454a, this branch's merge base. The branch diff against that commit is exactly this change and nothing else, so one build serves as both the refreshed main arm and the fix-disabled arm — and it ran on the same machine, through the same harness, minutes before the green arm below.

Red detail (disabled-c0a7754, matching main-92fb2760 scenario for scenario):

FAIL serve-duplicate-sandboxed: exit=1 sigabrt=true  537ms  — second Electron main launched against an owned profile and aborted; expected exit 3
FAIL open-duplicate:            exit=1              15236ms — no machine-readable cause; a classified refusal must be prompt
FAIL recipe-json-duplicate:     exit=1 sigabrt=true  404ms  — same abort; expected exit 3
FAIL serve-fresh-sandboxed:     exit=1 sigabrt=true  410ms  — bare SIGABRT with no classified diagnostic
PASS serve-duplicate-open, serve-fresh-open

This branch (candidate-w10, final head):

PASS serve-duplicate-sandboxed: exit=3 127ms    PASS serve-duplicate-open:  exit=3 88ms
PASS open-duplicate:            exit=1 613ms    PASS recipe-json-duplicate: exit=3 96ms
PASS serve-fresh-sandboxed:     exit=1 396ms (classified)   PASS serve-fresh-open: reached Orca JS 520ms

Both unsandboxed scenarios stay green on every arm, which is the point: the in-process rule is correct, it just sits behind the boundary that aborts.

The packaged arm is profile-safe because the harness points the CLI at a wrapper that execs the build under test with --user-data-dir pinned to the run profile — a packaged main ignores ORCA_DEV_USER_DATA_PATH. The wrapper lives at a <name>.app/Contents/MacOS/ path so the CLI's bundle-shape branch (serve-update handoff) takes the same path a real bundle would.

Coverage

New reliability gate cli-launch.macos-pre-js-abort-refusal with the invariant, oracle, four-arm red/green evidence, runtime budget, and promotion criteria. Deterministic vitest coverage for the refusal wiring, the JSON envelope, owner classification, the EPERM ownership case, the open classification, and the diagnostic wording runs on every platform shard; the sandbox-exec scenarios are macOS-only and local, because PR CI unit shards are Linux.

Scope notes

  • No wire change. The refusal is a local CLI decision about a local profile, so paired client/host versions are unaffected; on an SSH host the refusal happens on that host against that host's profile.
  • Nothing here is workspace-shaped, so git worktrees and folder workspaces behave identically.
  • orca open gets strictly faster on this path (fails at the first launch exit instead of 15s).

Relation to #14542

#14542 was treated as an untrusted hypothesis and was not used as a base; this branch was written against the oracle above and its byte-identical arms.

Do not merge — review only for now.


Review round 1 (cross-model, GPT-5.6 via codex exec)

Three independent reviewers — adversarial, performance, elegance — ran against git diff 5b7f44278a..HEAD. Confirmed findings and what changed:

Finding Verdict Response
Stale metadata + recycled pid → permanent false refusal; orca serve exits 3 forever with no runtime Confirmed findServingProfileOwner now disbelieves an unreachable owner more than STARTING_OWNER_TRUST_WINDOW_MS (120s) past metadata.startedAt. A reachable owner stays definitive at any age. The refusal message also names orca-runtime.json so the manual escape hatch is always visible.
--recipe-json refusal wrote an RPC envelope to recipe stdout, which carries a strict result schema (the serve path already diverts even schema-valid non-orca-server results to stderr) Confirmed The refusal goes to stderr for recipe mode; only --json writes the stdout envelope. Exit 3 carries the signal. Regression test asserts stdout is untouched.
orca open on a packaged macOS build goes through /usr/bin/open, which exits 0 as soon as Launch Services accepts — so the new exit watcher cannot see a later Electron abort Confirmed, recorded not fixed Added to knownGaps. Verified separately that a Launch-Services-denied seatbelt does not make /usr/bin/open fail, because the requested app is launched outside the caller's sandbox. The classification covers the direct-exec path; packaged open still falls back to the 15s no-window timeout.
Oracle's open-duplicate accepts either runtime_open_failed or desktop_activation_blocked and never exercises /usr/bin/open Confirmed, recorded Already noted; knownGaps now names the /usr/bin/open limitation explicitly.
process.kill(pid, 0) errno handling Found by me while acting on the above Adopted the repo's existing "only ESRCH proves death" rule, then found it too wide: a corrupt non-integer pid makes process.kill throw a TypeError with no errno, which read as alive. Now requires a numeric errno before believing an owner.
orca status EPERM test pinned pid 1 Confirmed (would pass vacuously as root/in a container) Replaced with a mocked process.kill for EPERM and ESRCH, plus a real non-integer-pid case.
serve-signal-exit-diagnostic.ts now classifies both serve and open Confirmed (naming) Renamed to launch-exit-diagnostic.ts.
Diagnostic asserted _RegisterApplication though the parent only observes a signal Confirmed Reworded to "almost always … Retrying cannot help in that case"; the crash-report next-step confirms it.
Performance No regression One local status read on the serve path (the same read orca status already does), one once('exit') listener. orca open on the direct-exec path is ~14.6s faster. performanceBudget corrected — it previously claimed "no IPC", but the preflight does use the existing local RPC socket.
"Delete serving-profile-owner.ts, route through reportCliError" Declined It would change the envelope shape that --json consumers and the oracle assert on, in exchange for line count.

Oracle re-run after every round: 6/6 green (candidate-w3, candidate-w4).


Review round 2

CodeRabbit (GitHub):

Finding Verdict Response
spawnDetached discarded child.once('error'), so a command that never starts left failedExit() null and orca open waited out its 15s window Confirmed Fixed. failedExit() now carries spawnError, and openLaunchExitError reports it without the Launch Services guidance — nothing ran, so crash reports and mach-lookup would send the user to the wrong place. Mutation-checked: reverting the handler or dropping the branch fails the new tests.
Packaged macOS open lifecycle is not followed Confirmed, recorded not fixed Measured: a Launch-Services-denied seatbelt does not make /usr/bin/open fail (the app launches outside the caller's sandbox), so this needs machine-wide LS breakage rather than the sandboxed-agent case; and orca serve never goes through /usr/bin/open. Following the real lifetime means tracking NSRunningApplication or open -W, which changes detach semantics for every packaged launch. Explicit knownGaps entry.
Oracle wrapper interpolated paths into single-quoted /bin/sh words without escaping Confirmed Fixed and verified end to end: a profile of /tmp/o'brien; touch /tmp/PWNED now reaches the wrapper as one argument, no injection.
Comments longer than the one-line rule in AGENTS.md Confirmed Trimmed across the changed files.
orca status EPERM test pinned pid 1 Confirmed Already fixed in round 1 (mocked process.kill with faithful errnos, plus a real non-integer-pid case). CodeRabbit marked it addressed.

Self-review that changed the design. Round 1's 120s trust window fixed the permanent false refusal but reopened the headline failure mode in a narrower form: metadata.startedAt is written once at runtime construction and never refreshed, so a healthy serve older than 120s whose RPC went quiet for one second stopped refusing — and a supervisor retrying into it on a Launch-Services-broken host is exactly the pre-JS SIGABRT loop this PR exists to stop (a SIGABRT exit is not exit 3, so RestartPreventExitStatus=3 does not catch it). Replacing the window with the listener probe removes both defects and deletes the heuristic rather than tuning it. Verified myself, not assumed:

  • runtime-rpc.ts starts its transports before writeMetadata(), and every later writeMetadata() call site mutates only the websocket entry, always registering the live transport first. The local endpoint in metadata was always bound before the write.
  • findTransport(metadata, 'unix', 'named-pipe') cannot return a websocket, so the probe is local-only and never reachable off-box.

Mutation testing. Every new assertion was checked by breaking the production code it covers: trusting the pid instead of the listener (2 fail), dropping the listening tier (5 fail), accepting on connect error (2 fail), discarding spawn errors (1 fail), dropping the spawnError diagnostic branch (1 fail). None pass vacuously.

Oracle re-run after every round: 6/6 green (candidate-w3candidate-w6).

Review round 3

Self-review found a fail-open in round 2's own fix. The listener probe returned a boolean, so only a completed connect meant "owner" and everything else — a timed-out connect, EACCES, any unexpected errno — meant "this profile is free, go spawn". On a host where Launch Services is unreachable, that is the pre-JS SIGABRT this PR exists to prevent: the ambiguous case was failing open into the bug.

I measured the real outcomes rather than reasoning about them (macOS, local unix sockets):

owner state connect outcome time
listening connect 0.25ms
stopped gracefully (socket unlinked) ENOENT 0.08ms
SIGKILLed, socket file still on disk ECONNREFUSED 0.16ms
path unreachable (unreadable dir) EACCES

Every way an owner really goes away is definitive and sub-millisecond, so the probe became a tri-state and only those two codes free the profile. This is the same rule orca status already applies one layer up, where only ESRCH proves a pid is gone. The 250ms cap is now a ceiling nobody pays rather than a silent verdict.

Also hardened: the probe held once('error'), so a second error event on a socket destroyed mid-connect would have had no listener and taken the CLI down with an uncaught exception (verified — the process dies). It holds on('error') now and ignores anything after it settles.

Supervisor re-entry checked. superviseForegroundServe does loop and respawn, but only when the handoff file reads install-requested and names the exiting child's pid. A pre-JS abort runs no JavaScript and so writes no handoff, which means it exits through serveSignalExitError and its classified _RegisterApplication diagnostic instead. The CLI cannot become the retry loop itself.

Mutation testing of the new branches (all killed): timeout frees the profile → 1 failed; any connect error frees it → 1 failed; unproven treated as not-an-owner → 1 failed.

Cross-model review was unavailable for this round. Two codex exec passes were started and both wedged in gateway reconnect loops without producing a single line of review — 56 minutes and 14 minutes. A one-line liveness prompt then failed to return in 180s, confirming the gateway is down rather than the prompts being at fault; both were stopped. Round 3 is therefore my own adversarial pass plus CodeRabbit, and I have said so rather than implying a second model signed off.

Oracle re-run on the redesign: candidate-w7 6/6 green (duplicate serve 97ms, duplicate serve with a reachable owner 87ms, --recipe-json 101ms, all exit 3 without spawning; open classified in 604ms; fresh sandboxed serve failed once with the diagnostic).

Review round 4 (CodeRabbit)

  • Malformed transport endpoint (Major) — real, fixed. tryReadMetadata is an unvalidated JSON.parse, so the file's contents reached createConnection directly. Measured: undefined throws ERR_MISSING_ARGS, ''/' ' throw ERR_SOCKET_BAD_PORT — both inside the promise executor, so they failed orca serve outright — and 42 does not throw at all, it is read as a TCP port and dials 127.0.0.1:42, where something that is not Orca could accept and produce a false refusal. The probe now requires a non-blank string endpoint; anything else frees the profile, because refusing forever on junk metadata is the permanent-refusal failure mode, and the in-process lock is still the backstop.
  • /usr/bin/open branch untested (Minor) — added, as a new case rather than by repurposing the ORCA_APP_EXECUTABLE test.
  • Duplicate detection is not atomic (Major) — real, staying a documented gap. Two serve runs racing on an ownerless profile can both spawn. A pre-launch reservation needs a lock the OS releases on holder death; Node has no flock, so a pure-JS lock is a file plus a staleness rule — which is precisely the unsound time window round 2 deleted, and a stale lock refuses serve permanently. The shape that actually crash-loops (a supervisor restarting against a profile that already has an owner) is sequential and is fixed here.

Base merged with main (c0a775454a) — two CI failures on the previous head were stale-base, not this change: static analysis was fixed on main by #14755, and the failing test shard was three generated skill-guides/*.md artifacts that a repo-wide pnpm format had reflowed out of sync with their generator. The artifacts are restored.

Oracle at the final head: candidate-w9 6/6 green.

Review round 5

  • Diff cleanup. 18 files this change never touches were carrying format-only churn from a repo-wide pnpm format run — README.md, AGENTS.md, docs/, .github/, config/electron-builder.config.cjs, and several tests/tools/ fixtures. All restored to main, so the diff is now exactly the 20 files that implement and cover the fix. (CodeRabbit's AGENTS.md typo note went away with them; that line is main's, not this branch's.)
  • Red arm rebuilt at the current design. The earlier revert arm was taken at the first fix commit, two design revisions ago, so it no longer disproved anything about the code being reviewed. Rebuilt from c0a775454a and re-graded: same four failures, same harness, same machine, minutes before candidate-w10 went 6/6 green.

Oracle at the final head: candidate-w10 6/6 green.

On macOS the Electron main constructs NSApplication before any JS runs, and
_RegisterApplication calls abort() when Launch Services is unreachable. The
"one Orca per userData profile" rule lives inside that main, so a duplicate
`orca serve` never reaches it: the process SIGABRTs pre-JS and a supervisor
that retries loops on it. Each abort also wedges the next GUI app launch for
~45s, so the loop degrades launches machine-wide.

Move the decision to the CLI, before the exec:

- `serve` preflights the profile and returns the existing exit code 3 without
  spawning, printing the RPC failure envelope for --json/--recipe-json.
- `open` watches the detached launch and reports a classified
  runtime_open_failed instead of a 15s "no window" timeout.
- `isProcessRunning` treats EPERM as alive, so another user's serve still
  counts as the owner.
- The macOS signal diagnostic now names _RegisterApplication and says
  retrying cannot help.

Adds a byte-identical oracle (config/scripts/run-macos-launch-abort-oracle.mjs)
that denies the Launch Services mach services with sandbox-exec and grades six
entrypoints, plus a reliability gate for the invariant.
…ackaged build

The CLI spawns ORCA_APP_EXECUTABLE with no profile switch, and only
ORCA_DEV_USER_DATA_PATH redirects userData — which a packaged main ignores. Run
the CLI against a wrapper that pins --user-data-dir instead, so the packaged arm
cannot reach the real profile. The wrapper sits at a .app/Contents/MacOS/ path so
the CLI's bundle-shape branch is unchanged.

Records the four-arm red/green/red evidence: packaged 1.4.177, latest main
92fb276 (already contains #12212), and the candidate with the fix reverted are
each 4/6 red; the candidate is 6/6 green.
An unreachable owner is believed on its recorded pid alone, so a pid the OS
recycled would refuse every serve on that profile forever with no way out of
the message. Records the concurrent-serve race in the gate's known gaps.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds duplicate serving-profile detection with structured and human-readable refusal responses. Detached launches now expose early exit information, and serve and open report classified macOS startup failures with executable-specific crash-report guidance. A macOS Launch Services abort oracle executes isolated scenarios, collects evidence, and grades results. Reliability-gate configuration records its scenarios, evidence, budgets, promotion criteria, and limitations. Tests cover ownership, status checks, diagnostics, launch behavior, oracle judgments, and asynchronous setup.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ⚠️ Warning The functional fix is accompanied by many unrelated documentation, Markdown formatting, contributor-guide, README, and workflow-only changes. Remove unrelated formatting and documentation changes, or explain why each is required for this fix.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The description links the work to STA-4336 and issue #14541 with explicit Fixes and Closes references.
Title check ✅ Passed The title clearly identifies the CLI duplicate-serve fix and its relationship to macOS pre-JavaScript aborts.
Description check ✅ Passed The description is detailed and covers the problem, fix, linked issues, testing evidence, scope, known gaps, and review history.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/shared/single-instance-exit-code.ts (1)

1-3: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shorten the implementation comments.

Keep each comment to one brief, non-obvious reason. Move incident history and detailed behavior explanations to documentation or tests.

  • src/shared/single-instance-exit-code.ts#L1-L3: reduce the rationale to one line.
  • src/cli/runtime/status.ts#L110-L112: reduce the EPERM rationale to one line.
  • src/cli/runtime/serving-profile-owner.ts#L10-L17: reduce the startup-boundary rationale to one line.
  • src/cli/runtime/serving-profile-owner.ts#L31-L34: reduce the JSON-output rationale to one line.
  • src/cli/runtime/launch.ts#L80-L83: reduce the launch-failure rationale to one line.
  • src/cli/runtime/launch.ts#L109-L112: reduce the duplicate-refusal rationale to one line.
  • src/cli/runtime/serve-duplicate-refusal.test.ts#L13-L18: reduce the test scenario rationale to one line.
  • src/cli/runtime/serve-duplicate-refusal.test.ts#L45-L47: reduce the fixture rationale to one line.
  • src/cli/runtime/serve-duplicate-refusal.test.ts#L68-L69: reduce the JSON-output rationale to one line.
  • src/cli/runtime/launch.test.ts#L72-L73: reduce the async setup rationale to one line.
  • src/cli/runtime/launch.test.ts#L93-L95: reduce the isolated-profile rationale to one line.

As per coding guidelines: “Comments must be concise, non-obvious, and brief—prefer one line.”

Source: Coding guidelines

config/scripts/run-macos-launch-abort-oracle.mjs (1)

387-399: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Escape single quotes when you build the wrapper script.

executableWrapperScript places electron and profile inside single-quoted /bin/sh words. A single quote in the --electron value ends the quoting, and the rest of the path is then interpreted as shell code. The wrapper then either fails with a confusing syntax error or runs unintended commands.

♻️ Proposed escaping
+function shellSingleQuote(value) {
+  return `'${value.replaceAll("'", `'\\''`)}'`
+}
+
 export function executableWrapperScript(electron, profile) {
-  return `#!/bin/sh\nexec '${electron}' '--user-data-dir=${profile}' "$@"\n`
+  return `#!/bin/sh\nexec ${shellSingleQuote(electron)} ${shellSingleQuote(`--user-data-dir=${profile}`)} "$@"\n`
 }
config/scripts/run-macos-launch-abort-oracle.test.mjs (1)

3-3: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the duplicated exit code to the shared constant.

run-macos-launch-abort-oracle.mjs duplicates the value 3 because it must run against packaged builds with no repo on disk. This test always runs inside the repo, so it can prove the copy still matches. Without that assertion, a change to SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE leaves the oracle grading duplicate scenarios against a stale exit code and every duplicate scenario fails for the wrong reason.

💚 Proposed drift pin
+import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from '../../src/shared/single-instance-exit-code'
+
+it('keeps the duplicated exit code aligned with the shared constant', () => {
+  expect(ALREADY_RUNNING_EXIT_CODE).toBe(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)
+})

Run the following script to confirm the exported name and path of the shared constant:

#!/bin/bash
# Confirm the shared single-instance exit-code export.
fd -t f 'single-instance-exit-code*' src
rg -nP -C3 'ALREADY_RUNNING_EXIT_CODE' src

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b8638a72-f398-43b7-989e-997735689bd0

📥 Commits

Reviewing files that changed from the base of the PR and between 92fb276 and e7ae539.

📒 Files selected for processing (16)
  • config/reliability-gates.jsonc
  • config/scripts/run-macos-launch-abort-oracle.mjs
  • config/scripts/run-macos-launch-abort-oracle.test.mjs
  • src/cli/runtime/client.ts
  • src/cli/runtime/launch.test.ts
  • src/cli/runtime/launch.ts
  • src/cli/runtime/open-launch-failure.test.ts
  • src/cli/runtime/serve-duplicate-refusal.test.ts
  • src/cli/runtime/serve-signal-exit-diagnostic.test.ts
  • src/cli/runtime/serve-signal-exit-diagnostic.ts
  • src/cli/runtime/serving-profile-owner.test.ts
  • src/cli/runtime/serving-profile-owner.ts
  • src/cli/runtime/status.test.ts
  • src/cli/runtime/status.ts
  • src/main/startup/single-instance-lock.ts
  • src/shared/single-instance-exit-code.ts

Comment thread src/cli/runtime/launch.ts
Comment thread src/cli/runtime/launch.ts Outdated
Comment thread src/cli/runtime/status.test.ts Outdated
Cross-model review found two defects in the refusal added here:

- An unreachable owner was believed on its recorded pid alone. Once the OS
  recycled that pid the claim was permanent, so `orca serve` would exit 3
  forever against a profile with no runtime. The belief is now bounded to a
  startup window; a reachable owner is still definitive at any age.
- `--recipe-json` refusals wrote an RPC failure envelope to recipe stdout,
  which carries a strict result schema — the serve path already diverts even
  schema-valid non-orca-server results to stderr. The refusal now goes to
  stderr and exit 3 carries the signal.

Also adopts the repo's "only ESRCH proves death" pid rule, replaces the
environment-dependent pid-1 EPERM test with a controlled one, and renames
serve-signal-exit-diagnostic to launch-exit-diagnostic now that it classifies
both serve and open.
process.kill throws a TypeError with no errno for a pid the OS will not accept
— a non-integer or out-of-range value in a corrupt orca-runtime.json. The
"only ESRCH proves death" rule read that as alive, which would refuse serve on
a profile with nothing running.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f0eae234-58fe-481c-acaf-c3ed475ce077

📥 Commits

Reviewing files that changed from the base of the PR and between e7ae539 and 3e2e09e.

📒 Files selected for processing (5)
  • config/reliability-gates.jsonc
  • src/cli/runtime/launch.ts
  • src/cli/runtime/serve-duplicate-refusal.test.ts
  • src/cli/runtime/serving-profile-owner.test.ts
  • src/cli/runtime/serving-profile-owner.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/cli/runtime/serving-profile-owner.test.ts
  • src/cli/runtime/serve-duplicate-refusal.test.ts
  • config/reliability-gates.jsonc

Comment thread src/cli/runtime/serving-profile-owner.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 954edbaa-5e46-4ac3-91a2-72dd4eac6f12

📥 Commits

Reviewing files that changed from the base of the PR and between 3e2e09e and deeebfc.

📒 Files selected for processing (11)
  • config/reliability-gates.jsonc
  • src/cli/runtime/client.ts
  • src/cli/runtime/launch-exit-diagnostic.test.ts
  • src/cli/runtime/launch-exit-diagnostic.ts
  • src/cli/runtime/launch.ts
  • src/cli/runtime/serve-duplicate-refusal.test.ts
  • src/cli/runtime/serve-update-supervisor.ts
  • src/cli/runtime/serving-profile-owner.test.ts
  • src/cli/runtime/serving-profile-owner.ts
  • src/cli/runtime/status.test.ts
  • src/cli/runtime/status.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/cli/runtime/client.ts
  • src/cli/runtime/status.ts
  • config/reliability-gates.jsonc

Comment thread src/cli/runtime/serving-profile-owner.ts Outdated
…STA-4336)

`orca serve` refused a profile whenever metadata named a live pid, so a
crashed runtime whose pid the OS recycled refused every serve on that
profile forever. Bounding that belief to a startup window traded one
defect for another: a healthy serve older than the window stopped
refusing, and a supervisor retrying into it on a Launch-Services-broken
macOS host is exactly the pre-JS SIGABRT loop this fixes.

Ownership is now only ever asserted on a live runtime answering for
itself — an RPC reply, or failing that a bounded 250ms connect to the
endpoint it published. The runtime binds its transports before writing
metadata, so an accepted connect proves a live owner even while it is
too busy to answer RPC; a crash leaves the socket path behind with
nothing to accept on it. The recorded pid names the owner in the
message and never establishes one.

Also stop discarding detached spawn errors: a command that never starts
emits `error` and no `exit`, so `orca open` waited out its full 15s
window and then blamed a missing window for a process that was never
created.
@Jinwoo-H

Copy link
Copy Markdown
Contributor Author

CodeRabbit round 1 — responses

1. launch.ts — "Record spawn errors as launch failures." (Major, quick win) — fixed in 10c7c1c4dc.

Confirmed by reading the code: child.once('error', () => {}) discarded the only event a failed spawn ever emits, so failedExit() stayed null and openOrca waited out its full 15s window before blaming a missing window for a process that was never created. spawnDetached now records { code: null, signal: null, spawnError: error.message }, and openLaunchExitError reports it without the Launch Services guidance — nothing ran, so the crash-report and mach-lookup next steps would send the user to the wrong place. Covered by launch.test.ts ("reports an asynchronous detached spawn error as a launch failure", plus a companion asserting a clean exit(0) from /usr/bin/open still is not a failure) and launch-exit-diagnostic.test.ts ("reports a command that never started without the abort guidance"). Both assertions were mutation-checked: reverting the handler to () => {} or dropping the spawnError branch fails them.

2. launch.ts / client.ts — "Follow the launched app's lifecycle for macOS bundle launches." (Major, heavy lift) — accurate, recorded as a known gap rather than fixed.

The analysis is right: /usr/bin/open exits 0 as soon as Launch Services accepts the request, so the exit watcher classifies the direct-exec path (ORCA_APP_EXECUTABLE, dev, and this oracle) but cannot see an abort that happens after open returns; that case still falls back to the 15s timeout.

Two things bound how much this matters here, both measured rather than assumed:

  • A Launch-Services-denied seatbelt does not make /usr/bin/open fail — the requested app is launched outside the caller's sandbox. Verified directly: sandbox-exec -f ls-deny.sb /usr/bin/open /System/Applications/Calculator.app → exit 0, Calculator launched. So reaching this path requires machine-wide Launch Services breakage, not the sandboxed-agent case in the report.
  • STA-4336 is an orca serve crash loop. orca open requires a GUI session by definition, and the serve path does not go through /usr/bin/open.

Following the app's real lifetime means tracking an NSRunningApplication (or open -W, which changes the detach semantics of every packaged launch). That is a larger change than this P0 warrants and it is now an explicit knownGaps entry on the cli-launch.macos-pre-js-abort-refusal reliability gate, together with the sandbox finding above, so it is not silently lost.

A single quote in an `--electron` or profile path closed the quoting in
the generated /bin/sh wrapper and ran the rest as shell code. Verified
end to end: a profile of `/tmp/o'brien; touch /tmp/PWNED` now reaches
the wrapper as one argument.

Also compresses the rationales AGENTS.md asks to keep to one line.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c34af32-67bc-40de-be2b-3ad774e17c61

📥 Commits

Reviewing files that changed from the base of the PR and between deeebfc and 10c7c1c.

📒 Files selected for processing (32)
  • .github/CONTRIBUTING.md
  • .github/pull_request_template.md
  • .github/workflows/pullfrog.yml
  • AGENTS.md
  • README.md
  • config/electron-builder.config.cjs
  • config/reliability-gates.jsonc
  • docs/STYLEGUIDE.md
  • docs/reference/git-compatibility.md
  • docs/reference/headless-linux-server.md
  • docs/reference/linux-glibc-compatibility.md
  • docs/reference/windows-setup-shell.md
  • docs/reference/worktree-scan-fingerprint.md
  • skill-guides/orca-emulator-android.md
  • skill-guides/orca-emulator.md
  • skill-guides/orca-per-workspace-env.md
  • src/cli/runtime/launch-exit-diagnostic.test.ts
  • src/cli/runtime/launch-exit-diagnostic.ts
  • src/cli/runtime/launch.test.ts
  • src/cli/runtime/launch.ts
  • src/cli/runtime/runtime-listener-probe.test.ts
  • src/cli/runtime/runtime-listener-probe.ts
  • src/cli/runtime/serve-duplicate-refusal.test.ts
  • src/cli/runtime/serving-profile-owner.test.ts
  • src/cli/runtime/serving-profile-owner.ts
  • src/cli/runtime/status.test.ts
  • tests/e2e/fixtures/terminal-emoji-table.md
  • tests/e2e/ssh-config-host-picker.PLAN.md
  • tests/tools/daemon-relocation-spike/README.md
  • tests/tools/repro-watcher-crash-7547/fixed-child.cjs
  • tests/tools/repro-watcher-crash-7547/run.cjs
  • tests/tools/win-update-e2e/README.md
💤 Files with no reviewable changes (1)
  • .github/CONTRIBUTING.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/cli/runtime/launch-exit-diagnostic.test.ts
  • src/cli/runtime/status.test.ts
  • config/reliability-gates.jsonc
  • src/cli/runtime/launch-exit-diagnostic.ts

Comment thread AGENTS.md

# Style

## Concise/Brief Non-obviosu comments ONLY

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the heading typo.

Line [11] contains Non-obviosu. Change it to Non-obvious.

Comment thread src/cli/runtime/launch.test.ts
Comment thread src/cli/runtime/launch.ts
Comment on lines +118 to +124
const owner = await findServingProfileOwner(
(await getCliStatus(userDataPath)).result,
// Why: re-reading is deliberate. A runtime that removed its metadata in the
// meantime is shutting down and a fresh serve should proceed; one that
// rewrote it is newer, and that is the endpoint worth probing.
tryReadMetadata(userDataPath)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make duplicate detection atomic.

Two concurrent serve commands can both observe no owner before either child publishes metadata. Both commands then spawn Electron. The losing process can still hit the post-NSApplication single-instance path and abort on macOS.

Acquire a per-profile pre-launch lock or reservation before this check. Transfer or release it deterministically during owner startup. Add a concurrent-start test that asserts one launch and one exit code 3.

🧰 Tools
🪛 ast-grep (0.45.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

Comment thread src/cli/runtime/runtime-listener-probe.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/cli/runtime/launch.ts (1)

87-95: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Keep rationale comments to one line.

  • src/cli/runtime/launch.ts#L87-L95: Compress each // Why: rationale into one line.
  • src/cli/runtime/launch.ts#L117-L126: Compress the ownership-refusal rationale into one line.
    As per coding guidelines: “BE CONCISE. 1 LINE if possible.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d86287b-0b2f-4c88-b09d-04e92a6abaef

📥 Commits

Reviewing files that changed from the base of the PR and between 10c7c1c and 10cc936.

📒 Files selected for processing (9)
  • config/scripts/run-macos-launch-abort-oracle.mjs
  • config/scripts/run-macos-launch-abort-oracle.test.mjs
  • src/cli/runtime/launch.test.ts
  • src/cli/runtime/launch.ts
  • src/cli/runtime/runtime-listener-probe.ts
  • src/cli/runtime/serve-duplicate-refusal.test.ts
  • src/cli/runtime/serving-profile-owner.ts
  • src/cli/runtime/status.ts
  • src/shared/single-instance-exit-code.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/shared/single-instance-exit-code.ts
  • src/cli/runtime/status.ts
  • src/cli/runtime/serve-duplicate-refusal.test.ts
  • src/cli/runtime/serving-profile-owner.ts
  • src/cli/runtime/runtime-listener-probe.ts
  • config/scripts/run-macos-launch-abort-oracle.test.mjs
  • src/cli/runtime/launch.test.ts
  • config/scripts/run-macos-launch-abort-oracle.mjs

The listener probe returned a boolean, so every outcome that was not a completed
connect — a timed-out connect, EACCES, any unexpected errno — read as "nobody
owns this profile" and let serve spawn a second GUI Electron main. On a host
where Launch Services is unreachable that is the pre-JS SIGABRT this change
exists to prevent, so the ambiguous case was failing open into the bug.

Measured on macOS: a runtime that stops unlinks its socket (ENOENT, 0.08ms) and
one that is killed leaves a path that refuses (ECONNREFUSED, 0.16ms). Every way
an owner really goes away is definitive and immediate, so those two codes now
free the profile and nothing else does. Same rule `orca status` already applies
to pids, where only ESRCH proves the process is gone.

The refusal names the endpoint-held case separately and points at the one manual
escape hatch it needs, since it is the only refusal a user may have to clear.

Oracle candidate-w7: 6/6 green.
A consumed `once('error')` leaves the socket with no listener, and a second
error event on a destroyed socket then throws uncaught — verified: the process
dies. The probe already ignores anything after it settles, so hold the listener.
…wrote

A `pnpm format` run reflowed three checked-in generator outputs (markdown table
padding and emphasis markers), which the generator then no longer reproduced.
These are build artifacts, not hand-edited files.
…(STA-4336)

orca-runtime.json is parsed with no validation, so the probe fed whatever the
file held straight to createConnection. A missing or blank endpoint throws
synchronously (ERR_MISSING_ARGS / ERR_SOCKET_BAD_PORT) and would fail serve
outright, and a numeric one is read as a TCP port — the probe would dial
127.0.0.1:42 and could be answered by something that is not Orca.

Junk metadata leaves nothing to probe, so it frees the profile rather than
refusing forever; the in-process single-instance lock stays the backstop.

Also covers the ORCA_OPEN_COMMAND launch branch, which the exit watcher already
handled but no test exercised.
@Jinwoo-H

Copy link
Copy Markdown
Contributor Author

CodeRabbit round 2 — responses

1. runtime-listener-probe.ts — "Reject malformed transport endpoints." (Major) — real, fixed in f01e4be9e0.

tryReadMetadata is a bare JSON.parse(...) as RuntimeMetadata with no validation, so whatever the file holds reached createConnection. Measured on Node 24:

endpoint value result
undefined throws ERR_MISSING_ARGS
'' / ' ' throws ERR_SOCKET_BAD_PORT
42 no throw — read as a TCP port, dials 127.0.0.1:42

The synchronous throws happen inside the promise executor, so they rejected the probe and failed orca serve outright. The numeric case is worse than a crash: it would dial loopback and could be accepted by something that is not Orca, producing a false refusal. The probe now requires a non-blank string endpoint and treats anything else as not-listening — junk metadata leaves nothing to probe, and refusing forever on junk is the permanent-refusal failure this design exists to avoid; the in-process single-instance lock stays the backstop. Covered by three new cases, and the guard is mutation-tested (weakening it to endpoint === undefined fails one).

2. launch.test.ts:647 — "Exercise the /usr/bin/open branch." (Minor) — added in f01e4be9e0, as a new test rather than a rewrite.

Changing the existing test's env would have dropped the ORCA_APP_EXECUTABLE coverage it exists for, so the ORCA_OPEN_COMMAND branch got its own case: it asserts the shell: true, detached: true spawn shape and that a nonzero exit still reaches failedExit(). Worth being precise about what this can and cannot prove — /usr/bin/open exits 0 as soon as Launch Services accepts the request, so the watcher catches an open that fails to hand off but not an abort after it returns. That remains a recorded knownGap on the gate.

3. launch.ts:120 — "Make duplicate detection atomic." (Major) — real, and staying a documented gap. Here is why.

Two serve invocations racing on an ownerless profile can still both spawn; the loser is then rejected by the in-process single-instance rule, which on a Launch-Services-broken host is behind the abort. That is genuinely not covered, and it is already recorded as a knownGap on cli-launch.macos-pre-js-abort-refusal.

The suggested fix — a per-profile reservation before the exec — needs a lock that the OS releases when its holder dies. Node has no flock binding, so a pure-JS lock is a file plus a staleness rule, and a staleness rule is exactly the unsound time window this PR deleted in round 2 (see the PR body: metadata.startedAt is written once at boot and never refreshed, so any age-based trust decision misjudges a healthy long-lived serve). Trading a documented race for a stale lock that can refuse serve permanently is a worse failure, and it would reintroduce the class of bug this change is about.

What this PR does cover is the shape that actually crash-loops: a supervisor restarting orca serve against a profile that already has an owner. That is sequential, not racing, and it now exits 3 without spawning. Narrowing the concurrent-start window needs a native lock and belongs in its own change.

4. AGENTS.md:11 typo — out of scope. That line is on main and untouched by this PR; it arrived here through the merge, not an edit.

Oracle re-run after this round: candidate-w9.

A repo-wide `pnpm format` had reflowed 18 files this change never touches.
Restores them to main so the diff is only the CLI launch routing.
…10 (STA-4336)

The fix-disabled arm is rebuilt at the branch's merge base, so the red and
green runs are the same machine and the same harness minutes apart.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: orca serve SIGABRTs in _RegisterApplication before JS (crash loop)

1 participant