Fix stale terminal daemon spawn health (#5064)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-09 20:22:39 -07:00
committed by GitHub
co-authored by Orca
parent 1cbc40b2aa
commit e98febcdbc
10 changed files with 362 additions and 53 deletions
@@ -42,7 +42,12 @@ describe('daemon health socket listener cleanup', () => {
const result = healthCheckDaemon(socketPath, tokenPath)
socket.emit('connect')
socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n'))
socket.emit(
'data',
Buffer.from(
'{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n{"id":"health-2","ok":true}\n'
)
)
await expect(result).resolves.toBe(true)
expect(socket.listenerCount('connect')).toBe(0)
+21
View File
@@ -74,15 +74,36 @@ describe('daemon health', () => {
})
it('passes when a daemon answers ping', async () => {
const ptySpawnHealthCheck = vi.fn(async () => {})
const server = new DaemonServer({
socketPath,
tokenPath,
ptySpawnHealthCheck,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
try {
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
expect(ptySpawnHealthCheck).toHaveBeenCalledOnce()
} finally {
await server.shutdown()
}
})
it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => {
const server = new DaemonServer({
socketPath,
tokenPath,
ptySpawnHealthCheck: vi.fn(async () => {
throw new Error('stale node-pty helper')
}),
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
try {
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false)
} finally {
await server.shutdown()
}
+30 -9
View File
@@ -19,6 +19,8 @@ const KILL_WAIT_MS = 3_000
const KILL_POLL_MS = 100
const START_TIME_TOLERANCE_MS = 1_500
export type DaemonHealthCheckResult = 'healthy' | 'unhealthy' | 'pty-spawn-unhealthy'
type ParsedDaemonPid = {
pid: number
startedAtMs: number | null
@@ -63,10 +65,13 @@ function canConnectSocket(socketPath: string): Promise<boolean> {
})
}
export function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
export function checkDaemonHealth(
socketPath: string,
tokenPath: string
): Promise<DaemonHealthCheckResult> {
return new Promise((resolve) => {
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve(false)
resolve('unhealthy')
return
}
@@ -74,13 +79,13 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis
try {
token = readFileSync(tokenPath, 'utf8').trim()
} catch {
resolve(false)
resolve('unhealthy')
return
}
let settled = false
let sock: Socket | null = null
const settle = (result: boolean): void => {
const settle = (result: DaemonHealthCheckResult): void => {
if (settled) {
return
}
@@ -95,7 +100,7 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis
sock?.off('connect', onConnect)
sock?.off('data', onData)
}
const onError = (): void => settle(false)
const onError = (): void => settle('unhealthy')
const onConnect = (): void => {
const hello: HelloMessage = {
type: 'hello',
@@ -126,13 +131,13 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis
try {
message = JSON.parse(line) as Record<string, unknown>
} catch {
settle(false)
settle('unhealthy')
return
}
if (message.type === 'hello') {
if (!(message as HelloResponse).ok) {
settle(false)
settle('unhealthy')
return
}
sock?.write(encodeNdjson({ id: 'health-1', type: 'ping' }))
@@ -140,12 +145,24 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis
}
if (message.id === 'health-1') {
settle(Boolean(message.ok))
if (!message.ok) {
settle('unhealthy')
return
}
// Why: protocol ping only proves the socket loop is alive. New
// terminals also depend on node-pty's native helper state inside
// the daemon process, which can go stale after dev rebuilds.
sock?.write(encodeNdjson({ id: 'health-2', type: 'ptySpawnHealth' }))
continue
}
if (message.id === 'health-2') {
settle(message.ok ? 'healthy' : 'pty-spawn-unhealthy')
return
}
}
}
const timer = setTimeout(() => settle(false), HEALTH_CHECK_TIMEOUT_MS)
const timer = setTimeout(() => settle('unhealthy'), HEALTH_CHECK_TIMEOUT_MS)
sock = connect({ path: socketPath })
sock.on('error', onError)
@@ -156,6 +173,10 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis
})
}
export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy'
}
function isSystemResolverHealth(value: unknown): value is SystemResolverHealth {
return value === 'healthy' || value === 'unhealthy' || value === 'unknown'
}
+91 -3
View File
@@ -20,6 +20,7 @@ const {
writeFileSyncMock,
netConnectMock,
forkMock,
checkDaemonHealthMock,
healthCheckDaemonMock,
getMacDaemonSystemResolverHealthMock,
getDaemonLaunchIdentityMock,
@@ -66,6 +67,7 @@ const {
}
})
const checkDaemonHealthMock = vi.fn(async () => 'healthy')
const healthCheckDaemonMock = vi.fn(async () => true)
const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy')
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
@@ -101,6 +103,7 @@ const {
writeFileSyncMock,
netConnectMock,
forkMock,
checkDaemonHealthMock,
healthCheckDaemonMock,
getMacDaemonSystemResolverHealthMock,
getDaemonLaunchIdentityMock,
@@ -172,6 +175,7 @@ vi.mock('child_process', () => ({ fork: forkMock }))
vi.mock('net', () => ({ connect: netConnectMock }))
vi.mock('./daemon-health', () => ({
checkDaemonHealth: checkDaemonHealthMock,
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
healthCheckDaemon: healthCheckDaemonMock,
@@ -268,6 +272,8 @@ async function importFresh() {
setLocalPtyProviderMock.mockClear()
unbindLocalProviderListenersMock.mockClear()
rebindLocalProviderListenersMock.mockClear()
checkDaemonHealthMock.mockClear()
checkDaemonHealthMock.mockResolvedValue('healthy')
healthCheckDaemonMock.mockClear()
getMacDaemonSystemResolverHealthMock.mockReset()
getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy')
@@ -1025,7 +1031,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
probeSocketExistsMock.mockImplementation(
(p?: string) => p === '/fake/app/out/main/daemon-entry.js'
)
healthCheckDaemonMock.mockResolvedValueOnce(false)
checkDaemonHealthMock.mockResolvedValueOnce('unhealthy')
const mod = await importFresh()
getAppPathMock.mockReturnValue('/fake/app/out/main')
await mod.initDaemonPtyProvider()
@@ -1068,7 +1074,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('removes detached daemon startup listeners after readiness', async () => {
healthCheckDaemonMock.mockResolvedValueOnce(false)
checkDaemonHealthMock.mockResolvedValueOnce('unhealthy')
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -1123,7 +1129,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('removes detached daemon startup listeners after startup error', async () => {
healthCheckDaemonMock.mockResolvedValueOnce(false)
checkDaemonHealthMock.mockResolvedValueOnce('unhealthy')
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -1167,6 +1173,88 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(child.unref).not.toHaveBeenCalled()
})
it('preserves a spawn-unhealthy daemon when it owns live sessions', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
const requestMock = vi.fn(async (method: string) => {
if (method === 'listSessions') {
return {
sessions: [{ sessionId: 'wt-1@@live', isAlive: true }]
}
}
return {}
})
const disconnectMock = vi.fn()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
request: requestMock,
disconnect: disconnectMock
}
})
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
await launcher('/fake/socket', '/fake/token')
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined)
expect(disconnectMock).toHaveBeenCalledOnce()
expect(killStaleDaemonMock).not.toHaveBeenCalled()
expect(forkMock).not.toHaveBeenCalled()
})
it('replaces a spawn-unhealthy daemon when no live sessions would be lost', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
forkMock.mockImplementationOnce(() => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
return {
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
queueMicrotask(() => cb({ type: 'ready' }))
}
return this
},
off(event: string, cb: (arg?: unknown) => void) {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return this
},
disconnect: vi.fn(),
unref: vi.fn()
}
})
await launcher('/fake/socket', '/fake/token')
expect(killStaleDaemonMock).toHaveBeenCalledWith(
'/fake/userData/daemon',
'/fake/socket',
'/fake/token'
)
expect(forkMock).toHaveBeenCalledWith(
'/fake/app/out/main/daemon-entry.js',
['--socket', '/fake/socket', '--token', '/fake/token'],
expect.objectContaining({ detached: true })
)
})
it('preserves a packaged healthy daemon when its app bundle is current', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
+51 -35
View File
@@ -28,10 +28,10 @@ import {
type ListSessionsResult
} from './types'
import {
checkDaemonHealth,
getMacDaemonSystemResolverHealth,
getDaemonLaunchIdentity,
getProcessStartedAtMs,
healthCheckDaemon,
isDaemonStaleForCurrentBundle,
killStaleDaemon
} from './daemon-health'
@@ -164,49 +164,65 @@ async function shouldPreserveDaemonWithLiveSessions(
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
return async (socketPath, tokenPath) => {
const entryPath = getDaemonEntryPath()
const healthy = await healthCheckDaemon(socketPath, tokenPath)
if (healthy) {
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
if (resolverHealth === 'unhealthy') {
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount !== 0) {
console.warn(
liveSessionCount === null
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
const health = await checkDaemonHealth(socketPath, tokenPath)
if (health !== 'unhealthy') {
if (health === 'pty-spawn-unhealthy') {
if (
await shouldPreserveDaemonWithLiveSessions(
socketPath,
tokenPath,
'that cannot spawn new PTYs'
)
) {
return createPreservedDaemonHandle(runtimeDir)
}
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
console.warn('[daemon] Replacing daemon that cannot spawn new PTYs')
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: a protocol-healthy daemon can outlive the app bundle that
// launched it. In dev this happens after deleting/rebuilding a
// worktree; in packaged apps it happens when the stable
// /Applications/Orca.app path is replaced during update.
const identity = getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
const stalePackagedBundle =
app.isPackaged &&
isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion())
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code
// freshness until no live terminal sessions would be lost.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) {
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
if (resolverHealth === 'unhealthy') {
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount !== 0) {
console.warn(
liveSessionCount === null
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return createPreservedDaemonHandle(runtimeDir)
}
console.warn(
stalePackagedBundle
? '[daemon] Replacing daemon launched before the current app bundle was installed'
: '[daemon] Replacing daemon launched from a different app path'
)
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: daemon is already running from a previous app session and
// responded to a protocol-level ping. Safe to reuse.
return createPreservedDaemonHandle(runtimeDir)
// Why: a protocol-healthy daemon can outlive the app bundle that
// launched it. In dev this happens after deleting/rebuilding a
// worktree; in packaged apps it happens when the stable
// /Applications/Orca.app path is replaced during update.
const identity = getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
const stalePackagedBundle =
app.isPackaged &&
isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion())
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code
// freshness until no live terminal sessions would be lost.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
if (
await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)
) {
return createPreservedDaemonHandle(runtimeDir)
}
console.warn(
stalePackagedBundle
? '[daemon] Replacing daemon launched before the current app bundle was installed'
: '[daemon] Replacing daemon launched from a different app path'
)
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: daemon is already running from a previous app session and
// responded to a protocol-level ping. Safe to reuse.
return createPreservedDaemonHandle(runtimeDir)
}
}
}
}
+15
View File
@@ -198,6 +198,21 @@ describe('DaemonServer', () => {
expect(result).toEqual({ pong: true })
})
it('handles ptySpawnHealth through the daemon process', async () => {
const ptySpawnHealthCheck = vi.fn(async () => {})
server = new DaemonServer({
socketPath,
tokenPath,
ptySpawnHealthCheck,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
const c = await connectClient()
await expect(c.request('ptySpawnHealth', undefined)).resolves.toEqual({ healthy: true })
expect(ptySpawnHealthCheck).toHaveBeenCalledOnce()
})
it('handles systemResolverHealth', async () => {
await startServer()
const c = await connectClient()
+8
View File
@@ -11,6 +11,7 @@ import { TerminalHost } from './terminal-host'
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
import type { SubprocessHandle } from './session'
import { checkPtySpawnHealth } from './pty-subprocess'
import {
PROTOCOL_VERSION,
NOTIFY_PREFIX,
@@ -22,6 +23,7 @@ import {
export type DaemonServerOptions = {
socketPath: string
tokenPath: string
ptySpawnHealthCheck?: () => Promise<void>
spawnSubprocess: (opts: {
sessionId: string
cols: number
@@ -45,6 +47,7 @@ export class DaemonServer {
private host: TerminalHost
private socketPath: string
private tokenPath: string
private ptySpawnHealthCheck: () => Promise<void>
private clients = new Map<string, ConnectedClient>()
private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId))
@@ -62,6 +65,7 @@ export class DaemonServer {
this.tokenPath = opts.tokenPath
this.token = randomUUID()
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
}
async start(): Promise<void> {
@@ -375,6 +379,10 @@ export class DaemonServer {
case 'systemResolverHealth':
return { health: await readCurrentProcessMacSystemResolverHealth() }
case 'ptySpawnHealth':
await this.ptySpawnHealthCheck()
return { healthy: true }
case 'shutdown':
if (request.payload.killSessions) {
this.host.dispose()
+62 -3
View File
@@ -1,13 +1,19 @@
/* oxlint-disable max-lines -- Why: exercises full PTY subprocess surface (spawn setup, signal routing, data events, platform-specific shell configs, and Windows PowerShell implementations) with co-located test scenarios to prevent fixture drift. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, realpathSync, rmSync } from 'fs'
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type * as LocalPtyUtils from '../providers/local-pty-utils'
const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock } = vi.hoisted(() => ({
const {
spawnMock,
isPwshAvailableMock,
validateWorkingDirectoryMock,
getNodePtySpawnHelperCandidatesMock
} = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
getNodePtySpawnHelperCandidatesMock: vi.fn(),
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
if (cwd.includes('definitely-missing')) {
throw new Error(
@@ -29,11 +35,12 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
getNodePtySpawnHelperCandidates: getNodePtySpawnHelperCandidatesMock,
validateWorkingDirectory: validateWorkingDirectoryMock
}
})
import { createPtySubprocess } from './pty-subprocess'
import { checkPtySpawnHealth, createPtySubprocess } from './pty-subprocess'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_ATTRIBUTION_SHIM_DIR',
@@ -79,6 +86,10 @@ describe('createPtySubprocess', () => {
isPwshAvailableMock.mockReturnValue(false)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-'))
const spawnHelperPath = join(userDataPath, 'spawn-helper')
writeFileSync(spawnHelperPath, '')
getNodePtySpawnHelperCandidatesMock.mockReset()
getNodePtySpawnHelperCandidatesMock.mockReturnValue([spawnHelperPath])
process.env.ORCA_USER_DATA_PATH = userDataPath
for (const key of ORCA_SHELL_WRAPPER_ENV) {
savedWrapperEnv[key] = process.env[key]
@@ -169,6 +180,54 @@ describe('createPtySubprocess', () => {
)
})
it('checks macOS PTY spawn health with a short-lived shell', async () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
try {
const result = checkPtySpawnHealth()
proc._simulateExit(0)
await expect(result).resolves.toBeUndefined()
expect(proc.kill).not.toHaveBeenCalled()
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
'/bin/sh',
['-c', 'exit 0'],
expect.objectContaining({
cols: 2,
rows: 1,
cwd: userDataPath,
name: 'xterm-256color'
})
)
})
it('surfaces stale node-pty helper failures during macOS PTY spawn health', async () => {
spawnMock.mockImplementation(() => {
throw new Error(
"node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'"
)
})
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
try {
await expect(checkPtySpawnHealth()).rejects.toThrow('Daemon failed to spawn shell "/bin/sh"')
await expect(checkPtySpawnHealth()).rejects.toThrow('posix_spawn failed: ENOENT')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('returns a SubprocessHandle with correct pid', () => {
const proc = mockPtyProcess(42)
spawnMock.mockReturnValue(proc)
+70
View File
@@ -29,6 +29,7 @@ import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../gi
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
export type PtySubprocessOptions = {
sessionId: string
@@ -231,6 +232,75 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string):
return formatted
}
export async function checkPtySpawnHealth(): Promise<void> {
if (process.platform !== 'darwin') {
return
}
ensureNodePtySpawnHelperExecutable()
preflightMacNodePtySpawnEnvironment()
const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH)
? process.env.ORCA_USER_DATA_PATH
: getDefaultCwd()
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)
}
await 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)
return
}
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)
// Why: ping only proves the daemon protocol is alive. A real short-lived
// PTY spawn catches stale node-pty helper paths captured by this process.
exitDisposable = proc.onExit(({ exitCode }) => {
if (exitCode === 0) {
finish()
return
}
finish(new Error(`PTY spawn health check exited with code ${exitCode}`))
})
})
}
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
if (!trimmed || trimmed === 'xterm-256color') {
+8 -2
View File
@@ -3,8 +3,8 @@
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bump when adding daemon wire behavior so same-version old daemons do
// not silently accept the handshake and then reject new RPCs.
export const PROTOCOL_VERSION = 11
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const
export const PROTOCOL_VERSION = 12
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const
// ─── Session State Machine ──────────────────────────────────────────
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
@@ -182,6 +182,11 @@ export type SystemResolverHealthRequest = {
type: 'systemResolverHealth'
}
export type PtySpawnHealthRequest = {
id: string
type: 'ptySpawnHealth'
}
export type GetSnapshotRequest = {
id: string
type: 'getSnapshot'
@@ -205,6 +210,7 @@ export type DaemonRequest =
| ShutdownRequest
| PingRequest
| SystemResolverHealthRequest
| PtySpawnHealthRequest
| GetSnapshotRequest
// ─── RPC Responses (Daemon → Client, on control socket) ────────────