mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
refactor(daemon): split oversized PTY services
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
+204
-1440
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'
|
||||
|
||||
|
||||
+49
-1355
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
|
||||
|
||||
Reference in New Issue
Block a user