mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(server): gracefully stop verifier daemon
This commit is contained in:
@@ -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`)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user