mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
Enable eleven oxlint rules that simplify code without changing behavior, and fix
every existing violation. Each candidate was gated on measured cost rather than
assumption, so rules that regressed runtime performance or type checking were
dropped instead of suppressed.
typescript/no-redundant-type-constituents is the largest addition: 113 sites, no
autofix. Dead constituents are deleted. Where the redundant literal existed to
document intent (`string | 'all'`), it is preserved as `(string & {})`, which
keeps the autocomplete hint the original code was reaching for instead of
flattening it away. The rule also caught a broken import —
remote-shared-control-retirement-probe.ts pulled RuntimeStatus from
src/shared/types, which does not export it, so the type silently degraded to
`any`; no tsconfig covers that file, so tsc never saw it.
oxlint stays at 1.77.0 rather than 1.78.0 because .npmrc sets
minimum-release-age=4320 and 1.78.0 is younger than that window.
Rules evaluated and rejected, with what disqualified each:
- prefer-string-raw: String.raw is a runtime call, not a literal (184x slower)
- prefer-string-replace-all: 26% slower
- text-encoding-identifier-case: ~5% slower, reproducible
- prefer-spread: [...str] is 110% slower than split('') and differs on surrogates
- no-implicit-coercion: `!!x` narrows types and `Boolean(x)` does not (22 tsc errors)
- prefer-arrow-callback: arrows are not constructible, breaking `new` on mocks
- object-shorthand: rewrites source text asserted by a tracked reliability gate
- switch-case-braces: pushes ten files past max-lines, which cannot be suppressed
- no-useless-switch-case: drops `case undefined:` that switch-exhaustiveness-check needs
- arrow-body-style: 115 violations have no fix, and it breaks max-lines
- newline-after-import: false-positives on the leading-semicolon ASI idiom
electron-vite-output-contract asserted on the literal
Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which
rejects inherited keys identically.
1378 lines
53 KiB
TypeScript
1378 lines
53 KiB
TypeScript
/* eslint-disable max-lines -- Why: one class owns the daemon socket protocol, routing, stream fanout, and session lifecycle. */
|
|
import { createServer, type Server, type Socket } from 'node:net'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { performance } from 'node:perf_hooks'
|
|
import { writeFileSync, chmodSync, unlinkSync } from 'node:fs'
|
|
import { StringDecoder } from 'node:string_decoder'
|
|
import { encodeNdjson, createNdjsonParser } from './ndjson'
|
|
import { TerminalHost } from './terminal-host'
|
|
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
|
import {
|
|
BackgroundTransientFactRelay,
|
|
BACKGROUND_STREAM_DROP_ENABLED
|
|
} from './daemon-background-transient-facts'
|
|
import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction'
|
|
import {
|
|
recordDaemonStreamBacklogEvent,
|
|
startDaemonStreamBacklogProbe
|
|
} from './daemon-stream-backlog-probe'
|
|
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
|
import type { SubprocessHandle } from './session'
|
|
import { checkPtySpawnHealth } from './pty-subprocess'
|
|
import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log'
|
|
import { isTuiAgent } from '../../shared/tui-agent-config'
|
|
import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
|
|
import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner'
|
|
import {
|
|
DAEMON_ENDPOINT_LOST_MESSAGE,
|
|
DaemonEndpointUnavailableError,
|
|
type DaemonEndpointOwnershipState,
|
|
getDaemonSocketBindPath,
|
|
publishDaemonEndpoint,
|
|
readDaemonEndpointOwnershipState,
|
|
type DaemonSocketIdentity
|
|
} from './daemon-endpoint-ownership'
|
|
import { probeSocketConnect } from './daemon-endpoint-probe'
|
|
import {
|
|
CLEAN_DISCONNECT_PROTOCOL_VERSION,
|
|
PROTOCOL_VERSION,
|
|
NOTIFY_PREFIX,
|
|
SessionNotFoundError,
|
|
TerminalAttachCanceledError,
|
|
type HelloMessage,
|
|
type DaemonRequest
|
|
} from './types'
|
|
import {
|
|
isAgentSessionExecutionClaim,
|
|
isAgentSessionSurfaceBinding
|
|
} from '../../shared/agent-session-host-authority'
|
|
import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
|
|
|
|
export type DaemonServerOptions = {
|
|
socketPath: string
|
|
tokenPath: string
|
|
pidPath?: string
|
|
launchNonce?: string
|
|
startedAtMs?: number
|
|
publishEndpointOwnership?: () => void
|
|
/** Reported in the hello so a repaired PID record can carry the real owner's metadata. */
|
|
entryPath?: string
|
|
appVersion?: string
|
|
spawnerExecPath?: string
|
|
/** Direct-construction seam for protocol fixture tests; production never overrides it. */
|
|
protocolVersion?: number
|
|
onIdleShutdown?: () => void
|
|
onRpcShutdown?: () => void
|
|
/** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */
|
|
initialAdoptionTestConfig?: {
|
|
timeoutMs: number
|
|
clock: {
|
|
setTimeout(callback: () => void, delayMs: number): unknown
|
|
clearTimeout(handle: unknown): void
|
|
now(): number
|
|
}
|
|
}
|
|
ptySpawnHealthCheck?: () => Promise<void>
|
|
preparePtySpawn?: () => Promise<void>
|
|
// Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections.
|
|
onPtySessionExit?: (sessionId: string) => void
|
|
onAuthenticatedClientPair?: () => void
|
|
log?: DaemonFileLog
|
|
spawnSubprocess: (opts: {
|
|
sessionId: string
|
|
cols: number
|
|
rows: number
|
|
cwd?: string
|
|
env?: Record<string, string>
|
|
command?: string
|
|
shellOverride?: string
|
|
}) => SubprocessHandle
|
|
}
|
|
|
|
type ConnectedClient = {
|
|
clientId: string
|
|
controlSocket: Socket
|
|
streamSocket: Socket | null
|
|
authenticatedPairEstablished: boolean
|
|
}
|
|
|
|
type PendingPtySpawnPreparation = {
|
|
canceled: boolean
|
|
// Why: preparations are keyed by sessionId, but a control-socket close must
|
|
// cancel only the disconnecting client's preps, not another client's (F4).
|
|
clientId: string
|
|
}
|
|
|
|
type PendingShutdownReply = {
|
|
start: () => void
|
|
}
|
|
|
|
export class DaemonServer {
|
|
// Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first.
|
|
private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000
|
|
private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000
|
|
private static readonly ENDPOINT_OWNERSHIP_POLL_MS = 30 * 1000
|
|
private static readonly ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS = 2
|
|
private server: Server | null = null
|
|
private token: string
|
|
private host: TerminalHost
|
|
private socketPath: string
|
|
private tokenPath: string
|
|
private pidPath: string | null
|
|
private launchNonce: string | null
|
|
private startedAtMs: number | null
|
|
private publishEndpointOwnership: () => void
|
|
private entryPath: string | null
|
|
private appVersion: string | null
|
|
private spawnerExecPath: string | null
|
|
private ownedSocketIdentity: DaemonSocketIdentity | null = null
|
|
/** Set once start() has been rejected, so async publication can tell it is no longer wanted. */
|
|
private startupFailure: Error | null = null
|
|
private endpointOwnershipTimer: ReturnType<typeof setInterval> | null = null
|
|
private endpointOwnershipLossStreak = 0
|
|
/**
|
|
* Sticky, and not inferred from `ownedSocketIdentity`: retiring nulls that, so a socket accepted
|
|
* before the takeover could finish its hello and reopen sessions on an unreachable daemon.
|
|
*/
|
|
private endpointOwnershipLost = false
|
|
private protocolVersion: number
|
|
private onIdleShutdown: () => void
|
|
private onRpcShutdown: () => void
|
|
private onAuthenticatedClientPair: () => void
|
|
private ptySpawnHealthCheck: () => Promise<void>
|
|
private preparePtySpawn: () => Promise<void>
|
|
private log: DaemonFileLog
|
|
private transportSockets = new Set<Socket>()
|
|
private createOrAttachInFlight = 0
|
|
private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running'
|
|
private initialAdoptionTimer: unknown = null
|
|
private initialAdoptionDeadlineMs: number | null = null
|
|
private retirementRequested = false
|
|
private shutdownPromise: Promise<void> | null = null
|
|
private ordinaryShutdownServerClose: Promise<void> | null = null
|
|
private pendingShutdownReplies = new Map<string, PendingShutdownReply>()
|
|
private initialAdoptionTimeoutMs: number
|
|
private lifecycleClock: NonNullable<DaemonServerOptions['initialAdoptionTestConfig']>['clock']
|
|
|
|
private clients = new Map<string, ConnectedClient>()
|
|
private streamDataBatcher = new DaemonStreamDataBatcher(
|
|
(clientId) => this.clients.get(clientId),
|
|
{
|
|
isSessionDroppable: (sessionId) =>
|
|
BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId),
|
|
salvageDroppedData: (dropped) => {
|
|
if (!dropped.includes('\x1b')) {
|
|
return ''
|
|
}
|
|
const extracted = extractHiddenStartupRendererQueryData(dropped, '')
|
|
return (
|
|
extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData
|
|
)
|
|
}
|
|
}
|
|
)
|
|
// Facts ride the stream queue as control entries so they hold byte order (else a fact could arrive after the reveal snapshot).
|
|
private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => {
|
|
const clientId = this.streamClientIdBySessionId.get(sessionId)
|
|
if (clientId) {
|
|
this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, {
|
|
type: 'event',
|
|
event: 'transientFact',
|
|
sessionId,
|
|
payload: fact
|
|
})
|
|
}
|
|
})
|
|
private streamClientIdBySessionId = new Map<string, string>()
|
|
private lastInputAtBySessionId = new Map<string, number>()
|
|
private pendingPtySpawnPreparations = new Map<string, Set<PendingPtySpawnPreparation>>()
|
|
private historySeedTransfers = new TerminalHistorySeedTransferRegistry()
|
|
private stopStreamBacklogProbe: () => void = () => {}
|
|
|
|
// Why: bypass batching within this window so keystroke echo/redraws skip the daemon's fixed batch delay.
|
|
private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100
|
|
private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024
|
|
|
|
constructor(opts: DaemonServerOptions) {
|
|
this.socketPath = opts.socketPath
|
|
this.tokenPath = opts.tokenPath
|
|
this.pidPath = opts.pidPath ?? null
|
|
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
|
this.launchNonce =
|
|
opts.launchNonce ??
|
|
(this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null)
|
|
this.startedAtMs =
|
|
opts.startedAtMs ??
|
|
(this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION
|
|
? Date.now() - process.uptime() * 1000
|
|
: null)
|
|
this.publishEndpointOwnership = opts.publishEndpointOwnership ?? (() => {})
|
|
this.entryPath = opts.entryPath ?? null
|
|
this.appVersion = opts.appVersion ?? null
|
|
this.spawnerExecPath = opts.spawnerExecPath ?? null
|
|
this.onIdleShutdown = opts.onIdleShutdown ?? (() => {})
|
|
this.onRpcShutdown = opts.onRpcShutdown ?? (() => {})
|
|
this.initialAdoptionTimeoutMs =
|
|
opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS
|
|
this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? {
|
|
setTimeout: (callback, delayMs) => {
|
|
const timer = setTimeout(callback, delayMs)
|
|
timer.unref()
|
|
return timer
|
|
},
|
|
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
now: () => Date.now()
|
|
}
|
|
this.token = randomUUID()
|
|
this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {})
|
|
this.host = new TerminalHost({
|
|
spawnSubprocess: opts.spawnSubprocess,
|
|
...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {})
|
|
})
|
|
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
|
|
this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve())
|
|
this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({
|
|
clients: Array.from(this.clients.values(), (client) => ({
|
|
clientId: client.clientId,
|
|
socketBufferedBytes: client.streamSocket?.writableLength ?? 0,
|
|
batcherQueuedChars: this.streamDataBatcher.queuedCharsForClient(client.clientId)
|
|
})),
|
|
backgroundedSessionIdSuffixes: this.transientFactRelay.backgroundedSessionIdSuffixes()
|
|
}))
|
|
this.log = opts.log ?? createNoopDaemonFileLog()
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
this.server = createServer((socket) => this.handleConnection(socket))
|
|
// Permanent, not `once`: an unhandled 'error' is an uncaught exception, so a post-startup
|
|
// accept failure (EMFILE) would kill a daemon hosting every terminal.
|
|
let startupSettled = false
|
|
const onServerError = (err: Error): void => {
|
|
if (startupSettled) {
|
|
// The daemon is serving; an operational error must not read as a startup failure.
|
|
this.log.log('server-error', { message: err.message })
|
|
console.warn(`[daemon] Socket server error: ${err.message}`)
|
|
return
|
|
}
|
|
startupSettled = true
|
|
// Publishing is async, so the continuation reads this and tears down instead of arming
|
|
// a daemon whose caller was already told startup failed.
|
|
this.startupFailure = err
|
|
reject(err)
|
|
}
|
|
|
|
this.server.on('error', onServerError)
|
|
|
|
// Private bind name, linked into place: libuv's close-time unlink can then only ever
|
|
// remove our own name, never a replacement daemon's endpoint.
|
|
const bindPath =
|
|
process.platform === 'win32' ? this.socketPath : getDaemonSocketBindPath(this.socketPath)
|
|
|
|
this.server.listen(bindPath, () => {
|
|
try {
|
|
// So the endpoint is never briefly reachable with default permissions.
|
|
chmodSync(bindPath, 0o600)
|
|
} catch {
|
|
// Best-effort on platforms that support it
|
|
}
|
|
const abandonStartup = (error: unknown): void => {
|
|
startupSettled = true
|
|
const server = this.server
|
|
this.server = null
|
|
// Settle before close: an accepted connection defers the close callback indefinitely.
|
|
reject(error)
|
|
server?.close()
|
|
if (process.platform !== 'win32') {
|
|
try {
|
|
unlinkSync(bindPath)
|
|
} catch {
|
|
// Already consumed by a successful link or rename, or never created.
|
|
}
|
|
}
|
|
}
|
|
void this.publishAndArm(bindPath).then(() => {
|
|
// The server can fail while publishing awaits its probe; resolving then would strand a
|
|
// live daemon behind a caller told startup failed, who would launch a replacement.
|
|
if (this.startupFailure) {
|
|
this.retireUnstartedDaemon()
|
|
abandonStartup(this.startupFailure)
|
|
return
|
|
}
|
|
// Serving from here, so later server errors are logged, not treated as startup failure.
|
|
startupSettled = true
|
|
resolve()
|
|
}, abandonStartup)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Takes the canonical endpoint, then makes this listener adoptable — in that order. Never rolled
|
|
* back: an aborting daemon just closes, and the next publisher replaces the dead entry.
|
|
*/
|
|
private async publishAndArm(bindPath: string): Promise<void> {
|
|
const outcome = await publishDaemonEndpoint(bindPath, this.socketPath, probeSocketConnect)
|
|
if (outcome.status !== 'published') {
|
|
// The only point the design declines to serve, so a field regression surfaces here.
|
|
this.log.log('endpoint-publish-declined', { reason: outcome.status })
|
|
console.warn(`[daemon] Endpoint unavailable at startup: reason=${outcome.status}`)
|
|
throw new DaemonEndpointUnavailableError(outcome.status)
|
|
}
|
|
this.ownedSocketIdentity = outcome.identity
|
|
let publishedOwnership = false
|
|
try {
|
|
// The PID/nonce record must exist before the token makes this listener adoptable.
|
|
this.publishEndpointOwnership()
|
|
publishedOwnership = true
|
|
writeFileSync(this.tokenPath, this.token, { mode: 0o600 })
|
|
} catch (error) {
|
|
// Roll back only a record we wrote; anything else at that path belongs to another daemon.
|
|
if (publishedOwnership && this.pidPath && this.launchNonce) {
|
|
unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce)
|
|
}
|
|
this.ownedSocketIdentity = null
|
|
throw error
|
|
}
|
|
if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
|
|
// A parent crash before the first client pair must not strand an empty daemon forever.
|
|
this.armInitialAdoptionTimeout()
|
|
}
|
|
this.startEndpointOwnershipWatch()
|
|
}
|
|
|
|
/**
|
|
* Stands down a daemon that published after its startup was already reported failed. The
|
|
* endpoint stays, as on every exit path — removing it could delete a replacement's name.
|
|
*/
|
|
private retireUnstartedDaemon(): void {
|
|
this.stopEndpointOwnershipWatch()
|
|
this.cancelInitialAdoptionTimer()
|
|
this.unlinkOwnedEndpointArtifacts()
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
if (this.shutdownPromise) {
|
|
return this.shutdownPromise
|
|
}
|
|
const serverClose = this.beginOrdinaryShutdownFence()
|
|
this.shutdownPromise = this.finishOrdinaryShutdown(serverClose)
|
|
return this.shutdownPromise
|
|
}
|
|
|
|
private beginOrdinaryShutdownFence(): Promise<void> {
|
|
this.idleShutdownState = 'shutting-down'
|
|
this.cancelInitialAdoptionTimer()
|
|
this.ordinaryShutdownServerClose ??= this.beginServerClose()
|
|
return this.ordinaryShutdownServerClose
|
|
}
|
|
|
|
private async finishOrdinaryShutdown(serverClose: Promise<void>): Promise<void> {
|
|
this.unlinkOwnedEndpointArtifacts()
|
|
await this.disposeDaemonResources()
|
|
await serverClose
|
|
}
|
|
|
|
private async finishRpcShutdown(serverClose: Promise<void>): Promise<void> {
|
|
await this.finishOrdinaryShutdown(serverClose)
|
|
this.onRpcShutdown()
|
|
}
|
|
|
|
private unlinkOwnedEndpointArtifacts(): void {
|
|
// Ownership-checked so a late replacement's token or PID record is never removed.
|
|
unlinkOwnedDaemonTokenFile(this.tokenPath, this.token)
|
|
if (this.pidPath && this.launchNonce) {
|
|
unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce)
|
|
}
|
|
// The endpoint is deliberately left: fencing its removal against a replacement that published
|
|
// meanwhile was this component's largest source of defects, and a dead entry costs nothing.
|
|
this.ownedSocketIdentity = null
|
|
}
|
|
|
|
private startEndpointOwnershipWatch(): void {
|
|
if (process.platform === 'win32' || !this.ownedSocketIdentity) {
|
|
return
|
|
}
|
|
this.endpointOwnershipTimer = setInterval(
|
|
() => this.checkEndpointOwnership(),
|
|
DaemonServer.ENDPOINT_OWNERSHIP_POLL_MS
|
|
)
|
|
// A liveness poll must never be the reason the process cannot exit.
|
|
this.endpointOwnershipTimer.unref()
|
|
}
|
|
|
|
private stopEndpointOwnershipWatch(): void {
|
|
if (this.endpointOwnershipTimer === null) {
|
|
return
|
|
}
|
|
clearInterval(this.endpointOwnershipTimer)
|
|
this.endpointOwnershipTimer = null
|
|
}
|
|
|
|
/**
|
|
* Retires a daemon that no longer owns the canonical endpoint.
|
|
*
|
|
* Such a daemon keeps hosting PTYs no client can reach — terminals that take input and never
|
|
* run it. Retirement drains rather than kills: sessions finish and the process exits once idle.
|
|
*/
|
|
private checkEndpointOwnership(): void {
|
|
if (process.platform === 'win32' || !this.ownedSocketIdentity || this.shutdownPromise) {
|
|
return
|
|
}
|
|
// An inconclusive stat resets too: EACCES must not retire a daemon that is still serving.
|
|
if (this.observeEndpointOwnership() !== 'lost') {
|
|
return
|
|
}
|
|
this.endpointOwnershipLossStreak++
|
|
// Two, though a single rename leaves no gap to misread: this is a backstop, not the detector.
|
|
if (this.endpointOwnershipLossStreak < DaemonServer.ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS) {
|
|
return
|
|
}
|
|
this.requestRetirementForLostEndpoint()
|
|
}
|
|
|
|
/**
|
|
* Whether the endpoint demonstrably no longer resolves to this daemon. Only positive evidence
|
|
* counts — treating an unreadable stat as loss would refuse sessions on a healthy daemon.
|
|
*/
|
|
private hasLostEndpointOwnership(): boolean {
|
|
if (this.endpointOwnershipLost) {
|
|
return true
|
|
}
|
|
return this.observeEndpointOwnership() === 'lost'
|
|
}
|
|
|
|
/**
|
|
* Reads ownership and keeps the watchdog's loss streak honest. Both callers come through here
|
|
* because the watchdog retires on *consecutive* losses: a read that skipped the streak let two
|
|
* losses separated by an owned observation count as consecutive, poisoning a healthy daemon.
|
|
*/
|
|
private observeEndpointOwnership(): DaemonEndpointOwnershipState {
|
|
// The running check, not just `shutdownPromise`: both shutdown routes close the server before
|
|
// assigning it, and in that window the listener is gone while the recorded identity is not.
|
|
if (
|
|
process.platform === 'win32' ||
|
|
!this.ownedSocketIdentity ||
|
|
this.idleShutdownState !== 'running'
|
|
) {
|
|
return 'indeterminate'
|
|
}
|
|
const state = readDaemonEndpointOwnershipState(this.socketPath, this.ownedSocketIdentity)
|
|
if (state !== 'lost') {
|
|
this.endpointOwnershipLossStreak = 0
|
|
}
|
|
return state
|
|
}
|
|
|
|
private requestRetirementForLostEndpoint(): void {
|
|
// Recorded before any dedup and not gated on retirement: folding the two together left the
|
|
// loss unrecorded when already retiring, and a later hello returned the daemon to service.
|
|
const alreadyLost = this.endpointOwnershipLost
|
|
this.endpointOwnershipLost = true
|
|
this.ownedSocketIdentity = null
|
|
if (!alreadyLost) {
|
|
this.log.log('endpoint-ownership-lost', { socketPath: this.socketPath })
|
|
console.warn(
|
|
'[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end'
|
|
)
|
|
}
|
|
this.retirementRequested = true
|
|
// The identity it compares against was just cleared, so every later tick early-returns.
|
|
this.stopEndpointOwnershipWatch()
|
|
this.reevaluateIdleShutdown()
|
|
}
|
|
|
|
private async disposeDaemonResources(): Promise<void> {
|
|
this.stopEndpointOwnershipWatch()
|
|
this.stopStreamBacklogProbe()
|
|
this.transientFactRelay.dispose()
|
|
this.cancelAllPendingPtySpawnPreparations()
|
|
try {
|
|
await this.host.dispose()
|
|
} catch (err) {
|
|
// Why: an unreapable child must not block daemon exit — post-exit it reparents to init anyway.
|
|
this.log.log('shutdown-dispose-failed', {
|
|
error: err instanceof Error ? err.message : String(err)
|
|
})
|
|
}
|
|
this.streamDataBatcher.clear()
|
|
this.historySeedTransfers.dispose()
|
|
this.pendingShutdownReplies.clear()
|
|
|
|
for (const [, client] of this.clients) {
|
|
client.controlSocket.destroy()
|
|
client.streamSocket?.destroy()
|
|
}
|
|
this.clients.clear()
|
|
for (const socket of this.transportSockets) {
|
|
socket.destroy()
|
|
}
|
|
this.transportSockets.clear()
|
|
}
|
|
|
|
private beginServerClose(): Promise<void> {
|
|
const server = this.server
|
|
this.server = null
|
|
if (!server) {
|
|
return Promise.resolve()
|
|
}
|
|
return new Promise<void>((resolve) => {
|
|
// Why: close synchronously before any awaited cleanup so no new transport enters after the empty proof.
|
|
server.close(() => {
|
|
// Why: libuv unlinks the path this server bound, which is our private bind name and is
|
|
// already gone. Nothing removes the canonical endpoint — a departing daemon leaves it
|
|
// for the next publisher to replace — so closing late cannot delete a replacement's.
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
private isIdle(): boolean {
|
|
if (this.createOrAttachInFlight > 0 || this.host.listSessions().length > 0) {
|
|
return false
|
|
}
|
|
// Why open connections stop counting once the endpoint is lost: they belong to clients that
|
|
// reached this daemon before the takeover, and holding them open cannot make it reachable to
|
|
// anyone new. Waiting for them turns a drained retirement into an orphan that outlives its
|
|
// usefulness — the sessions are gone and nothing can route a new one here.
|
|
if (this.endpointOwnershipLost) {
|
|
return true
|
|
}
|
|
return this.transportSockets.size === 0 && this.clients.size === 0
|
|
}
|
|
|
|
private reevaluateIdleShutdown(): void {
|
|
if (this.idleShutdownState !== 'running') {
|
|
return
|
|
}
|
|
if (this.retirementRequested) {
|
|
this.cancelInitialAdoptionTimer()
|
|
if (this.isIdle()) {
|
|
this.beginIdleShutdown()
|
|
}
|
|
return
|
|
}
|
|
if (!this.isIdle() || this.initialAdoptionDeadlineMs === null) {
|
|
this.cancelInitialAdoptionTimer()
|
|
return
|
|
}
|
|
if (this.initialAdoptionTimer !== null) {
|
|
return
|
|
}
|
|
const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.lifecycleClock.now())
|
|
if (remainingMs === 0) {
|
|
this.initialAdoptionDeadlineMs = null
|
|
this.retirementRequested = true
|
|
this.beginIdleShutdown()
|
|
return
|
|
}
|
|
this.initialAdoptionTimer = this.lifecycleClock.setTimeout(() => {
|
|
this.initialAdoptionTimer = null
|
|
this.initialAdoptionDeadlineMs = null
|
|
this.retirementRequested = true
|
|
this.beginIdleShutdown()
|
|
}, remainingMs)
|
|
}
|
|
|
|
private armInitialAdoptionTimeout(): void {
|
|
this.initialAdoptionDeadlineMs = this.lifecycleClock.now() + this.initialAdoptionTimeoutMs
|
|
this.reevaluateIdleShutdown()
|
|
}
|
|
|
|
private cancelInitialAdoptionTimer(): void {
|
|
if (this.initialAdoptionTimer === null) {
|
|
return
|
|
}
|
|
this.lifecycleClock.clearTimeout(this.initialAdoptionTimer)
|
|
this.initialAdoptionTimer = null
|
|
}
|
|
|
|
private beginIdleShutdown(): void {
|
|
this.initialAdoptionTimer = null
|
|
if (this.idleShutdownState !== 'running') {
|
|
return
|
|
}
|
|
this.idleShutdownState = 'idle-shutdown-pending'
|
|
if (!this.isIdle()) {
|
|
// Why: work admitted before the fence wins; clear pending state to keep it usable.
|
|
this.idleShutdownState = 'running'
|
|
this.reevaluateIdleShutdown()
|
|
return
|
|
}
|
|
|
|
this.idleShutdownState = 'shutting-down'
|
|
// beginServerClose() runs synchronously up to server.close() before any yield to a racing connection.
|
|
const serverClose = this.beginServerClose()
|
|
this.shutdownPromise = this.finishIdleShutdown(serverClose)
|
|
}
|
|
|
|
private async finishIdleShutdown(serverClose: Promise<void>): Promise<void> {
|
|
this.unlinkOwnedEndpointArtifacts()
|
|
await this.disposeDaemonResources()
|
|
await serverClose
|
|
this.onIdleShutdown()
|
|
}
|
|
|
|
private handleConnection(socket: Socket): void {
|
|
this.cancelInitialAdoptionTimer()
|
|
this.transportSockets.add(socket)
|
|
const removeTransport = (): void => {
|
|
this.transportSockets.delete(socket)
|
|
this.reevaluateIdleShutdown()
|
|
}
|
|
socket.once('close', removeTransport)
|
|
socket.on('error', () => socket.destroy())
|
|
|
|
if (this.idleShutdownState !== 'running') {
|
|
// Why: a connection accepted just before close() gets an explicit retry signal instead of dying mid-auth.
|
|
socket.end(
|
|
encodeNdjson({
|
|
type: 'hello',
|
|
ok: false,
|
|
error: 'Daemon temporarily unavailable; reconnect',
|
|
retryable: true
|
|
})
|
|
)
|
|
return
|
|
}
|
|
// Why: keep UTF-8 sequences intact across socket chunks before NDJSON parsing.
|
|
const decoder = new StringDecoder('utf8')
|
|
const parser = createNdjsonParser(
|
|
(msg) => this.handleFirstMessage(socket, msg, parser),
|
|
() => {
|
|
socket.destroy()
|
|
}
|
|
)
|
|
|
|
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
|
|
}
|
|
|
|
private handleFirstMessage(
|
|
socket: Socket,
|
|
msg: unknown,
|
|
_parser: ReturnType<typeof createNdjsonParser>
|
|
): void {
|
|
const hello = msg as HelloMessage
|
|
if (hello.type !== 'hello') {
|
|
this.log.log('client-hello-rejected', { reason: 'expected-hello' })
|
|
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' }))
|
|
socket.destroy()
|
|
return
|
|
}
|
|
|
|
if (hello.version !== this.protocolVersion) {
|
|
this.log.log('client-hello-rejected', {
|
|
reason: 'protocol-mismatch',
|
|
clientVersion: hello.version
|
|
})
|
|
socket.write(
|
|
encodeNdjson({
|
|
type: 'hello',
|
|
ok: false,
|
|
error: 'Protocol version mismatch'
|
|
})
|
|
)
|
|
socket.destroy()
|
|
return
|
|
}
|
|
|
|
if (hello.token !== this.token) {
|
|
this.log.log('client-hello-rejected', {
|
|
reason: 'invalid-token',
|
|
role: hello.role
|
|
})
|
|
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' }))
|
|
socket.destroy()
|
|
return
|
|
}
|
|
|
|
this.log.log('client-hello-accepted', {
|
|
role: hello.role,
|
|
clientId: hello.clientId
|
|
})
|
|
socket.write(
|
|
encodeNdjson({
|
|
type: 'hello',
|
|
ok: true,
|
|
...(this.launchNonce && this.startedAtMs
|
|
? {
|
|
daemonIdentity: {
|
|
pid: process.pid,
|
|
startedAtMs: this.startedAtMs,
|
|
launchNonce: this.launchNonce,
|
|
...(this.entryPath ? { entryPath: this.entryPath } : {}),
|
|
...(this.appVersion ? { appVersion: this.appVersion } : {}),
|
|
...(this.spawnerExecPath ? { spawnerExecPath: this.spawnerExecPath } : {})
|
|
}
|
|
}
|
|
: {})
|
|
})
|
|
)
|
|
|
|
if (hello.role === 'control') {
|
|
const previous = this.clients.get(hello.clientId)
|
|
const client: ConnectedClient = {
|
|
clientId: hello.clientId,
|
|
controlSocket: socket,
|
|
streamSocket: null,
|
|
authenticatedPairEstablished: false
|
|
}
|
|
this.clients.set(hello.clientId, client)
|
|
this.setupControlSocket(socket, hello.clientId)
|
|
if (previous) {
|
|
// Why: reconnect reuses clientId before stale close fires; cancel the old owner's preflight at handoff.
|
|
this.cancelPendingPtySpawnPreparationsForClient(hello.clientId)
|
|
this.historySeedTransfers.clearOwner(hello.clientId)
|
|
this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished)
|
|
// Why: tear down the old sockets after installing the new owner so a stale close can't delete the replacement.
|
|
previous.streamSocket?.destroy()
|
|
previous.controlSocket.destroy()
|
|
}
|
|
} else if (hello.role === 'stream') {
|
|
const client = this.clients.get(hello.clientId)
|
|
if (!client) {
|
|
// Why: a stream socket is meaningless without its control socket; drop the orphan.
|
|
socket.destroy()
|
|
return
|
|
}
|
|
this.setupStreamSocket(socket, client)
|
|
client.authenticatedPairEstablished = true
|
|
// Why: one-shot health probes authenticate only a control socket; they are not fresh app activity.
|
|
this.onAuthenticatedClientPair()
|
|
// A complete app connection (unlike a probe) re-owns the endpoint and cancels pending
|
|
// retirement — but not a retirement caused by losing the endpoint itself. A client that
|
|
// connected before the takeover cannot make this daemon reachable again, and treating it
|
|
// as re-ownership would reopen session creation on a daemon nothing can find.
|
|
this.initialAdoptionDeadlineMs = null
|
|
this.cancelInitialAdoptionTimer()
|
|
if (!this.endpointOwnershipLost) {
|
|
this.retirementRequested = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private setupControlSocket(socket: Socket, clientId: string): void {
|
|
// Why: decode as a UTF-8 stream so emoji/Unicode split across chunks isn't corrupted.
|
|
const decoder = new StringDecoder('utf8')
|
|
const parser = createNdjsonParser(
|
|
(msg) => this.handleRequest(socket, clientId, msg as DaemonRequest),
|
|
() => {} // Ignore parse errors
|
|
)
|
|
|
|
// Remove the initial data listener and replace with the RPC parser
|
|
socket.removeAllListeners('data')
|
|
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
|
|
|
|
socket.on('close', () => {
|
|
const client = this.clients.get(clientId)
|
|
if (client?.controlSocket !== socket) {
|
|
return
|
|
}
|
|
// Why: a client that disconnects mid-preflight would otherwise still create
|
|
// its daemon PTY, orphaning a durable, unattached session — cancel its preps (F4).
|
|
this.cancelPendingPtySpawnPreparationsForClient(clientId)
|
|
this.historySeedTransfers.clearOwner(clientId)
|
|
const wasFullyAuthenticated = client.authenticatedPairEstablished
|
|
this.streamDataBatcher.clear(clientId)
|
|
client.streamSocket?.destroy()
|
|
this.clients.delete(clientId)
|
|
this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated)
|
|
this.reevaluateIdleShutdown()
|
|
})
|
|
}
|
|
|
|
private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void {
|
|
if (
|
|
!wasFullyAuthenticated ||
|
|
[...this.clients.values()].some((remaining) => remaining.authenticatedPairEstablished) ||
|
|
this.idleShutdownState !== 'running'
|
|
) {
|
|
return
|
|
}
|
|
// Why: once the last full client is gone, incomplete transports may block retirement but never erase it.
|
|
this.retirementRequested = true
|
|
}
|
|
|
|
private setupStreamSocket(socket: Socket, client: ConnectedClient): void {
|
|
const previous = client.streamSocket
|
|
socket.removeAllListeners('data')
|
|
client.streamSocket = socket
|
|
// Why: 'drain' is the wake-up for the batcher's shallow-gate held bulk.
|
|
socket.on('drain', () => {
|
|
this.streamDataBatcher.flush(client.clientId)
|
|
})
|
|
|
|
const cleanup = (): void => {
|
|
socket.removeListener('close', cleanup)
|
|
socket.removeListener('error', cleanup)
|
|
if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) {
|
|
return
|
|
}
|
|
// Why: a preflight that outlives its output channel would create an unattached daemon PTY.
|
|
this.cancelPendingPtySpawnPreparationsForClient(client.clientId)
|
|
this.streamDataBatcher.clear(client.clientId)
|
|
client.streamSocket = null
|
|
}
|
|
|
|
socket.on('close', cleanup)
|
|
socket.on('error', cleanup)
|
|
|
|
if (previous && previous !== socket) {
|
|
// Why: replacing a stream socket must not leave the old channel alive and untracked.
|
|
previous.destroy()
|
|
}
|
|
}
|
|
|
|
private async handleRequest(
|
|
socket: Socket,
|
|
clientId: string,
|
|
request: DaemonRequest
|
|
): Promise<void> {
|
|
const isNotify = request.id.startsWith(NOTIFY_PREFIX)
|
|
|
|
try {
|
|
const result = await this.routeRequest(clientId, request)
|
|
if (!isNotify) {
|
|
const pendingShutdown = this.pendingShutdownReplies.get(
|
|
this.shutdownReplyKey(clientId, request.id)
|
|
)
|
|
socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => {
|
|
pendingShutdown?.start()
|
|
})
|
|
}
|
|
} catch (err) {
|
|
if (!isNotify) {
|
|
socket.write(
|
|
encodeNdjson({
|
|
id: request.id,
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : String(err)
|
|
})
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private shutdownReplyKey(clientId: string, requestId: string): string {
|
|
return `${clientId}\u0000${requestId}`
|
|
}
|
|
|
|
private deferShutdownUntilReply(
|
|
clientId: string,
|
|
requestId: string,
|
|
socket: Socket,
|
|
finish: () => Promise<void>
|
|
): void {
|
|
const key = this.shutdownReplyKey(clientId, requestId)
|
|
let started = false
|
|
let timer: ReturnType<typeof setTimeout>
|
|
const start = (): void => {
|
|
if (started) {
|
|
return
|
|
}
|
|
started = true
|
|
clearTimeout(timer)
|
|
socket.off('close', start)
|
|
socket.off('error', start)
|
|
this.pendingShutdownReplies.delete(key)
|
|
if (!this.shutdownPromise) {
|
|
this.shutdownPromise = finish()
|
|
}
|
|
}
|
|
// Why: a non-reading peer must not pin a fenced daemon by holding its ack behind permanent socket backpressure.
|
|
timer = setTimeout(start, DaemonServer.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS)
|
|
timer.unref()
|
|
socket.once('close', start)
|
|
socket.once('error', start)
|
|
this.pendingShutdownReplies.set(key, { start })
|
|
}
|
|
|
|
private async preparePtySpawnUnlessCanceled(sessionId: string, clientId: string): Promise<void> {
|
|
const preparation: PendingPtySpawnPreparation = {
|
|
canceled: false,
|
|
clientId
|
|
}
|
|
const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set()
|
|
pending.add(preparation)
|
|
this.pendingPtySpawnPreparations.set(sessionId, pending)
|
|
try {
|
|
// Why: register before the async probe so a concurrent close can cancel this creation before a subprocess exists.
|
|
await this.preparePtySpawn()
|
|
if (preparation.canceled) {
|
|
throw new TerminalAttachCanceledError(sessionId)
|
|
}
|
|
} finally {
|
|
pending.delete(preparation)
|
|
if (pending.size === 0) {
|
|
this.pendingPtySpawnPreparations.delete(sessionId)
|
|
}
|
|
}
|
|
}
|
|
|
|
private cancelPendingPtySpawnPreparations(sessionId: string): boolean {
|
|
const pending = this.pendingPtySpawnPreparations.get(sessionId)
|
|
if (!pending) {
|
|
return false
|
|
}
|
|
for (const preparation of pending) {
|
|
preparation.canceled = true
|
|
}
|
|
return true
|
|
}
|
|
|
|
private cancelAllPendingPtySpawnPreparations(): void {
|
|
for (const sessionId of this.pendingPtySpawnPreparations.keys()) {
|
|
this.cancelPendingPtySpawnPreparations(sessionId)
|
|
}
|
|
}
|
|
|
|
private cancelPendingPtySpawnPreparationsForClient(clientId: string): void {
|
|
for (const pending of this.pendingPtySpawnPreparations.values()) {
|
|
for (const preparation of pending) {
|
|
if (preparation.clientId === clientId) {
|
|
preparation.canceled = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async routeRequest(clientId: string, request: DaemonRequest): Promise<unknown> {
|
|
const client = this.clients.get(clientId)
|
|
|
|
switch (request.type) {
|
|
case 'startHistorySeedTransfer': {
|
|
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
|
|
throw new Error('Daemon client connection is incomplete; reconnect')
|
|
}
|
|
const transferId = this.historySeedTransfers.start(clientId, request.payload)
|
|
return { transferId }
|
|
}
|
|
|
|
case 'appendHistorySeedTransfer':
|
|
this.historySeedTransfers.append(
|
|
clientId,
|
|
request.payload.transferId,
|
|
request.payload.index,
|
|
request.payload.data
|
|
)
|
|
return {}
|
|
|
|
case 'finishHistorySeedTransfer':
|
|
this.historySeedTransfers.finish(clientId, request.payload.transferId)
|
|
return {}
|
|
|
|
case 'abortHistorySeedTransfer':
|
|
this.historySeedTransfers.abort(clientId, request.payload.transferId)
|
|
return {}
|
|
|
|
case 'createOrAttach': {
|
|
if (this.idleShutdownState !== 'running') {
|
|
throw new Error('Daemon temporarily unavailable; reconnect')
|
|
}
|
|
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
|
|
// Why: a control-only replacement can't own terminal admission or erase the prior client's retirement request.
|
|
throw new Error('Daemon client connection is incomplete; reconnect')
|
|
}
|
|
const p = request.payload
|
|
const attachOnly = p.attachOnly === true
|
|
// Why check here and not only on the watchdog: publishing cannot be made atomic against
|
|
// a publisher preempted between proving an entry dead and replacing it, so this daemon
|
|
// can lose the endpoint at any moment. The watchdog notices within a poll, which is far
|
|
// too late if a session was accepted in between — that session is then reachable by
|
|
// nobody, and the user sees a terminal that acknowledges input and never runs it.
|
|
// Why creation only: an attach reaches a session this daemon already hosts, over a
|
|
// connection that already exists. Refusing that would break the drain a retiring daemon
|
|
// depends on, and it strands nothing — the session is already here.
|
|
if (!attachOnly && this.hasLostEndpointOwnership()) {
|
|
this.requestRetirementForLostEndpoint()
|
|
throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE)
|
|
}
|
|
this.createOrAttachInFlight++
|
|
let routedSessionId = p.sessionId
|
|
let result: Awaited<ReturnType<TerminalHost['createOrAttach']>>
|
|
try {
|
|
if (
|
|
p.agentSessionEnsure !== undefined &&
|
|
(!isAgentSessionExecutionClaim(p.agentSessionEnsure.claim) ||
|
|
!isAgentSessionSurfaceBinding(p.agentSessionEnsure.surface))
|
|
) {
|
|
throw new Error('agent_session_identity_required')
|
|
}
|
|
if (!attachOnly) {
|
|
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
|
|
}
|
|
if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) {
|
|
throw new Error('Multiple terminal history seed sources')
|
|
}
|
|
const historySeedChunks =
|
|
p.historySeedTransferId !== undefined
|
|
? this.historySeedTransfers.take(clientId, p.historySeedTransferId)
|
|
: p.historySeed !== undefined
|
|
? [p.historySeed]
|
|
: undefined
|
|
result = await this.host.createOrAttach({
|
|
sessionId: p.sessionId,
|
|
cols: p.cols,
|
|
rows: p.rows,
|
|
cwd: p.cwd,
|
|
env: p.env,
|
|
envToDelete: p.envToDelete,
|
|
command: p.command,
|
|
startupCommandDelivery: p.startupCommandDelivery,
|
|
...(attachOnly ? { attachOnly: true } : {}),
|
|
// Why: RPC payloads are untrusted JSON; persist only the allowlisted routing enum, never arbitrary identity.
|
|
...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}),
|
|
shellOverride: p.shellOverride,
|
|
terminalWindowsWslDistro: p.terminalWindowsWslDistro,
|
|
terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation,
|
|
shellReadySupported: p.shellReadySupported,
|
|
historySeedChunks,
|
|
startupIngress: parsePtyStartupIngressIntent(p.startupIngress),
|
|
...(p.shellReadyTimeoutMs !== undefined
|
|
? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
|
|
: {}),
|
|
...(p.agentSessionEnsure ? { agentSessionEnsure: p.agentSessionEnsure } : {}),
|
|
onSessionResolved: (sessionId) => {
|
|
routedSessionId = sessionId
|
|
},
|
|
streamClient: {
|
|
onData: (data, rawLength = data.length, transformed = false, seq) => {
|
|
// Scan BEFORE enqueue: the batcher may drop this chunk, but its facts must be captured regardless.
|
|
this.transientFactRelay.onSessionData(routedSessionId, data)
|
|
const lastInputAt = this.lastInputAtBySessionId.get(routedSessionId)
|
|
const isInteractiveOutput =
|
|
data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS &&
|
|
lastInputAt !== undefined &&
|
|
performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS
|
|
this.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
|
|
flushImmediately: isInteractiveOutput,
|
|
flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS,
|
|
rawLength,
|
|
transformed,
|
|
seq
|
|
})
|
|
},
|
|
onExit: (code, incarnationId) => {
|
|
// Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output.
|
|
this.log.log('session-exited', {
|
|
sessionId: routedSessionId,
|
|
code
|
|
})
|
|
this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
|
|
type: 'event',
|
|
event: 'exit',
|
|
sessionId: routedSessionId,
|
|
payload: { code, incarnationId }
|
|
})
|
|
this.streamDataBatcher.flush(clientId)
|
|
recordDaemonStreamBacklogEvent('sessionExit', {
|
|
sessionIdSuffix: routedSessionId.slice(-10)
|
|
})
|
|
this.transientFactRelay.onSessionExit(routedSessionId)
|
|
this.streamDataBatcher.refreshSessionDroppability(routedSessionId)
|
|
this.streamClientIdBySessionId.delete(routedSessionId)
|
|
this.lastInputAtBySessionId.delete(routedSessionId)
|
|
this.reevaluateIdleShutdown()
|
|
}
|
|
}
|
|
})
|
|
} finally {
|
|
this.createOrAttachInFlight--
|
|
this.reevaluateIdleShutdown()
|
|
}
|
|
routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId
|
|
this.streamClientIdBySessionId.set(routedSessionId, clientId)
|
|
this.streamDataBatcher.refreshSessionDroppability(routedSessionId)
|
|
// Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head.
|
|
if (this.transientFactRelay.isBackgrounded(routedSessionId)) {
|
|
this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
|
|
type: 'event',
|
|
event: 'sessionBackgroundMarker',
|
|
sessionId: routedSessionId,
|
|
payload: { background: true }
|
|
})
|
|
}
|
|
this.log.log(result.isNew ? 'session-created' : 'session-attached', {
|
|
sessionId: routedSessionId,
|
|
pid: result.pid
|
|
})
|
|
return {
|
|
isNew: result.isNew,
|
|
snapshot: result.snapshot,
|
|
pid: result.pid,
|
|
shellState: result.shellState,
|
|
incarnationId: result.incarnationId,
|
|
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
|
|
wslDistro: result.wslDistro,
|
|
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}),
|
|
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
|
|
}
|
|
}
|
|
|
|
case 'cancelCreateOrAttach':
|
|
this.cancelPendingPtySpawnPreparations(request.payload.sessionId)
|
|
return {}
|
|
|
|
case 'closeStartupQueryAuthority':
|
|
return {
|
|
appliedSeq: this.host.closeStartupQueryAuthority(request.payload.sessionId)
|
|
}
|
|
|
|
case 'write':
|
|
try {
|
|
this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now())
|
|
this.host.write(request.payload.sessionId, request.payload.data)
|
|
} catch (err) {
|
|
this.lastInputAtBySessionId.delete(request.payload.sessionId)
|
|
if (err instanceof SessionNotFoundError) {
|
|
this.sendExitEvent(client, request.payload.sessionId, -1)
|
|
}
|
|
throw err
|
|
}
|
|
return {}
|
|
|
|
case 'resize':
|
|
try {
|
|
this.host.resize(request.payload.sessionId, request.payload.cols, request.payload.rows)
|
|
} catch (err) {
|
|
if (err instanceof SessionNotFoundError) {
|
|
this.sendExitEvent(client, request.payload.sessionId, -1)
|
|
}
|
|
throw err
|
|
}
|
|
return {}
|
|
|
|
case 'pausePty':
|
|
this.host.pauseProducer(request.payload.sessionId)
|
|
return {}
|
|
|
|
case 'resumePty':
|
|
this.host.resumeProducer(request.payload.sessionId)
|
|
return {}
|
|
|
|
case 'setSessionBackground': {
|
|
const sessionId = request.payload.sessionId
|
|
const background = request.payload.background === true
|
|
recordDaemonStreamBacklogEvent('setSessionBackground', {
|
|
sessionIdSuffix: sessionId.slice(-10),
|
|
background
|
|
})
|
|
const backgroundChanged = this.transientFactRelay.setSessionBackground(
|
|
sessionId,
|
|
background
|
|
)
|
|
this.streamDataBatcher.refreshSessionDroppability(sessionId)
|
|
if (!backgroundChanged) {
|
|
return {}
|
|
}
|
|
if (background) {
|
|
// Seed the fresh relay tracker with the emulator's dangling escape so a handoff-split sequence still parses.
|
|
this.transientFactRelay.seedSessionScanState(
|
|
sessionId,
|
|
this.host.getPartialEscapeTailAnsi(sessionId)
|
|
)
|
|
}
|
|
const streamClientId = this.streamClientIdBySessionId.get(sessionId)
|
|
if (!streamClientId) {
|
|
// Not attached yet — the attach-time marker covers the handoff.
|
|
return {}
|
|
}
|
|
// Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker.
|
|
const mode2031State = this.transientFactRelay.getMode2031ReplyScanState(sessionId)
|
|
const scanSeedAnsi = background
|
|
? ''
|
|
: mode2031State.pendingSubscribe
|
|
? mode2031State.tail
|
|
: this.host.getPartialEscapeTailAnsi(sessionId)
|
|
this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
|
|
type: 'event',
|
|
event: 'sessionBackgroundMarker',
|
|
sessionId,
|
|
payload: {
|
|
background,
|
|
...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}),
|
|
...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {})
|
|
}
|
|
})
|
|
return {}
|
|
}
|
|
|
|
case 'kill': {
|
|
const canceledPendingSpawn = this.cancelPendingPtySpawnPreparations(
|
|
request.payload.sessionId
|
|
)
|
|
this.lastInputAtBySessionId.delete(request.payload.sessionId)
|
|
const attribution = {
|
|
sessionId: request.payload.sessionId,
|
|
immediate: request.payload.immediate === true,
|
|
// Daemon control identity, not the paired-device bearer credential.
|
|
clientId
|
|
}
|
|
try {
|
|
await this.host.kill(request.payload.sessionId, {
|
|
immediate: request.payload.immediate
|
|
})
|
|
} catch (error) {
|
|
// Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done.
|
|
if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) {
|
|
this.log.log('session-kill-failed', attribution)
|
|
throw error
|
|
}
|
|
}
|
|
this.log.log('session-killed', attribution)
|
|
return {}
|
|
}
|
|
|
|
case 'signal':
|
|
this.host.signal(request.payload.sessionId, request.payload.signal)
|
|
return {}
|
|
|
|
case 'detach':
|
|
// Note: detach token handling simplified — full impl would track tokens per client
|
|
this.log.log('session-detached', {
|
|
sessionId: request.payload.sessionId
|
|
})
|
|
return {}
|
|
|
|
case 'getCwd':
|
|
return { cwd: await this.host.getCwd(request.payload.sessionId) }
|
|
|
|
case 'getForegroundProcess':
|
|
return {
|
|
foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId)
|
|
}
|
|
|
|
case 'inspectProcess':
|
|
return this.host.inspectProcess(request.payload.sessionId)
|
|
|
|
case 'confirmForegroundProcess':
|
|
return {
|
|
foregroundProcess: await this.host.confirmForegroundProcess(request.payload.sessionId)
|
|
}
|
|
|
|
case 'clearScrollback':
|
|
this.host.clearScrollback(request.payload.sessionId)
|
|
return {}
|
|
|
|
case 'listSessions':
|
|
return { sessions: this.host.listSessions() }
|
|
|
|
case 'shutdownIfIdle': {
|
|
const authenticatedClient = this.clients.get(clientId)
|
|
const retiring =
|
|
authenticatedClient !== undefined &&
|
|
authenticatedClient.streamSocket !== null &&
|
|
this.clients.size === 1 &&
|
|
this.createOrAttachInFlight === 0 &&
|
|
this.host.listSessions().length === 0 &&
|
|
[...this.transportSockets].every(
|
|
(transport) =>
|
|
transport === authenticatedClient.controlSocket ||
|
|
transport === authenticatedClient.streamSocket
|
|
)
|
|
if (!retiring) {
|
|
return { retiring: false }
|
|
}
|
|
this.idleShutdownState = 'shutting-down'
|
|
this.initialAdoptionDeadlineMs = null
|
|
this.retirementRequested = false
|
|
this.cancelInitialAdoptionTimer()
|
|
// Why: close before acknowledging retirement so no new terminal races between the empty proof and disposal.
|
|
const serverClose = this.beginServerClose()
|
|
this.deferShutdownUntilReply(clientId, request.id, authenticatedClient.controlSocket, () =>
|
|
this.finishIdleShutdown(serverClose)
|
|
)
|
|
return { retiring: true }
|
|
}
|
|
|
|
case 'getSnapshot': {
|
|
const snapshotStart = performance.now()
|
|
const requestedScrollbackRows = request.payload.scrollbackRows
|
|
const scrollbackRows =
|
|
typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows)
|
|
? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows)))
|
|
: undefined
|
|
const snapshot = this.host.getSnapshot(request.payload.sessionId, {
|
|
scrollbackRows
|
|
})
|
|
const snapshotMs = performance.now() - snapshotStart
|
|
if (snapshotMs >= 25) {
|
|
// Serialize stalls block the daemon's single thread; surface them to attribute field typing stalls (issue #5096 family).
|
|
recordDaemonStreamBacklogEvent('slowGetSnapshot', {
|
|
sessionIdSuffix: request.payload.sessionId.slice(-10),
|
|
snapshotMs: Math.round(snapshotMs)
|
|
})
|
|
}
|
|
return { snapshot }
|
|
}
|
|
|
|
case 'getSize':
|
|
return { size: this.host.getAppliedSize(request.payload.sessionId) }
|
|
|
|
case 'takePendingOutput':
|
|
// Why no await: with includeSnapshot, drain+serialize must share one sync turn or cold restore replays doubled PTY bytes.
|
|
return this.host.takePendingOutput(
|
|
request.payload.sessionId,
|
|
request.payload.includeSnapshot === true,
|
|
{ teardownSnapshot: request.payload.teardownSnapshot === true }
|
|
)
|
|
|
|
case 'ping':
|
|
return { pong: true }
|
|
|
|
case 'systemResolverHealth':
|
|
return { health: await readCurrentProcessMacSystemResolverHealth() }
|
|
|
|
case 'ptySpawnHealth':
|
|
await this.ptySpawnHealthCheck()
|
|
return { healthy: true }
|
|
|
|
case 'shutdown': {
|
|
this.log.log('shutdown', {
|
|
reason: 'rpc',
|
|
killSessions: request.payload.killSessions === true
|
|
})
|
|
const serverClose = this.beginOrdinaryShutdownFence()
|
|
if (request.payload.killSessions) {
|
|
try {
|
|
await this.host.dispose()
|
|
} catch (err) {
|
|
// Why: shutdown must always self-terminate; failed owners stay retryable for the follow-up shutdown() below.
|
|
this.log.log('shutdown-dispose-failed', {
|
|
error: err instanceof Error ? err.message : String(err)
|
|
})
|
|
}
|
|
}
|
|
const controlSocket = this.clients.get(clientId)?.controlSocket
|
|
if (controlSocket) {
|
|
this.deferShutdownUntilReply(clientId, request.id, controlSocket, () =>
|
|
this.finishRpcShutdown(serverClose)
|
|
)
|
|
} else if (!this.shutdownPromise) {
|
|
this.shutdownPromise = this.finishRpcShutdown(serverClose)
|
|
}
|
|
return {}
|
|
}
|
|
}
|
|
throw new Error(`Unknown request type: ${(request as { type: string }).type}`)
|
|
}
|
|
|
|
private sendExitEvent(
|
|
client: ConnectedClient | undefined,
|
|
sessionId: string,
|
|
code: number
|
|
): void {
|
|
if (!client?.streamSocket) {
|
|
return
|
|
}
|
|
// Why: write/resize don't wait for replies, so this synthetic exit is the renderer's only signal to clear stale pane bindings.
|
|
this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, {
|
|
type: 'event',
|
|
event: 'exit',
|
|
sessionId,
|
|
payload: { code }
|
|
})
|
|
this.streamDataBatcher.flush(client.clientId)
|
|
}
|
|
}
|