mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
* fix(daemon): bound the whole boot-recovery sequence with one budget (STA-5732)
* fix(daemon): keep socket probes inside recovery budget
* fix(daemon): size the recovery budget against the real post-kill tail
The 24s budget reserved only 9s for everything after the deadline, leaving
27s of the startup PTY gate's fail-open cap unused — and every unused second
is one where a daemon that would have drained gets killed with its live PTYs
instead. Reserve each post-deadline stage's actual hard cap (kill 10.5s, fork
10s, lease 5s) and spend the rest: 24s -> 32s of adopt window.
* fix(daemon): keep the last-resort endpoint rescue outside the recovery budget
The rescue probe in the launcher's outer catch was clamped to the recovery
budget's remainder, but it runs *after* that budget by construction — past
prepareDaemonReplacement, killStaleDaemon, the fork and the adoption lease.
The remainder is therefore essentially always negative, so Math.max(1, ...)
handed a live socket a 1ms connect window. On the loaded machine this path
exists for the probe loses to its own timer, the launcher rethrows, and a
recoverable degraded adoption becomes total daemon loss for the whole run —
the outcome the comment above it exists to prevent. Restore the 1s default
and pin the window with a test that drives the launcher to that catch with
the budget already spent.
Also make the deliberate narrowing legible instead of implicit:
- daemon-recovery-budget.ts: TRANSIENT_WEDGE_DRAIN_MS documented 20s as the
grace #8697 sized, but #8697's merged second commit (840d3277d1) widened
it to 11 retries ~= 60s. Record that 20s is the drain estimate and that the
budget deliberately sits under #8697's shipped grace.
- daemon-init-wedged-daemon-grace.test.ts: pin the trade directly — a wedge
draining after the budget is replaced and loses its live sessions.
- Rewrite 'preserves a daemon that stays wedged until the LAST allowed grace
retry' onto the simulated clock. It never mocked Date.now, so its 12 probes
elapsed ~0ms and asserted a retry grace the wall clock can no longer
deliver; it now pins the last drain the budget still adopts.
* fix(daemon): name the socket probe default and correct the grace-retry rationale
Answers the review round on the budget accounting: the outer-catch endpoint
rescue is deliberately outside it, and the preflight clamp no longer duplicates
probeDaemonSocket's default as a bare literal.
112 lines
4.4 KiB
TypeScript
112 lines
4.4 KiB
TypeScript
import { existsSync, mkdirSync } from 'node:fs'
|
|
import { connect } from 'node:net'
|
|
import { join } from 'node:path'
|
|
import { getAppEnvironment } from '../../shared/app-environment'
|
|
import { getDaemonLogFilePath } from '../observability/logs-directory'
|
|
import { DaemonClient } from './client'
|
|
import { daemonRecoveryProbeTimeoutMs } from './daemon-recovery-budget'
|
|
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
|
|
import { PROTOCOL_VERSION, type ListSessionsResult } from './types'
|
|
|
|
export function getDaemonRuntimeDir(): string {
|
|
const dir = join(getAppEnvironment().getPath('userData'), 'daemon')
|
|
mkdirSync(dir, { recursive: true })
|
|
return dir
|
|
}
|
|
|
|
export function getDaemonHistoryDir(): string {
|
|
const dir = join(getAppEnvironment().getPath('userData'), 'terminal-history')
|
|
mkdirSync(dir, { recursive: true })
|
|
return dir
|
|
}
|
|
|
|
export function getDaemonEntryPath(): string {
|
|
const appPath = getAppEnvironment().getAppPath()
|
|
// Why: packaged getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable.
|
|
// Why asar and not isPackaged: orcad is a packaged non-Electron host whose bundle root holds
|
|
// orcad.js and daemon-entry.js side by side with no asar to redirect (see parcel-watcher-entry-path.ts).
|
|
const basePath = appPath.includes('app.asar')
|
|
? appPath.replace('app.asar', 'app.asar.unpacked')
|
|
: appPath
|
|
const directEntryPath = join(basePath, 'daemon-entry.js')
|
|
return existsSync(directEntryPath)
|
|
? directEntryPath
|
|
: join(basePath, 'out', 'main', 'daemon-entry.js')
|
|
}
|
|
|
|
// macOS TCC attribution pins the daemon to a packaged app bundle; there is none on a Node host.
|
|
export function resolvePackagedDarwinAppVersion(): string | null {
|
|
const environment = getAppEnvironment()
|
|
return process.platform === 'darwin' && environment.isPackaged() ? environment.getVersion() : null
|
|
}
|
|
|
|
// Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch.
|
|
export function daemonLogArgs(): string[] {
|
|
const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase()
|
|
return disabled === '1' || disabled === 'true' ? [] : ['--log-file', getDaemonLogFilePath()]
|
|
}
|
|
|
|
/** Named so a caller clamping this probe to a deadline cannot silently decouple from its default. */
|
|
export const DAEMON_SOCKET_PROBE_TIMEOUT_MS = 1_000
|
|
|
|
// Why: a socket that accepts a connection proves a daemon survived a previous app session and can be reused.
|
|
export function probeDaemonSocket(
|
|
socketPath: string,
|
|
timeoutMs = DAEMON_SOCKET_PROBE_TIMEOUT_MS
|
|
): Promise<boolean> {
|
|
const { promise, resolve } = Promise.withResolvers<boolean>()
|
|
if (process.platform !== 'win32' && !existsSync(socketPath)) {
|
|
resolve(false)
|
|
return promise
|
|
}
|
|
const socket = connect({ path: socketPath })
|
|
let settled = false
|
|
let timer: ReturnType<typeof setTimeout>
|
|
const finish = (alive: boolean, destroy = false): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(timer)
|
|
socket.removeListener('connect', onConnect)
|
|
socket.removeListener('error', onError)
|
|
if (destroy) {
|
|
socket.destroy()
|
|
}
|
|
resolve(alive)
|
|
}
|
|
const onConnect = (): void => finish(true, true)
|
|
const onError = (): void => finish(false)
|
|
timer = setTimeout(() => finish(false, true), timeoutMs)
|
|
socket.on('connect', onConnect)
|
|
socket.on('error', onError)
|
|
return promise
|
|
}
|
|
|
|
// Why recoveryDeadlineMs is required: this probe only ever runs on a startup path that has a
|
|
// budget, and the client's own defaults are far larger than any of them.
|
|
export async function getAliveDaemonSessionCount(
|
|
socketPath: string,
|
|
tokenPath: string,
|
|
recoveryDeadlineMs: number,
|
|
protocolVersion = PROTOCOL_VERSION
|
|
): Promise<number | null> {
|
|
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
|
|
// Why one slice for both: a wedged handshake must not leave the request its own fresh 30s.
|
|
const probeTimeoutMs = daemonRecoveryProbeTimeoutMs(recoveryDeadlineMs)
|
|
const probeDeadlineMs = Date.now() + probeTimeoutMs
|
|
try {
|
|
await client.ensureConnectedWithin(probeTimeoutMs)
|
|
const result = await client.request<ListSessionsResult>(
|
|
'listSessions',
|
|
undefined,
|
|
remainingDaemonRequestTimeoutMs(probeDeadlineMs)
|
|
)
|
|
return result.sessions.filter((session) => session.isAlive).length
|
|
} catch {
|
|
return null
|
|
} finally {
|
|
client.disconnect()
|
|
}
|
|
}
|