mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Fix stale terminal daemon spawn health (#5064)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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) ────────────
|
||||
|
||||
Reference in New Issue
Block a user