fix(server): gracefully stop verifier daemon

This commit is contained in:
Jinwoo-H
2026-08-09 23:49:31 -04:00
parent e857fb4570
commit 68b49fb4f4
3 changed files with 112 additions and 38 deletions
+3 -38
View File
@@ -1,10 +1,11 @@
import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { decodePairingOffer, type PairingOffer } from '../../src/shared/pairing'
import { sendRemoteRuntimeRequest } from '../../src/shared/remote-runtime-client'
import type { RuntimeStatus, RuntimeTerminalRead } from '../../src/shared/runtime-types'
import { stopNodeServerVerifierDaemons } from './node-server-verifier-daemon-cleanup'
type ReadyPayload = {
type: 'orca_server_ready'
@@ -70,7 +71,7 @@ async function main(): Promise<void> {
if (activeServer?.child.exitCode === null) {
await stopServer(activeServer).catch(() => activeServer?.child.kill('SIGKILL'))
}
await stopOwnedDaemon()
await stopNodeServerVerifierDaemons(dataPath)
rmSync(ownedRoot, { force: true, maxRetries: 20, recursive: true, retryDelay: 100 })
}
}
@@ -277,42 +278,6 @@ async function call<TResult>(
return response.result
}
async function stopOwnedDaemon(): Promise<void> {
const daemonPath = join(dataPath, 'daemon')
if (!existsSync(daemonPath)) {
return
}
const stoppedPids: number[] = []
for (const entry of readdirSync(daemonPath)) {
if (!/^daemon-v\d+\.pid$/.test(entry)) {
continue
}
try {
const record = JSON.parse(readFileSync(join(daemonPath, entry), 'utf8')) as { pid?: unknown }
if (typeof record.pid === 'number' && Number.isInteger(record.pid)) {
process.kill(record.pid, 'SIGTERM')
stoppedPids.push(record.pid)
}
} catch {
// The isolated verifier directory can contain a daemon that already retired.
}
}
await Promise.all(stoppedPids.map(waitForProcessExit))
}
async function waitForProcessExit(pid: number): Promise<void> {
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
try {
process.kill(pid, 0)
} catch {
return
}
await new Promise((resolveWait) => setTimeout(resolveWait, 100))
}
throw new Error(`terminal daemon ${pid} did not exit after SIGTERM`)
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message)
@@ -0,0 +1,70 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { DaemonClient } from '../../src/main/daemon/client'
import { getDaemonSocketPath, getDaemonTokenPath } from '../../src/main/daemon/daemon-spawner'
export async function stopNodeServerVerifierDaemons(dataPath: string): Promise<void> {
const daemonPath = join(dataPath, 'daemon')
if (!existsSync(daemonPath)) {
return
}
const shutdowns: Promise<void>[] = []
for (const entry of readdirSync(daemonPath)) {
const match = /^daemon-v(\d+)\.pid$/.exec(entry)
if (!match) {
continue
}
try {
const record = JSON.parse(readFileSync(join(daemonPath, entry), 'utf8')) as { pid?: unknown }
if (typeof record.pid === 'number' && Number.isInteger(record.pid)) {
shutdowns.push(shutdownDaemon(daemonPath, Number(match[1]), record.pid))
}
} catch {
// The isolated verifier directory can contain a daemon that already retired.
}
}
await Promise.all(shutdowns)
}
async function shutdownDaemon(
daemonPath: string,
protocolVersion: number,
pid: number
): Promise<void> {
const client = new DaemonClient({
socketPath: getDaemonSocketPath(daemonPath, protocolVersion),
tokenPath: getDaemonTokenPath(daemonPath, protocolVersion),
protocolVersion
})
let requestError: unknown
try {
await client.ensureConnectedWithin(5_000)
await client.request('shutdown', { killSessions: true }, 10_000)
} catch (error) {
requestError = error
} finally {
client.disconnect()
}
try {
await waitForProcessExit(pid)
} catch (error) {
if (requestError) {
throw new AggregateError([requestError, error], `terminal daemon ${pid} cleanup failed`)
}
throw error
}
}
async function waitForProcessExit(pid: number): Promise<void> {
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
try {
process.kill(pid, 0)
} catch {
return
}
await new Promise((resolveWait) => setTimeout(resolveWait, 100))
}
throw new Error(`terminal daemon ${pid} did not exit after the shutdown request`)
}
+39
View File
@@ -34,6 +34,7 @@ agents, and orchestration. The missing boundary is the process environment aroun
- Keep one implementation of workspace, terminal, Git, file, agent, and orchestration behavior.
- Make zero-argument startup useful over SSH without exposing a public interface by accident.
- Keep desktop `orca serve` working during migration.
- Let the npm host and desktop app coexist safely as distinct hosts.
- Support folder workspaces and Git worktrees.
- Preserve macOS, Linux, Windows, SSH, WSL, Git 2.25, and glibc 2.31 compatibility.
@@ -45,6 +46,8 @@ agents, and orchestration. The missing boundary is the process environment aroun
- Making a client-local fallback browser part of the remote host or giving it remote ownership.
- Automatically configuring a VPN, firewall, reverse proxy, or public network listener.
- Shipping an Electron binary inside an npm wrapper.
- Letting two processes write one profile or automatically handing an npm host to the desktop app
in the initial release.
## User experience
@@ -169,6 +172,31 @@ device identity.
Every listener, socket, timer, and signal handler installed by the Node composition root has a
matching cleanup path. Startup failure runs the same cleanup before exiting nonzero.
## Desktop coexistence and handoff
The initial npm host and desktop app use separate default data directories. They can run on the
same machine without sharing writable state, but they appear as separate Orca hosts and opening
the desktop app does not take ownership of the npm host.
The intended follow-up is a coordinated handoff, not an attempt to turn the running Node process
into Electron. Standalone-backend architectures keep the host process independent and let desktop
clients attach to or supervise it. Orca can move toward that model while keeping browser panes
desktop-local.
A safe handoff requires:
1. one cross-process profile lease with an owner identity and generation;
2. an authenticated local request from the desktop app to the npm host;
3. a host checkpoint followed by listener shutdown and explicit lease release;
4. desktop acquisition of the next generation before it opens the profile;
5. reuse of the device registry, E2EE keypair, runtime metadata, and durable terminal daemon;
6. client reconnect without re-pairing and terminal input/output after daemon adoption;
7. bounded rollback to the npm host if desktop startup fails before readiness.
No implementation may copy a live profile, infer ownership from a PID file alone, or let both
processes publish runtime metadata. A desktop-only browser backend must remain a capability of the
desktop owner rather than silently executing remote browser work on a viewing client.
## Packaging gates
The publishable tarball must:
@@ -255,6 +283,17 @@ the terminal marker.
- [x] Link the shorter npm-server documentation from the existing Linux guide.
- [x] Capture visual proof for the UI change.
### Desktop coexistence and handoff
- [x] Use separate npm and desktop profiles so both installations can run safely today.
- [x] Document the ownership, identity, daemon, and rollback contract for a future handoff.
- [ ] Add a cross-process profile lease with generation fencing.
- [ ] Let desktop discover and authenticate to the local npm host.
- [ ] Checkpoint and transfer ownership without two live profile writers.
- [ ] Preserve pairing identity and daemon-backed terminal continuity across the handoff.
- [ ] Add failure-before-yield, failure-after-yield, mixed-version, and rollback oracles.
- [ ] Add packaged macOS, Linux, and Windows desktop-handoff journeys.
### Tests
- [x] Unit-test CLI parsing, address discovery, paths, readiness, and shutdown.