mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
* fix(terminal): fence daemon endpoint ownership * fix(terminal): clean failed daemon PID claims * fix(terminal): close daemon ownership review gaps * test(daemon): release startup IPC in boot smoke * test(daemon): mirror production stdio in boot smoke * fix(daemon): exit after rpc shutdown cleanup * fix(terminal): make the socket name the daemon endpoint authority The reported failure was a live daemon hosting PTYs that nothing could reach: terminals acknowledged input and never ran it, listings diverged from reality, and restarting the app never helped because the detached helper survived. The ownership fence added for it could not fire in the sequence that produces the split brain. libuv unlinks the pathname a server bound to when that server closes, with no ownership check. A daemon that lost its endpoint name therefore deleted whichever socket then sat at that path — including a live replacement's — stranding a daemon that still hosted every session. Bind a private same-directory name and hard-link it into place instead: libuv can only ever unlink our own bind name, the exclusive link is a kernel-enforced endpoint claim, and the canonical name is removed only under an inode ownership check. The bind name replaces the basename rather than extending it, so it cannot overflow sun_path. killStaleDaemon removed the PID record unconditionally immediately before every fork, so the exclusive PID claim was always uncontested at bind time. It also unlinked a live daemon's endpoint whenever a connect probe merely timed out, and treated a `ps` timeout as proof of PID recycling. Now only positive evidence of a dead endpoint authorizes reclaiming it, SIGKILL is confirmed rather than assumed, and a daemon that cannot be proven stopped keeps its record and endpoint while the launcher refuses to fork beside it. A daemon whose endpoint was taken over now retires itself, draining rather than killing, so an unreachable orphan stops being permanent. A repaired PID record re-derives entryPath, appVersion and the Linux incarnation markers from the authenticated owner instead of dropping them; without appVersion a healthy daemon read as a permanently stale bundle and, on Windows, went unpinned against daemon-host pruning. Repair failure now fails open — abandoning a healthy daemon over a pid file write cost every persistent terminal on the machine. Also: treat only ENOENT as an unclaimed record so a Windows file lock is not reported as an ownership conflict; settle start() before close() so an accepted connection cannot defer it forever; sweep abandoned claim and bind names; and type the endpoint-identity seam so a rename cannot silently disable the fence. Adds a real-process handover smoke that reproduces the failure with two daemons racing one endpoint, and wires it into the native-smoke job. * fix(daemon): retire only on proven endpoint ownership loss The ownership watchdog read a null identity for any stat failure, so a transient EACCES or EIO on the runtime directory would retire a daemon that was still serving every terminal on the machine. Distinguish "the entry is gone" from "the probe failed" and act only on the former. Also require the loss to persist across two polls: a replacement publishes by unlink-then-link, and a single observation can land in that gap. * fix(daemon): source repaired ownership metadata from the authenticated hello Adversarial review found three defects in the previous two commits. Re-deriving entryPath from the owner's command line truncated it at the first space. A command line is a single space-joined string, so `C:\Program Files\Orca\...` and `/Applications/Orca 2.app/...` came back as `"C:\Program` and `/Applications/Orca`. getDaemonLaunchIdentity treats a present entryPath as authoritative, so a healthy daemon read as `different_app_path` and was killed and re-forked — worse than the missing-metadata case the derivation was added to fix. Carry entryPath and appVersion as optional fields on the daemon hello identity instead: the daemon already has both from its own argv, and per docs/reference/remote-wire-compatibility.md a new optional field is safe because every reader falls back when it is absent. This also removes a synchronous `ps` spawn from the Electron main thread during startup. `start()` rolled back the PID record even when it never published one. Losing the endpoint link now runs that path, and the ownership-checked unlink briefly renames the incumbent's record aside — enough to strand a live daemon's ownership. Roll back only what we actually wrote. publishDaemonSocketPath read its identity from the canonical name after linking, so a concurrent unlink returned null: no ownership watchdog and no endpoint cleanup on any shutdown path. Read it from the bound name before linking, which shares the inode. Refusing to fork beside an unconfirmed daemon left the user with no daemon at all and no in-app recovery, since restart re-entered the same fence. We have just proved something answers the endpoint, so adopt it in degraded mode: live sessions keep working, fresh terminals run locally. SIGTERM is also individually guarded now — an EPERM fell into the blanket catch and reported "nothing alive", authorizing the very duplicate this fence exists to prevent. Also reset the ownership-loss streak on an inconclusive probe so the confirmations are consecutive, and sweep scratch names before the launch so a failed launch still reclaims them.
165 lines
5.4 KiB
JavaScript
165 lines
5.4 KiB
JavaScript
/**
|
|
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
|
|
*
|
|
* The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still
|
|
* alive used to delete the *replacement's* socket when it finally exited, because libuv
|
|
* unlinks the pathname a server bound to with no ownership check. The replacement stayed
|
|
* alive hosting PTYs that nothing could reach — terminals that acknowledge input and never
|
|
* run it, and that a user cannot fix by restarting the app.
|
|
*
|
|
* Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur.
|
|
*
|
|
* Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs
|
|
*/
|
|
import { fork } from 'node:child_process'
|
|
import { connect } from 'node:net'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
|
|
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
|
const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js')
|
|
const READY_TIMEOUT_MS = 20_000
|
|
const EXIT_TIMEOUT_MS = 15_000
|
|
|
|
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
|
|
|
|
function bootDaemon(tag, dir, socketPath) {
|
|
const tokenPath = join(dir, `${tag}.token`)
|
|
const pidPath = join(dir, `${tag}.pid`)
|
|
const child = fork(
|
|
entryPath,
|
|
[
|
|
'--socket',
|
|
socketPath,
|
|
'--token',
|
|
tokenPath,
|
|
'--pid-record',
|
|
pidPath,
|
|
'--launch-nonce',
|
|
randomUUID(),
|
|
'--entry-path',
|
|
entryPath,
|
|
'--app-version',
|
|
'endpoint-handover-smoke'
|
|
],
|
|
{
|
|
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
env: { ...process.env, ORCA_USER_DATA_PATH: dir }
|
|
}
|
|
)
|
|
let stderr = ''
|
|
child.stderr?.on('data', (chunk) => {
|
|
stderr += chunk.toString('utf8')
|
|
})
|
|
return new Promise((resolveReady, rejectReady) => {
|
|
const timer = setTimeout(
|
|
() => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)),
|
|
READY_TIMEOUT_MS
|
|
)
|
|
child.on('message', (msg) => {
|
|
if (msg && typeof msg === 'object' && msg.type === 'ready') {
|
|
clearTimeout(timer)
|
|
resolveReady({ child, tokenPath, pidPath })
|
|
}
|
|
})
|
|
child.on('exit', (code) => {
|
|
clearTimeout(timer)
|
|
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
function isReachable(socketPath) {
|
|
return new Promise((resolveReachable) => {
|
|
const socket = connect({ path: socketPath })
|
|
socket.on('connect', () => {
|
|
socket.destroy()
|
|
resolveReachable(true)
|
|
})
|
|
socket.on('error', () => resolveReachable(false))
|
|
})
|
|
}
|
|
|
|
function killAndWait(child) {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
return Promise.resolve()
|
|
}
|
|
return new Promise((resolveExit, rejectExit) => {
|
|
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
|
|
child.on('exit', () => {
|
|
clearTimeout(timer)
|
|
resolveExit()
|
|
})
|
|
child.kill('SIGTERM')
|
|
})
|
|
}
|
|
|
|
async function main() {
|
|
if (process.platform === 'win32') {
|
|
log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur')
|
|
return
|
|
}
|
|
if (!existsSync(entryPath)) {
|
|
throw new Error(`missing ${entryPath} — run \`pnpm build\` first`)
|
|
}
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-'))
|
|
const socketPath = join(dir, 'daemon.sock')
|
|
let replaced
|
|
let replacement
|
|
try {
|
|
replaced = await bootDaemon('replaced', dir, socketPath)
|
|
const replacedInode = statSync(socketPath).ino
|
|
log('daemon A published the endpoint')
|
|
|
|
// Reclaim the endpoint name the way daemon replacement does, while A is still alive.
|
|
unlinkSync(socketPath)
|
|
replacement = await bootDaemon('replacement', dir, socketPath)
|
|
const replacementInode = statSync(socketPath).ino
|
|
if (replacedInode === replacementInode) {
|
|
throw new Error('daemon B did not publish a distinct endpoint')
|
|
}
|
|
if (!(await isReachable(socketPath))) {
|
|
throw new Error('daemon B is not reachable through the canonical endpoint')
|
|
}
|
|
log('daemon B took over the endpoint and is reachable')
|
|
|
|
// A exits long after losing the endpoint. This is the step that used to break B.
|
|
await killAndWait(replaced.child)
|
|
await new Promise((r) => setTimeout(r, 300))
|
|
|
|
if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) {
|
|
throw new Error("daemon A's late exit deleted daemon B's endpoint")
|
|
}
|
|
if (!(await isReachable(socketPath))) {
|
|
throw new Error('daemon B became unreachable after daemon A exited')
|
|
}
|
|
if (!existsSync(replacement.pidPath)) {
|
|
throw new Error("daemon A's late exit removed daemon B's ownership record")
|
|
}
|
|
if (existsSync(replaced.pidPath)) {
|
|
throw new Error('daemon A left its own ownership record behind')
|
|
}
|
|
|
|
log('PASS: the endpoint owner and the session host stayed the same daemon')
|
|
} finally {
|
|
for (const daemon of [replaced, replacement]) {
|
|
if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) {
|
|
try {
|
|
daemon.child.kill('SIGKILL')
|
|
} catch {
|
|
// already gone
|
|
}
|
|
}
|
|
}
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`)
|
|
process.exit(1)
|
|
})
|