Files
orca/src/main/daemon/daemon-entry.ts
T
Brennan Benson eb9e2a8908 fix(terminal): harden macOS login-preflight spawn path (#9301 follow-ups)
Follow-ups to the #9301 two-pass review. Re-derived severities: none are
release blockers; these are low-likelihood robustness/observability fixes.

F1 macos-tcc-login-shell: only cache a conclusive PAM verdict. A probe our
own SIGKILL timeout / ETIMEDOUT / maxBuffer killed proves nothing about PAM,
so it no longer sticks the whole process into direct-spawn — it fails open
for that spawn, re-probes next time, and self-heals once PAM answers.

F2 daemon-entry: prepareMacosTccLoginShell returns the outcome; the daemon
emits a structured macos-login-preflight {ok, reason} log on degrade, since
detached daemons destroy stderr and lose the console.warn.

F3 local-pty-provider: an atomic guard before the tracking write coalesces a
concurrent same-session-id spawn onto the winner instead of orphaning an
untracked, unkillable PTY.

F4 daemon-server: preparations carry their clientId; a control-socket close
cancels only that client's pending preps, so a mid-preflight disconnect no
longer leaves a durable orphan daemon PTY.

F7 warm the PAM probe at daemon startup (mirrors warmPwshAvailabilityCache);
note the pipe-vs-PTY probe fidelity limit; test that kill with no pending
prep still surfaces SessionNotFoundError.

Deliberately skipped F5 (self-limits to next reload; a real fix needs
speculative in-flight-generation tracking) and F6 (inherent to legacy
v22 attach; self-heals once daemons are v23).
2026-07-20 13:23:44 -07:00

196 lines
6.9 KiB
TypeScript

/**
* Daemon entry point — runs as a standalone Node.js process.
*
* Usage: node daemon-entry.js --socket /path/to/sock --token /path/to/token
*
* Signals readiness to parent via IPC: { type: 'ready' }
* Shuts down cleanly on SIGTERM.
*/
import { startDaemon, type DaemonHandle } from './daemon-main'
import { createPtySubprocess } from './pty-subprocess'
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
import { warmPwshAvailabilityCache } from '../pwsh'
import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log'
import { PROTOCOL_VERSION } from './types'
import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell'
export type ParsedDaemonArgs = {
socketPath: string
tokenPath: string
pidPath?: string
launchNonce?: string
/** Optional — absent for adopted old daemons and tests, which log nothing. */
logFilePath?: string
}
export function parseArgs(argv: string[]): ParsedDaemonArgs {
let socketPath = ''
let tokenPath = ''
let logFilePath = ''
let pidPath = ''
let launchNonce = ''
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--socket' && argv[i + 1]) {
socketPath = argv[i + 1]
i++
} else if (argv[i] === '--token' && argv[i + 1]) {
tokenPath = argv[i + 1]
i++
} else if (argv[i] === '--log-file' && argv[i + 1]) {
logFilePath = argv[i + 1]
i++
} else if (argv[i] === '--pid-record' && argv[i + 1]) {
pidPath = argv[i + 1]
i++
} else if (argv[i] === '--launch-nonce' && argv[i + 1]) {
launchNonce = argv[i + 1]
i++
}
}
if (!socketPath || !tokenPath) {
throw new Error('Usage: daemon-entry --socket <path> --token <path> [--log-file <path>]')
}
if ((pidPath && !launchNonce) || (!pidPath && launchNonce)) {
throw new Error('Daemon PID record path and launch nonce must be provided together')
}
return {
socketPath,
tokenPath,
...(pidPath ? { pidPath, launchNonce } : {}),
...(logFilePath ? { logFilePath } : {})
}
}
async function main(): Promise<void> {
// Why: the parent captures daemon startup stderr then destroys its end of the
// pipe once the daemon is ready. A later write here (e.g. the uncaughtException
// console.error below) would then hit a broken pipe and emit 'error' on
// process.stderr — with no listener that becomes an unhandled error that kills
// an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only.
process.stderr.on('error', () => {})
const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs(
process.argv.slice(2)
)
const startedAtMs = Date.now() - process.uptime() * 1000
// Fail-open: a broken log path must never block daemon startup.
const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog()
daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath })
void warmPwshAvailabilityCache()
// Why: detached daemons destroy stderr, so the preflight's console.warn is lost;
// surface a degraded TCC attribution here where it's diagnosable (F2).
const runMacosLoginPreflight = async (): Promise<void> => {
const outcome = await prepareMacosTccLoginShell()
if (outcome && !outcome.ok) {
daemonLog.log('macos-login-preflight', { ok: outcome.ok, reason: outcome.reason })
}
}
// Why: warm the PAM probe at idle startup so the first terminal spawn doesn't
// pay it under load — shrinking the window where a slow probe degrades (F1/F7).
void runMacosLoginPreflight()
// Why: node-pty can throw a C++ Napi::Error that escapes all JS try/catch
// blocks (e.g. writing to a PTY whose fd was closed between the native
// exit signal and the JS onExit callback). Without this handler, Node's
// default behavior is to print the stack and exit — killing the entire
// daemon and all terminal sessions. Logging and continuing is safe because
// the individual PTY is already dead; the daemon itself is still healthy.
// Non-PTY errors (logic bugs, corrupt state) are re-thrown so they still
// crash the daemon — masking those would hide real issues.
process.on('uncaughtException', (err) => {
const msg = err?.message ?? ''
const isNativeError =
err?.name === 'Error' &&
(msg.includes('pty') ||
msg.includes('Pty') ||
msg.includes('EIO') ||
msg.includes('EPIPE') ||
msg.includes('EBADF') ||
msg.includes('ENXIO'))
if (isNativeError) {
daemonLog.log('uncaught-exception-suppressed', { name: err?.name, message: msg })
console.error('[daemon] Native PTY exception (suppressed):', err)
return
}
daemonLog.log('uncaught-exception-fatal', { name: err?.name, message: msg })
console.error('[daemon] Uncaught exception (fatal):', err)
throw err
})
let daemon: DaemonHandle | null = null
let shuttingDown = false
// Bound the wait so a wedged native shutdown can't leave the daemon running
// forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates).
const SHUTDOWN_TIMEOUT_MS = 5000
const shutdown = async (reason: string): Promise<void> => {
// SIGTERM and SIGINT can both fire; guard against a double daemon.shutdown().
if (shuttingDown) {
return
}
shuttingDown = true
daemonLog.log('shutdown', { reason })
try {
if (daemon) {
await Promise.race([
daemon.shutdown(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('shutdown timeout')), SHUTDOWN_TIMEOUT_MS)
)
])
daemon = null
}
} catch (err) {
// Never let a rejected shutdown() escape as an unhandled rejection and skip exit.
daemonLog.log('shutdown-error', { message: (err as Error)?.message })
} finally {
daemonLog.close()
process.exit(0)
}
}
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
daemon = await startDaemon({
socketPath,
tokenPath,
...(pidPath ? { pidPath } : {}),
...(launchNonce ? { launchNonce } : {}),
...(pidPath ? { startedAtMs } : {}),
log: daemonLog,
preparePtySpawn: runMacosLoginPreflight,
spawnSubprocess: (opts) => createPtySubprocess(opts),
onIdleShutdown: () => {
shuttingDown = true
daemonLog.log('shutdown', { reason: 'idle' })
daemonLog.close()
process.exit(0)
}
})
// Signal readiness to parent via IPC (if available)
if (process.send) {
// Why: Windows has no cheap OS query for a child's start time, so the
// daemon self-reports it here for the pid file's pid-recycling guard.
process.send({ type: 'ready', startedAtMs })
}
daemonLog.log('ready')
warmWindowsConptyOnce()
}
// Only auto-run when executed directly (not imported for testing)
const isDirectExecution = !process.env.VITEST
if (isDirectExecution) {
main().catch((err) => {
console.error('[daemon] Fatal:', err)
process.exit(1)
})
}