mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +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.
289 lines
9.5 KiB
JavaScript
289 lines
9.5 KiB
JavaScript
/**
|
|
* Boots the BUILT terminal daemon (out/main/daemon-entry.js) under plain Node —
|
|
* the exact way production forks it (ELECTRON_RUN_AS_NODE = a plain-Node
|
|
* process) — and asserts it starts, serves a real PTY, and stops.
|
|
*
|
|
* Why this exists: native-smoke CI (and packaging) went green while
|
|
* v1.4.129-rc.1 shipped a daemon that exited code 1 at module load because an
|
|
* electron `require` leaked into its bundle graph. Nothing executed the built
|
|
* entry under plain Node, so the outage was invisible until an adopted old
|
|
* daemon died in the field. This runs on every PR that touches the daemon.
|
|
*
|
|
* Hard assertions (fail the job):
|
|
* - the daemon signals `{ type: 'ready' }` over IPC within the timeout, and
|
|
* - it terminates when asked (no hang / zombie).
|
|
* Best-effort (logged skip, never fails): an end-to-end `ptySpawnHealth` RPC,
|
|
* because node-pty spawn can be flaky on constrained CI runners.
|
|
*/
|
|
import { fork } from 'node:child_process'
|
|
import { connect } from 'node:net'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
|
|
const projectDir = resolve(import.meta.dirname, '../..')
|
|
const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js')
|
|
|
|
const READY_TIMEOUT_MS = 30_000
|
|
const PTY_HEALTH_TIMEOUT_MS = 10_000
|
|
const SHUTDOWN_TIMEOUT_MS = 10_000
|
|
|
|
function log(message) {
|
|
process.stdout.write(`[daemon-boot-smoke] ${message}\n`)
|
|
}
|
|
|
|
// Why: the daemon rejects a hello whose protocol version differs, so read the
|
|
// current version from source rather than hardcoding a number that can drift.
|
|
function readProtocolVersion() {
|
|
const protocolSourcePath = 'src/main/daemon/daemon-protocol-version.ts'
|
|
const source = readFileSync(join(projectDir, protocolSourcePath), 'utf8')
|
|
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
|
|
if (!match) {
|
|
throw new Error(`could not read PROTOCOL_VERSION from ${protocolSourcePath}`)
|
|
}
|
|
return Number(match[1])
|
|
}
|
|
|
|
function makeSocketPath(userDataDir) {
|
|
// Why: Windows AF_UNIX-style IPC uses named pipes; POSIX uses a filesystem
|
|
// socket kept under the scratch userData dir so cleanup removes it.
|
|
if (process.platform === 'win32') {
|
|
return `\\\\.\\pipe\\orca-daemon-smoke-${process.pid}-${randomUUID()}`
|
|
}
|
|
return join(userDataDir, 'daemon.sock')
|
|
}
|
|
|
|
function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) {
|
|
return new Promise((resolveRpc, rejectRpc) => {
|
|
let settled = false
|
|
let buffer = ''
|
|
const socket = connect(socketPath)
|
|
const finish = (error, response) => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(timer)
|
|
socket.destroy()
|
|
if (error) {
|
|
rejectRpc(error)
|
|
} else {
|
|
resolveRpc(response)
|
|
}
|
|
}
|
|
const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs)
|
|
|
|
socket.on('error', (error) => finish(error))
|
|
socket.on('connect', () => {
|
|
const token = readFileSync(tokenPath, 'utf8').trim()
|
|
socket.write(
|
|
`${JSON.stringify({
|
|
type: 'hello',
|
|
version: protocolVersion,
|
|
token,
|
|
clientId: randomUUID(),
|
|
role: 'control'
|
|
})}\n`
|
|
)
|
|
})
|
|
socket.on('data', (chunk) => {
|
|
buffer += chunk.toString('utf8')
|
|
let newlineIdx = buffer.indexOf('\n')
|
|
while (newlineIdx !== -1) {
|
|
const line = buffer.slice(0, newlineIdx)
|
|
buffer = buffer.slice(newlineIdx + 1)
|
|
let msg
|
|
try {
|
|
msg = JSON.parse(line)
|
|
} catch {
|
|
finish(new Error('invalid response line'))
|
|
return
|
|
}
|
|
if (msg.type === 'hello') {
|
|
if (!msg.ok) {
|
|
finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`))
|
|
return
|
|
}
|
|
socket.write(`${JSON.stringify(request)}\n`)
|
|
} else if (msg.id === request.id) {
|
|
finish(
|
|
msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`),
|
|
msg
|
|
)
|
|
return
|
|
}
|
|
newlineIdx = buffer.indexOf('\n')
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// Best-effort: constrained CI runners can make node-pty spawn flaky.
|
|
async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
|
try {
|
|
await runDaemonRpc(
|
|
socketPath,
|
|
tokenPath,
|
|
protocolVersion,
|
|
{ id: 'health-1', type: 'ptySpawnHealth' },
|
|
PTY_HEALTH_TIMEOUT_MS
|
|
)
|
|
return true
|
|
} catch (error) {
|
|
log(`PTY spawn health check skipped (best-effort): ${error.message}`)
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
|
|
const socketPath = makeSocketPath(userDataDir)
|
|
const tokenPath = join(userDataDir, 'daemon.token')
|
|
const pidPath = join(userDataDir, 'daemon.pid')
|
|
const launchNonce = randomUUID()
|
|
const protocolVersion = readProtocolVersion()
|
|
|
|
log(`forking ${entryPath} under plain Node (${process.execPath})`)
|
|
const child = fork(
|
|
entryPath,
|
|
[
|
|
'--socket',
|
|
socketPath,
|
|
'--token',
|
|
tokenPath,
|
|
'--pid-record',
|
|
pidPath,
|
|
'--launch-nonce',
|
|
launchNonce,
|
|
'--entry-path',
|
|
entryPath,
|
|
'--app-version',
|
|
'daemon-boot-smoke'
|
|
],
|
|
{
|
|
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
|
|
// CI, and this is exactly the runtime where a leaked `require("electron")`
|
|
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
|
|
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
|
|
}
|
|
)
|
|
|
|
let stderr = ''
|
|
child.stderr?.on('data', (chunk) => {
|
|
stderr += chunk.toString('utf8')
|
|
})
|
|
|
|
const cleanup = () => {
|
|
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
|
try {
|
|
child.kill('SIGKILL')
|
|
} catch {
|
|
// already gone
|
|
}
|
|
}
|
|
rmSync(userDataDir, { recursive: true, force: true })
|
|
}
|
|
|
|
try {
|
|
await new Promise((resolveReady, rejectReady) => {
|
|
const timer = setTimeout(() => {
|
|
rejectReady(
|
|
new Error(
|
|
`daemon did not signal 'ready' within ${READY_TIMEOUT_MS}ms.\nstderr:\n${stderr}`
|
|
)
|
|
)
|
|
}, READY_TIMEOUT_MS)
|
|
child.on('message', (msg) => {
|
|
if (msg && typeof msg === 'object' && msg.type === 'ready') {
|
|
clearTimeout(timer)
|
|
resolveReady()
|
|
}
|
|
})
|
|
child.on('error', (err) => {
|
|
clearTimeout(timer)
|
|
rejectReady(new Error(`daemon fork errored: ${err.message}\nstderr:\n${stderr}`))
|
|
})
|
|
child.on('exit', (code, signal) => {
|
|
clearTimeout(timer)
|
|
rejectReady(
|
|
new Error(
|
|
`daemon exited before 'ready' (code=${code}, signal=${signal}).\nstderr:\n${stderr}`
|
|
)
|
|
)
|
|
})
|
|
})
|
|
log('daemon signaled ready')
|
|
const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8'))
|
|
if (
|
|
pidRecord.pid !== child.pid ||
|
|
pidRecord.launchNonce !== launchNonce ||
|
|
pidRecord.entryPath !== entryPath ||
|
|
pidRecord.appVersion !== 'daemon-boot-smoke'
|
|
) {
|
|
throw new Error('daemon readiness did not publish the expected PID ownership record')
|
|
}
|
|
log('PID ownership record matches the ready daemon')
|
|
if (!existsSync(socketPath)) {
|
|
throw new Error('daemon did not publish its endpoint at the canonical socket path')
|
|
}
|
|
log('endpoint published at the canonical socket path')
|
|
// Production releases startup-only handles after ready; they can pin the child on Windows.
|
|
// Diagnostics past this point therefore carry the tail captured up to readiness only.
|
|
child.stderr?.destroy()
|
|
stderr += '[boot-smoke] stderr released at readiness, mirroring production\n'
|
|
child.disconnect()
|
|
|
|
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
|
|
if (ptyHealthy) {
|
|
log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end')
|
|
}
|
|
|
|
await new Promise((resolveExit, rejectExit) => {
|
|
const timer = setTimeout(() => {
|
|
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`))
|
|
}, SHUTDOWN_TIMEOUT_MS)
|
|
child.on('exit', (code, signal) => {
|
|
clearTimeout(timer)
|
|
log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`)
|
|
resolveExit()
|
|
})
|
|
void runDaemonRpc(
|
|
socketPath,
|
|
tokenPath,
|
|
protocolVersion,
|
|
{
|
|
id: 'shutdown-1',
|
|
type: 'shutdown',
|
|
payload: { killSessions: false }
|
|
},
|
|
SHUTDOWN_TIMEOUT_MS
|
|
).catch((error) => {
|
|
clearTimeout(timer)
|
|
rejectExit(error)
|
|
})
|
|
})
|
|
if (existsSync(pidPath)) {
|
|
throw new Error('daemon left its PID ownership record behind after shutdown')
|
|
}
|
|
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
|
throw new Error('daemon left its endpoint socket behind after shutdown')
|
|
}
|
|
// The private bind name is consumed by the publish; nothing may linger in the runtime dir.
|
|
const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b'))
|
|
if (leaked.length > 0) {
|
|
throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`)
|
|
}
|
|
|
|
log('PASS: daemon booted, served, and shut down under plain Node')
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`[daemon-boot-smoke] FAIL: ${error.message}\n`)
|
|
process.exitCode = 1
|
|
})
|