refactor(daemon): split oversized PTY services

This commit is contained in:
Neil
2026-08-23 16:01:52 -07:00
parent d1127b9939
commit d3e8d711af
58 changed files with 6483 additions and 6016 deletions
-3
View File
@@ -22,9 +22,6 @@ inline src/main/codex-accounts/service.ts
inline src/main/codex/config-toml-trust.ts
inline src/main/codex/hook-service.ts
inline src/main/daemon/daemon-init.ts
inline src/main/daemon/daemon-pty-adapter.ts
inline src/main/daemon/daemon-server.ts
inline src/main/daemon/pty-subprocess.ts
inline src/main/git/repo.ts
inline src/main/git/runner.ts
inline src/main/git/status.ts
@@ -0,0 +1,249 @@
import type { Socket } from 'node:net'
import { StringDecoder } from 'node:string_decoder'
import type { DaemonFileLog } from './daemon-file-log'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import { createNdjsonParser, encodeNdjson } from './ndjson'
import type { DaemonRequest, HelloMessage } from './types'
export type ConnectedDaemonClient = {
clientId: string
controlSocket: Socket
streamSocket: Socket | null
authenticatedPairEstablished: boolean
}
type DaemonClientConnectionOptions = {
token: string
protocolVersion: number
identity: {
launchNonce: string | null
startedAtMs: number | null
entryPath: string | null
appVersion: string | null
spawnerExecPath: string | null
}
log: DaemonFileLog
streamDataBatcher: DaemonStreamDataBatcher
isAcceptingWork: () => boolean
onTransportChanged: () => void
onConnectionAccepted: () => void
onAuthenticatedPair: () => void
onLastAuthenticatedClientDisconnected: () => void
onControlRequest: (socket: Socket, clientId: string, request: DaemonRequest) => void
onControlReplaced: (clientId: string) => void
onClientDisconnected: (clientId: string) => void
onStreamDisconnected: (clientId: string) => void
}
export class DaemonClientConnections {
private readonly clients = new Map<string, ConnectedDaemonClient>()
private readonly transportSockets = new Set<Socket>()
constructor(private readonly options: DaemonClientConnectionOptions) {}
accept(socket: Socket): void {
this.options.onConnectionAccepted()
this.transportSockets.add(socket)
socket.once('close', () => {
this.transportSockets.delete(socket)
this.options.onTransportChanged()
})
socket.on('error', () => socket.destroy())
if (!this.options.isAcceptingWork()) {
socket.end(
encodeNdjson({
type: 'hello',
ok: false,
error: 'Daemon temporarily unavailable; reconnect',
retryable: true
})
)
return
}
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(message) => this.handleFirstMessage(socket, message),
() => socket.destroy()
)
socket.on('data', (chunk) => parser.feed(decoder.write(chunk)))
}
get(clientId: string): ConnectedDaemonClient | undefined {
return this.clients.get(clientId)
}
values(): IterableIterator<ConnectedDaemonClient> {
return this.clients.values()
}
get size(): number {
return this.clients.size
}
get transportCount(): number {
return this.transportSockets.size
}
hasOnlyTransportsFor(client: ConnectedDaemonClient): boolean {
return [...this.transportSockets].every(
(transport) => transport === client.controlSocket || transport === client.streamSocket
)
}
dispose(): void {
for (const client of this.clients.values()) {
client.controlSocket.destroy()
client.streamSocket?.destroy()
}
this.clients.clear()
for (const socket of this.transportSockets) {
socket.destroy()
}
this.transportSockets.clear()
}
private handleFirstMessage(socket: Socket, message: unknown): void {
const hello = message as HelloMessage
if (hello.type !== 'hello') {
this.options.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.options.protocolVersion) {
this.options.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.options.token) {
this.options.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role })
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' }))
socket.destroy()
return
}
if (hello.role !== 'control' && hello.role !== 'stream') {
this.options.log.log('client-hello-rejected', {
reason: 'invalid-role',
role: hello.role
})
socket.end(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid role' }))
return
}
this.options.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId })
const identity = this.options.identity
socket.write(
encodeNdjson({
type: 'hello',
ok: true,
...(identity.launchNonce && identity.startedAtMs
? {
daemonIdentity: {
pid: process.pid,
startedAtMs: identity.startedAtMs,
launchNonce: identity.launchNonce,
...(identity.entryPath ? { entryPath: identity.entryPath } : {}),
...(identity.appVersion ? { appVersion: identity.appVersion } : {}),
...(identity.spawnerExecPath ? { spawnerExecPath: identity.spawnerExecPath } : {})
}
}
: {})
})
)
if (hello.role === 'control') {
this.installControlSocket(socket, hello.clientId)
return
}
if (hello.role === 'stream') {
const client = this.clients.get(hello.clientId)
if (!client) {
socket.destroy()
return
}
this.installStreamSocket(socket, client)
client.authenticatedPairEstablished = true
this.options.onAuthenticatedPair()
return
}
// Parsed wire data is not made safe by the HelloMessage assertion above.
socket.destroy()
}
private installControlSocket(socket: Socket, clientId: string): void {
const previous = this.clients.get(clientId)
const client: ConnectedDaemonClient = {
clientId,
controlSocket: socket,
streamSocket: null,
authenticatedPairEstablished: false
}
this.clients.set(clientId, client)
this.setupControlParser(socket, clientId)
if (!previous) {
return
}
this.options.onControlReplaced(clientId)
this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished)
previous.streamSocket?.destroy()
previous.controlSocket.destroy()
}
private setupControlParser(socket: Socket, clientId: string): void {
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(message) => this.options.onControlRequest(socket, clientId, message as DaemonRequest),
() => {}
)
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
}
this.options.onClientDisconnected(clientId)
const wasFullyAuthenticated = client.authenticatedPairEstablished
client.streamSocket?.destroy()
this.clients.delete(clientId)
this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated)
this.options.onTransportChanged()
})
}
private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void {
if (
wasFullyAuthenticated &&
![...this.clients.values()].some((client) => client.authenticatedPairEstablished) &&
this.options.isAcceptingWork()
) {
this.options.onLastAuthenticatedClientDisconnected()
}
}
private installStreamSocket(socket: Socket, client: ConnectedDaemonClient): void {
const previous = client.streamSocket
socket.removeAllListeners('data')
client.streamSocket = socket
socket.on('drain', () => this.options.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
}
this.options.onStreamDisconnected(client.clientId)
client.streamSocket = null
}
socket.on('close', cleanup)
socket.on('error', cleanup)
if (previous && previous !== socket) {
previous.destroy()
}
}
}
@@ -2,7 +2,7 @@ import type { Socket } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DaemonPendingRequests } from './daemon-client-pending-requests'
import { requestDaemonRpc } from './daemon-client-rpc-request'
import { isDaemonGoneError } from './daemon-pty-adapter'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import {
DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
DaemonConnectionLostError,
+40
View File
@@ -0,0 +1,40 @@
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE } from './types'
/**
* Narrow on purpose: only the daemon's own reply for a request type it does not implement.
* A transient failure must stay unproven rather than be mistaken for a missing capability.
* The server throws `Unknown request type: <type>`; the client rejects with that text, which
* `addNodePtyRecoveryHint` only ever prepends to.
*/
export function isUnknownRequestTypeError(err: unknown): boolean {
return err instanceof Error && err.message.includes('Unknown request type')
}
// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall);
// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn.
export function isDaemonGoneError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false
}
const errno = err as NodeJS.ErrnoException
if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') {
return true
}
const msg = err.message
return (
msg === 'Connection lost' ||
msg === 'Not connected' ||
msg === 'Hello response timed out' ||
msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE ||
msg === DAEMON_ENDPOINT_LOST_MESSAGE
)
}
export function isMissingWindowsNamedPipeError(err: unknown): boolean {
if (process.platform !== 'win32' || !(err instanceof Error)) {
return false
}
const errno = err as NodeJS.ErrnoException
return errno.code === 'ENOENT' && errno.syscall === 'connect'
}
@@ -0,0 +1,46 @@
import { readFileSync } from 'node:fs'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse'
export function sameEndpointIdentity(
left: DaemonEndpointIdentity,
right: DaemonEndpointIdentity
): boolean {
return (
left.pid === right.pid &&
left.startedAtMs === right.startedAtMs &&
left.launchNonce === right.launchNonce
)
}
export function exactDaemonIncarnationForPidRecord(
identity: DaemonEndpointIdentity,
pidRecord: ParsedDaemonPid | null
): ExactDaemonIncarnation {
return {
identity: { ...identity },
...(process.platform === 'linux' &&
pidRecord?.pid === identity.pid &&
pidRecord.startedAtMs === identity.startedAtMs &&
pidRecord.launchNonce === identity.launchNonce &&
pidRecord.linuxStartTicks &&
pidRecord.bootId
? {
linuxStartTicks: pidRecord.linuxStartTicks,
bootId: pidRecord.bootId
}
: {})
}
}
export function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null {
if (!pidPath) {
return null
}
try {
return parseDaemonPidFile(readFileSync(pidPath, 'utf8'))
} catch {
return null
}
}
@@ -0,0 +1,173 @@
import { chmodSync, unlinkSync, writeFileSync } from 'node:fs'
import type { DaemonFileLog } from './daemon-file-log'
import {
DaemonEndpointUnavailableError,
getDaemonSocketBindPath,
publishDaemonEndpoint,
readDaemonEndpointOwnershipState,
type DaemonEndpointOwnershipState,
type DaemonSocketIdentity
} from './daemon-endpoint-ownership'
import { probeSocketConnect } from './daemon-endpoint-probe'
import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner'
type DaemonEndpointLifecycleOptions = {
socketPath: string
tokenPath: string
pidPath: string | null
launchNonce: string | null
token: string
publishEndpointOwnership: () => void
log: DaemonFileLog
isServing: () => boolean
onOwnershipLost: () => void
}
export class DaemonEndpointLifecycle {
private static readonly OWNERSHIP_POLL_MS = 30 * 1000
private static readonly LOSS_CONFIRMATIONS = 2
private ownedSocketIdentity: DaemonSocketIdentity | null = null
private ownershipTimer: ReturnType<typeof setInterval> | null = null
private ownershipLossStreak = 0
private ownershipLost = false
constructor(private readonly options: DaemonEndpointLifecycleOptions) {}
bindPath(): string {
return process.platform === 'win32'
? this.options.socketPath
: getDaemonSocketBindPath(this.options.socketPath)
}
secureBindPath(bindPath: string): void {
try {
chmodSync(bindPath, 0o600)
} catch {
// Best-effort on platforms that support it.
}
}
abandonBindPath(bindPath: string): void {
if (process.platform === 'win32') {
return
}
try {
unlinkSync(bindPath)
} catch {
// Already consumed by publication, or never created.
}
}
async publish(bindPath: string): Promise<void> {
const outcome = await publishDaemonEndpoint(
bindPath,
this.options.socketPath,
probeSocketConnect
)
if (outcome.status !== 'published') {
this.options.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 {
this.options.publishEndpointOwnership()
publishedOwnership = true
writeFileSync(this.options.tokenPath, this.options.token, { mode: 0o600 })
} catch (error) {
if (publishedOwnership && this.options.pidPath && this.options.launchNonce) {
unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce)
}
this.ownedSocketIdentity = null
throw error
}
}
retireUnstarted(): void {
this.stopOwnershipWatch()
this.unlinkOwnedArtifacts()
}
unlinkOwnedArtifacts(): void {
unlinkOwnedDaemonTokenFile(this.options.tokenPath, this.options.token)
if (this.options.pidPath && this.options.launchNonce) {
unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce)
}
// The canonical endpoint stays: removing it could delete a replacement's name.
this.ownedSocketIdentity = null
}
stopOwnershipWatch(): void {
if (this.ownershipTimer === null) {
return
}
clearInterval(this.ownershipTimer)
this.ownershipTimer = null
}
hasLostOwnership(): boolean {
if (this.ownershipLost) {
return true
}
return this.observeOwnership() === 'lost'
}
get lost(): boolean {
return this.ownershipLost
}
requestRetirementForLoss(): void {
const alreadyLost = this.ownershipLost
this.ownershipLost = true
this.ownedSocketIdentity = null
if (!alreadyLost) {
this.options.log.log('endpoint-ownership-lost', {
socketPath: this.options.socketPath
})
console.warn(
'[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end'
)
}
this.stopOwnershipWatch()
this.options.onOwnershipLost()
}
startOwnershipWatch(): void {
if (process.platform === 'win32' || !this.ownedSocketIdentity) {
return
}
this.ownershipTimer = setInterval(
() => this.checkOwnership(),
DaemonEndpointLifecycle.OWNERSHIP_POLL_MS
)
this.ownershipTimer.unref()
}
private checkOwnership(): void {
if (process.platform === 'win32' || !this.ownedSocketIdentity) {
return
}
if (this.observeOwnership() !== 'lost') {
return
}
this.ownershipLossStreak++
if (this.ownershipLossStreak >= DaemonEndpointLifecycle.LOSS_CONFIRMATIONS) {
this.requestRetirementForLoss()
}
}
private observeOwnership(): DaemonEndpointOwnershipState {
if (process.platform === 'win32' || !this.ownedSocketIdentity || !this.options.isServing()) {
return 'indeterminate'
}
const state = readDaemonEndpointOwnershipState(
this.options.socketPath,
this.ownedSocketIdentity
)
if (state !== 'lost') {
this.ownershipLossStreak = 0
}
return state
}
}
@@ -205,13 +205,13 @@ describe('daemon endpoint ownership publication', () => {
await server.start()
const daemon = server as unknown as {
checkEndpointOwnership: () => void
retirementRequested: boolean
endpoint: { checkOwnership: () => void }
lifecycle: { retirementRequested: boolean }
}
// An inconclusive or matching probe must never retire a healthy daemon, however often it runs.
daemon.checkEndpointOwnership()
daemon.checkEndpointOwnership()
expect(daemon.retirementRequested).toBe(false)
daemon.endpoint.checkOwnership()
daemon.endpoint.checkOwnership()
expect(daemon.lifecycle.retirementRequested).toBe(false)
// Another daemon takes the endpoint name.
unlinkSync(socketPath)
@@ -224,13 +224,13 @@ describe('daemon endpoint ownership publication', () => {
try {
// A single observation can land inside a replacement's unlink-then-link gap, so the
// first one must not retire anything.
daemon.checkEndpointOwnership()
expect(daemon.retirementRequested).toBe(false)
daemon.endpoint.checkOwnership()
expect(daemon.lifecycle.retirementRequested).toBe(false)
daemon.checkEndpointOwnership()
daemon.endpoint.checkOwnership()
// Why: retirement drains rather than kills — an orphaned daemon stops being a
// permanent unreachable host without tearing live sessions out from under the user.
expect(daemon.retirementRequested).toBe(true)
expect(daemon.lifecycle.retirementRequested).toBe(true)
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
try {
@@ -0,0 +1,13 @@
import type { HistoryRecoveryContext } from './daemon-pty-runtime-state'
import type { HistoryRecoveryFreeze } from './history-manager'
// Taking and clearing together prevents any recovery branch from retaining a consumed freeze.
export function takeHistoryRecoveryFreeze(
historyRecovery: HistoryRecoveryContext,
sessionId: string
): HistoryRecoveryFreeze | undefined {
const freeze =
historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined
historyRecovery.freeze = null
return freeze
}
+48 -56
View File
@@ -58,6 +58,22 @@ class ManualIdleClock {
return this.timers.size
}
}
type DaemonServerInternals = {
connections: {
clients: Map<string, { controlSocket: Socket }>
accept(socket: Socket): void
}
admission: { createOrAttachInFlight: number }
lifecycle: {
retirementRequested: boolean
state: string
beginIdleShutdown(): void
}
requestRouter: {
route(clientId: string, request: unknown): Promise<unknown>
}
host: { dispose: () => Promise<void> }
}
function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } {
let onExit: ((code: number) => void) | null = null
@@ -222,11 +238,8 @@ describe('current daemon lifecycle retirement', () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as {
clients: Map<string, { controlSocket: Socket }>
host: { dispose: () => Promise<void> }
}
const controlSocket = [...daemon.clients.values()][0].controlSocket
const daemon = server as unknown as DaemonServerInternals
const controlSocket = [...daemon.connections.clients.values()][0].controlSocket
const originalWrite = controlSocket.write.bind(controlSocket)
let replyFlushed: (() => void) | undefined
vi.spyOn(controlSocket, 'write').mockImplementation(((
@@ -252,11 +265,8 @@ describe('current daemon lifecycle retirement', () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as {
clients: Map<string, { controlSocket: Socket }>
host: { dispose: () => Promise<void> }
}
const controlSocket = [...daemon.clients.values()][0].controlSocket
const daemon = server as unknown as DaemonServerInternals
const controlSocket = [...daemon.connections.clients.values()][0].controlSocket
const originalWrite = controlSocket.write.bind(controlSocket)
vi.spyOn(controlSocket, 'write').mockImplementation(((chunk: string | Uint8Array) =>
originalWrite(chunk)) as unknown as Socket['write'])
@@ -301,8 +311,8 @@ describe('current daemon lifecycle retirement', () => {
await startServer()
const client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as { createOrAttachInFlight: number }
daemon.createOrAttachInFlight = 1
const daemon = server as unknown as DaemonServerInternals
daemon.admission.createOrAttachInFlight = 1
await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({
retiring: false
@@ -395,13 +405,13 @@ describe('current daemon lifecycle retirement', () => {
await first.ensureConnected()
await first.request('createOrAttach', { sessionId: 'reconnected', cols: 80, rows: 24 })
first.disconnect()
const daemon = server as unknown as { retirementRequested: boolean }
await waitFor(() => daemon.retirementRequested)
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.lifecycle.retirementRequested)
expect(onIdleShutdown).not.toHaveBeenCalled()
const second = new DaemonClient({ socketPath, tokenPath })
await second.ensureConnected()
await waitFor(() => !daemon.retirementRequested)
await waitFor(() => !daemon.lifecycle.retirementRequested)
subprocess.exit(0)
expect(onIdleShutdown).not.toHaveBeenCalled()
@@ -444,8 +454,8 @@ describe('current daemon lifecycle retirement', () => {
role: 'control'
})}\n`
)
const daemon = server as unknown as { clients: Map<string, unknown> }
await waitFor(() => daemon.clients.has('startup-health-control'))
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.connections.clients.has('startup-health-control'))
clock.advanceBy(1_000)
healthControl.destroy()
@@ -465,18 +475,15 @@ describe('current daemon lifecycle retirement', () => {
role: 'control'
})}\n`
)
const daemon = server as unknown as {
clients: Map<string, unknown>
retirementRequested: boolean
}
await waitFor(() => daemon.clients.has('startup-control-create'))
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.connections.clients.has('startup-control-create'))
const response = await requestOnRawSocket(control, {
id: 'control-only-create',
type: 'createOrAttach',
payload: { sessionId: 'must-not-start', cols: 80, rows: 24 }
})
expect(response.error).toContain('connection is incomplete')
expect(daemon.retirementRequested).toBe(false)
expect(daemon.lifecycle.retirementRequested).toBe(false)
expect(clock.pendingCount).toBe(0)
clock.advanceBy(1_000)
@@ -501,14 +508,11 @@ describe('current daemon lifecycle retirement', () => {
role: 'control'
})}\n`
)
const daemon = server as unknown as {
clients: Map<string, unknown>
retirementRequested: boolean
}
await waitFor(() => daemon.clients.has('control-only-overlap'))
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.connections.clients.has('control-only-overlap'))
paired.disconnect()
await waitFor(() => daemon.retirementRequested)
await waitFor(() => daemon.lifecycle.retirementRequested)
expect(clock.pendingCount).toBe(0)
const response = await requestOnRawSocket(incomplete, {
id: 'overlap-control-create',
@@ -516,7 +520,7 @@ describe('current daemon lifecycle retirement', () => {
payload: { sessionId: 'must-not-start', cols: 80, rows: 24 }
})
expect(response.error).toContain('connection is incomplete')
expect(daemon.retirementRequested).toBe(true)
expect(daemon.lifecycle.retirementRequested).toBe(true)
incomplete.destroy()
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
@@ -538,10 +542,8 @@ describe('current daemon lifecycle retirement', () => {
role: 'control'
})}\n`
)
const daemon = server as unknown as {
retirementRequested: boolean
}
await waitFor(() => daemon.retirementRequested)
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.lifecycle.retirementRequested)
expect(clock.pendingCount).toBe(0)
replacementControl.destroy()
@@ -554,8 +556,8 @@ describe('current daemon lifecycle retirement', () => {
await client.ensureConnected()
await client.request('createOrAttach', { sessionId: 'live', cols: 80, rows: 24 })
client.disconnect()
const daemon = server as unknown as { retirementRequested: boolean }
await waitFor(() => daemon.retirementRequested)
const daemon = server as unknown as DaemonServerInternals
await waitFor(() => daemon.lifecycle.retirementRequested)
expect(onIdleShutdown).not.toHaveBeenCalled()
expect(clock.pendingCount).toBe(0)
@@ -612,14 +614,11 @@ describe('current daemon lifecycle retirement', () => {
it('rejects create or attach once the idle admission fence is pending', async () => {
await startServer()
const daemon = server as unknown as {
idleShutdownState: string
routeRequest(clientId: string, request: unknown): Promise<unknown>
}
daemon.idleShutdownState = 'idle-shutdown-pending'
const daemon = server as unknown as DaemonServerInternals
daemon.lifecycle.state = 'idle-shutdown-pending'
await expect(
daemon.routeRequest('late-client', {
daemon.requestRouter.route('late-client', {
id: 'late-create',
type: 'createOrAttach',
payload: { sessionId: 'late', cols: 80, rows: 24 }
@@ -629,30 +628,23 @@ describe('current daemon lifecycle retirement', () => {
it('aborts the pending shutdown when create or attach started before the fence', async () => {
await startServer()
const daemon = server as unknown as {
createOrAttachInFlight: number
idleShutdownState: string
beginIdleShutdown(): void
}
daemon.createOrAttachInFlight = 1
const daemon = server as unknown as DaemonServerInternals
daemon.admission.createOrAttachInFlight = 1
daemon.beginIdleShutdown()
daemon.lifecycle.beginIdleShutdown()
expect(daemon.idleShutdownState).toBe('running')
expect(daemon.lifecycle.state).toBe('running')
expect(onIdleShutdown).not.toHaveBeenCalled()
})
it('explicitly marks a post-fence accepted transport as retryable', async () => {
await startServer()
const daemon = server as unknown as {
idleShutdownState: string
handleConnection(socket: Socket): void
}
daemon.idleShutdownState = 'idle-shutdown-pending'
const daemon = server as unknown as DaemonServerInternals
daemon.lifecycle.state = 'idle-shutdown-pending'
const socket = new EventEmitter() as Socket
socket.end = vi.fn() as unknown as Socket['end']
daemon.handleConnection(socket)
daemon.connections.accept(socket)
const payload = vi.mocked(socket.end).mock.calls[0]?.[0]
expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true })
+2 -1
View File
@@ -18,7 +18,8 @@ import {
type DaemonProcessHandle
} from './daemon-spawner'
import { DAEMON_EXIT_ENDPOINT_OCCUPIED } from './daemon-endpoint-ownership'
import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
import { DaemonPtyRouter } from './daemon-pty-router'
import { DaemonClient } from './client'
import {
@@ -0,0 +1,19 @@
export function removeDaemonListener<T>(listeners: T[], listener: T): void {
const index = listeners.indexOf(listener)
if (index !== -1) {
listeners.splice(index, 1)
}
}
export function notifyDaemonAuditListeners<T>(
listeners: readonly ((value: T) => void)[],
value: T
): void {
for (const listener of listeners.slice()) {
try {
listener(value)
} catch {
// Audit observers cannot affect daemon operations.
}
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { DaemonServer, type DaemonServerOptions } from './daemon-server'
import { DaemonServer } from './daemon-server'
import type { DaemonServerOptions } from './daemon-server-options'
import type { DaemonFileLog } from './daemon-file-log'
export type DaemonStartOptions = {
@@ -10,8 +10,8 @@ import { PROTOCOL_VERSION } from './types'
import type { SubprocessHandle } from './session-subprocess-handle'
type DaemonServerPrivate = {
pendingPtySpawnPreparations: Map<string, Set<unknown>>
clients: Map<string, { streamSocket: Socket | null }>
preparations: { pending: Map<string, Set<unknown>> }
connections: { clients: Map<string, { streamSocket: Socket | null }> }
}
function createMockSubprocess(): SubprocessHandle {
@@ -96,7 +96,7 @@ describe('daemon preflight client replacement', () => {
await replacement.ensureConnected()
finishPreparation()
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
)
expect(spawnSubprocess).not.toHaveBeenCalled()
@@ -139,12 +139,13 @@ describe('daemon preflight client replacement', () => {
disconnected.stream.destroy()
await vi.waitFor(() =>
expect(
(server as unknown as DaemonServerPrivate).clients.get(disconnectedClientId)?.streamSocket
(server as unknown as DaemonServerPrivate).connections.clients.get(disconnectedClientId)
?.streamSocket
).toBeNull()
)
finishPreparation()
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
)
expect(spawnSubprocess).toHaveBeenCalledOnce()
@@ -2,7 +2,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rmSync } from 'node:fs'
import { DaemonClient } from './client'
import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-control'
import {
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy'
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
import { DaemonPtySessionControl } from './daemon-pty-session-control'
import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth'
import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window'
import type { GetSnapshotResult } from './types'
import type { PtyProviderBufferSnapshot } from '../providers/types'
const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000
export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl {
async getBufferSnapshot(
id: string,
opts: { scrollbackRows?: number } = {}
): Promise<PtyProviderBufferSnapshot | null> {
if (!this.supportsAuthoritativeBufferSnapshots) {
return null
}
try {
const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows)
const result = await this.client.request<GetSnapshotResult>('getSnapshot', {
sessionId: id,
...(scrollbackRows !== undefined ? { scrollbackRows } : {})
})
const snapshot = result.snapshot
// Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket.
if (!snapshot || typeof snapshot.outputSequence !== 'number') {
return null
}
const restored =
this.historyManager &&
this.historyReader &&
(scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS)
? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows)
: snapshot
return this.toProviderBufferSnapshot(restored)
} catch {
return null
}
}
protected toProviderBufferSnapshot(
snapshot: NonNullable<GetSnapshotResult['snapshot']>
): PtyProviderBufferSnapshot | null {
if (typeof snapshot.outputSequence !== 'number') {
return null
}
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags)
return {
data: snapshot.rehydrateSequences + snapshot.snapshotAnsi,
frameRestoreAnsi: snapshot.frameRestoreAnsi,
scrollbackAnsi: snapshot.scrollbackAnsi,
cols: snapshot.cols,
rows: snapshot.rows,
cwd: snapshot.cwd,
lastTitle: snapshot.lastTitle,
seq: snapshot.outputSequence,
source: 'headless',
oscLinks: snapshot.oscLinks,
alternateScreen: snapshot.modes.alternateScreen,
// Why known `0` is carried too: it proves the app negotiated nothing at
// this boundary, which is a different fact from a source that cannot say.
...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}),
...(snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
: {})
}
}
protected async overlayDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
if (!this.historyManager || !this.historyReader) {
return liveSnapshot
}
// Why turn the caller away instead of queueing: this session already has a
// compact in flight whose result a third one would only duplicate, and an
// unbounded queue is how a stalled history filesystem grows without limit.
if (this.checkpointQueue.isSaturated(sessionId)) {
return liveSnapshot
}
// Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts.
// Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work.
if (!this.tryAdmitNonFinalCheckpoint(sessionId)) {
return liveSnapshot
}
// Why per session with a deadline: a reattach is a user click, so it must wait
// on this session's own compact and nothing else. A blown deadline does not
// cancel that compact — it keeps running and still commits — so the fallback
// costs restore depth for this one reattach, never durable history (STA-4173).
return await this.checkpointQueue.runWithDeadline(
sessionId,
async () => {
try {
return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows)
} finally {
this.releaseNonFinalCheckpointAdmission(sessionId)
this.overlayDeadlineWarnedSessionIds.delete(sessionId)
}
},
DURABLE_HISTORY_OVERLAY_DEADLINE_MS,
liveSnapshot,
{
onDeadline: () => {
if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) {
this.overlayDeadlineWarnedSessionIds.add(sessionId)
console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId)
}
}
}
)
}
protected tryAdmitNonFinalCheckpoint(sessionId: string): boolean {
if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) {
if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) {
this.nonFinalAdmissionDeniedSessionIds.add(sessionId)
console.warn('[history] non-final checkpoint already in flight:', sessionId)
}
return false
}
if (
this.nonFinalCheckpointAdmissionSessionIds.size >=
(this.constructor as typeof DaemonPtyBufferSnapshots).MAX_CONCURRENT_CHECKPOINTS
) {
this.reportNonFinalGlobalAdmissionDenial(sessionId)
return false
}
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
this.nonFinalCheckpointAdmissionSessionIds.add(sessionId)
return true
}
protected reportNonFinalGlobalAdmissionDenial(sessionId: string): void {
if (!this.nonFinalGlobalAdmissionWarningActive) {
this.nonFinalGlobalAdmissionWarningActive = true
console.warn('[history] non-final checkpoint global admission limit reached:', sessionId)
}
}
protected releaseNonFinalCheckpointAdmission(sessionId: string): void {
if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) {
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
this.nonFinalGlobalAdmissionWarningActive = false
}
}
protected async compactDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
if (!this.historyReader) {
return liveSnapshot
}
try {
// Why compact before reading: an independent take/append races the 5s tick, can
// seq-gap the log, and would remount a stale checkpoint over the live window.
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId),
requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId)
})
if (checkpoint.checkpoint === 'committed') {
this.sessionsNeedingFullCheckpoint.delete(sessionId)
}
if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) {
return checkpoint.snapshot ?? liveSnapshot
}
if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) {
return checkpoint.snapshot
}
const restoreInfo = await this.historyReader.detectColdRestore(sessionId, {
ignoreCleanEnd: true,
wslDistro: this.wslDistrosBySessionId.get(sessionId)
})
if (!restoreInfo) {
return liveSnapshot
}
return await buildDurableCheckpointSnapshot({
liveSnapshot: checkpoint.snapshot,
restoreInfo,
scrollbackRows
})
} catch (error) {
console.warn('[history] durable snapshot overlay failed:', sessionId, error)
return liveSnapshot
}
}
async clearBuffer(id: string): Promise<void> {
await this.client.request('clearScrollback', { sessionId: id })
this.markSessionDirty(id)
}
acknowledgeDataEvent(_id: string, _charCount: number): void {
// No flow control for daemon-backed terminals
}
}
@@ -0,0 +1,192 @@
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
import { DaemonPtyCheckpointScheduler } from './daemon-pty-checkpoint-scheduler'
import type { SnapshotCheckpointResult } from './daemon-pty-runtime-state'
import type { GetSnapshotResult, TakePendingOutputResult } from './types'
export abstract class DaemonPtyCheckpointPersistence extends DaemonPtyCheckpointScheduler {
// Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry;
// skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole.
protected async writeSessionCheckpoint(
sessionId: string,
opts: { final: boolean; teardown: boolean }
): Promise<'done' | 'deferred'> {
if (!this.supportsIncrementalCheckpoints) {
const result = await this.client.request<GetSnapshotResult>('getSnapshot', { sessionId })
if (result.snapshot && this.historyManager) {
const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot)
return checkpoint === 'retryable' ? 'deferred' : 'done'
}
return 'done'
}
if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) {
if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) {
return 'deferred'
}
// Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize,
// so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore).
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: opts.teardown,
forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId),
requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId)
})
if (checkpoint.checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return 'done'
}
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
sessionId
})
if (!take) {
return 'done'
}
if (take.overflowed) {
// Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it.
if (this.isFullCheckpointCoolingDown(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: true
})
if (checkpoint.checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
return 'done'
}
if (take.records.length === 0) {
return 'done'
}
if (!this.historyManager) {
return 'done'
}
const appendResult = await this.historyManager.appendIncrements(
sessionId,
take.seq,
take.records
)
if (appendResult === 'needs-checkpoint') {
// Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them.
if (this.isFullCheckpointCoolingDown(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: true
})
if (checkpoint.checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
}
return 'done'
}
protected async takeSnapshotAndCheckpoint(
sessionId: string,
opts: {
teardown: boolean
forceLiveSnapshot?: boolean
requireContinuityProof?: boolean
}
): Promise<SnapshotCheckpointResult> {
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
sessionId,
includeSnapshot: true,
teardownSnapshot: opts.teardown
})
if (take?.snapshot && this.historyManager) {
// Why require drainedRecords: an older daemon still empties the pending
// queue on includeSnapshot but omits the field. Treating absence as []
// would compact stale disk history and reset the log.
const snapshot =
take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed
? take.snapshot
: await this.buildDurableHistorySnapshot(
sessionId,
take.snapshot,
[...take.drainedRecords, ...take.records],
{
pendingRecordsAreComplete: take.seq === 1,
...(opts.requireContinuityProof === true
? { requiredPreviousPendingOutputSeq: take.seq - 1 }
: {})
}
)
const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, {
pendingOutputSeq: take.seq
})
if (checkpoint === 'retryable') {
// Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that
// failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and
// defeat the log's seq-gap detection. A stale prefix beats an undetectable hole.
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.sessionsNeedingLiveCheckpoint.add(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
this.markSessionDirty(sessionId)
return { checkpoint, snapshot: take.snapshot }
}
if (checkpoint === 'unavailable') {
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return { checkpoint, snapshot: take.snapshot }
}
this.lastFullCheckpointAt.set(sessionId, Date.now())
if (take.records.length > 0 && snapshot === take.snapshot) {
// Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail.
await this.historyManager.appendIncrements(sessionId, take.seq, take.records)
}
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return { checkpoint: 'committed', snapshot }
}
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null }
}
protected async buildDurableHistorySnapshot(
sessionId: string,
liveSnapshot: NonNullable<TakePendingOutputResult['snapshot']>,
pendingRecords: TakePendingOutputResult['records'],
opts: {
pendingRecordsAreComplete: boolean
requiredPreviousPendingOutputSeq?: number
}
): Promise<NonNullable<TakePendingOutputResult['snapshot']>> {
if (!this.historyReader) {
return liveSnapshot
}
try {
const restoreInfo = await this.historyReader.detectColdRestore(sessionId, {
ignoreCleanEnd: true,
wslDistro: this.wslDistrosBySessionId.get(sessionId)
})
if (
(!restoreInfo && !opts.pendingRecordsAreComplete) ||
(opts.requiredPreviousPendingOutputSeq !== undefined &&
restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq)
) {
console.warn('[history] durable continuity unproven; using live snapshot:', sessionId)
return liveSnapshot
}
return await buildDurableCheckpointSnapshot({
liveSnapshot,
restoreInfo,
pendingRecords
})
} catch (error) {
console.warn('[history] durable history rebuild failed:', sessionId, error)
return liveSnapshot
}
}
}
@@ -0,0 +1,251 @@
import { awaitDaemonWorkWithinCallerDeadline } from './daemon-request-deadline'
import { DaemonPtyConnectionLifecycle } from './daemon-pty-connection-lifecycle'
export abstract class DaemonPtyCheckpointScheduler extends DaemonPtyConnectionLifecycle {
protected stopCheckpointTimer(): void {
if (!this.checkpointTimer) {
return
}
clearTimeout(this.checkpointTimer)
this.checkpointTimer = null
}
protected stopCheckpointTimerIfIdle(): void {
if (this.dirtySessionVersions.size === 0) {
this.stopCheckpointTimer()
}
}
protected scheduleCheckpointTimer(): void {
if (
this.checkpointTimer ||
!this.historyManager ||
!this.supportsCheckpoints ||
this.dirtySessionVersions.size === 0
) {
return
}
// Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write.
this.checkpointTimer = setTimeout(
() => {
this.checkpointTimer = null
// Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes.
if (this.checkpointInFlight) {
this.scheduleCheckpointTimer()
return
}
const checkpoint = this.checkpointDirtySessions()
this.checkpointInFlight = checkpoint
void checkpoint
.finally(() => {
if (this.checkpointInFlight === checkpoint) {
this.checkpointInFlight = null
this.scheduleCheckpointTimer()
}
})
// Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here.
.catch(() => {})
},
(this.constructor as typeof DaemonPtyCheckpointScheduler).CHECKPOINT_INTERVAL_MS
)
}
protected markSessionDirty(sessionId: string): void {
if (!this.activeSessionIds.has(sessionId)) {
return
}
this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1)
this.scheduleCheckpointTimer()
}
protected async checkpointDirtySessions(): Promise<void> {
if (!this.historyManager || this.dirtySessionVersions.size === 0) {
return
}
// Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces)
// while not dropping writes that arrive mid-checkpoint.
const versions = new Map(
[...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId))
)
if (versions.size === 0) {
this.dirtySessionVersions.clear()
this.stopCheckpointTimer()
return
}
const completed = await this.checkpointSessions(versions.keys())
for (const [sessionId, version] of versions) {
if (completed.has(sessionId) && this.dirtySessionVersions.get(sessionId) === version) {
this.dirtySessionVersions.delete(sessionId)
}
}
this.stopCheckpointTimerIfIdle()
}
/** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */
protected async runExclusiveCheckpoint(
operation: () => Promise<void>,
options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {}
): Promise<boolean> {
this.stopCheckpointTimer()
// Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together.
const previous = this.checkpointInFlight ?? Promise.resolve()
const checkpoint = previous.catch(() => {}).then(operation)
this.checkpointInFlight = checkpoint
// Why the release rides the checkpoint instead of the caller's await: a caller that walks away
// at its deadline must leave this checkpoint as the tail, so the durable write still runs to
// completion, still commits, and the next waiter still queues behind it (STA-4228).
const settled = checkpoint.then(
() => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty),
(err: unknown) => {
this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty)
throw err
}
)
if (options.callerDeadlineMs === undefined) {
await settled
return true
}
return await awaitDaemonWorkWithinCallerDeadline(settled, options.callerDeadlineMs)
}
protected releaseExclusiveCheckpoint(
checkpoint: Promise<void>,
rescheduleDirty: boolean | undefined
): void {
if (this.checkpointInFlight === checkpoint) {
this.checkpointInFlight = null
}
this.stopCheckpointTimer()
if (rescheduleDirty !== false) {
this.scheduleCheckpointTimer()
}
}
// Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the
// detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact.
protected async checkpointAllSessions(): Promise<void> {
const completed = await this.checkpointSessions(this.activeSessionIds, { final: true })
for (const sessionId of completed) {
this.dirtySessionVersions.delete(sessionId)
}
}
protected async checkpointSessions(
sessionIds: Iterable<string>,
opts?: { final?: boolean; teardown?: boolean }
): Promise<Set<string>> {
const completed = new Set<string>()
if (!this.historyManager) {
return completed
}
const ids = Array.from(sessionIds)
let nextIndex = 0
const checkpointNext = async (): Promise<void> => {
for (;;) {
// No worker in this pass awaits abandoned work, so a full admission set cannot open here.
if (
opts?.final !== true &&
this.nonFinalCheckpointAdmissionSessionIds.size >=
(this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS
) {
const deferredSessionId = ids[nextIndex]
if (deferredSessionId !== undefined) {
this.reportNonFinalGlobalAdmissionDenial(deferredSessionId)
}
return
}
const index = nextIndex
nextIndex++
if (index >= ids.length) {
return
}
const sessionId = ids[index]
await this.checkpointSession(sessionId, {
final: opts?.final === true,
teardown: opts?.teardown === true
})
.then((result) => {
// Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires.
if (result === 'done') {
completed.add(sessionId)
}
})
.catch((err) => console.warn('[history] checkpoint failed:', sessionId, err))
}
}
// Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once.
const workers = Array.from(
{
length: Math.min(
(this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS,
ids.length
)
},
() => checkpointNext()
)
await Promise.all(workers)
return completed
}
// Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately.
protected isFullCheckpointCoolingDown(sessionId: string): boolean {
const last = this.lastFullCheckpointAt.get(sessionId)
if (last === undefined) {
return false
}
const elapsed = Date.now() - last
// Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window.
return (
elapsed >= 0 &&
elapsed <
(this.constructor as typeof DaemonPtyCheckpointScheduler).FULL_CHECKPOINT_COOLDOWN_MS
)
}
protected async checkpointSession(
sessionId: string,
opts: { final: boolean; teardown: boolean }
): Promise<'done' | 'deferred'> {
// Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and
// deferring there would silently drop what the user left on screen rather than delay it.
if (opts.final) {
return await this.checkpointQueue.run(sessionId, () =>
this.writeSessionCheckpoint(sessionId, opts)
)
}
// Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue
// still commits and the next tick retries this one. Nothing on disk is discarded.
if (
this.checkpointQueue.isSaturated(sessionId) ||
!this.tryAdmitNonFinalCheckpoint(sessionId)
) {
return 'deferred'
}
const run = async (): Promise<'done' | 'deferred'> => {
try {
return await this.writeSessionCheckpoint(sessionId, opts)
} finally {
this.releaseNonFinalCheckpointAdmission(sessionId)
this.periodicDeadlineWarnedSessionIds.delete(sessionId)
}
}
return await this.checkpointQueue.runWithDeadline(
sessionId,
run,
(this.constructor as typeof DaemonPtyCheckpointScheduler).PERIODIC_CHECKPOINT_DEADLINE_MS,
'deferred',
{
onDeadline: () => {
if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) {
this.periodicDeadlineWarnedSessionIds.add(sessionId)
console.warn('[history] periodic checkpoint deadline exceeded:', sessionId)
}
},
onAbandonedRejection: (error) => {
console.warn('[history] checkpoint failed:', sessionId, error)
}
}
)
}
}
@@ -0,0 +1,156 @@
import { readFile } from 'node:fs/promises'
import {
classifyDaemonAuditFailure,
type DaemonAuditObservation,
type DaemonAuditTrigger
} from './daemon-audit-classifier'
import {
exactDaemonIncarnationForPidRecord,
sameEndpointIdentity
} from './daemon-endpoint-incarnation'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { notifyDaemonAuditListeners } from './daemon-listener-registry'
import { DaemonPtyEventSubscriptions } from './daemon-pty-event-subscriptions'
import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse'
export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscriptions {
protected async ensureConnected(deadlineMs?: number): Promise<void> {
try {
// Why: destructive teardown bounds the handshake by its deadline so a wedged
// connect fails fast; undefined keeps the default connect behavior.
await (deadlineMs !== undefined
? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
: this.client.ensureConnected())
} finally {
// Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks.
this.releasePendingRespawnAdoptionLease()
}
this.recordAuthenticatedIdentity()
// Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the
// daemon-side backgrounded set (process state lost with the old daemon) needs a resync.
const isFreshConnection = this.removeEventListener === null
this.setupEventRouting()
this.scheduleCheckpointTimer()
this.flushOwedProducerResumes()
if (isFreshConnection) {
this.resyncBackgroundedSessions()
}
}
protected recordAuthenticatedIdentity(): void {
const current = this.client.getDaemonIdentity()
if (!current) {
return
}
const previous = this.lastAuthenticatedIdentity
if (previous && sameEndpointIdentity(previous, current)) {
return
}
this.lastAuthenticatedIdentity = { ...current }
this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord)
if (!previous) {
return
}
const event = { previous: { ...previous }, current: { ...current } }
notifyDaemonAuditListeners(this.identityChangeListeners, event)
}
protected async resolveExactDaemonIncarnation(
exactIncarnation: ExactDaemonIncarnation | null
): Promise<ExactDaemonIncarnation | null> {
if (
!exactIncarnation ||
process.platform !== 'linux' ||
(exactIncarnation.linuxStartTicks && exactIncarnation.bootId)
) {
return exactIncarnation
}
const cachedIncarnation = exactDaemonIncarnationForPidRecord(
exactIncarnation.identity,
this.pidRecord
)
if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) {
return cachedIncarnation
}
const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity)
this.pidRecord = pidRecord ?? this.pidRecord
return pidRecord?.linuxStartTicks && pidRecord.bootId
? {
identity: { ...exactIncarnation.identity },
linuxStartTicks: pidRecord.linuxStartTicks,
bootId: pidRecord.bootId
}
: exactIncarnation
}
protected cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void {
if (
exactIncarnation &&
this.lastAuthenticatedIdentity &&
sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity)
) {
this.exactDaemonIncarnation = exactIncarnation
}
}
protected async readMatchingPidRecord(
identity: DaemonEndpointIdentity
): Promise<ParsedDaemonPid | null> {
if (!this.pidPath) {
return null
}
try {
const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8'))
return parsed?.pid === identity.pid &&
parsed.startedAtMs === identity.startedAtMs &&
parsed.launchNonce === identity.launchNonce
? parsed
: null
} catch {
return null
}
}
protected observeAuditFailure(
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
exactIncarnation = this.exactDaemonIncarnation,
additionalEvidenceSources: readonly DaemonEvidenceSource[] = [],
endpointGoneProof?: 'windows_named_pipe_missing'
): void {
void this.resolveExactDaemonIncarnation(exactIncarnation)
.then((resolvedIncarnation) => {
this.cacheExactDaemonIncarnation(resolvedIncarnation)
return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, {
additionalEvidenceSources,
endpointGoneProof
})
})
.then((observation) => this.publishAuditObservation(observation))
.catch(() => {})
}
protected publishAuditObservation(observation: DaemonAuditObservation): void {
this.lastAuditObservation = observation
this.trackAuditEligibility(observation)
notifyDaemonAuditListeners(this.auditObservationListeners, observation)
}
protected resyncBackgroundedSessions(): void {
for (const id of this.backgroundedSessionIds) {
// Harmless no-op for sessions the daemon doesn't know (yet).
this.client.notify('setSessionBackground', { sessionId: id, background: true })
}
}
protected flushOwedProducerResumes(): void {
if (this.producerResumesOwedOnReconnect.size === 0) {
return
}
for (const id of this.producerResumesOwedOnReconnect) {
// Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency.
this.client.notify('resumePty', { sessionId: id })
}
this.producerResumesOwedOnReconnect.clear()
}
}
@@ -0,0 +1,290 @@
import { existsSync } from 'node:fs'
import { getMacDaemonSystemResolverHealth } from './daemon-health'
import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import { DaemonPtyCheckpointPersistence } from './daemon-pty-checkpoint-persistence'
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
import type { ListSessionsResult } from './types'
import type { PtyBackgroundStreamEvent } from '../providers/types'
export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersistence {
// Why: the token read no longer throws, so audit its absence directly after an authenticated drop.
protected isRetiredEndpointTokenMissing(): boolean {
return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath)
}
protected async withDaemonRetry<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (err) {
const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing()
if (missingRetiredEndpointToken) {
this.observeAuditFailure(
'token_missing_after_authenticated_disconnect',
this.exactDaemonIncarnation,
['token_file']
)
}
if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) {
throw err
}
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn().finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
try {
return await fn()
} finally {
// Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn).
this.releasePendingRespawnAdoptionLease()
}
}
}
protected reconnectAfterWriteFailure(): void {
if (
this.writeRecoveryPromise ||
this.writeRecoveryAttempted ||
this.respawnAdoptionClosed ||
!this.respawnFn
) {
return
}
this.writeRecoveryAttempted = true
// Why: the dead endpoint took down every session on this daemon. Signal all
// active panes now — while they are still in activeSessionIds, so the
// renderer's liveness gate still reads them live — so background panes
// remount + re-attach alongside the one that was written, instead of being
// left frozen with silently dropped input until each is typed into.
this.notifyActiveSessionsWriteUnavailable()
const recovery = this.withDaemonRetry(() => this.ensureConnected())
.catch((error) => console.warn('[daemon] Failed to recover after rejected PTY input:', error))
.finally(() => {
this.releasePendingRespawnAdoptionLease()
if (this.writeRecoveryPromise === recovery) {
this.writeRecoveryPromise = null
}
})
this.writeRecoveryPromise = recovery
}
protected notifyActiveSessionsWriteUnavailable(): void {
// Snapshot first: a listener that kills a pane would mutate activeSessionIds
// mid-iteration and silently skip the sibling this fan-out exists to reach.
const ids = [...this.activeSessionIds]
for (const id of ids) {
this.sessionsAwaitingDaemonRecovery.add(id)
this.emitWriteUnavailable(id)
}
}
protected clearSessionAwaitingDaemonRecovery(sessionId: string): void {
this.sessionsAwaitingDaemonRecovery.delete(sessionId)
if (this.sessionsAwaitingDaemonRecovery.size === 0) {
this.writeRecoveryAttempted = false
}
}
protected async withHistorySpawnLock<T>(
sessionId: string,
operation: () => Promise<T>
): Promise<T> {
if (!this.historyManager) {
return await operation()
}
const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>((resolve) => {
release = resolve
})
const tail = previous.then(
() => current,
() => current
)
this.historySpawnLocks.set(sessionId, tail)
await previous.catch(() => {})
try {
return await operation()
} finally {
release()
if (this.historySpawnLocks.get(sessionId) === tail) {
this.historySpawnLocks.delete(sessionId)
}
}
}
protected async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise<void> {
if (!this.respawnFn) {
return
}
const health = await getMacDaemonSystemResolverHealth(
this.socketPath,
this.tokenPath,
this.protocolVersion
)
if (health !== 'unhealthy') {
return
}
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
console.warn(
daemonLiveSessionCount === null
? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified'
: `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return
}
// Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs.
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
'[daemon] macOS system resolver unavailable - respawning daemon',
'unhealthy_resolver'
).finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
}
/** Replace a stale packaged daemon only after its live sessions drain. */
protected async replaceStaleBundleDaemonBeforeNewPty(): Promise<void> {
if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) {
return
}
if (!this.staleBundleReplacementPromise) {
this.staleBundleReplacementPromise = this.replaceStaleBundleDaemonOnce(
this.runtimeDir,
this.packagedAppVersion
).finally(() => {
this.staleBundleReplacementPromise = null
})
}
await this.staleBundleReplacementPromise
}
protected async replaceStaleBundleDaemonOnce(
runtimeDir: string,
packagedAppVersion: string
): Promise<void> {
const stale = await isDaemonStaleForCurrentBundle(
runtimeDir,
this.socketPath,
this.tokenPath,
packagedAppVersion,
this.protocolVersion
)
if (!stale) {
return
}
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
console.warn(
daemonLiveSessionCount === null
? '[daemon] Packaged daemon is stale - preserving it because live session state could not be verified'
: `[daemon] Packaged daemon is stale - preserving it because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return
}
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
'[daemon] Packaged daemon is stale - respawning from the current app bundle',
'stale_bundle'
).finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
}
/** Replace a TCC-severed daemon only after its live sessions drain. */
protected async replaceSeveredMacTccDaemonBeforeNewPty(): Promise<void> {
// Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS.
if (!this.respawnFn || !this.runtimeDir) {
return
}
const health = await getMacDaemonTccAttributionHealth(
this.runtimeDir,
this.socketPath,
this.tokenPath,
this.protocolVersion
)
if (health !== 'severed') {
return
}
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
console.warn(
daemonLiveSessionCount === null
? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified'
: `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready`
)
return
}
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
'[daemon] macOS TCC attribution severed - respawning daemon under the current app binary',
'severed_tcc_attribution'
).finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
}
protected async getDaemonLiveSessionCount(): Promise<number | null> {
try {
await this.client.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
return result.sessions.filter((session) => session.isAlive).length
} catch {
return null
}
}
protected emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void {
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.backgroundStreamListeners]) {
listener(payload)
}
}
protected async doRespawn(
message = '[daemon] Daemon died — respawning',
reason: DaemonRespawnReason = 'daemon_died'
): Promise<void> {
console.warn(message)
this.removeEventListener?.()
this.removeEventListener = null
this.client.disconnect()
const releaseAdoptionLease = await this.respawnFn!(reason)
if (this.respawnAdoptionClosed) {
// Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns.
releaseAdoptionLease?.()
throw new Error('Daemon adapter closed during respawn')
}
this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null
}
protected releasePendingRespawnAdoptionLease(): void {
const release = this.pendingRespawnAdoptionRelease
this.pendingRespawnAdoptionRelease = null
release?.()
}
}
@@ -0,0 +1,157 @@
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import { DaemonPtySessionInventory } from './daemon-pty-session-inventory'
import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types'
import type { PtyBackgroundStreamEvent } from '../providers/types'
export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory {
onData(
callback: (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
): () => void {
this.dataListeners.push(callback)
return () => {
const idx = this.dataListeners.indexOf(callback)
if (idx !== -1) {
this.dataListeners.splice(idx, 1)
}
}
}
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
this.backgroundStreamListeners.push(callback)
return () => {
const idx = this.backgroundStreamListeners.indexOf(callback)
if (idx !== -1) {
this.backgroundStreamListeners.splice(idx, 1)
}
}
}
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
return () => {}
}
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
if (idx !== -1) {
this.exitListeners.splice(idx, 1)
}
}
}
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
this.writeUnavailableListeners.push(callback)
return () => {
const idx = this.writeUnavailableListeners.indexOf(callback)
if (idx !== -1) {
this.writeUnavailableListeners.splice(idx, 1)
}
}
}
protected emitWriteUnavailable(id: string): void {
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.writeUnavailableListeners]) {
listener({ id })
}
}
dispose(): void {
this.respawnAdoptionClosed = true
this.sessionsAwaitingDaemonRecovery.clear()
this.writeRecoveryAttempted = false
this.releasePendingRespawnAdoptionLease()
this.stopCheckpointTimer()
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.sessionsNeedingFullCheckpoint.clear()
this.sessionsNeedingLiveCheckpoint.clear()
this.sessionsNeedingContinuityCheckpoint.clear()
this.overlayDeadlineWarnedSessionIds.clear()
this.periodicDeadlineWarnedSessionIds.clear()
this.nonFinalAdmissionDeniedSessionIds.clear()
this.coldRestoreCache.clear()
this.wslDistrosBySessionId.clear()
this.pausedProducerSessionIds.clear()
this.producerResumesOwedOnReconnect.clear()
this.auditObservationListeners.length = 0
this.identityChangeListeners.length = 0
this.removeEventListener?.()
this.removeEventListener = null
// Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions
// cleanly ended so they don't trigger false cold restores.
if (this.historyManager) {
void this.historyManager
.dispose()
.catch((err) => console.warn('[history] dispose failed:', err))
}
this.client.disconnect()
}
async establishLifecycleLease(): Promise<void> {
if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) {
return
}
// Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit.
await this.client.ensureConnected()
this.recordAuthenticatedIdentity()
}
// Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable,
// but still write a final checkpoint so a daemon crash while Orca is closed has recovery data.
async disconnectOnly(): Promise<void> {
if (!this.disconnectOnlyPromise) {
this.respawnAdoptionClosed = true
this.sessionsAwaitingDaemonRecovery.clear()
this.writeRecoveryAttempted = false
this.releasePendingRespawnAdoptionLease()
this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns])
}
await this.disconnectOnlyPromise
}
protected async finishDisconnectOnly(keepHistoryShutdowns: Promise<void>[]): Promise<void> {
// Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations.
await Promise.allSettled(keepHistoryShutdowns)
this.respawnAdoptionClosed = true
// Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon
// later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs.
await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), {
rescheduleDirty: false
})
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.coldRestoreCache.clear()
this.wslDistrosBySessionId.clear()
// Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window.
for (const id of this.pausedProducerSessionIds) {
this.client.notify('resumePty', { sessionId: id })
}
this.pausedProducerSessionIds.clear()
this.producerResumesOwedOnReconnect.clear()
this.removeEventListener?.()
this.removeEventListener = null
if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
try {
// Why: only the authenticated daemon can atomically prove it's empty; a shared budget keeps this off quit's critical path.
const deadlineMs = Date.now() + 250
if (!this.client.isConnected()) {
await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now()))
}
await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now()))
} catch {
// An unreachable daemon falls back to event-driven retirement once its auth sockets close and it proves itself empty.
}
}
this.client.disconnect()
}
}
@@ -0,0 +1,13 @@
export class FinalCheckpointWaitExpiredError extends Error {
constructor(sessionId: string) {
super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`)
this.name = 'FinalCheckpointWaitExpiredError'
}
}
export class TerminalKilledError extends Error {
constructor(sessionId: string) {
super(`Session "${sessionId}" was explicitly killed`)
this.name = 'TerminalKilledError'
}
}
@@ -0,0 +1,177 @@
import { isShellProcess } from '../../shared/agent-detection'
import { DaemonPtyBufferSnapshots } from './daemon-pty-buffer-snapshots'
import { parsePtySessionId } from './pty-session-id'
import {
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
type ListSessionsResult,
type SessionInfo
} from './types'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots {
// Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells.
protected hasChildProcessesFromForeground(foregroundProcess: string | null): boolean {
return foregroundProcess !== null && !isShellProcess(foregroundProcess)
}
async hasChildProcesses(id: string): Promise<boolean> {
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
return true
}
return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id))
}
async inspectProcess(id: string): Promise<PtyProcessInspection> {
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
return { foregroundProcess: null, hasChildProcesses: true, unavailable: true }
}
if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) {
// Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the
// one call they do support instead of throwing, or completion detection stays dead until recreate.
// Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than
// reading as an idle foreground and dispatching a false completion.
const { foregroundProcess } = await this.client.request<{
foregroundProcess: string | null
}>('getForegroundProcess', { sessionId: id })
return {
foregroundProcess,
hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess)
}
}
return this.client.request<{
foregroundProcess: string | null
hasChildProcesses: boolean
}>('inspectProcess', { sessionId: id })
}
async getForegroundProcess(id: string): Promise<string | null> {
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
return null
}
try {
const result = await this.client.request<{ foregroundProcess: string | null }>(
'getForegroundProcess',
{ sessionId: id }
)
return result.foregroundProcess
} catch {
return null
}
}
async confirmForegroundProcess(id: string): Promise<string | null> {
try {
const result = await this.client.request<{ foregroundProcess: string | null }>(
'confirmForegroundProcess',
{ sessionId: id }
)
return result.foregroundProcess
} catch {
return null
}
}
async serialize(ids: string[]): Promise<string> {
const sessions: Record<string, { initialCwd?: string }> = {}
for (const id of ids) {
sessions[id] = { initialCwd: this.initialCwds.get(id) }
}
return JSON.stringify(sessions)
}
async revive(_state: string): Promise<void> {
// Sessions already live in the daemon — no revival needed
}
/** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId
* no longer exists, and caches alive session IDs.
*
* IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed
* worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live
* worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false
* orphans. No production caller yet; wire the alias in when it gains one. */
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
alive: string[]
killed: string[]
}> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
const alive: string[] = []
const killed: string[] = []
for (const session of result.sessions) {
if (!session.isAlive) {
continue
}
// Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan.
const { worktreeId } = parsePtySessionId(session.sessionId)
if (worktreeId === null || !validWorktreeIds.has(worktreeId)) {
try {
await this.client.request('kill', { sessionId: session.sessionId })
} catch {
/* already dead */
}
killed.push(session.sessionId)
} else {
alive.push(session.sessionId)
// Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data.
this.activeSessionIds.add(session.sessionId)
await this.reconcileLiveSessionHistory(session).catch((err) =>
console.warn('[history] live-session reconciliation failed:', session.sessionId, err)
)
}
}
return { alive, killed }
}
protected async reconcileLiveSessionHistory(session: SessionInfo): Promise<void> {
const historyManager = this.historyManager
const historyReader = this.historyReader
if (!historyManager || !historyReader) {
return
}
await this.withHistorySpawnLock(session.sessionId, async () => {
if (historyManager.hasWriter(session.sessionId)) {
return
}
const probe = historyReader.probeRestorableHistory(session.sessionId)
if (probe.status === 'unreadable') {
return
}
if (probe.status === 'none') {
await historyManager.openSession(session.sessionId, {
cwd: session.cwd ?? '',
cols: session.cols,
rows: session.rows
})
} else {
const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId)
try {
const detection = await historyReader.detectColdRestoreState(session.sessionId, {
wslDistro: session.wslDistro ?? undefined
})
if (
detection.status === 'unreadable' ||
(detection.status === 'restored' && detection.hasUnreadableRecovery)
) {
historyManager.suspendSession(session.sessionId, recoveryFreeze)
return
}
historyManager.reopenSession(session.sessionId, recoveryFreeze)
} finally {
historyManager.abandonRecoveryFreeze(recoveryFreeze)
}
}
if (historyManager.hasWriter(session.sessionId)) {
this.sessionsNeedingFullCheckpoint.add(session.sessionId)
this.sessionsNeedingContinuityCheckpoint.add(session.sessionId)
this.lastFullCheckpointAt.delete(session.sessionId)
this.markSessionDirty(session.sessionId)
}
})
}
}
@@ -0,0 +1,13 @@
import type { CreateOrAttachResult } from './types'
import type { PtySpawnResult } from '../providers/types'
export function providerSequenceFromCreateOrAttach(
result: CreateOrAttachResult
): PtySpawnResult['providerSequence'] {
if (result.isNew) {
return { value: 0, generation: 'reset' }
}
return typeof result.snapshot?.outputSequence === 'number'
? { value: result.snapshot.outputSequence, generation: 'continued' }
: undefined
}
+260
View File
@@ -0,0 +1,260 @@
import { DaemonClient } from './client'
import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event'
import type {
DaemonAuditContext,
DaemonAuditObservation,
DaemonAuditTrigger
} from './daemon-audit-classifier'
import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue'
import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
import { removeDaemonListener } from './daemon-listener-registry'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
supportsMode2031UnsubscribeFact,
supportsPtyStartupIngress,
type TakePendingOutputResult
} from './types'
import { ColdRestorePayloadCache } from './cold-restore-payload-cache'
import {
HistoryManager,
type HistoryCheckpointResult,
type HistoryRecoveryFreeze
} from './history-manager'
import { HistoryReader } from './history-reader'
import type { PtyBackgroundStreamEvent } from '../providers/types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
export type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
ignoredExitIncarnationIds: Set<string>
ignoreNextExit: boolean
}
export type HistoryRecoveryContext = {
freeze: HistoryRecoveryFreeze | null
unreadableSessionId: string | null
identityChanged: boolean
}
export type SnapshotCheckpointResult = {
checkpoint: HistoryCheckpointResult
snapshot: NonNullable<TakePendingOutputResult['snapshot']> | null
}
export type DaemonPtyAdapterOptions = {
socketPath: string
tokenPath: string
pidPath?: string
profileScope?: string
protocolVersion?: number
historyPath?: string
runtimeDir?: string
packagedAppVersion?: string | null
respawn?: (reason: DaemonRespawnReason) => Promise<void | (() => void)>
}
export type DaemonRespawnReason =
| 'daemon_died'
| 'unhealthy_resolver'
| 'stale_bundle'
| 'severed_tcc_attribution'
export type DaemonIdentityChangeEvent = {
previous: DaemonEndpointIdentity
current: DaemonEndpointIdentity
}
export abstract class DaemonPtyRuntimeState {
readonly protocolVersion: number
protected socketPath: string
protected tokenPath: string
protected pidPath: string | null
protected pidRecord: ParsedDaemonPid | null
protected client: DaemonClient
protected auditContext: DaemonAuditContext
protected lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null
protected exactDaemonIncarnation: ExactDaemonIncarnation | null = null
protected lastAuditObservation: DaemonAuditObservation | null = null
protected readonly trackAuditEligibility = createDaemonAuditEligibilityTracker()
protected auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = []
protected identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = []
protected historyManager: HistoryManager | null
protected historyReader: HistoryReader | null
protected respawnFn: DaemonPtyAdapterOptions['respawn'] | null
protected runtimeDir: string | null
protected packagedAppVersion: string | null
protected pendingRespawnAdoptionRelease: (() => void) | null = null
protected respawnAdoptionClosed = false
protected respawnPromise: Promise<void> | null = null
protected staleBundleReplacementPromise: Promise<void> | null = null
protected writeRecoveryPromise: Promise<void> | null = null
protected writeRecoveryAttempted = false
protected dataListeners: ((payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void)[] = []
protected exitListeners: ((payload: {
id: string
code: number
incarnationId?: PtyIncarnationId
cause?: TerminalExitCause
}) => void)[] = []
protected backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
protected writeUnavailableListeners: ((payload: { id: string }) => void)[] = []
protected removeEventListener: (() => void) | null = null
protected initialCwds = new Map<string, string>()
protected wslDistrosBySessionId = new Map<string, string>()
protected killedSessionTombstones = new Map<string, number>()
protected sleepRestoreSessionIds = new Set<string>()
protected coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => {
this.sleepRestoreSessionIds.delete(sessionId)
})
protected activeSessionIds = new Set<string>()
protected getSizeUnsupported = false
protected sessionsAwaitingDaemonRecovery = new Set<string>()
protected sessionIncarnations = new Map<string, string>()
protected pendingSpawnOperationsBySessionId = new Map<string, Set<PendingDaemonSpawnOperation>>()
protected pendingClaimSpawnOperations = new Set<PendingDaemonSpawnOperation>()
protected historySpawnLocks = new Map<string, Promise<void>>()
protected dirtySessionVersions = new Map<string, number>()
protected sessionsNeedingFullCheckpoint = new Set<string>()
protected sessionsNeedingLiveCheckpoint = new Set<string>()
protected sessionsNeedingContinuityCheckpoint = new Set<string>()
protected checkpointTimer: ReturnType<typeof setTimeout> | null = null
protected checkpointInFlight: Promise<void> | null = null
protected checkpointQueue = new CheckpointSessionQueue()
protected nonFinalCheckpointAdmissionSessionIds = new Set<string>()
protected nonFinalAdmissionDeniedSessionIds = new Set<string>()
protected nonFinalGlobalAdmissionWarningActive = false
protected overlayDeadlineWarnedSessionIds = new Set<string>()
protected periodicDeadlineWarnedSessionIds = new Set<string>()
protected keepHistoryShutdowns = new Set<Promise<void>>()
protected disconnectOnlyPromise: Promise<void> | null = null
protected supportsCheckpoints: boolean
protected supportsIncrementalCheckpoints: boolean
protected supportsProducerFlowControl: boolean
protected supportsAuthoritativeBufferSnapshots: boolean
protected supportsStartupIngress: boolean
protected pausedProducerSessionIds = new Set<string>()
protected backgroundedSessionIds = new Set<string>()
protected producerResumesOwedOnReconnect = new Set<string>()
protected static CHECKPOINT_INTERVAL_MS = 5_000
protected static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000
protected static MAX_CONCURRENT_CHECKPOINTS = 4
protected static FULL_CHECKPOINT_COOLDOWN_MS = 45_000
protected lastFullCheckpointAt = new Map<string, number>()
protected abstract observeAuditFailure(
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
exactIncarnation?: ExactDaemonIncarnation | null,
additionalEvidenceSources?: readonly DaemonEvidenceSource[],
endpointGoneProof?: 'windows_named_pipe_missing'
): void
constructor(opts: DaemonPtyAdapterOptions) {
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
this.pidPath = opts.pidPath ?? null
this.pidRecord = readDaemonPidRecord(this.pidPath)
this.auditContext = {
protocolGeneration: this.protocolVersion,
provider: 'local-daemon',
endpoint: opts.socketPath,
tokenPath: opts.tokenPath,
endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket',
profileScope: opts.profileScope ?? ''
}
this.client = new DaemonClient({
socketPath: opts.socketPath,
tokenPath: opts.tokenPath,
protocolVersion: opts.protocolVersion
})
this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
this.respawnFn = opts.respawn ?? null
this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null
this.packagedAppVersion = opts.packagedAppVersion ?? null
this.supportsCheckpoints = this.protocolVersion >= 4
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
this.supportsProducerFlowControl = this.protocolVersion >= 19
this.supportsAuthoritativeBufferSnapshots =
this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION
this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion)
this.client.onDisconnected(() => {
if (!this.respawnAdoptionClosed) {
this.writeRecoveryAttempted = false
for (const id of this.activeSessionIds) {
this.sessionsAwaitingDaemonRecovery.add(id)
}
}
for (const id of this.pausedProducerSessionIds) {
this.producerResumesOwedOnReconnect.add(id)
}
this.pausedProducerSessionIds.clear()
this.observeAuditFailure('transport_closed')
})
}
supportsGitCredentialGuardHost(): boolean {
return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
}
canProvideAuthoritativeBufferSnapshot(_id: string): boolean {
return this.supportsAuthoritativeBufferSnapshots
}
protected get canDelegateBackgroundToDaemon(): boolean {
return (
this.supportsAuthoritativeBufferSnapshots &&
supportsMode2031UnsubscribeFact(this.protocolVersion)
)
}
getHistoryManager(): HistoryManager | null {
return this.historyManager
}
getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null {
return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null
}
getLastAuditObservation(): DaemonAuditObservation | null {
return this.lastAuditObservation
}
onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void {
this.identityChangeListeners.push(listener)
return () => removeDaemonListener(this.identityChangeListeners, listener)
}
onAuditEligibilityObservation(
listener: (observation: DaemonAuditObservation) => void
): () => void {
this.auditObservationListeners.push(listener)
return () => removeDaemonListener(this.auditObservationListeners, listener)
}
supportsAgentSessionClaims(): boolean {
return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
}
providesAgentSessionOwnerListings(_ptyId: string): boolean {
return this.supportsAgentSessionClaims()
}
supportsAgentSessionCreateOperations(): boolean {
return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION
}
}
@@ -0,0 +1,357 @@
import type { ColdRestorePayload } from './cold-restore-payload-cache'
import { isUnknownRequestTypeError } from './daemon-endpoint-errors'
import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version'
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySessionSpawn } from './daemon-pty-session-spawn'
import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence'
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
import type { ColdRestoreInfo } from './history-reader'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { SessionNotFoundError, type CreateOrAttachResult, type ListSessionsResult } from './types'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import type { PtySpawnResult } from '../providers/types'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
export const LIVENESS_PROBE_TIMEOUT_MS = 2_000
const MAX_TOMBSTONES = 1000
export abstract class DaemonPtySessionControl extends DaemonPtySessionSpawn {
async attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence'> | void> {
await this.ensureConnected()
if (!this.canDelegateBackgroundToDaemon) {
this.setPtyBackgrounded(id, false)
}
// Why size-first: attach must ride the session's own geometry — a fixed
// 80×24 here could resize a live agent's TUI — and a null size means the
// daemon cannot prove the session, so refuse rather than risk a create.
const size = await this.getAppliedSize(id)
if (!size) {
throw new SessionNotFoundError(id)
}
const result = await this.client.request<CreateOrAttachResult>('createOrAttach', {
sessionId: id,
cols: size.cols,
rows: size.rows,
attachOnly: true
})
if (result.isNew) {
// Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn
// instead of publishing a fresh shell as an attach.
await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => {
// Why surface, not swallow: a failed retire leaves an untracked orphan shell.
console.warn('[daemon] attach-only retire of accidental legacy spawn failed', {
sessionId: id,
error
})
})
throw new SessionNotFoundError(id)
}
this.clearSessionAwaitingDaemonRecovery(id)
const providerSequence = providerSequenceFromCreateOrAttach(result)
return providerSequence ? { providerSequence } : undefined
}
hasPty(id: string): boolean {
return this.activeSessionIds.has(id)
}
async probePtyLiveness(id: string): Promise<boolean | null> {
try {
if (!this.getSizeUnsupported && this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION) {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
'getSize',
{ sessionId: id },
LIVENESS_PROBE_TIMEOUT_MS
)
return result.size !== null
} catch (error) {
// Why the capability probe rather than the version alone: `getSize` shipped into an
// already-released protocol without a bump, so a daemon can report a version that
// implies support and still reject the request. Ask what it can do, not what its
// number implies — and remember the answer so later probes skip the dead round trip.
if (!isUnknownRequestTypeError(error)) {
throw error
}
this.getSizeUnsupported = true
}
}
// Why: a daemon without `getSize` would otherwise answer `null` forever, and one `null`
// makes the whole owner fan-out unprovable — a dead pane could then never be retired.
// `listSessions` is the same inventory legacy discovery routes by, and has existed since
// the first daemon protocol. Requested directly rather than through `listProcesses` so a
// liveness probe does not publish inventory audit observations as a side effect; both
// rethrow on failure, so either way a dead socket stays `null` instead of reading absent.
const { sessions } = await this.client.request<ListSessionsResult>(
'listSessions',
undefined,
LIVENESS_PROBE_TIMEOUT_MS
)
return sessions.some((session) => session.sessionId === id && session.isAlive)
} catch {
return null
}
}
write(id: string, data: string): boolean {
const recoverable = this.prepareWrite(id)
return this.finishWrite(id, this.client.notify('write', { sessionId: id, data }), recoverable)
}
async writeWithSettlement(id: string, data: string): Promise<boolean> {
const recoverable = this.prepareWrite(id)
return this.finishWrite(
id,
await this.client.notifyWithSettlement('write', { sessionId: id, data }),
recoverable
)
}
protected prepareWrite(id: string): boolean {
this.markSessionDirty(id)
// Why recoverable and not just active: rejecting a write asks the pane to remount,
// which only helps if this endpoint can come back. A legacy adapter has no respawn,
// so its reattach fails and the pane rebuilds empty — losing scrollback the user
// could still read. Keep the pre-existing silent drop for those.
const recoverable =
this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn)
if (
recoverable &&
(this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected())
) {
this.sessionsAwaitingDaemonRecovery.add(id)
this.reconnectAfterWriteFailure()
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
}
return recoverable
}
protected finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean {
if (!delivered && recoverable) {
this.sessionsAwaitingDaemonRecovery.add(id)
this.reconnectAfterWriteFailure()
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
}
return delivered
}
resize(id: string, cols: number, rows: number): void {
this.markSessionDirty(id)
this.client.notify('resize', { sessionId: id, cols, rows })
}
pauseProducer(id: string): void {
if (!this.supportsProducerFlowControl) {
return
}
this.pausedProducerSessionIds.add(id)
this.client.notify('pausePty', { sessionId: id })
}
resumeProducer(id: string): void {
this.producerResumesOwedOnReconnect.delete(id)
if (!this.supportsProducerFlowControl) {
return
}
this.pausedProducerSessionIds.delete(id)
this.client.notify('resumePty', { sessionId: id })
}
// Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning.
setPtyBackgrounded(id: string, background: boolean): void {
if (!this.supportsProducerFlowControl) {
return
}
// Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream.
// Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan
// authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never
// retract it, so a TUI exiting while hidden would strand the subscription and the
// next theme flip would inject CSI 997 into its replacement shell. Declining to
// background keeps main's scanner — which emits both facts — authoritative.
const safeBackground = this.canDelegateBackgroundToDaemon && background
if (safeBackground) {
this.backgroundedSessionIds.add(id)
} else {
this.backgroundedSessionIds.delete(id)
}
this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground })
}
async shutdown(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
if (opts.keepHistory && this.disconnectOnlyPromise) {
throw new Error('Cannot keep history after daemon disconnect has started')
}
const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts))
if (!opts.keepHistory) {
await shutdown
return
}
this.keepHistoryShutdowns.add(shutdown)
try {
await shutdown
} finally {
this.keepHistoryShutdowns.delete(shutdown)
}
}
protected async shutdownWithHistoryLock(
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
// Why: shutdown can be the first lazy-client operation after restart; connect
// before killing so a healthy daemon session is not orphaned (#7742). Connect,
// the final-checkpoint wait, and kill all share the caller's one absolute
// deadline, so neither a wedged handshake nor a stalled history write can burn
// the whole teardown budget before the kill even starts. Only the waits are
// bounded — the checkpoint itself stays deadline-free and lossless (STA-4228).
await this.ensureConnected(opts.deadlineMs)
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
// Force a final snapshot so wake can restore the pane users left.
if (opts.keepHistory) {
const committed = await this.runExclusiveCheckpoint(
async () => {
await this.checkpointSessions([id], { final: true, teardown: true })
},
{ callerDeadlineMs: opts.deadlineMs }
)
// Why throw instead of killing anyway: the snapshot the caller asked us to prove is still
// being written. Killing here would race the wake-time restore source to disk, so report the
// pty unverified and leave it alive — worktree sleep declines to commit it and retries.
if (!committed) {
throw new FinalCheckpointWaitExpiredError(id)
}
const wslDistro = this.wslDistrosBySessionId.get(id)
const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro })
const detected = detection?.status === 'restored' ? detection.restoreInfo : null
const restoreInfo = detected
? {
...detected,
cwd:
normalizeWslColdRestoreCwd({
recoveredCwd: detected.cwd,
requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(),
wslDistro
}) ?? ''
}
: null
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
if (coldRestore) {
this.coldRestoreCache.set(id, coldRestore)
if (this.coldRestoreCache.has(id)) {
this.sleepRestoreSessionIds.add(id)
}
// Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority.
this.historyManager?.suspendSession(id)
} else if (
detection?.status === 'unreadable' ||
(detection?.status === 'restored' && detection.hasUnreadableRecovery)
) {
this.historyManager?.suspendSession(id)
}
}
await this.client.request(
'kill',
{ sessionId: id, immediate: opts.immediate ?? false },
remainingDaemonRequestTimeoutMs(opts.deadlineMs)
)
this.activeSessionIds.delete(id)
this.clearSessionAwaitingDaemonRecovery(id)
this.dirtySessionVersions.delete(id)
if (!opts.keepHistory) {
this.coldRestoreCache.delete(id)
this.sleepRestoreSessionIds.delete(id)
}
// Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory).
this.sessionsNeedingFullCheckpoint.delete(id)
this.sessionsNeedingLiveCheckpoint.delete(id)
this.sessionsNeedingContinuityCheckpoint.delete(id)
this.overlayDeadlineWarnedSessionIds.delete(id)
this.periodicDeadlineWarnedSessionIds.delete(id)
this.nonFinalAdmissionDeniedSessionIds.delete(id)
this.lastFullCheckpointAt.delete(id)
this.stopCheckpointTimerIfIdle()
this.initialCwds.delete(id)
this.wslDistrosBySessionId.delete(id)
// Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory).
if (this.historyManager && !opts.keepHistory) {
await this.historyManager
.removeSession(id)
.catch((err) => console.warn('[history] removeSession failed:', id, err))
}
// Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory.
if (!opts.keepHistory) {
this.killedSessionTombstones.delete(id)
this.killedSessionTombstones.set(id, Date.now())
if (this.killedSessionTombstones.size > MAX_TOMBSTONES) {
const oldest = this.killedSessionTombstones.keys().next().value
if (oldest) {
this.killedSessionTombstones.delete(oldest)
}
}
}
}
ackColdRestore(sessionId: string): void {
this.coldRestoreCache.delete(sessionId)
this.sleepRestoreSessionIds.delete(sessionId)
}
clearTombstone(sessionId: string): void {
this.killedSessionTombstones.delete(sessionId)
}
protected buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null {
// Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake.
const scrollback = restoreInfo.modes.alternateScreen
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
if (!scrollback) {
return null
}
return {
scrollback,
cwd: restoreInfo.cwd,
cols: restoreInfo.cols,
rows: restoreInfo.rows,
oscLinks: restoreInfo.oscLinks,
...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {})
}
}
async sendSignal(id: string, signal: string): Promise<void> {
await this.client.request('signal', { sessionId: id, signal })
}
async getCwd(id: string): Promise<string> {
try {
const result = await this.client.request<{ cwd: string | null }>('getCwd', {
sessionId: id
})
return result.cwd ?? ''
} catch {
return ''
}
}
async getInitialCwd(id: string): Promise<string> {
return this.initialCwds.get(id) ?? ''
}
// Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert.
async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
'getSize',
{ sessionId: id }
)
return result.size ?? null
} catch {
return null
}
}
}
@@ -0,0 +1,174 @@
import { basename } from 'node:path'
import { existsSync } from 'node:fs'
import {
isAgentSessionOwnerBinding,
type AgentSessionOwnerBinding
} from '../../shared/agent-session-host-authority'
import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner'
import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot'
import { recordAuthenticatedInventory } from './daemon-audit-classifier'
import { isMissingWindowsNamedPipeError } from './daemon-endpoint-errors'
import { DaemonPtyProcessInspection } from './daemon-pty-process-inspection'
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
import { parsePtySessionId } from './pty-session-id'
import type { ListSessionsResult, SessionInfo } from './types'
import { PtyProcessListAdmission } from '../providers/pty-process-list-admission'
import type { PtyProcessInfo } from '../providers/types'
export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspection {
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
// Why: snapshotted before the request so ids spawned mid-flight can never
// be reconciled away below.
const preRequestActiveIds = new Set(this.activeSessionIds)
try {
// Why: connect + listSessions share the caller's one absolute deadline so a
// wedged handshake cannot burn the whole teardown budget before the list issues.
await this.ensureConnected(opts?.deadlineMs)
const result = await this.client.request<ListSessionsResult>(
'listSessions',
undefined,
remainingDaemonRequestTimeoutMs(opts?.deadlineMs)
)
const admission = new PtyProcessListAdmission()
const processes: PtyProcessInfo[] = []
const aliveSessionIds = new Set<string>()
for (const session of result.sessions) {
if (!session.isAlive) {
continue
}
aliveSessionIds.add(session.sessionId)
const { worktreeId } = parsePtySessionId(session.sessionId)
processes.push(
admission.admit({
id: session.sessionId,
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
title: 'shell',
...(worktreeId ? { worktreeId } : {}),
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
...this.validatedAgentSessionOwners(session.agentSessionOwners)
})
)
}
// Why: hasPty reads activeSessionIds, and an exit missed while the socket
// was disconnected otherwise survives an authoritative inventory forever —
// defeating every absence proof built on the cache.
for (const id of preRequestActiveIds) {
if (!aliveSessionIds.has(id)) {
this.activeSessionIds.delete(id)
}
}
this.publishAuditObservation(
recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation)
)
return processes
} catch (error) {
const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing()
const missingNamedPipe = isMissingWindowsNamedPipeError(error)
this.observeAuditFailure(
missingAuthenticatedToken
? 'token_missing_after_authenticated_disconnect'
: 'inventory_failed',
this.exactDaemonIncarnation,
[
...(missingAuthenticatedToken ? (['token_file'] as const) : []),
...(missingNamedPipe ? (['windows_named_pipe'] as const) : [])
],
missingNamedPipe ? 'windows_named_pipe_missing' : undefined
)
throw error
}
}
protected validatedAgentSessionOwners(
owners: unknown
): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record<string, never> {
if (owners === undefined) {
return {}
}
if (
!Array.isArray(owners) ||
owners.length > MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES ||
!owners.every((owner) => isAgentSessionOwnerBinding(owner) && owner.phase === 'live')
) {
throw new Error('agent_session_ownership_unknown')
}
return owners.length > 0
? { agentSessionOwners: owners.map(cloneAgentSessionOwnerBinding) }
: {}
}
// Why: the Manage Sessions panel needs the full SessionInfo (pid, state,
// createdAt) per session for display; listProcesses drops that detail for
// the IPtyProvider contract. Keep both in parallel rather than widening
// the provider surface.
async listSessions(): Promise<SessionInfo[]> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
return result.sessions
.filter((s) => s.isAlive)
.map((session) => ({
...session,
...this.validatedAgentSessionOwners(session.agentSessionOwners)
}))
}
getActiveSessionIds(): string[] {
return [...this.activeSessionIds]
}
// Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit
// for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever.
fanoutSyntheticExits(code: number): void {
const ids = [...this.activeSessionIds]
this.activeSessionIds.clear()
this.sessionsAwaitingDaemonRecovery.clear()
this.writeRecoveryAttempted = false
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.sessionsNeedingFullCheckpoint.clear()
this.sessionsNeedingLiveCheckpoint.clear()
this.sessionsNeedingContinuityCheckpoint.clear()
this.overlayDeadlineWarnedSessionIds.clear()
this.periodicDeadlineWarnedSessionIds.clear()
this.nonFinalAdmissionDeniedSessionIds.clear()
this.pausedProducerSessionIds.clear()
this.producerResumesOwedOnReconnect.clear()
this.stopCheckpointTimer()
for (const id of ids) {
this.coldRestoreCache.delete(id)
// Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics.
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.exitListeners]) {
listener({
id,
code,
...(this.sessionIncarnations.get(id)
? { incarnationId: this.sessionIncarnations.get(id) }
: {})
})
}
this.sessionIncarnations.delete(id)
}
}
async getDefaultShell(): Promise<string> {
if (process.platform === 'win32') {
return process.env.COMSPEC || 'powershell.exe'
}
return process.env.SHELL || '/bin/zsh'
}
async getProfiles(): Promise<{ name: string; path: string }[]> {
if (process.platform === 'win32') {
return [
{ name: 'PowerShell', path: 'powershell.exe' },
{ name: 'Command Prompt', path: 'cmd.exe' }
]
}
const shells = ['/bin/zsh', '/bin/bash', '/bin/sh']
return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s }))
}
}
+281
View File
@@ -0,0 +1,281 @@
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import type {
HistoryRecoveryContext,
PendingDaemonSpawnOperation
} from './daemon-pty-runtime-state'
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
import { TerminalKilledError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request'
import type { ColdRestoreInfo } from './history-reader'
import { mintPtySessionId } from './pty-session-id'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier'
import { supportsPtyStartupBarrier } from './shell-ready'
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { resolveWslSessionContext } from './wsl-session-context'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { resolveUnixShellPath } from '../providers/local-pty-utils'
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
import { addWslEnvKeys } from '../wsl-env'
export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
const spawnOpts = this.withHistoryIsolation(opts)
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
const operation = {
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
ignoredExitIncarnationIds: new Set<string>(),
ignoreNextExit: false
}
const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set()
operations.add(operation)
this.pendingSpawnOperationsBySessionId.set(sessionId, operations)
if (opts.agentSessionEnsure) {
this.pendingClaimSpawnOperations.add(operation)
}
const historyRecovery: HistoryRecoveryContext = {
freeze: null,
unreadableSessionId: null,
identityChanged: false
}
try {
return await this.withHistorySpawnLock(sessionId, () =>
this.withDaemonRetry(() =>
this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery)
)
)
} finally {
if (historyRecovery.freeze) {
this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze)
}
this.pendingClaimSpawnOperations.delete(operation)
operations.delete(operation)
if (operations.size === 0) {
this.pendingSpawnOperationsBySessionId.delete(sessionId)
}
}
}
protected withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions {
const wslContext = resolveWslSessionContext({
cwd: opts.cwd,
sessionId: opts.sessionId,
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
})
if (
opts.attachOnly === true ||
(opts.sessionId !== undefined && opts.isNewSession !== true) ||
!opts.worktreeId ||
opts.historyIsolationEnabled !== true ||
(process.platform === 'win32' && !wslContext)
) {
return opts
}
const env = { ...opts.env }
const preferredShell = wslContext
? 'bash'
: opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh'
const shellPath = resolveUnixShellPath(preferredShell)
const historyArgs = [
env,
opts.worktreeId,
shellPath,
opts.cwd ?? resolveSafePtyDefaultCwd()
] as const
const result = wslContext
? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro })
: injectHistoryEnv(...historyArgs)
if (wslContext) {
injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro)
addWslEnvKeys(env, ['HISTFILE', 'fish_history'])
}
logHistoryInjection(opts.worktreeId, result)
return { ...opts, env }
}
protected async doSpawn(
opts: PtySpawnOptions,
operation: PendingDaemonSpawnOperation,
historyRecovery: HistoryRecoveryContext
): Promise<PtySpawnResult> {
if (
opts.agentSessionEnsure &&
this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
) {
throw new Error('agent_session_claim_unavailable')
}
const requestedSessionId = opts.sessionId!
// Why: v30 daemons survive upgrades; reject their accidental create result before publication.
const attachOnly = opts.attachOnly === true
const emulateLegacyAttachOnly =
attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
let sessionId = requestedSessionId
let wslDistro = resolveWslSessionContext({
cwd: opts.cwd,
sessionId,
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
})?.distro
let activeSpawnContext: DaemonPtySpawnContext | null = null
const freezeHistory = async (): Promise<void> => {
if (!this.historyManager) {
return
}
const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId
if (historyRecovery.freeze?.sessionId === recoverySessionId) {
return
}
if (historyRecovery.freeze) {
this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze)
}
historyRecovery.freeze = await this.historyManager.freezeForRecovery(recoverySessionId)
historyRecovery.unreadableSessionId = null
}
const detectColdRestore = async (options?: {
ignoreCleanEnd?: boolean
}): Promise<ColdRestoreInfo | null> => {
if (!this.historyReader) {
return null
}
await freezeHistory()
const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId
const recoveryWslDistro = activeSpawnContext?.wslDistro ?? wslDistro
const detection = await this.historyReader.detectColdRestoreState(recoverySessionId, {
...options,
wslDistro: recoveryWslDistro
})
if (detection.status === 'unreadable') {
historyRecovery.unreadableSessionId = detection.sessionId
return null
}
const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null
if (detection.status === 'restored' && detection.hasUnreadableRecovery) {
historyRecovery.unreadableSessionId = detection.sessionId
}
if (!restoreInfo) {
return null
}
return {
...restoreInfo,
cwd:
normalizeWslColdRestoreCwd({
recoveredCwd: restoreInfo.cwd,
requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(),
wslDistro: recoveryWslDistro
}) ?? ''
}
}
if (this.killedSessionTombstones.has(sessionId)) {
throw new TerminalKilledError(sessionId)
}
if (opts.isNewSession) {
await this.replaceUnhealthyMacResolverDaemonBeforeNewPty()
await this.replaceStaleBundleDaemonBeforeNewPty()
await this.replaceSeveredMacTccDaemonBeforeNewPty()
}
await this.ensureConnected()
// Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from
// a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop
// handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached.
if (!this.canDelegateBackgroundToDaemon) {
this.setPtyBackgrounded(sessionId, false)
}
// Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request.
// Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted.
let restoreInfo: ColdRestoreInfo | null = null
let restoreSkippedForLiveSession = false
const historyProbe = opts.attachOnly
? undefined
: this.historyReader?.probeRestorableHistory(sessionId)
if (historyProbe && historyProbe.status !== 'none') {
if ((await this.getAppliedSize(sessionId)) !== null) {
restoreSkippedForLiveSession = true
if (this.historyManager && !this.historyManager.hasWriter(sessionId)) {
await detectColdRestore()
restoreInfo = null
}
} else {
restoreInfo = await detectColdRestore()
}
}
let effectiveCwd = restoreInfo?.cwd ?? opts.cwd
let effectiveCols = restoreInfo?.cols ?? opts.cols
let effectiveRows = restoreInfo?.rows ?? opts.rows
const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false
const isCodexStartupCommand =
recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex'
const shouldWaitForShellReady =
isCodexStartupCommand &&
shouldUseShellReadyStartupDelivery({
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery
})
const shellReadyTimeoutMs =
shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady
? CODEX_SHELL_READY_TIMEOUT_MS
: undefined
const context: DaemonPtySpawnContext = {
opts,
operation,
historyRecovery,
requestedSessionId,
attachOnly,
emulateLegacyAttachOnly,
sessionId,
wslDistro,
restoreInfo,
restoreSkippedForLiveSession,
effectiveCwd,
effectiveCols,
effectiveRows,
shellReadySupported,
shellReadyTimeoutMs,
historySeedSegments: restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null,
detectColdRestore
}
activeSpawnContext = context
const result = await this.createOrAttachSpawn(context, context.historySeedSegments)
return this.finishSpawn(context, result)
}
protected resultForExitBeforeSpawnReply(
sessionId: string,
result: CreateOrAttachResult,
operation: PendingDaemonSpawnOperation
): PtySpawnResult | null {
const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some(
(exit) =>
!(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) &&
(!exit.incarnationId ||
!result.incarnationId ||
exit.incarnationId === result.incarnationId)
)
if (!matchingExit) {
return null
}
// Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state.
const exitedResult: PtySpawnResult = {
id: sessionId,
exitedBeforeSpawnReply: true,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
return exitedResult
}
didExitBeforeSpawnReply(result: PtySpawnResult): boolean {
return result.exitedBeforeSpawnReply === true
}
}
@@ -0,0 +1,118 @@
import { TerminalAttachCanceledError } from './types'
export type PendingPtySpawnPreparation = {
canceled: boolean
// Why: `canceled` is only polled between spawn steps; the signal is what
// actually interrupts an in-progress cwd probe on a dead share.
readonly controller: AbortController
cancelTimer?: ReturnType<typeof setTimeout>
// 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
requestId: string
}
export class DaemonPtySpawnPreparations {
private readonly pending = new Map<string, Set<PendingPtySpawnPreparation>>()
private readonly cancellationByPreparation = new WeakMap<
PendingPtySpawnPreparation,
{ promise: Promise<void>; resolve: () => void }
>()
constructor(private readonly preparePtySpawn: () => Promise<void>) {}
register(
sessionId: string,
clientId: string,
requestId: string,
cancelAfterMs: unknown
): PendingPtySpawnPreparation {
const preparation: PendingPtySpawnPreparation = {
canceled: false,
controller: new AbortController(),
clientId,
requestId
}
this.cancellationByPreparation.set(preparation, Promise.withResolvers<void>())
if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) {
preparation.cancelTimer = setTimeout(
() => this.cancelPreparation(preparation),
Math.min(Number(cancelAfterMs), 300_000)
)
preparation.cancelTimer.unref()
}
const pendingForSession = this.pending.get(sessionId) ?? new Set()
pendingForSession.add(preparation)
this.pending.set(sessionId, pendingForSession)
return preparation
}
async prepareUnlessCanceled(
sessionId: string,
preparation: PendingPtySpawnPreparation
): Promise<void> {
// Race cancellation against preflight so shutdown/disconnect can return the
// protocol cancellation error before tearing down the client transport.
const cancellation = this.cancellationByPreparation.get(preparation)
const preparationTask = cancellation
? Promise.race([this.preparePtySpawn(), cancellation.promise])
: this.preparePtySpawn()
await preparationTask
if (preparation.canceled) {
throw new TerminalAttachCanceledError(sessionId)
}
}
finish(sessionId: string, preparation: PendingPtySpawnPreparation): void {
if (preparation.cancelTimer) {
clearTimeout(preparation.cancelTimer)
}
this.cancellationByPreparation.delete(preparation)
const pendingForSession = this.pending.get(sessionId)
pendingForSession?.delete(preparation)
if (pendingForSession?.size === 0) {
this.pending.delete(sessionId)
}
}
cancel(sessionId: string, request?: { clientId: string; requestId?: string }): boolean {
const pendingForSession = this.pending.get(sessionId)
if (!pendingForSession) {
return false
}
let canceled = false
for (const preparation of pendingForSession) {
if (
request &&
(preparation.clientId !== request.clientId ||
(request.requestId !== undefined && preparation.requestId !== request.requestId))
) {
continue
}
this.cancelPreparation(preparation)
canceled = true
}
return canceled
}
cancelForClient(clientId: string): void {
for (const pendingForSession of this.pending.values()) {
for (const preparation of pendingForSession) {
if (preparation.clientId === clientId) {
this.cancelPreparation(preparation)
}
}
}
}
private cancelPreparation(preparation: PendingPtySpawnPreparation): void {
preparation.canceled = true
preparation.controller.abort()
this.cancellationByPreparation.get(preparation)?.resolve()
}
cancelAll(): void {
for (const sessionId of this.pending.keys()) {
this.cancel(sessionId)
}
}
}
+202
View File
@@ -0,0 +1,202 @@
import type { ColdRestorePayload } from './cold-restore-payload-cache'
import type { DaemonAuditObservation } from './daemon-audit-classifier'
import {
DaemonPtyRuntimeState,
type HistoryRecoveryContext,
type PendingDaemonSpawnOperation,
type SnapshotCheckpointResult
} from './daemon-pty-runtime-state'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
import type { ColdRestoreInfo } from './history-reader'
import { NdjsonLineTooLongError } from './ndjson'
import {
iterateTerminalHistorySeedChunks,
measureTerminalHistorySeed,
TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS
} from './terminal-history-seed-chunks'
import type { CreateOrAttachResult, GetSnapshotResult } from './types'
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
export type DaemonPtySpawnContext = {
opts: PtySpawnOptions
operation: PendingDaemonSpawnOperation
historyRecovery: HistoryRecoveryContext
requestedSessionId: string
attachOnly: boolean
emulateLegacyAttachOnly: boolean
sessionId: string
wslDistro: string | undefined
restoreInfo: ColdRestoreInfo | null
restoreSkippedForLiveSession: boolean
effectiveCwd: string | undefined
effectiveCols: number
effectiveRows: number
shellReadySupported: boolean
shellReadyTimeoutMs: number | undefined
historySeedSegments: readonly string[] | null
detectColdRestore(options?: { ignoreCleanEnd?: boolean }): Promise<ColdRestoreInfo | null>
}
export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState {
protected abstract takeSnapshotAndCheckpoint(
sessionId: string,
opts: {
teardown: boolean
forceLiveSnapshot?: boolean
requireContinuityProof?: boolean
}
): Promise<SnapshotCheckpointResult>
protected abstract markSessionDirty(sessionId: string): void
protected abstract writeSessionCheckpoint(
sessionId: string,
opts: { final: boolean; teardown: boolean }
): Promise<'done' | 'deferred'>
protected abstract releasePendingRespawnAdoptionLease(): void
protected abstract setupEventRouting(): void
protected abstract scheduleCheckpointTimer(): void
protected abstract stopCheckpointTimer(): void
protected abstract stopCheckpointTimerIfIdle(): void
protected abstract recordAuthenticatedIdentity(): void
protected abstract runExclusiveCheckpoint(
operation: () => Promise<void>,
options?: { rescheduleDirty?: boolean; callerDeadlineMs?: number }
): Promise<boolean>
protected abstract checkpointAllSessions(): Promise<void>
protected abstract ensureConnected(deadlineMs?: number): Promise<void>
protected abstract withHistorySpawnLock<T>(
sessionId: string,
operation: () => Promise<T>
): Promise<T>
protected abstract clearSessionAwaitingDaemonRecovery(sessionId: string): void
protected abstract reconnectAfterWriteFailure(): void
protected abstract checkpointSessions(
sessionIds: Iterable<string>,
opts?: { final?: boolean; teardown?: boolean }
): Promise<Set<string>>
protected abstract publishAuditObservation(observation: DaemonAuditObservation): void
protected abstract isRetiredEndpointTokenMissing(): boolean
protected abstract withDaemonRetry<T>(fn: () => Promise<T>): Promise<T>
protected abstract replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise<void>
protected abstract replaceStaleBundleDaemonBeforeNewPty(): Promise<void>
protected abstract replaceSeveredMacTccDaemonBeforeNewPty(): Promise<void>
abstract setPtyBackgrounded(id: string, background: boolean): void
abstract getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null>
protected abstract resultForExitBeforeSpawnReply(
sessionId: string,
result: CreateOrAttachResult,
operation: PendingDaemonSpawnOperation
): PtySpawnResult | null
protected abstract buildColdRestorePayload(
restoreInfo: ColdRestoreInfo
): ColdRestorePayload | null
protected abstract overlayDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>>
protected async createOrAttachSpawn(
context: DaemonPtySpawnContext,
historySeedSegments: readonly string[] | null
): Promise<CreateOrAttachResult> {
const requestCreateOrAttach = (
historySeed: string | undefined,
historySeedTransferId: string | undefined
) => {
const { opts } = context
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
const payload = {
sessionId: context.sessionId,
cols: context.effectiveCols,
rows: context.effectiveRows,
cwd: context.attachOnly ? undefined : context.effectiveCwd,
env: context.attachOnly ? undefined : opts.env,
envToDelete: context.attachOnly ? undefined : opts.envToDelete,
command: context.attachOnly ? undefined : opts.command,
startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery,
launchAgent: context.attachOnly ? undefined : opts.launchAgent,
...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}),
shellOverride: context.attachOnly ? undefined : opts.shellOverride,
terminalWindowsWslDistro: context.attachOnly ? undefined : opts.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: context.attachOnly
? undefined
: opts.terminalWindowsPowerShellImplementation,
shellReadySupported: context.attachOnly ? false : context.shellReadySupported,
...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: context.shellReadyTimeoutMs }
: {}),
...(historySeed ? { historySeed } : {}),
...(historySeedTransferId ? { historySeedTransferId } : {}),
...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress
? { startupIngress: opts.startupIngress }
: {}),
...(!context.attachOnly && opts.agentSessionEnsure
? { agentSessionEnsure: opts.agentSessionEnsure }
: {})
}
return opts.signal
? this.client.request<CreateOrAttachResult>(
'createOrAttach',
payload,
undefined,
opts.signal
)
: this.client.request<CreateOrAttachResult>('createOrAttach', payload)
}
let historySeedUnavailable = false
const deliverSeedAndCreate = async (): Promise<CreateOrAttachResult> => {
if (!historySeedSegments || historySeedSegments.length === 0) {
return requestCreateOrAttach(undefined, undefined)
}
const metrics = measureTerminalHistorySeed(historySeedSegments)
if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) {
try {
return await requestCreateOrAttach(historySeedSegments.join(''), undefined)
} catch (error) {
if (!(error instanceof NdjsonLineTooLongError)) {
throw error
}
historySeedUnavailable = true
return requestCreateOrAttach(undefined, undefined)
}
}
if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) {
historySeedUnavailable = true
return requestCreateOrAttach(undefined, undefined)
}
let transferId: string | undefined
try {
const started = await this.client.request<{ transferId: string }>(
'startHistorySeedTransfer',
metrics
)
transferId = started.transferId
let index = 0
for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) {
await this.client.request('appendHistorySeedTransfer', { transferId, index, data })
index += 1
}
await this.client.request('finishHistorySeedTransfer', { transferId })
} catch (error) {
if (transferId) {
await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {})
}
if (isDaemonGoneError(error)) {
throw error
}
historySeedUnavailable = true
return requestCreateOrAttach(undefined, undefined)
}
return requestCreateOrAttach(undefined, transferId)
}
const result = await deliverSeedAndCreate()
return historySeedUnavailable && result.historySeeded === undefined
? { ...result, historySeeded: false }
: result
}
}
+326
View File
@@ -0,0 +1,326 @@
import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority'
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request'
import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence'
import { takeHistoryRecoveryFreeze } from './daemon-history-recovery-freeze'
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
import { SessionNotFoundError, type CreateOrAttachResult } from './types'
import type { PtySpawnResult } from '../providers/types'
export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
protected async finishSpawn(
context: DaemonPtySpawnContext,
initialResult: CreateOrAttachResult
): Promise<PtySpawnResult> {
const {
opts,
operation,
historyRecovery,
requestedSessionId,
emulateLegacyAttachOnly,
restoreSkippedForLiveSession,
detectColdRestore
} = context
let { sessionId, wslDistro, restoreInfo, effectiveCwd, effectiveCols, effectiveRows } = context
const createOrAttach = (historySeedSegments: readonly string[] | null) => {
Object.assign(context, {
sessionId,
effectiveCwd,
effectiveCols,
effectiveRows
})
return this.createOrAttachSpawn(context, historySeedSegments)
}
let result = initialResult
let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise<void> => {
const requestedSessionId = sessionId
if (
opts.agentSessionEnsure &&
!isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure)
) {
// Why: a claim-incapable owner may already have spawned before returning
// a malformed response; retire only this requested session before failing closed.
await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {})
throw new Error('agent_session_claim_unavailable')
}
sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId
context.sessionId = sessionId
if (requestedSessionId === sessionId) {
return
}
if (historyRecovery.freeze) {
this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze)
historyRecovery.freeze = null
}
historyRecovery.unreadableSessionId = null
historyRecovery.identityChanged = true
restoreInfo = null
historySeedSegments = null
}
if (emulateLegacyAttachOnly && result.isNew) {
operation.ignoreNextExit = true
await this.client.request('kill', { sessionId: requestedSessionId, immediate: true })
throw new SessionNotFoundError(requestedSessionId)
}
await adoptSpawnResultSession(result)
// Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner.
this.clearSessionAwaitingDaemonRecovery(requestedSessionId)
this.clearSessionAwaitingDaemonRecovery(sessionId)
const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
if (exitedResult) {
return exitedResult
}
if (result.incarnationId) {
this.sessionIncarnations.set(sessionId, result.incarnationId)
}
const claimResult = (): Pick<PtySpawnResult, 'agentSessionEnsure'> | Record<string, never> =>
result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}
const incarnationResult = (): Pick<PtySpawnResult, 'incarnationId'> | Record<string, never> =>
result.incarnationId ? { incarnationId: result.incarnationId } : {}
let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
// Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons.
wslDistro = providerWslDistro ?? undefined
context.wslDistro = wslDistro
if (wslDistro) {
this.wslDistrosBySessionId.set(sessionId, wslDistro)
} else if (providerWslDistro === null || result.isNew) {
this.wslDistrosBySessionId.delete(sessionId)
}
const launchIdentity = (): { launchAgent?: NonNullable<typeof result.launchAgent> } =>
result.launchAgent ? { launchAgent: result.launchAgent } : {}
if (effectiveCwd) {
this.initialCwds.set(sessionId, effectiveCwd)
}
// Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor.
let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null
// Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data.
const cachedRestore = this.coldRestoreCache.get(sessionId)
if (cachedRestore) {
// Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal.
this.activeSessionIds.add(sessionId)
if (this.historyManager && !historyRecovery.identityChanged) {
const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId)
if (historyRecovery.unreadableSessionId === sessionId) {
this.historyManager.suspendSession(sessionId, recoveryFreeze)
} else {
this.historyManager.reopenSession(sessionId, recoveryFreeze)
}
}
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
coldRestore: cachedRestore,
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
}
// Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path.
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) {
restoreInfo = await detectColdRestore({ ignoreCleanEnd: true })
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) {
// Why: the aliveness probe raced with session death, so the first
// create lacked recovery bytes. Replace it before exposing the PTY.
if (result.incarnationId) {
operation.ignoredExitIncarnationIds.add(result.incarnationId)
}
operation.ignoreNextExit = true
await this.client.request('kill', { sessionId, immediate: true })
effectiveCwd = restoreInfo.cwd
effectiveCols = restoreInfo.cols
effectiveRows = restoreInfo.rows
result = await createOrAttach(historySeedSegments)
await adoptSpawnResultSession(result)
const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
if (exitedRetryResult) {
return exitedRetryResult
}
if (result.incarnationId) {
this.sessionIncarnations.set(sessionId, result.incarnationId)
}
providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
wslDistro = providerWslDistro ?? undefined
context.wslDistro = wslDistro
if (wslDistro) {
this.wslDistrosBySessionId.set(sessionId, wslDistro)
} else if (providerWslDistro === null || result.isNew) {
this.wslDistrosBySessionId.delete(sessionId)
}
pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null
this.initialCwds.set(sessionId, effectiveCwd)
}
} else if (
!historyRecovery.identityChanged &&
!result.isNew &&
result.historySeeded === false
) {
restoreInfo = await detectColdRestore()
historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null
}
const wasAlreadyManaged = this.activeSessionIds.has(sessionId)
this.activeSessionIds.add(sessionId)
const providerSequence = providerSequenceFromCreateOrAttach(result)
// Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback.
if (restoreInfo && (result.isNew || result.historySeeded === false)) {
const coldRestore = this.buildColdRestorePayload(restoreInfo)
const canReanchorHistory =
!historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true
// Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick.
if (this.historyManager && !historyRecovery.identityChanged) {
const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId)
if (historyRecovery.unreadableSessionId === sessionId) {
await this.historyManager.openSession(sessionId, {
cwd: effectiveCwd ?? '',
cols: effectiveCols,
rows: effectiveRows,
...(recoveryFreeze ? { recoveryFreeze } : {}),
quarantineUnreadableRecovery: true
})
if (this.historyManager.hasWriter(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.sessionsNeedingLiveCheckpoint.add(sessionId)
this.lastFullCheckpointAt.delete(sessionId)
}
} else if (canReanchorHistory) {
this.historyManager.registerWriter(sessionId, recoveryFreeze)
this.sessionsNeedingFullCheckpoint.add(sessionId)
// Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor.
this.lastFullCheckpointAt.delete(sessionId)
} else {
// Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent.
this.historyManager.suspendSession(sessionId, recoveryFreeze)
}
}
if (coldRestore) {
this.coldRestoreCache.set(sessionId, coldRestore)
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
coldRestore,
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
}
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {})
}
}
if (this.historyManager && !historyRecovery.identityChanged && result.isNew) {
const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId)
await this.historyManager.openSession(sessionId, {
cwd: effectiveCwd ?? '',
cols: effectiveCols,
rows: effectiveRows,
...(recoveryFreeze ? { recoveryFreeze } : {}),
...(historyRecovery.unreadableSessionId === sessionId
? { quarantineUnreadableRecovery: true }
: {})
})
} else if (
this.historyManager &&
!historyRecovery.identityChanged &&
(result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId)
) {
// Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart.
this.historyManager.suspendSession(
sessionId,
takeHistoryRecoveryFreeze(historyRecovery, sessionId)
)
} else if (this.historyManager && !historyRecovery.identityChanged) {
// Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint.
this.historyManager.registerWriter(
sessionId,
takeHistoryRecoveryFreeze(historyRecovery, sessionId)
)
if (!wasAlreadyManaged) {
// Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity.
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.sessionsNeedingContinuityCheckpoint.add(sessionId)
this.lastFullCheckpointAt.delete(sessionId)
}
}
const isReattach = !result.isNew
if (!isReattach || !result.snapshot) {
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {}),
...(isReattach ? { isReattach: true } : {})
}
}
const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot)
const reattachProviderSequence =
typeof reattachSnapshot.outputSequence === 'number'
? { value: reattachSnapshot.outputSequence, generation: 'continued' as const }
: providerSequence
const isAltScreen = reattachSnapshot.modes.alternateScreen
const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences
const snapshotFrame = reattachSnapshot.snapshotAnsi
const snapshotPayload = snapshotPrefix + snapshotFrame
// Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty).
// Why known `0` is no longer dropped: the pane tracker must be able to tell
// "the app negotiated nothing" from "this reattach proved nothing".
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(
reattachSnapshot.modes.kittyKeyboardFlags
)
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
snapshot: snapshotPayload,
snapshotCols: reattachSnapshot.cols,
snapshotRows: reattachSnapshot.rows,
// Why only for an alt frame: normal history remains safe to replay at its capture grid.
...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi
? {
snapshotPrefixAnsi: snapshotPrefix,
snapshotFrameAnsi: snapshotFrame,
snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi
}
: {}),
...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}),
...(kittyKeyboardFlags !== undefined
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
: {}),
isReattach: true,
isAlternateScreen: isAltScreen,
// Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch.
...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}),
// Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329).
...(reattachSnapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi }
: {})
}
}
}
@@ -0,0 +1,25 @@
export function remainingDaemonRequestTimeoutMs(
deadlineMs: number | undefined
): number | undefined {
return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now())
}
// The caller's wait is bounded without cancelling durable work, which keeps running and committing.
export async function awaitDaemonWorkWithinCallerDeadline(
work: Promise<void>,
deadlineMs: number
): Promise<boolean> {
void work.catch(() => {})
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
work.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now()))
timer.unref?.()
})
])
} finally {
clearTimeout(timer)
}
}
+282
View File
@@ -0,0 +1,282 @@
import { performance } from 'node:perf_hooks'
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections'
import type { DaemonFileLog } from './daemon-file-log'
import type { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations'
import type { DaemonServerLifecycle } from './daemon-server-lifecycle'
import type { DaemonSessionAttachments } from './daemon-session-attachments'
import type { DaemonSessionBackgroundRouting } from './daemon-session-background-routing'
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { DaemonTerminalAdmission } from './daemon-terminal-admission'
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
import type { TerminalHost } from './terminal-host'
import { SessionNotFoundError, type DaemonRequest } from './types'
type DaemonRequestRouterOptions = {
host: TerminalHost
connections: DaemonClientConnections
lifecycle: DaemonServerLifecycle
admission: DaemonTerminalAdmission
preparations: DaemonPtySpawnPreparations
attachments: DaemonSessionAttachments
historySeedTransfers: TerminalHistorySeedTransferRegistry
sessionBackgroundRouting: DaemonSessionBackgroundRouting
streamDataBatcher: DaemonStreamDataBatcher
ptySpawnHealthCheck: () => Promise<void>
log: DaemonFileLog
}
export class DaemonRequestRouter {
constructor(private readonly options: DaemonRequestRouterOptions) {}
async route(clientId: string, request: DaemonRequest): Promise<unknown> {
const client = this.options.connections.get(clientId)
switch (request.type) {
case 'startHistorySeedTransfer': {
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
throw new Error('Daemon client connection is incomplete; reconnect')
}
return {
transferId: this.options.historySeedTransfers.start(clientId, request.payload)
}
}
case 'appendHistorySeedTransfer':
this.options.historySeedTransfers.append(
clientId,
request.payload.transferId,
request.payload.index,
request.payload.data
)
return {}
case 'finishHistorySeedTransfer':
this.options.historySeedTransfers.finish(clientId, request.payload.transferId)
return {}
case 'abortHistorySeedTransfer':
this.options.historySeedTransfers.abort(clientId, request.payload.transferId)
return {}
case 'createOrAttach':
return this.options.admission.createOrAttach(clientId, request)
case 'cancelCreateOrAttach':
return {
canceled: this.options.preparations.cancel(request.payload.sessionId, {
clientId,
...(typeof request.payload.requestId === 'string'
? { requestId: request.payload.requestId }
: {})
})
}
case 'closeStartupQueryAuthority':
return {
appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId)
}
case 'write':
return this.write(client, request.payload.sessionId, request.payload.data)
case 'resize':
return this.resize(
client,
request.payload.sessionId,
request.payload.cols,
request.payload.rows
)
case 'pausePty':
this.options.host.pauseProducer(request.payload.sessionId)
return {}
case 'resumePty':
this.options.host.resumeProducer(request.payload.sessionId)
return {}
case 'setSessionBackground':
return this.options.sessionBackgroundRouting.setBackground(
request.payload.sessionId,
request.payload.background === true
)
case 'kill':
return this.kill(clientId, request.payload.sessionId, request.payload.immediate)
case 'signal':
this.options.host.signal(request.payload.sessionId, request.payload.signal)
return {}
case 'detach':
this.options.attachments.detachSessionForClient(request.payload.sessionId, clientId)
this.options.log.log('session-detached', { sessionId: request.payload.sessionId })
return {}
case 'getCwd':
return { cwd: await this.options.host.getCwd(request.payload.sessionId) }
case 'getForegroundProcess':
return {
foregroundProcess: this.options.host.getForegroundProcess(request.payload.sessionId)
}
case 'inspectProcess':
return this.options.host.inspectProcess(request.payload.sessionId)
case 'confirmForegroundProcess':
return {
foregroundProcess: await this.options.host.confirmForegroundProcess(
request.payload.sessionId
)
}
case 'clearScrollback':
this.options.host.clearScrollback(request.payload.sessionId)
return {}
case 'listSessions':
return { sessions: this.options.host.listSessions() }
case 'shutdownIfIdle':
return this.shutdownIfIdle(clientId, request.id)
case 'getSnapshot':
return this.getSnapshot(request.payload.sessionId, request.payload.scrollbackRows)
case 'getSize':
return { size: this.options.host.getAppliedSize(request.payload.sessionId) }
case 'takePendingOutput':
return this.options.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.options.ptySpawnHealthCheck()
return { healthy: true }
case 'shutdown':
return this.shutdown(clientId, request.id, request.payload.killSessions)
}
throw new Error(`Unknown request type: ${(request as { type: string }).type}`)
}
private write(
client: ConnectedDaemonClient | undefined,
sessionId: string,
data: string
): Record<string, never> {
try {
this.options.attachments.recordInput(sessionId)
this.options.host.write(sessionId, data)
} catch (error) {
this.options.attachments.clearInput(sessionId)
if (error instanceof SessionNotFoundError) {
this.sendExitEvent(client, sessionId, -1)
}
throw error
}
return {}
}
private resize(
client: ConnectedDaemonClient | undefined,
sessionId: string,
cols: number,
rows: number
): Record<string, never> {
try {
this.options.host.resize(sessionId, cols, rows)
} catch (error) {
if (error instanceof SessionNotFoundError) {
this.sendExitEvent(client, sessionId, -1)
}
throw error
}
return {}
}
private async kill(
clientId: string,
sessionId: string,
immediate: boolean | undefined
): Promise<Record<string, never>> {
const canceledPendingSpawn = this.options.preparations.cancel(sessionId)
this.options.attachments.clearInput(sessionId)
const attribution = { sessionId, immediate: immediate === true, clientId }
try {
await this.options.host.kill(sessionId, { immediate })
} catch (error) {
if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) {
this.options.log.log('session-kill-failed', attribution)
throw error
}
}
this.options.log.log('session-killed', attribution)
return {}
}
private shutdownIfIdle(clientId: string, requestId: string): { retiring: boolean } {
const client = this.options.connections.get(clientId)
const retiring =
client !== undefined &&
client.streamSocket !== null &&
this.options.connections.size === 1 &&
this.options.admission.inFlight === 0 &&
this.options.host.listSessions().length === 0 &&
this.options.connections.hasOnlyTransportsFor(client)
if (retiring) {
this.options.lifecycle.retireAfterIdleReply(clientId, requestId, client.controlSocket)
}
return { retiring }
}
private getSnapshot(sessionId: string, requestedRows: unknown): unknown {
const startedAt = performance.now()
const scrollbackRows =
typeof requestedRows === 'number' && Number.isFinite(requestedRows)
? Math.max(0, Math.min(50_000, Math.floor(requestedRows)))
: undefined
const snapshot = this.options.host.getSnapshot(sessionId, { scrollbackRows })
const snapshotMs = performance.now() - startedAt
if (snapshotMs >= 25) {
recordDaemonStreamBacklogEvent('slowGetSnapshot', {
sessionIdSuffix: sessionId.slice(-10),
snapshotMs: Math.round(snapshotMs)
})
}
return { snapshot }
}
private async shutdown(
clientId: string,
requestId: string,
killSessions: boolean
): Promise<Record<string, never>> {
this.options.log.log('shutdown', {
reason: 'rpc',
killSessions: killSessions === true
})
const serverClose = this.options.lifecycle.beginOrdinaryShutdownFence()
if (killSessions) {
try {
await this.options.host.dispose()
} catch (error) {
this.options.log.log('shutdown-dispose-failed', {
error: error instanceof Error ? error.message : String(error)
})
}
}
const controlSocket = this.options.connections.get(clientId)?.controlSocket
if (controlSocket) {
this.options.lifecycle.deferRpcShutdownUntilReply(
clientId,
requestId,
controlSocket,
serverClose
)
} else {
this.options.lifecycle.finishRpcShutdownWithoutReply(serverClose)
}
return {}
}
private sendExitEvent(
client: ConnectedDaemonClient | undefined,
sessionId: string,
code: number
): void {
if (!client?.streamSocket) {
return
}
this.options.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, {
type: 'event',
event: 'exit',
sessionId,
payload: { code }
})
this.options.streamDataBatcher.flush(client.clientId)
}
}
@@ -6,6 +6,12 @@ import { DaemonClient } from './client'
import { createMockSubprocess } from './daemon-pty-adapter-test-harness'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
type DaemonServerInternals = {
preparations: {
pending: Map<string, Set<{ canceled: boolean }>>
}
host: { listSessions: () => unknown[] }
}
describe('daemon async spawn cancellation', () => {
let directory: string
@@ -82,14 +88,11 @@ describe('daemon async spawn cancellation', () => {
await spawnStarted
client.disconnect()
const daemon = server as unknown as {
pendingPtySpawnPreparations: Map<string, Set<{ canceled: boolean }>>
host: { listSessions: () => unknown[] }
}
const daemon = server as unknown as DaemonServerInternals
await vi.waitFor(() =>
expect(
[...(daemon.pendingPtySpawnPreparations.get('disconnected-spawn') ?? [])][0]?.canceled
).toBe(true)
expect([...(daemon.preparations.pending.get('disconnected-spawn') ?? [])][0]?.canceled).toBe(
true
)
)
releaseSpawn()
await create
@@ -145,14 +148,10 @@ describe('daemon async spawn cancellation', () => {
30_000,
abort.signal
)
const daemon = server as unknown as {
pendingPtySpawnPreparations: Map<string, Set<unknown>>
}
const daemon = server as unknown as DaemonServerInternals
// Attach-only used to register nothing, so the daemon could not match the
// cancel and the client dropped its only timeout.
await vi.waitFor(() =>
expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2)
)
await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2))
abort.abort()
await expect(attach).rejects.toThrow('client_disconnected')
@@ -175,12 +174,8 @@ describe('daemon async spawn cancellation', () => {
abort.signal
)
await spawnStarted
const daemon = server as unknown as {
pendingPtySpawnPreparations: Map<string, Set<unknown>>
}
await vi.waitFor(() =>
expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2)
)
const daemon = server as unknown as DaemonServerInternals
await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2))
abort.abort()
await expect(second).rejects.toThrow('client_disconnected')
@@ -35,9 +35,13 @@ type DaemonAttachmentPrivate = {
createOrAttach: (options: CreateOrAttachOptions) => Promise<CreateOrAttachResult>
sessions: Map<string, { hasAttachedClients: boolean }>
}
clients: Map<string, { streamSocket: Socket | null }>
streamClientIdBySessionId: Map<string, string>
attachTokenBySessionId: Map<string, symbol>
connections: {
clients: Map<string, { streamSocket: Socket | null }>
}
attachments: {
clientIdBySessionId: Map<string, string>
tokenBySessionId: Map<string, symbol>
}
}
describe('DaemonServer attachment lifecycle', () => {
@@ -73,7 +77,7 @@ describe('DaemonServer attachment lifecycle', () => {
await createSession('transport-owned-session')
const daemon = server as unknown as DaemonAttachmentPrivate
const detachClients = vi.spyOn(daemon.host, 'detachClients')
const streamSocket = [...daemon.clients.values()][0]?.streamSocket
const streamSocket = [...daemon.connections.clients.values()][0]?.streamSocket
streamSocket?.destroy()
@@ -82,8 +86,8 @@ describe('DaemonServer attachment lifecycle', () => {
{ sessionId: 'transport-owned-session', token: expect.any(Symbol) }
])
expect(daemon.host.sessions.get('transport-owned-session')?.hasAttachedClients).toBe(false)
expect(daemon.streamClientIdBySessionId.has('transport-owned-session')).toBe(false)
expect(daemon.attachTokenBySessionId.has('transport-owned-session')).toBe(false)
expect(daemon.attachments.clientIdBySessionId.has('transport-owned-session')).toBe(false)
expect(daemon.attachments.tokenBySessionId.has('transport-owned-session')).toBe(false)
})
it('parks only the requesting client attachment on explicit detach', async () => {
@@ -95,8 +99,8 @@ describe('DaemonServer attachment lifecycle', () => {
expect(detach).toHaveBeenCalledWith('explicitly-detached-session', expect.any(Symbol))
expect(daemon.host.sessions.get('explicitly-detached-session')?.hasAttachedClients).toBe(false)
expect(daemon.streamClientIdBySessionId.has('explicitly-detached-session')).toBe(false)
expect(daemon.attachTokenBySessionId.has('explicitly-detached-session')).toBe(false)
expect(daemon.attachments.clientIdBySessionId.has('explicitly-detached-session')).toBe(false)
expect(daemon.attachments.tokenBySessionId.has('explicitly-detached-session')).toBe(false)
})
it('parks an attachment completed after its transport already closed', async () => {
@@ -122,7 +126,7 @@ describe('DaemonServer attachment lifecycle', () => {
await created
client.disconnect()
await vi.waitFor(() => expect(daemon.clients.size).toBe(0))
await vi.waitFor(() => expect(daemon.connections.clients.size).toBe(0))
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(true)
finishRequest()
@@ -130,7 +134,7 @@ describe('DaemonServer attachment lifecycle', () => {
await vi.waitFor(() =>
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(false)
)
expect(daemon.streamClientIdBySessionId.has('close-race-session')).toBe(false)
expect(daemon.attachTokenBySessionId.has('close-race-session')).toBe(false)
expect(daemon.attachments.clientIdBySessionId.has('close-race-session')).toBe(false)
expect(daemon.attachments.tokenBySessionId.has('close-race-session')).toBe(false)
})
})
@@ -1,14 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { chmodSync, linkSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs'
import { chmodSync, linkSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer, type Server } from 'node:net'
import { connect, createServer, type Server } from 'node:net'
import { DaemonServer } from './daemon-server'
import type { ConnectedDaemonClient } from './daemon-client-connections'
import { DaemonClient } from './client'
import { isDaemonGoneError } from './daemon-pty-adapter'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
import { encodeNdjson } from './ndjson'
import { getDaemonSocketPath } from './daemon-spawner'
import type { SubprocessHandle } from './session-subprocess-handle'
import { PROTOCOL_VERSION } from './types'
import { waitForEndpointUnreachable } from './daemon-endpoint-reachability-test-harness'
// A killed process must actually report its exit: teardown waits
@@ -32,6 +35,21 @@ function createMockSubprocess(): SubprocessHandle {
dispose() {}
}
}
type DaemonServerInternals = {
endpoint: {
hasLostOwnership(): boolean
requestRetirementForLoss(): void
checkOwnership(): void
ownershipLossStreak: number
ownershipLost: boolean
}
lifecycle: {
retirementRequested: boolean
state: string
server: Server | null
}
connections: { clients: Map<string, ConnectedDaemonClient> }
}
describe('daemon server error handling', () => {
let dir: string
@@ -88,8 +106,8 @@ describe('daemon server error handling', () => {
).rejects.toThrow(/no longer owns its endpoint/)
// And it stands down rather than lingering as an unreachable host.
const daemon = server as unknown as { retirementRequested: boolean }
expect(daemon.retirementRequested).toBe(true)
const daemon = server as unknown as DaemonServerInternals
expect(daemon.lifecycle.retirementRequested).toBe(true)
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
}
@@ -108,17 +126,14 @@ describe('daemon server error handling', () => {
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
const daemon = server as unknown as {
hasLostEndpointOwnership: () => boolean
retirementRequested: boolean
}
const daemon = server as unknown as DaemonServerInternals
try {
// Drop search permission on the directory so the ownership stat fails EACCES.
chmodSync(dir, 0o600)
expect(daemon.hasLostEndpointOwnership()).toBe(false)
expect(daemon.retirementRequested).toBe(false)
expect(daemon.endpoint.hasLostOwnership()).toBe(false)
expect(daemon.lifecycle.retirementRequested).toBe(false)
} finally {
chmodSync(dir, 0o700)
}
@@ -150,8 +165,8 @@ describe('daemon server error handling', () => {
linkSync(usurperBind, socketPath)
// The guard is still armed here — no creation has been refused yet.
const daemon = server as unknown as { hasLostEndpointOwnership: () => boolean }
expect(daemon.hasLostEndpointOwnership()).toBe(true)
const daemon = server as unknown as DaemonServerInternals
expect(daemon.endpoint.hasLostOwnership()).toBe(true)
await expect(
client.request('createOrAttach', {
@@ -198,14 +213,11 @@ describe('daemon server error handling', () => {
).rejects.toThrow(/no longer owns its endpoint/)
// Simulate a late client completing its handshake and clearing pending retirement.
const daemon = server as unknown as {
retirementRequested: boolean
hasLostEndpointOwnership: () => boolean
}
daemon.retirementRequested = false
const daemon = server as unknown as DaemonServerInternals
daemon.lifecycle.retirementRequested = false
// The loss must still be remembered, so creation stays closed.
expect(daemon.hasLostEndpointOwnership()).toBe(true)
expect(daemon.endpoint.hasLostOwnership()).toBe(true)
await expect(
client.request('createOrAttach', { sessionId: 'b', cols: 80, rows: 24 })
).rejects.toThrow(/no longer owns its endpoint/)
@@ -248,16 +260,13 @@ describe('daemon server error handling', () => {
// Losing the endpoint with nothing left to drain must stand the daemon down, even
// though this client is still connected.
const daemon = server as unknown as {
requestRetirementForLostEndpoint: () => void
idleShutdownState: string
}
expect(daemon.idleShutdownState).toBe('running')
daemon.requestRetirementForLostEndpoint()
const daemon = server as unknown as DaemonServerInternals
expect(daemon.lifecycle.state).toBe('running')
daemon.endpoint.requestRetirementForLoss()
// Drained and unreachable: it must begin standing down rather than wait for this
// client, which can never make it routable again.
expect(daemon.idleShutdownState).not.toBe('running')
expect(daemon.lifecycle.state).not.toBe('running')
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
}
@@ -320,19 +329,15 @@ describe('daemon server error handling', () => {
unlinkSync(socketPath)
linkSync(usurperBind, socketPath)
const daemon = server as unknown as {
retirementRequested: boolean
endpointOwnershipLost: boolean
checkEndpointOwnership: () => void
}
const daemon = server as unknown as DaemonServerInternals
// Retirement is already pending for an unrelated reason.
daemon.retirementRequested = true
daemon.lifecycle.retirementRequested = true
daemon.checkEndpointOwnership()
daemon.checkEndpointOwnership()
daemon.endpoint.checkOwnership()
daemon.endpoint.checkOwnership()
// The loss must be recorded regardless, so a later hello cannot undo it.
expect(daemon.endpointOwnershipLost).toBe(true)
expect(daemon.endpoint.ownershipLost).toBe(true)
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
}
@@ -352,12 +357,7 @@ describe('daemon server error handling', () => {
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
const daemon = server as unknown as {
checkEndpointOwnership: () => void
hasLostEndpointOwnership: () => boolean
endpointOwnershipLost: boolean
endpointOwnershipLossStreak: number
}
const daemon = server as unknown as DaemonServerInternals
// A second name for our own socket inode, so ownership can be handed back.
const ourAlias = join(dir, '.ours')
linkSync(socketPath, ourAlias)
@@ -369,21 +369,21 @@ describe('daemon server error handling', () => {
// First loss.
unlinkSync(socketPath)
linkSync(usurperBind, socketPath)
daemon.checkEndpointOwnership()
expect(daemon.endpointOwnershipLossStreak).toBe(1)
expect(daemon.endpointOwnershipLost).toBe(false)
daemon.endpoint.checkOwnership()
expect(daemon.endpoint.ownershipLossStreak).toBe(1)
expect(daemon.endpoint.ownershipLost).toBe(false)
// Ownership demonstrably returns, observed through the admission path.
unlinkSync(socketPath)
linkSync(ourAlias, socketPath)
expect(daemon.hasLostEndpointOwnership()).toBe(false)
expect(daemon.endpointOwnershipLossStreak).toBe(0)
expect(daemon.endpoint.hasLostOwnership()).toBe(false)
expect(daemon.endpoint.ownershipLossStreak).toBe(0)
// A later isolated loss is confirmation #1, not #2, so it must not retire.
unlinkSync(socketPath)
linkSync(usurperBind, socketPath)
daemon.checkEndpointOwnership()
expect(daemon.endpointOwnershipLost).toBe(false)
daemon.endpoint.checkOwnership()
expect(daemon.endpoint.ownershipLost).toBe(false)
} finally {
await new Promise<void>((resolve) => usurper.close(() => resolve()))
}
@@ -400,19 +400,21 @@ describe('daemon server error handling', () => {
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
const daemon = server as unknown as { server: Server | null }
const daemon = server as unknown as DaemonServerInternals
expect(daemon.server?.listenerCount('error')).toBe(1)
expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
// Twice on purpose: a one-shot listener survives the first error and dies on the second,
// so a single emit cannot tell a permanent handler from `once`.
expect(() => daemon.server?.emit('error', new Error('EMFILE: accept failed'))).not.toThrow()
expect(() =>
daemon.server?.emit('error', new Error('EMFILE: accept failed again'))
daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed'))
).not.toThrow()
expect(() =>
daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed again'))
).not.toThrow()
expect(warn).toHaveBeenCalledTimes(2)
expect(daemon.server?.listenerCount('error')).toBe(1)
expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1)
} finally {
warn.mockRestore()
}
@@ -422,6 +424,44 @@ describe('daemon server error handling', () => {
expect(client.isConnected()).toBe(true)
})
it('rejects unsupported hello roles without replacing an authenticated stream', async () => {
const onAuthenticatedClientPair = vi.fn()
server = new DaemonServer({
socketPath,
tokenPath,
onAuthenticatedClientPair,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const daemon = server as unknown as DaemonServerInternals
const connectedClient = [...daemon.connections.clients.values()][0]
const originalStream = connectedClient.streamSocket
const unsupported = connect(socketPath)
unsupported.on('error', () => {})
await new Promise<void>((resolve) => unsupported.once('connect', resolve))
const response = new Promise<{ ok: boolean; error?: string }>((resolve) => {
unsupported.once('data', (data) => resolve(JSON.parse(data.toString())))
})
unsupported.write(
encodeNdjson({
type: 'hello',
version: PROTOCOL_VERSION,
token: readFileSync(tokenPath, 'utf8'),
clientId: connectedClient.clientId,
role: 'observer'
})
)
await expect(response).resolves.toMatchObject({ ok: false, error: 'Invalid role' })
unsupported.destroy()
expect(connectedClient.streamSocket).toBe(originalStream)
expect(connectedClient.authenticatedPairEstablished).toBe(true)
expect(onAuthenticatedClientPair).toHaveBeenCalledOnce()
})
it.skipIf(process.platform === 'win32')(
'stands down instead of serving when the server errors while publication is in flight',
async () => {
@@ -443,14 +483,14 @@ describe('daemon server error handling', () => {
spawnSubprocess: () => createMockSubprocess()
})
const started = server.start()
const daemon = server as unknown as { server: Server | null }
await vi.waitFor(() => expect(daemon.server?.listening).toBe(true))
daemon.server?.emit('error', new Error('injected accept failure'))
const daemon = server as unknown as DaemonServerInternals
await vi.waitFor(() => expect(daemon.lifecycle.server?.listening).toBe(true))
daemon.lifecycle.server?.emit('error', new Error('injected accept failure'))
await expect(started).rejects.toThrow('injected accept failure')
// Why wait: start() rejects the moment the error lands, while publication is still in
// flight. Reporting failure must end with the daemon actually not serving.
await vi.waitFor(() => expect(daemon.server).toBeNull())
await vi.waitFor(() => expect(daemon.lifecycle.server).toBeNull())
await expect(waitForEndpointUnreachable(socketPath)).resolves.toBe(true)
}
)
@@ -7,8 +7,12 @@ import { SessionNotFoundError, type DaemonRequest } from './types'
type DaemonServerPrivate = {
host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void> }
pendingPtySpawnPreparations: Map<string, Set<{ canceled: boolean; clientId: string }>>
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
preparations: {
pending: Map<string, Set<{ canceled: boolean; clientId: string }>>
}
requestRouter: {
route(clientId: string, request: DaemonRequest): Promise<unknown>
}
}
describe('daemon kill attribution', () => {
@@ -38,7 +42,7 @@ describe('daemon kill attribution', () => {
})
vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished)
const request = daemon.routeRequest('control-42', {
const request = daemon.requestRouter.route('control-42', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
@@ -71,7 +75,7 @@ describe('daemon kill attribution', () => {
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused'))
await expect(
daemon.routeRequest('control-42', {
daemon.requestRouter.route('control-42', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
@@ -103,10 +107,10 @@ describe('daemon kill attribution', () => {
controller: new AbortController(),
clientId: 'control-42'
}
daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation]))
daemon.preparations.pending.set('agent-session', new Set([pendingPreparation]))
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session'))
await daemon.routeRequest('control-42', {
await daemon.requestRouter.route('control-42', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
+278
View File
@@ -0,0 +1,278 @@
import { createServer, type Server, type Socket } from 'node:net'
import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle'
import type { DaemonFileLog } from './daemon-file-log'
import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types'
type PendingShutdownReply = { start: () => void }
type DaemonServerLifecycleOptions = {
protocolVersion: number
initialAdoptionTimeoutMs: number
clock: {
setTimeout(callback: () => void, delayMs: number): unknown
clearTimeout(handle: unknown): void
now(): number
}
endpoint: DaemonEndpointLifecycle
log: DaemonFileLog
isIdle: () => boolean
disposeResources: () => Promise<void>
onIdleShutdown: () => void
onRpcShutdown: () => void
}
export class DaemonServerLifecycle {
private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000
private server: Server | null = null
private startupFailure: Error | null = null
private state: '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 readonly pendingShutdownReplies = new Map<string, PendingShutdownReply>()
constructor(private readonly options: DaemonServerLifecycleOptions) {}
isAcceptingWork(): boolean {
return this.state === 'running'
}
async start(handleConnection: (socket: Socket) => void): Promise<void> {
return new Promise((resolve, reject) => {
this.server = createServer(handleConnection)
let startupSettled = false
const onServerError = (error: Error): void => {
if (startupSettled) {
this.options.log.log('server-error', { message: error.message })
console.warn(`[daemon] Socket server error: ${error.message}`)
return
}
startupSettled = true
this.startupFailure = error
reject(error)
}
this.server.on('error', onServerError)
const bindPath = this.options.endpoint.bindPath()
this.server.listen(bindPath, () => {
this.options.endpoint.secureBindPath(bindPath)
const abandonStartup = (error: unknown): void => {
startupSettled = true
const server = this.server
this.server = null
reject(error)
server?.close()
this.options.endpoint.abandonBindPath(bindPath)
}
void this.options.endpoint.publish(bindPath).then(() => {
if (this.startupFailure) {
this.options.endpoint.retireUnstarted()
this.cancelInitialAdoptionTimer()
abandonStartup(this.startupFailure)
return
}
if (this.options.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
this.armInitialAdoptionTimeout()
}
this.options.endpoint.startOwnershipWatch()
startupSettled = true
resolve()
}, abandonStartup)
})
})
}
shutdown(): Promise<void> {
if (!this.shutdownPromise) {
const serverClose = this.beginOrdinaryShutdownFence()
this.shutdownPromise = this.finishOrdinaryShutdown(serverClose)
}
return this.shutdownPromise
}
onConnectionAccepted(): void {
this.cancelInitialAdoptionTimer()
}
onAuthenticatedPair(): void {
this.initialAdoptionDeadlineMs = null
this.cancelInitialAdoptionTimer()
if (!this.options.endpoint.lost) {
this.retirementRequested = false
}
}
onLastAuthenticatedClientDisconnected(): void {
if (this.state === 'running') {
this.retirementRequested = true
}
}
onEndpointOwnershipLost(): void {
this.retirementRequested = true
this.reevaluateIdleShutdown()
}
reevaluateIdleShutdown(): void {
if (this.state !== 'running') {
return
}
if (this.retirementRequested) {
this.cancelInitialAdoptionTimer()
if (this.options.isIdle()) {
this.beginIdleShutdown()
}
return
}
if (!this.options.isIdle() || this.initialAdoptionDeadlineMs === null) {
this.cancelInitialAdoptionTimer()
return
}
if (this.initialAdoptionTimer !== null) {
return
}
const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.options.clock.now())
if (remainingMs === 0) {
this.initialAdoptionDeadlineMs = null
this.retirementRequested = true
this.beginIdleShutdown()
return
}
this.initialAdoptionTimer = this.options.clock.setTimeout(() => {
this.initialAdoptionTimer = null
this.initialAdoptionDeadlineMs = null
this.retirementRequested = true
this.beginIdleShutdown()
}, remainingMs)
}
beginOrdinaryShutdownFence(): Promise<void> {
this.state = 'shutting-down'
this.cancelInitialAdoptionTimer()
this.ordinaryShutdownServerClose ??= this.beginServerClose()
return this.ordinaryShutdownServerClose
}
deferRpcShutdownUntilReply(
clientId: string,
requestId: string,
socket: Socket,
serverClose: Promise<void>
): void {
this.deferShutdownUntilReply(clientId, requestId, socket, () =>
this.finishRpcShutdown(serverClose)
)
}
finishRpcShutdownWithoutReply(serverClose: Promise<void>): void {
this.shutdownPromise ??= this.finishRpcShutdown(serverClose)
}
retireAfterIdleReply(clientId: string, requestId: string, socket: Socket): void {
this.state = 'shutting-down'
this.initialAdoptionDeadlineMs = null
this.retirementRequested = false
this.cancelInitialAdoptionTimer()
const serverClose = this.beginServerClose()
this.deferShutdownUntilReply(clientId, requestId, socket, () =>
this.finishIdleShutdown(serverClose)
)
}
startPendingShutdownReply(clientId: string, requestId: string): void {
this.pendingShutdownReplies.get(this.shutdownReplyKey(clientId, requestId))?.start()
}
private armInitialAdoptionTimeout(): void {
this.initialAdoptionDeadlineMs =
this.options.clock.now() + this.options.initialAdoptionTimeoutMs
this.reevaluateIdleShutdown()
}
private cancelInitialAdoptionTimer(): void {
if (this.initialAdoptionTimer === null) {
return
}
this.options.clock.clearTimeout(this.initialAdoptionTimer)
this.initialAdoptionTimer = null
}
private beginIdleShutdown(): void {
this.initialAdoptionTimer = null
if (this.state !== 'running') {
return
}
this.state = 'idle-shutdown-pending'
if (!this.options.isIdle()) {
this.state = 'running'
this.reevaluateIdleShutdown()
return
}
this.state = 'shutting-down'
const serverClose = this.beginServerClose()
this.shutdownPromise = this.finishIdleShutdown(serverClose)
}
private async finishOrdinaryShutdown(serverClose: Promise<void>): Promise<void> {
this.options.endpoint.unlinkOwnedArtifacts()
await this.disposeResources()
await serverClose
}
private async finishRpcShutdown(serverClose: Promise<void>): Promise<void> {
await this.finishOrdinaryShutdown(serverClose)
this.options.onRpcShutdown()
}
private async finishIdleShutdown(serverClose: Promise<void>): Promise<void> {
this.options.endpoint.unlinkOwnedArtifacts()
await this.disposeResources()
await serverClose
this.options.onIdleShutdown()
}
private async disposeResources(): Promise<void> {
await this.options.disposeResources()
this.pendingShutdownReplies.clear()
}
private beginServerClose(): Promise<void> {
const server = this.server
this.server = null
if (!server) {
return Promise.resolve()
}
return new Promise<void>((resolve) => server.close(() => resolve()))
}
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)
this.shutdownPromise ??= finish()
}
timer = setTimeout(start, DaemonServerLifecycle.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS)
timer.unref()
socket.once('close', start)
socket.once('error', start)
this.pendingShutdownReplies.set(key, { start })
}
private shutdownReplyKey(clientId: string, requestId: string): string {
return `${clientId}\u0000${requestId}`
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { DaemonFileLog } from './daemon-file-log'
import type { SubprocessHandle } from './session-subprocess-handle'
export type DaemonServerOptions = {
socketPath: string
tokenPath: string
pidPath?: string
launchNonce?: string
startedAtMs?: number
publishEndpointOwnership?: () => void
entryPath?: string
appVersion?: string
spawnerExecPath?: string
protocolVersion?: number
onIdleShutdown?: () => void
onRpcShutdown?: () => void
initialAdoptionTestConfig?: {
timeoutMs: number
clock: {
setTimeout(callback: () => void, delayMs: number): unknown
clearTimeout(handle: unknown): void
now(): number
}
}
ptySpawnHealthCheck?: () => Promise<void>
preparePtySpawn?: () => Promise<void>
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
isCanceled?: () => boolean
}) => SubprocessHandle | Promise<SubprocessHandle>
}
+27 -31
View File
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'
import { DaemonServer } from './daemon-server'
import type { ConnectedDaemonClient } from './daemon-client-connections'
import { DaemonClient } from './client'
import { encodeNdjson } from './ndjson'
import { PROTOCOL_VERSION, type DaemonRequest } from './types'
@@ -50,21 +51,16 @@ function createMockSubprocess(): SubprocessHandle & {
}
type DaemonServerPrivate = {
server: Server | null
pendingPtySpawnPreparations: Map<string, Set<unknown>>
lifecycle: { server: Server | null }
preparations: { pending: Map<string, Set<unknown>> }
host: {
kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void>
dispose: () => Promise<void>
}
connections: { clients: Map<string, ConnectedDaemonClient> }
requestRouter: {
route(clientId: string, request: DaemonRequest): Promise<unknown>
}
clients: Map<
string,
{
clientId: string
controlSocket: Socket
streamSocket: Socket | null
authenticatedPairEstablished: boolean
}
>
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
}
describe('DaemonServer', () => {
@@ -345,11 +341,11 @@ describe('DaemonServer', () => {
// Wait for the server to process the close (and cancel the prep) before
// releasing the preflight, else the resumed spawn races ahead of cancellation.
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).clients.size).toBe(0)
expect((server as unknown as DaemonServerPrivate).connections.clients.size).toBe(0)
)
finishPreparation()
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0)
)
expect(spawnSubprocess).not.toHaveBeenCalled()
})
@@ -486,8 +482,8 @@ describe('DaemonServer', () => {
const kill = vi.spyOn(daemon.host, 'kill').mockReturnValue(teardown)
let acknowledged = false
const routed = daemon
.routeRequest('client-1', {
const routed = daemon.requestRouter
.route('client-1', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
@@ -588,14 +584,14 @@ describe('DaemonServer', () => {
write: vi.fn()
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
daemon.clients.set('client-1', {
daemon.connections.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket,
authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
@@ -608,7 +604,7 @@ describe('DaemonServer', () => {
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"background"')
streamSocket.write.mockClear()
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'req-2',
type: 'write',
payload: { sessionId: 'test-session', data: 'x' }
@@ -645,14 +641,14 @@ describe('DaemonServer', () => {
write: vi.fn()
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
daemon.clients.set('client-1', {
daemon.connections.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket,
authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
@@ -703,13 +699,13 @@ describe('DaemonServer', () => {
writableLength: number
}
daemon.clients.set('client-1', {
daemon.connections.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket,
authenticatedPairEstablished: true
})
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
@@ -776,11 +772,11 @@ describe('DaemonServer', () => {
const control = await connectRawHello('control', 'raw-client')
const stream = await connectRawHello('stream', 'raw-client')
expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy()
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy()
stream.destroy()
await waitFor(() => daemon.clients.get('raw-client')?.streamSocket === null)
await waitFor(() => daemon.connections.clients.get('raw-client')?.streamSocket === null)
control.destroy()
})
@@ -797,7 +793,7 @@ describe('DaemonServer', () => {
const secondStream = await connectRawHello('stream', 'raw-client')
await waitFor(() => firstClosed)
expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy()
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy()
secondStream.destroy()
control.destroy()
})
@@ -819,8 +815,8 @@ describe('DaemonServer', () => {
const secondControl = await connectRawHello('control', 'raw-client')
await waitFor(() => firstControlClosed && firstStreamClosed)
expect(daemon.clients.get('raw-client')?.controlSocket).toBeTruthy()
expect(daemon.clients.get('raw-client')?.streamSocket).toBeNull()
expect(daemon.connections.clients.get('raw-client')?.controlSocket).toBeTruthy()
expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeNull()
secondControl.destroy()
})
@@ -834,7 +830,7 @@ describe('DaemonServer', () => {
})
await waitFor(() => closed || stream.destroyed)
expect(daemon.clients.has('missing-client')).toBe(false)
expect(daemon.connections.clients.has('missing-client')).toBe(false)
})
})
@@ -846,7 +842,7 @@ describe('DaemonServer', () => {
const daemon = server as unknown as DaemonServerPrivate & {
host: { dispose: () => Promise<void> }
}
const controlSocket = [...daemon.clients.values()][0].controlSocket
const controlSocket = [...daemon.connections.clients.values()][0].controlSocket
const originalWrite = controlSocket.write.bind(controlSocket)
let replyFlushed: (() => void) | undefined
vi.spyOn(controlSocket, 'write').mockImplementation(((
@@ -928,7 +924,7 @@ describe('DaemonServer', () => {
const c = await connectClient()
await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({})
await waitFor(() => daemon.server === null)
await waitFor(() => daemon.lifecycle.server === null)
// Why not existsSync: the dead entry remains for the next publisher to replace.
expect(await waitForEndpointUnreachable(socketPath)).toBe(true)
const late = new DaemonClient({ socketPath, tokenPath })
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import { performance } from 'node:perf_hooks'
import type { TerminalHost } from './terminal-host'
export class DaemonSessionAttachments {
private readonly clientIdBySessionId = new Map<string, string>()
private readonly tokenBySessionId = new Map<string, symbol>()
private readonly lastInputAtBySessionId = new Map<string, number>()
constructor(private readonly host: TerminalHost) {}
attach(sessionId: string, clientId: string, token: symbol): void {
this.clientIdBySessionId.set(sessionId, clientId)
this.tokenBySessionId.set(sessionId, token)
}
clientIdForSession(sessionId: string): string | undefined {
return this.clientIdBySessionId.get(sessionId)
}
recordInput(sessionId: string): void {
this.lastInputAtBySessionId.set(sessionId, performance.now())
}
lastInputAt(sessionId: string): number | undefined {
return this.lastInputAtBySessionId.get(sessionId)
}
clearInput(sessionId: string): void {
this.lastInputAtBySessionId.delete(sessionId)
}
release(sessionId: string): void {
this.clientIdBySessionId.delete(sessionId)
this.tokenBySessionId.delete(sessionId)
this.lastInputAtBySessionId.delete(sessionId)
}
detachSessionForClient(sessionId: string, clientId: string): void {
if (this.clientIdBySessionId.get(sessionId) !== clientId) {
return
}
const token = this.tokenBySessionId.get(sessionId)
if (token) {
this.host.detach(sessionId, token)
}
this.clientIdBySessionId.delete(sessionId)
this.tokenBySessionId.delete(sessionId)
}
detachClientSessions(clientId: string): void {
const attachments: { sessionId: string; token: symbol }[] = []
for (const [sessionId, attachedClientId] of this.clientIdBySessionId) {
if (attachedClientId !== clientId) {
continue
}
const token = this.tokenBySessionId.get(sessionId)
if (token) {
attachments.push({ sessionId, token })
}
this.clientIdBySessionId.delete(sessionId)
this.tokenBySessionId.delete(sessionId)
}
if (attachments.length > 0) {
this.host.detachClients(attachments)
}
}
}
@@ -0,0 +1,55 @@
import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
import type { DaemonSessionAttachments } from './daemon-session-attachments'
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { TerminalHost } from './terminal-host'
type DaemonSessionBackgroundRoutingOptions = {
host: TerminalHost
attachments: DaemonSessionAttachments
transientFactRelay: BackgroundTransientFactRelay
streamDataBatcher: DaemonStreamDataBatcher
}
export class DaemonSessionBackgroundRouting {
constructor(private readonly options: DaemonSessionBackgroundRoutingOptions) {}
setBackground(sessionId: string, background: boolean): Record<string, never> {
recordDaemonStreamBacklogEvent('setSessionBackground', {
sessionIdSuffix: sessionId.slice(-10),
background
})
const changed = this.options.transientFactRelay.setSessionBackground(sessionId, background)
this.options.streamDataBatcher.refreshSessionDroppability(sessionId)
if (!changed) {
return {}
}
if (background) {
this.options.transientFactRelay.seedSessionScanState(
sessionId,
this.options.host.getPartialEscapeTailAnsi(sessionId)
)
}
const streamClientId = this.options.attachments.clientIdForSession(sessionId)
if (!streamClientId) {
return {}
}
const mode2031State = this.options.transientFactRelay.getMode2031ReplyScanState(sessionId)
const scanSeedAnsi = background
? ''
: mode2031State.pendingSubscribe
? mode2031State.tail
: this.options.host.getPartialEscapeTailAnsi(sessionId)
this.options.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
type: 'event',
event: 'sessionBackgroundMarker',
sessionId,
payload: {
background,
...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}),
...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {})
}
})
return {}
}
}
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonPtyAdapter, FinalCheckpointWaitExpiredError } from './daemon-pty-adapter'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import type { DaemonFileLog } from './daemon-file-log'
@@ -4,6 +4,7 @@ import type { Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonServer } from './daemon-server'
import type { ConnectedDaemonClient } from './daemon-client-connections'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
@@ -16,22 +17,14 @@ type MockSubprocess = SubprocessHandle & {
}
type DaemonLifecyclePrivate = {
clients: Map<
string,
{
clientId: string
controlSocket: Socket
streamSocket: Socket | null
authenticatedPairEstablished: boolean
}
>
host: {
getPartialEscapeTailAnsi(sessionId: string): string
connections: { clients: Map<string, ConnectedDaemonClient> }
host: { getPartialEscapeTailAnsi(sessionId: string): string }
requestRouter: {
route(clientId: string, request: DaemonRequest): Promise<unknown>
}
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
streamDataBatcher: DaemonStreamDataBatcher
transientFactRelay: BackgroundTransientFactRelay
streamClientIdBySessionId: Map<string, string>
attachments: { clientIdBySessionId: Map<string, string> }
}
function createMockSubprocess(): MockSubprocess {
@@ -95,7 +88,7 @@ function addClient(
write: ReturnType<typeof vi.fn>
writableLength: number
}
daemon.clients.set('client-1', {
daemon.connections.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket,
@@ -134,7 +127,7 @@ describe('daemon stream droppability lifecycle', () => {
server = harness.server
const { daemon } = harness
addClient(daemon)
daemon.streamClientIdBySessionId.set('session-toggle', 'client-1')
daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1')
vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('')
const lifecycle: string[] = []
vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation(
@@ -152,7 +145,7 @@ describe('daemon stream droppability lifecycle', () => {
}
)
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'background',
type: 'setSessionBackground',
payload: { sessionId: 'session-toggle', background: true }
@@ -160,7 +153,7 @@ describe('daemon stream droppability lifecycle', () => {
expect(lifecycle).toEqual(['refresh:true', 'marker:true'])
lifecycle.length = 0
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'duplicate-background',
type: 'setSessionBackground',
payload: { sessionId: 'session-toggle', background: true }
@@ -168,7 +161,7 @@ describe('daemon stream droppability lifecycle', () => {
expect(lifecycle).toEqual(['refresh:true'])
lifecycle.length = 0
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'foreground',
type: 'setSessionBackground',
payload: { sessionId: 'session-toggle', background: false }
@@ -181,13 +174,13 @@ describe('daemon stream droppability lifecycle', () => {
server = harness.server
const { daemon } = harness
addClient(daemon)
daemon.streamClientIdBySessionId.set('session-toggle', 'client-1')
daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1')
daemon.transientFactRelay.setSessionBackground('session-toggle', true)
daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?')
vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?')
const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent')
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'foreground',
type: 'setSessionBackground',
payload: { sessionId: 'session-toggle', background: false }
@@ -216,7 +209,9 @@ describe('daemon stream droppability lifecycle', () => {
const lifecycle: string[] = []
vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation(
(sessionId) => {
lifecycle.push(`refresh:${daemon.streamClientIdBySessionId.get(sessionId) ?? 'unrouted'}`)
lifecycle.push(
`refresh:${daemon.attachments.clientIdBySessionId.get(sessionId) ?? 'unrouted'}`
)
}
)
vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent').mockImplementation(
@@ -225,7 +220,7 @@ describe('daemon stream droppability lifecycle', () => {
}
)
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'attach',
type: 'createOrAttach',
payload: { sessionId: 'session-attach', cols: 80, rows: 24 }
@@ -241,7 +236,7 @@ describe('daemon stream droppability lifecycle', () => {
addClient(daemon, 128 * 1024)
daemon.transientFactRelay.setSessionBackground('session-exit', true)
await daemon.routeRequest('client-1', {
await daemon.requestRouter.route('client-1', {
id: 'attach',
type: 'createOrAttach',
payload: { sessionId: 'session-exit', cols: 80, rows: 24 }
@@ -262,6 +257,6 @@ describe('daemon stream droppability lifecycle', () => {
payload: { code: 42 }
})
expect(daemon.transientFactRelay.isBackgrounded('session-exit')).toBe(false)
expect(daemon.streamClientIdBySessionId.has('session-exit')).toBe(false)
expect(daemon.attachments.clientIdBySessionId.has('session-exit')).toBe(false)
})
})
@@ -0,0 +1,213 @@
import { performance } from 'node:perf_hooks'
import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts'
import type { DaemonClientConnections } from './daemon-client-connections'
import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle'
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
import type { DaemonFileLog } from './daemon-file-log'
import type {
DaemonPtySpawnPreparations,
PendingPtySpawnPreparation
} from './daemon-pty-spawn-preparations'
import type { DaemonSessionAttachments } from './daemon-session-attachments'
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
import type { CreateOrAttachOptions, CreateOrAttachResult, TerminalHost } from './terminal-host'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
import {
isAgentSessionExecutionClaim,
isAgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import { type DaemonRequest, TerminalAttachCanceledError } from './types'
type CreateOrAttachRequest = Extract<DaemonRequest, { type: 'createOrAttach' }>
type DaemonTerminalAdmissionOptions = {
host: TerminalHost
connections: DaemonClientConnections
endpoint: DaemonEndpointLifecycle
preparations: DaemonPtySpawnPreparations
attachments: DaemonSessionAttachments
historySeedTransfers: TerminalHistorySeedTransferRegistry
transientFactRelay: BackgroundTransientFactRelay
streamDataBatcher: DaemonStreamDataBatcher
log: DaemonFileLog
isAcceptingWork: () => boolean
requestEndpointRetirement: () => void
reevaluateIdleShutdown: () => void
}
export class DaemonTerminalAdmission {
private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100
private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024
private createOrAttachInFlight = 0
constructor(private readonly options: DaemonTerminalAdmissionOptions) {}
get inFlight(): number {
return this.createOrAttachInFlight
}
async createOrAttach(clientId: string, request: CreateOrAttachRequest): Promise<unknown> {
const client = this.options.connections.get(clientId)
if (!this.options.isAcceptingWork()) {
throw new Error('Daemon temporarily unavailable; reconnect')
}
if (!client?.authenticatedPairEstablished || client.streamSocket === null) {
throw new Error('Daemon client connection is incomplete; reconnect')
}
const payload = request.payload
const attachOnly = payload.attachOnly === true
if (!attachOnly && this.options.endpoint.hasLostOwnership()) {
this.options.requestEndpointRetirement()
throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE)
}
this.createOrAttachInFlight++
let routedSessionId = payload.sessionId
let result: CreateOrAttachResult
let spawnPreparation: PendingPtySpawnPreparation | null = null
try {
if (
payload.agentSessionEnsure !== undefined &&
(!isAgentSessionExecutionClaim(payload.agentSessionEnsure.claim) ||
!isAgentSessionSurfaceBinding(payload.agentSessionEnsure.surface))
) {
throw new Error('agent_session_identity_required')
}
spawnPreparation = this.options.preparations.register(
payload.sessionId,
clientId,
request.id,
payload.cancelAfterMs
)
if (!attachOnly) {
await this.options.preparations.prepareUnlessCanceled(payload.sessionId, spawnPreparation)
}
if (payload.historySeed !== undefined && payload.historySeedTransferId !== undefined) {
throw new Error('Multiple terminal history seed sources')
}
const historySeedChunks =
payload.historySeedTransferId !== undefined
? this.options.historySeedTransfers.take(clientId, payload.historySeedTransferId)
: payload.historySeed !== undefined
? [payload.historySeed]
: undefined
result = await this.options.host.createOrAttach({
sessionId: payload.sessionId,
cols: payload.cols,
rows: payload.rows,
cwd: payload.cwd,
env: payload.env,
envToDelete: payload.envToDelete,
command: payload.command,
startupCommandDelivery: payload.startupCommandDelivery,
...(attachOnly ? { attachOnly: true } : {}),
...(isTuiAgent(payload.launchAgent) ? { launchAgent: payload.launchAgent } : {}),
shellOverride: payload.shellOverride,
terminalWindowsWslDistro: payload.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: payload.terminalWindowsPowerShellImplementation,
shellReadySupported: payload.shellReadySupported,
historySeedChunks,
startupIngress: parsePtyStartupIngressIntent(payload.startupIngress),
...(payload.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: payload.shellReadyTimeoutMs }
: {}),
...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}),
isCanceled: () => spawnPreparation?.canceled === true,
cancelSignal: spawnPreparation.controller.signal,
onSessionResolved: (sessionId) => {
routedSessionId = sessionId
},
streamClient: this.createStreamClient(clientId, () => routedSessionId)
})
} finally {
if (spawnPreparation) {
this.options.preparations.finish(payload.sessionId, spawnPreparation)
}
this.createOrAttachInFlight--
this.options.reevaluateIdleShutdown()
}
routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? payload.sessionId
if (
this.options.connections.get(clientId) !== client ||
!client.authenticatedPairEstablished ||
client.streamSocket === null
) {
this.options.host.detach(routedSessionId, result.attachToken)
throw new TerminalAttachCanceledError(routedSessionId)
}
this.options.attachments.attach(routedSessionId, clientId, result.attachToken)
this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId)
if (this.options.transientFactRelay.isBackgrounded(routedSessionId)) {
this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
type: 'event',
event: 'sessionBackgroundMarker',
sessionId: routedSessionId,
payload: { background: true }
})
}
this.options.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 } : {})
}
}
private createStreamClient(
clientId: string,
sessionId: () => string
): CreateOrAttachOptions['streamClient'] {
return {
onData: (data, rawLength = data.length, transformed = false, seq) => {
const routedSessionId = sessionId()
this.options.transientFactRelay.onSessionData(routedSessionId, data)
const lastInputAt = this.options.attachments.lastInputAt(routedSessionId)
const isInteractiveOutput =
data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS
this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
flushImmediately: isInteractiveOutput,
flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS,
rawLength,
transformed,
seq
})
},
onExit: (code, incarnationId, cause) => {
const routedSessionId = sessionId()
this.options.log.log('session-exited', {
sessionId: routedSessionId,
code,
cause: cause?.kind
})
this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
type: 'event',
event: 'exit',
sessionId: routedSessionId,
payload: { code, incarnationId, ...(cause ? { cause } : {}) }
})
this.options.streamDataBatcher.flush(clientId)
recordDaemonStreamBacklogEvent('sessionExit', {
sessionIdSuffix: routedSessionId.slice(-10)
})
this.options.transientFactRelay.onSessionExit(routedSessionId)
this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId)
this.options.attachments.release(routedSessionId)
this.options.reevaluateIdleShutdown()
}
}
}
}
@@ -11,7 +11,10 @@ const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_MIMOCODE_HOME',
'ORCA_PI_CODING_AGENT_DIR',
'ORCA_OMP_CODING_AGENT_DIR',
'ORCA_CODEX_HOME'
'ORCA_OMP_STATUS_EXTENSION',
'ORCA_CODEX_HOME',
'ORCA_AGENT_TEAMS_SHIM_DIR',
'ORCA_REMOTE_CLI_BIN_DIR'
] as const
export const POWERLEVEL10K_WIZARD_DISABLE_ENV = 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,275 @@
import type * as pty from 'node-pty'
import { win32 as pathWin32 } from 'node:path'
import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths'
import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process'
import { readWindowsConptyProcessIds } from '../../providers/windows-conpty-process-membership'
import {
isAgentForegroundWrapperProcess,
recognizeAgentProcess,
type RecognizedAgentProcess
} from '../../../shared/agent-process-recognition'
import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent'
import { isShellProcess } from '../../../shared/shell-process-detection'
import { parsePtySessionId } from '../pty-session-id'
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000
const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
if (!trimmed || trimmed === 'xterm-256color') {
return null
}
return trimmed.split(/[\\/]/).pop() || null
}
function resolveFallbackForegroundProcess(
processName: string | null | undefined,
shellPath: string
): string | null {
const normalized = normalizeForegroundProcessName(processName)
if (normalized || process.platform !== 'win32') {
return normalized
}
return normalizeForegroundProcessName(pathWin32.basename(shellPath))
}
function shouldInspectOuterWrapperFallback(processName: string | null): boolean {
const recognized = recognizeAgentProcess(processName)
return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized)
}
export type PtyForegroundProcessTracker = {
recordOutput(data: string): void
markDead(): void
getForegroundProcess(): string | null
confirmForegroundProcess(): Promise<string | null>
}
export function createPtyForegroundProcessTracker(args: {
process: pty.IPty
shellPath: string
cwd?: string
sessionId: string
startupAgentRecognition: RecognizedAgentProcess | null
isDead: () => boolean
}): PtyForegroundProcessTracker {
const proc = args.process
let lastOutputAt = 0
let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null
const contextPaths = getAgentForegroundContextPaths({
cwd: args.cwd,
worktreeId: parsePtySessionId(args.sessionId).worktreeId
})
let startupAgentForeground: { processName: string; expiresAt: number } | null =
args.startupAgentRecognition
? {
processName: args.startupAgentRecognition.processName,
expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS
}
: null
let foregroundRefreshInFlight = false
let lastForegroundRefreshStartedAt = 0
const getFallbackProcess = (): string | null =>
resolveFallbackForegroundProcess(proc.process, args.shellPath)
const getActiveStartupAgent = (
now = Date.now()
): { processName: string; expiresAt: number } | null => {
if (!startupAgentForeground) {
return null
}
if (now > startupAgentForeground.expiresAt) {
startupAgentForeground = null
return null
}
return startupAgentForeground
}
const shouldInspectFallback = (fallbackProcess: string | null): boolean =>
fallbackProcess !== null &&
(isShellProcess(fallbackProcess) ||
isAgentForegroundWrapperProcess(fallbackProcess) ||
shouldInspectOuterWrapperFallback(fallbackProcess) ||
process.platform !== 'win32')
const scheduleRefresh = (fallbackProcess: string | null): void => {
if (args.isDead() || !proc.pid) {
return
}
const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess)
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (
!fallbackProcess ||
(fallbackRecognition !== null &&
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
!shouldInspectFallback(fallbackProcess)
) {
return
}
const now = Date.now()
const idleNoEvidenceShell =
fallbackIsShell && !getActiveStartupAgent(now) && !cachedAgentForeground
const retryMs = !idleNoEvidenceShell
? FOREGROUND_AGENT_CACHE_TTL_MS
: process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS
? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS
: SHELL_FOREGROUND_REFRESH_RETRY_MS
if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) {
return
}
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
const retireStaleForegroundIdentity = (): void => {
const currentFallbackProcess = getFallbackProcess()
if (
fallbackIsShell &&
!getActiveStartupAgent() &&
currentFallbackProcess !== null &&
isShellProcess(currentFallbackProcess)
) {
cachedAgentForeground = null
startupAgentForeground = null
} else if (
cachedAgentForeground !== null &&
Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS &&
currentFallbackProcess !== null &&
isAgentForegroundWrapperProcess(currentFallbackProcess)
) {
cachedAgentForeground = null
}
}
void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, {
contextPaths
})
.then<string | void>(({ processName, available }) => {
if (args.isDead() || !available) {
return
}
if (!processName || !recognizeAgentProcess(processName)) {
if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) {
return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => {
if (args.isDead() || consoleProcessIds === null || consoleProcessIds.size > 1) {
return
}
retireStaleForegroundIdentity()
})
}
retireStaleForegroundIdentity()
return
}
cachedAgentForeground = { processName, refreshedAt: Date.now() }
startupAgentForeground = null
return processName
})
.catch(() => {
// Best-effort only: foreground enrichment must never affect PTY health.
})
.finally(() => {
foregroundRefreshInFlight = false
})
}
return {
recordOutput: (data) => {
if (data.length > 0) {
lastOutputAt = Date.now()
}
},
markDead: () => {
cachedAgentForeground = null
startupAgentForeground = null
},
getForegroundProcess: () => {
if (args.isDead()) {
return null
}
try {
const fallbackProcess = getFallbackProcess()
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
const inspectOuterWrapper =
fallbackRecognition !== null &&
shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)
if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) {
cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() }
startupAgentForeground = null
return fallbackProcess
}
scheduleRefresh(fallbackProcess)
const now = Date.now()
if (
cachedAgentForeground &&
now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
) {
return cachedAgentForeground.processName
}
if (
cachedAgentForeground &&
fallbackProcess !== null &&
(isAgentForegroundWrapperProcess(fallbackProcess) ||
inspectOuterWrapper ||
(process.platform === 'win32' && isShellProcess(fallbackProcess)))
) {
return cachedAgentForeground.processName
}
const activeStartupAgentForeground = getActiveStartupAgent(now)
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
return activeStartupAgentForeground.processName
}
return fallbackProcess
} catch {
return null
}
},
confirmForegroundProcess: async () => {
if (args.isDead() || !proc.pid) {
return null
}
try {
const fallbackProcess = getFallbackProcess()
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (
!fallbackProcess ||
(fallbackRecognition !== null &&
process.platform !== 'win32' &&
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
(process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess))
) {
return fallbackProcess
}
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
fallbackProcess,
{
contextPaths,
fresh: true,
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid)
}
: {})
}
)
if (args.isDead() || !resolution.available) {
return null
}
const recognized = recognizeAgentProcess(resolution.processName)
if (recognized) {
cachedAgentForeground = {
processName: recognized.processName,
refreshedAt: Date.now()
}
startupAgentForeground = null
return recognized.processName
}
cachedAgentForeground = null
startupAgentForeground = null
return resolution.processName
} catch {
return null
}
}
}
}
@@ -0,0 +1,82 @@
import * as pty from 'node-pty'
import {
hostReportsChildExitStatus,
wrapShellSpawnForMacosTccAttribution
} from '../../providers/macos-tcc-login-shell'
import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain'
import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job'
export type SpawnedDaemonPty = {
process: pty.IPty
shellPath: string
spawnCwd: string
startupCommandDeliveredInShellArgs?: boolean
/** False when a wrapper owns the reported status, so no exit code may be read from it. */
reportsChildExitStatus: boolean
}
/** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */
export function spawnNativeDaemonPty(args: {
shellPath: string
shellArgs: string[]
spawnCwd: string
env: Record<string, string>
cols: number
rows: number
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void
}): SpawnedDaemonPty {
let reportsChildExitStatus = true
const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => {
const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env)
// Why: children inherit job membership, so the host job must exist before the first Windows PTY.
if (process.platform === 'win32') {
assignHostProcessToKillOnCloseJob()
}
const proc = pty.spawn(wrapped.file, wrapped.args, {
name: args.env.TERM ?? 'xterm-256color',
cols: args.cols,
rows: args.rows,
cwd,
env: args.env,
// Why: bundled ConPTY has the wrap-marker behavior xterm expects.
...(process.platform === 'win32' ? { useConptyDll: true } : {})
})
reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file)
args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped')
return proc
}
try {
const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd)
return {
process: process_,
shellPath: args.shellPath,
spawnCwd: args.spawnCwd,
reportsChildExitStatus
}
} catch (primaryErr) {
if (process.platform !== 'win32') {
throw primaryErr
}
for (const attempt of args.windowsFallbackAttempts.slice(1)) {
try {
const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd)
const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr)
console.warn(
`[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"`
)
return {
process,
shellPath: attempt.shellPath,
spawnCwd: attempt.effectiveCwd,
startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs,
reportsChildExitStatus
}
} catch {
// This fallback shell also failed -- try the next link in the chain.
}
}
throw primaryErr
}
}
@@ -0,0 +1,90 @@
import {
resolveProcessExitCause,
type TerminalExitCause
} from '../../../shared/terminal-exit-cause'
const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024
type DataListener = (data: string) => void
type ExitListener = (code: number, cause?: TerminalExitCause) => void
/** Preserves spawn-time output and exit ordering until Session installs its listeners. */
export class PtyPreListenerEvents {
private onDataCb: DataListener | null = null
private onExitCb: ExitListener | null = null
private pendingData: string[] = []
private pendingDataChars = 0
private pendingExitCode: number | null = null
private pendingExitCause: TerminalExitCause | null = null
acceptData(data: string): void {
if (this.onDataCb) {
this.onDataCb(data)
return
}
this.pendingData.push(data)
this.pendingDataChars += data.length
while (this.pendingDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) {
const removed = this.pendingData.shift()
if (removed === undefined) {
this.pendingDataChars = 0
return
}
this.pendingDataChars -= removed.length
}
}
acceptExit(args: {
exitCode: number
signal?: number
hostReportsChildExitStatus: boolean
}): void {
const cause = resolveProcessExitCause(args)
if (this.onExitCb) {
this.flushData()
this.onExitCb(args.exitCode, cause)
} else {
this.pendingExitCode = args.exitCode
this.pendingExitCause = cause
}
}
onData(cb: DataListener): void {
this.onDataCb = cb
this.flushData()
}
onExit(cb: ExitListener): void {
this.onExitCb = cb
if (this.pendingExitCode === null) {
return
}
const code = this.pendingExitCode
const cause = this.pendingExitCause ?? resolveProcessExitCause({ exitCode: code })
this.pendingExitCode = null
this.pendingExitCause = null
this.flushData()
cb(code, cause)
}
clear(): void {
this.onDataCb = null
this.onExitCb = null
this.pendingData = []
this.pendingDataChars = 0
this.pendingExitCode = null
this.pendingExitCause = null
}
private flushData(): void {
if (!this.onDataCb || this.pendingData.length === 0) {
return
}
const pending = this.pendingData
this.pendingData = []
this.pendingDataChars = 0
for (const data of pending) {
this.onDataCb(data)
}
}
}
@@ -0,0 +1,234 @@
import { win32 as pathWin32 } from 'node:path'
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash'
import { isPwshAvailable } from '../../pwsh'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../pty/codex-home-wsl-env'
import { addOrcaWslInteropEnv } from '../../pty/wsl-orca-env'
import {
POWERLEVEL10K_WIZARD_DISABLE_ENV,
seedPowerlevel10kWizardEnv
} from '../../pty/powerlevel10k-wizard-env'
import {
resolveSafePtyDefaultCwd,
assertSafeAgentStartupCwd
} from '../../providers/pty-default-cwd'
import {
resolveEffectiveWindowsPowerShell,
shouldProbeWindowsPowerShellAvailability,
type WindowsPowerShellShellFamily
} from '../../providers/windows-powershell'
import {
buildWindowsPowerShellSpawnAttempts,
type WindowsShellSpawnAttempt
} from '../../providers/windows-shell-fallback-chain'
import {
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
resolveWindowsShellLaunchArgs
} from '../../providers/windows-shell-args'
import { resolveUnixShellPath } from '../../providers/local-pty-utils'
import { selectShellStartupFeatures } from '../../shell-startup-features'
import { parseWslPath } from '../../wsl'
import { addWslEnvKeys } from '../../wsl-env'
import {
recognizeAgentProcessFromCommandLine,
type RecognizedAgentProcess
} from '../../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query'
import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell'
import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready'
import { resolveWslSessionContext } from '../wsl-session-context'
import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment'
import type { PtySubprocessOptions } from '../pty-subprocess'
export type PtyShellLaunchPlan = {
shellPath: string
shellArgs: string[]
spawnCwd: string
validationCwd: string
startupCommandDeliveredInShellArgs: boolean
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
startupAgentRecognition: RecognizedAgentProcess | null
}
export function createPtyShellLaunchPlan(
opts: PtySubprocessOptions,
env: Record<string, string>
): PtyShellLaunchPlan {
const resolvedWslContext = resolveWslSessionContext(opts)
let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env)
let shellArgs: string[]
let startupCommandDeliveredInShellArgs = false
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
const requestedCwd = opts.cwd || resolveSafePtyDefaultCwd()
if (opts.command && startupAgentRecognition) {
assertSafeAgentStartupCwd(requestedCwd, opts.command)
}
let spawnCwd = requestedCwd
let validationCwd = spawnCwd
if (process.platform === 'win32') {
const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase()
const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath)
const resolvedShellFamily: WindowsPowerShellShellFamily =
normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe'
? normalizedShellFamily
: normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe'
? normalizedShellFamily
: undefined
const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({
shellFamily: resolvedShellFamily,
implementation: opts.terminalWindowsPowerShellImplementation
})
const shouldResolvePowerShellFamily =
opts.terminalWindowsPowerShellImplementation !== undefined ||
pathWin32.basename(shellPath) === shellPath
if (resolvedGitBashPath) {
shellPath = resolvedGitBashPath
} else if (shellPath === WINDOWS_GIT_BASH_SHELL) {
shellPath = 'powershell.exe'
} else {
shellPath = shouldResolvePowerShellFamily
? (resolveEffectiveWindowsPowerShell({
shellFamily: resolvedShellFamily,
implementation: opts.terminalWindowsPowerShellImplementation,
pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false
}) ?? shellPath)
: shellPath
}
if (
pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' &&
env.ORCA_CODEX_LAUNCH_PREFLIGHT
) {
env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
}
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
shellPath,
cwd: spawnCwd,
defaultCwd: resolveSafePtyDefaultCwd(),
wslContext: resolvedWslContext,
startupCommand: opts.command
})
const primaryAttempt = windowsFallbackAttempts[0]
if (primaryAttempt) {
shellPath = primaryAttempt.shellPath
shellArgs = primaryAttempt.shellArgs
spawnCwd = primaryAttempt.effectiveCwd
validationCwd = primaryAttempt.validationCwd
startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs
} else {
const resolved = resolveWindowsShellLaunchArgs(
shellPath,
spawnCwd,
resolveSafePtyDefaultCwd(),
resolvedWslContext,
opts.command,
env.ORCA_CODEX_LAUNCH_PREFLIGHT
)
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
}
if (isWindowsGitBashShellPath(shellPath)) {
env.CHERE_INVOKING ??= '1'
}
const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
if (codexHomeWslInfo) {
const launchWslDistro = resolvedWslContext?.distro
if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) {
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
} else {
env.CODEX_HOME = codexHomeWslInfo.linuxPath
env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath
addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME'])
if (!launchWslDistro) {
const resolved = resolveWindowsShellLaunchArgs(
shellPath,
requestedCwd,
resolveSafePtyDefaultCwd(),
{ distro: codexHomeWslInfo.distro },
opts.command,
env.ORCA_CODEX_LAUNCH_PREFLIGHT
)
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
startupCommandDeliveredInShellArgs =
resolved.startupCommandDeliveredInShellArgs === true
}
}
} else if (isHostCodexHomeForWsl(env.CODEX_HOME)) {
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
} else if (env.CODEX_HOME) {
addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME'])
}
if (env.CLAUDE_CONFIG_DIR) {
addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR'])
}
if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV])
}
} else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) {
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
}
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
addOrcaWslInteropEnv(env)
}
} else {
rescrubDaemonPtyEnvironment(env, opts)
const preferredShellPath = shellPath
shellPath = resolveUnixShellPath(shellPath)
if (shellPath !== preferredShellPath) {
env.SHELL = shellPath
console.warn(
`[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"`
)
}
const waitsForShellReady =
Boolean(opts.command) &&
(!isCodexStartupCommand ||
shouldUseShellReadyStartupDelivery({
command: opts.command as string,
startupCommandDelivery: opts.startupCommandDelivery
}))
delete env.ORCA_SHELL_FEATURES
const shellLaunch = getShellLaunchConfig(
shellPath,
selectShellStartupFeatures({
shellPath,
env,
hasStartupCommand: Boolean(opts.command),
waitsForShellReady,
emitsStartupIdentity: waitsForShellReady
})
)
Object.assign(env, shellLaunch.env)
shellArgs = shellLaunch.args ?? ['-l']
}
seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete })
if (
env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined &&
process.platform === 'win32' &&
pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
) {
addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV])
}
finalizeDaemonPtyEnvironment(env, opts.env)
return {
shellPath,
shellArgs,
spawnCwd,
validationCwd,
startupCommandDeliveredInShellArgs,
windowsFallbackAttempts,
startupAgentRecognition
}
}
@@ -0,0 +1,189 @@
import { delimiter } from 'node:path'
import { dropInheritedOrcaFishHistory } from '../../fish-history-session'
import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../../pty/build-mode-env'
import { dropIncoherentCondaActivationEnv } from '../../pty/conda-activation-env'
import { stripLegacyTerminalShimEnv } from '../../pty/legacy-terminal-shim-dir'
import { removeInheritedNoColor } from '../../pty/terminal-color-env'
import { resolvePathEnvKey } from '../../pty/windows-environment-path'
import { dropInheritedOrcaHistFile } from '../../worktree-history-file-path'
import {
gitCredentialPromptGuardEnv,
mergeGitConfigEnvProtocol
} from '../../../shared/git-credential-prompt-env'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../../shared/terminal-git-credential-guard'
import {
expandWindowsEnvironmentVariables,
expandWindowsPathEnvironmentVariables
} from '../../../shared/windows-environment-expansion'
import type { TuiAgent } from '../../../shared/tui-agent'
import type { PtySubprocessOptions } from '../pty-subprocess'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN'
] as const
const WINDOWS_PATH_ENV_KEY_RE = /^path$/i
function composeGuardedDaemonGitConfigEnv(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined,
launchAgent: TuiAgent | undefined
): void {
const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
if (policy !== 'guard' && launchAgent === undefined) {
return
}
// Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard.
Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform))
}
function deleteRequestedDaemonEnvKeys(
env: Record<string, string>,
keys: readonly string[] | undefined
): void {
// Why: persistent daemon state can differ from Electron; delete CODEX_HOME only when its Orca overlay owns it.
const deleteOrcaOwnedCodexHome =
keys?.includes('ORCA_CODEX_HOME') === true &&
env.ORCA_CODEX_HOME !== undefined &&
env.CODEX_HOME === env.ORCA_CODEX_HOME
for (const key of keys ?? []) {
delete env[key]
}
if (deleteOrcaOwnedCodexHome) {
delete env.CODEX_HOME
}
}
function removeUnspecifiedPaneIdentityEnv(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
): void {
for (const key of PANE_IDENTITY_ENV_KEYS) {
if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) {
delete env[key]
}
}
}
/** Removes the second PATH key only when the daemon's env merge created it. */
function collapseWindowsPathEnvKeys(
env: Record<string, string>,
requestedEnv: Record<string, string> | undefined
): void {
if (process.platform !== 'win32') {
return
}
const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key))
if (pathKeys.length < 2) {
return
}
// Why: a one-key main patch is authoritative; zero or two keys came from inherited state.
const requestedKeys = requestedEnv
? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key))
: []
if (requestedKeys.length !== 1) {
return
}
const survivingKey = requestedKeys[0]
if (!survivingKey || env[survivingKey] === undefined) {
return
}
for (const key of pathKeys) {
if (key !== survivingKey) {
delete env[key]
}
}
}
/** Promotes the agent-teams shim path ahead of inherited PATH entries. */
function promoteAgentTeamsShimPath(
env: Record<string, string>,
requestedPath: string | undefined
): void {
if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) {
return
}
const normalizedRequestedPath =
process.platform === 'win32'
? expandWindowsEnvironmentVariables(requestedPath, env)
: requestedPath
const pathDelimiter = process.platform === 'win32' ? ';' : delimiter
const shimDir = normalizedRequestedPath.split(pathDelimiter)[0]
if (!shimDir) {
return
}
const pathKey = resolvePathEnvKey(env, process.platform)
const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? []
env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter)
}
function removeInheritedDevAgentHookEndpoint(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
): void {
if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) {
// Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec.
delete env.ORCA_AGENT_HOOK_ENDPOINT
}
}
export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record<string, string> {
const env: Record<string, string> = {
...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev',
FORCE_HYPERLINK: '1'
} as Record<string, string>
stripLegacyTerminalShimEnv(env, process.platform)
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
removeUnspecifiedPaneIdentityEnv(env, opts.env)
if (opts.env?.fish_history === undefined) {
dropInheritedOrcaFishHistory(env)
}
if (opts.env?.HISTFILE === undefined) {
dropInheritedOrcaHistFile(env)
}
if (opts.env?.ORCA_HISTFILE === undefined) {
delete env.ORCA_HISTFILE
}
removeInheritedDevAgentHookEndpoint(env, opts.env)
delete env.ELECTRON_RUN_AS_NODE
removeAppImageRuntimeEnv(env)
removeInheritedNoColor(env)
env.LANG ??= 'en_US.UTF-8'
return env
}
export function rescrubDaemonPtyEnvironment(
env: Record<string, string>,
opts: PtySubprocessOptions
): void {
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
}
export function finalizeDaemonPtyEnvironment(
env: Record<string, string>,
requestedEnv: Record<string, string> | undefined
): void {
expandWindowsPathEnvironmentVariables(env)
collapseWindowsPathEnvKeys(env, requestedEnv)
const requestedPath = requestedEnv
? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)]
: undefined
promoteAgentTeamsShimPath(env, requestedPath)
stripLegacyTerminalShimEnv(env, process.platform)
dropIncoherentCondaActivationEnv(env, process.platform)
}
@@ -0,0 +1,221 @@
import * as pty from 'node-pty'
import { statSync } from 'node:fs'
import { release } from 'node:os'
import {
ensureNodePtySpawnHelperExecutable,
getNodePtySpawnHelperCandidates,
validateWorkingDirectoryAsync,
WorkingDirectoryValidationAbortedError
} from '../../providers/local-pty-utils'
import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd'
import { TerminalAttachCanceledError } from '../daemon-errors'
import { DaemonProtocolError } from '../types'
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000
function daemonEnvironmentDiagSuffix(): string {
const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev'
const systemVersion =
(process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ||
release()
return ` (orca: ${orca}, arch: ${process.arch}, platform: ${process.platform} ${systemVersion})`
}
function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError {
const detailName = kind === 'helper' ? 'helper' : 'cwd'
const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd'
const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory'
return new DaemonProtocolError(
`Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${daemonEnvironmentDiagSuffix()}`
)
}
function isExistingDirectory(path: string | undefined): path is string {
if (!path) {
return false
}
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
function repairDaemonCwd(): string | null {
const candidates = [process.env.ORCA_USER_DATA_PATH]
try {
candidates.push(resolveSafePtyDefaultCwd())
} catch {
// Keep daemon cwd repair best-effort even when no user terminal cwd is safe.
}
candidates.push(process.platform === 'win32' ? 'C:\\' : '/')
for (const candidate of candidates) {
if (isExistingDirectory(candidate)) {
try {
process.chdir(candidate)
return candidate
} catch {
// Try the next stable cwd candidate.
}
}
}
return null
}
function preflightDaemonCwd(): void {
let daemonCwd = '<unavailable>'
try {
daemonCwd = process.cwd()
if (isExistingDirectory(daemonCwd)) {
return
}
} catch {
// Recover below; process.cwd() throws after the original cwd is deleted.
}
if (repairDaemonCwd()) {
return
}
throw formatMissingDaemonPathError('cwd', daemonCwd)
}
function preflightMacNodePtySpawnEnvironment(): void {
if (process.platform !== 'darwin') {
return
}
let candidates: string[]
try {
candidates = getNodePtySpawnHelperCandidates()
} catch {
throw formatMissingDaemonPathError('helper', '<unresolved>')
}
for (const candidate of candidates) {
try {
if (statSync(candidate).isFile()) {
return
}
} catch {
// Try the next node-pty native location.
}
}
throw formatMissingDaemonPathError('helper', candidates[0] ?? '<unresolved>')
}
function preflightUnixPtySpawnEnvironment(): void {
if (process.platform === 'win32') {
return
}
// Why: detached daemons can outlive their launch cwd; repair before every spawn.
preflightDaemonCwd()
preflightMacNodePtySpawnEnvironment()
}
function isNativeWindowsPath(path: string): boolean {
return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
}
export async function preflightPtySpawn(args: {
validationCwd: string
cwdWasExplicit: boolean
sessionId: string
signal?: AbortSignal
}): Promise<void> {
ensureNodePtySpawnHelperExecutable()
preflightUnixPtySpawnEnvironment()
try {
if (process.platform === 'win32') {
if (args.cwdWasExplicit && isNativeWindowsPath(args.validationCwd)) {
await validateWorkingDirectoryAsync(
args.validationCwd,
args.signal ? { signal: args.signal } : {}
)
}
} else {
await validateWorkingDirectoryAsync(
args.validationCwd,
args.signal ? { signal: args.signal } : {}
)
}
} catch (error) {
if (error instanceof WorkingDirectoryValidationAbortedError) {
throw new TerminalAttachCanceledError(args.sessionId)
}
throw error
}
}
export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error {
const message = err instanceof Error ? err.message : String(err)
const formatted = new DaemonProtocolError(
`Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${daemonEnvironmentDiagSuffix()}`
)
if (err instanceof Error && err.stack) {
formatted.stack = err.stack
}
return formatted
}
export function runPtySpawnHealthProbe(): Promise<void> {
const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH)
? process.env.ORCA_USER_DATA_PATH
: resolveSafePtyDefaultCwd()
let proc: pty.IPty
try {
proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
name: 'xterm-256color',
cols: 2,
rows: 1,
cwd,
env: { ...process.env, TERM: 'xterm-256color' }
})
} catch (err) {
throw formatPtySpawnError(err, '/bin/sh', cwd)
}
return new Promise<void>((resolve, reject) => {
let settled = false
let exitDisposable: { dispose(): void } | undefined
const finish = (error?: Error, opts?: { kill?: boolean }): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
exitDisposable?.dispose()
if (opts?.kill) {
try {
proc.kill()
} catch {
// Best-effort cleanup for a short-lived health probe.
}
}
if (error) {
reject(error)
} else {
resolve()
}
}
const timer = setTimeout(() => {
finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), {
kill: true
})
}, PTY_SPAWN_HEALTH_TIMEOUT_MS)
exitDisposable = proc.onExit(({ exitCode }) => {
if (exitCode === 0) {
finish()
} else {
finish(new Error(`PTY spawn health check exited with code ${exitCode}`))
}
})
})
}
export function preflightPtySpawnHealth(): boolean {
if (process.platform === 'win32') {
return false
}
if (process.platform === 'darwin') {
ensureNodePtySpawnHelperExecutable()
}
preflightUnixPtySpawnEnvironment()
return true
}
@@ -0,0 +1,202 @@
import type * as pty from 'node-pty'
import type { RecognizedAgentProcess } from '../../../shared/agent-process-recognition'
import { readPtySlavePath } from '../../../shared/pty-slave-line-discipline-echo'
import { forceKillPosixPtyProcessGroups } from '../../pty/posix-pty-process-groups'
import { signalPosixPtyForegroundGroup } from '../../pty/posix-pty-foreground-group'
import { readPtsName } from '../../pty/node-pty-pts-name'
import { terminatePtyJob } from '../../windows/windows-pty-job'
import { isValidPtySize } from '../daemon-pty-size'
import type { SubprocessHandle } from '../session-subprocess-handle'
import { createPtyForegroundProcessTracker } from './foreground-process-tracker'
import { PtyPreListenerEvents } from './pre-listener-events'
type DisposableNativePty = pty.IPty & { destroy?: () => void }
export function createDaemonPtySubprocessHandle(args: {
process: pty.IPty
shellPath: string
spawnCwd: string
env: Record<string, string>
startupCommandDeliveredInShellArgs: boolean
reportsChildExitStatus: boolean
requestedCwd?: string
sessionId: string
startupAgentRecognition: RecognizedAgentProcess | null
}): SubprocessHandle {
const proc = args.process
// node-pty exposes destroy at runtime but omits it from IPty.
const nativeProc = proc as DisposableNativePty
const events = new PtyPreListenerEvents()
let dead = false
let disposed = false
let nodePtyKillIssued = false
const foreground = createPtyForegroundProcessTracker({
process: proc,
shellPath: args.shellPath,
cwd: args.requestedCwd,
sessionId: args.sessionId,
startupAgentRecognition: args.startupAgentRecognition,
isDead: () => dead
})
proc.onData((data) => {
foreground.recordOutput(data)
events.acceptData(data)
})
proc.onExit(({ exitCode, signal }) => {
events.acceptExit({
exitCode,
signal,
hostReportsChildExitStatus: args.reportsChildExitStatus
})
})
proc.onExit(() => {
dead = true
foreground.markDead()
// Why: neutralize kill synchronously so a later async socket-close SIGHUP cannot hit a recycled pid.
if (process.platform !== 'win32') {
nativeProc.kill = () => {}
}
})
const slavePath = readPtySlavePath(proc)
return {
pid: proc.pid,
shellPath: args.shellPath,
shellCwd: args.spawnCwd,
shellPathEnv: args.env.PATH,
...(slavePath ? { slavePath } : {}),
...(args.startupCommandDeliveredInShellArgs
? { startupCommandDeliveredInShellArgs: true }
: {}),
getForegroundProcess: foreground.getForegroundProcess,
confirmForegroundProcess: foreground.confirmForegroundProcess,
write: (data) => {
if (dead) {
return
}
try {
proc.write(data)
} catch {
dead = true
}
},
resize: (cols, rows) => {
if (dead || !isValidPtySize(cols, rows)) {
return
}
try {
proc.resize(cols, rows)
} catch {
dead = true
}
},
// WindowsTerminal also wires _socket to the ConPTY conout pipe, so pausing backpressures the child.
pause: () => {
if (dead) {
return
}
try {
proc.pause()
} catch {
// Native handle already torn down; flow control is best-effort.
}
},
resume: () => {
if (dead) {
return
}
try {
proc.resume()
} catch {
// Native handle already torn down; flow control is best-effort.
}
},
clear: () => {
if (dead) {
return
}
try {
proc.clear()
} catch {
// A clear on a just-exited PTY is best-effort.
}
},
kill: () => {
if (dead) {
return
}
nodePtyKillIssued = true
try {
proc.kill()
} catch (error) {
// A rejected native kill is not proof of exit; keep the wrapper live for a retry.
nodePtyKillIssued = false
throw error
}
},
terminateOwnedTree: () => terminatePtyJob(proc),
forceKill: () => {
if (dead) {
return
}
// Escalate a ConPTY kill through the job without double-closing node-pty's shell handle.
if (process.platform === 'win32' && nodePtyKillIssued) {
terminatePtyJob(proc)
return
}
try {
forceKillPosixPtyProcessGroups(proc.pid, () => {
process.kill(proc.pid, 'SIGKILL')
})
} catch (signalError) {
try {
proc.kill()
nodePtyKillIssued = true
} catch {
nodePtyKillIssued = false
throw signalError
}
}
},
signal: (sig) => {
if (dead) {
return
}
const signalRootPid = (): void => {
try {
process.kill(proc.pid, sig)
} catch {
// Process may already be dead.
}
}
// SIGWINCH belongs to the tty foreground group; destructive signals keep the root-pid target.
if (sig === 'SIGWINCH') {
signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid)
return
}
signalRootPid()
},
onData: (cb) => events.onData(cb),
onExit: (cb) => events.onExit(cb),
dispose: () => {
if (disposed) {
return
}
disposed = true
dead = true
events.clear()
// POSIX destroy() can asynchronously signal a recycled pid; Windows needs kill() to close ConPTY.
if (process.platform !== 'win32') {
nativeProc.kill = () => {}
} else if (nodePtyKillIssued) {
return
}
try {
nativeProc.destroy?.()
} catch {
// Native handle was already torn down.
}
}
}
}
@@ -129,6 +129,8 @@ async function spawnAndPublishSession(
})
if (opts.isCanceled?.()) {
// Make the canceled spawn non-advertisable before cleanup ownership enters the host map.
session.beginTermination()
// Retain cleanup ownership if the native child refuses to exit.
deps.sessions.set(opts.sessionId, session)
await session.forceKillAndDisposeSubprocess()
@@ -8,7 +8,7 @@ export function listLiveTerminalHostSessions(
): SessionInfo[] {
const result: SessionInfo[] = []
for (const session of sessions.values()) {
if (!session.isAlive) {
if (!session.isAlive || session.isTerminating) {
continue
}
const size = session.getAppliedSize()
@@ -21,7 +21,6 @@ main/codex-accounts/service.ts
main/codex/codex-state-db-backfill-recovery.ts
main/codex/codex-trust-grant-host.ts
main/codex/codex-wsl-hook-install-plan.ts
main/daemon/pty-subprocess.ts
main/git/runner.ts
main/git/wsl-git-read-environment.ts
main/ipc/filesystem-watcher-wsl.ts