fix(serve): restart the macOS serve supervisor from the updated app (#31200)

* refactor(serve): move the update handoff file reads and writes into their own module

Move-only: the supervisor module is at its line budget, and the handoff file
IO is a separate concern from the child supervision loop.

Claude-Session: serve-supervisor-reexec

* fix(serve): restart the macOS serve supervisor from the updated app

After a remote update on macOS, the node-mode supervisor kept running the
old Orca executable, which the installer moved aside and deleted. Under a
LaunchAgent the system charges every descendant's privacy permissions
(Accessibility, Screen Recording, files) to that supervisor, so once its
path no longer resolved, screen capture and computer use failed for the
whole served tree while the permission check still reported them granted.

Once the new bundle is verified on disk and no stop is pending, the
supervisor now re-runs itself in place (same pid) from the installed
executable instead of spawning the replacement itself. The handoff stays
install-requested, so startup's existing resume path spawns, verifies and
completes it. A --supervise supervisor that runs from its copy outside the
app restarts through the app's own CLI entry, so it copies the new release
aside again rather than mixing old scripts with the new executable. If the
re-run fails it logs and starts the replacement as before. Only applies
when serving our own executable on macOS; Linux, Windows, orcad and
overridden executables are unchanged.

Claude-Session: serve-supervisor-reexec

* fix(serve): restart a supervisor whose executable the install replaced

Three review fixes on top of the update restart:

- Decide the restart by file identity instead of which path observed the
  install. The supervisor records the device and inode of its executable
  at start; after any install wait (including resuming an interrupted
  update at startup) it restarts only if the installed executable is a
  different file. A supervisor that started on the old app before the swap
  now restarts too, and the process after a restart never restarts again.
- A relocated --supervise supervisor releases its marker on the copy it is
  leaving, so the next relocation can delete that copy. Before, every
  update kept one more full copy of the app's scripts for the life of the
  service.
- A failed process.execve aborts the process natively rather than
  throwing, so the fallback never ran. The restart now checks first that
  the executable can run and the script entry exists, and otherwise logs
  and starts the replacement directly as before.

Claude-Session: serve-supervisor-reexec

* fix(serve): restart a parked serve service whose app a desktop install replaced

When the desktop app's Update & Restart parks an `orca serve --supervise`
service for another profile, it writes no update handoff for that profile.
After the install the service's outer loop just starts its next server, so
neither update restart point ran and the supervisor kept the deleted
executable.

Every server start now runs the same identity check right after the
park/stop gate and before spawning, so a supervisor whose executable was
replaced restarts in place first. The check is a no-op when the executable
is unchanged, so first starts and the process after a restart do not loop.

The test drives the real park and commit functions, so the CLI typecheck
project now includes that updater module (it imports only shared code).

Claude-Session: serve-supervisor-reexec

* refactor(serve): check for a replaced app in the one place every server start passes

The restart check ran at three call sites and was threaded through the update
loop as a hook, and each review round found another start path it missed. Every
path (first start, resumed update, after an install, after a park or crash)
spawns its server through the function spawnElectronServe builds, after its stop
and park gates, so the check now lives in that spawn and nothing else carries it.

The restart moves to its own module, the hook and the handoff-file move it
forced are gone, and the tests drive serveOrcaApp instead of the hook.

Claude-Session: serve-supervisor-reexec
This commit is contained in:
Jinwoo Hong
2026-10-20 22:01:54 -04:00
committed by GitHub
parent 60b8eb4a6f
commit d7ef7741d1
5 changed files with 475 additions and 9 deletions
+15 -1
View File
@@ -42,6 +42,10 @@ import {
relocateThisServeSupervisor,
SERVE_SUPERVISOR_APP_ROOT_ENV
} from './serve-supervisor-relocation'
import {
noteServeSupervisorStart,
restartThisServeSupervisorIfReplaced
} from './serve-replaced-app-restart'
import { serverOnlyServeMessage } from './serve-app-launcher'
import { isSupervisedSpawnHeld } from './serve-supervised-stop'
import { openServeBundlePark } from '../../shared/serve-bundle-park'
@@ -131,6 +135,7 @@ function spawnDetached(command: string, args: string[], options: SpawnOptions):
export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise<number> {
// Why first: as a container or PrivatePIDs init, nothing would reap orphaned descendants.
reexecThisServeUnderInitReaper()
noteServeSupervisorStart()
const executable = resolveForegroundOrcaExecutable()
// Why one value: the selector, orcad and Electron must all serve the profile the caller chose.
const userDataPath = resolveLaunchUserDataPath()
@@ -255,9 +260,18 @@ function spawnElectronServe(
: 'inherit',
env: childEnv
}
const spawnServeChild: ServeSpawnChild = shouldRedactSupervisedStdout(args.supervise === true)
const spawnServer: ServeSpawnChild = shouldRedactSupervisedStdout(args.supervise === true)
? redactingSpawn(spawnProcess)
: spawnProcess
// Why in the spawn: every server start (first, resumed, after an install or a park) passes here,
// after its stop and park gates. Only our own executable: an update never deletes an override.
const spawnServeChild: ServeSpawnChild =
handoffPath && executable === process.execPath
? (childSpec) => {
restartThisServeSupervisorIfReplaced(resolveAppRoot())
return spawnServer(childSpec)
}
: spawnServer
const interruptedHandoff = handoffPath ? readServeUpdateHandoffSync(handoffPath) : null
if (interruptedHandoff?.phase === 'install-requested') {
// Why: the node-mode CLI is not an NSRunningApplication, so it can retain launchd ownership while ShipIt swaps the app.