Files
orca/src/main/daemon/daemon-preflight-client-replacement.test.ts
T
Brennan Benson 51aab1c793 fix(terminal): harden macOS login-preflight spawn path (#9301 follow-ups) (#9606)
* fix(terminal): harden macOS login-preflight spawn path (#9301 follow-ups)

Follow-ups to the #9301 two-pass review. Re-derived severities: none are
release blockers; these are low-likelihood robustness/observability fixes.

F1 macos-tcc-login-shell: only cache a conclusive PAM verdict. A probe our
own SIGKILL timeout / ETIMEDOUT / maxBuffer killed proves nothing about PAM,
so it no longer sticks the whole process into direct-spawn — it fails open
for that spawn, re-probes next time, and self-heals once PAM answers.

F2 daemon-entry: prepareMacosTccLoginShell returns the outcome; the daemon
emits a structured macos-login-preflight {ok, reason} log on degrade, since
detached daemons destroy stderr and lose the console.warn.

F3 local-pty-provider: an atomic guard before the tracking write coalesces a
concurrent same-session-id spawn onto the winner instead of orphaning an
untracked, unkillable PTY.

F4 daemon-server: preparations carry their clientId; a control-socket close
cancels only that client's pending preps, so a mid-preflight disconnect no
longer leaves a durable orphan daemon PTY.

F7 warm the PAM probe at daemon startup (mirrors warmPwshAvailabilityCache);
note the pipe-vs-PTY probe fidelity limit; test that kill with no pending
prep still surfaces SessionNotFoundError.

Deliberately skipped F5 (self-limits to next reload; a real fix needs
speculative in-flight-generation tracking) and F6 (inherent to legacy
v22 attach; self-heals once daemons are v23).

* fix(terminal): bound preflight recovery work

* fix(terminal): cancel preflight when daemon stream closes
2026-07-20 14:43:42 -07:00

155 lines
5.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect, type Socket } from 'node:net'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { DaemonClient } from './client'
import { DaemonServer } from './daemon-server'
import { encodeNdjson } from './ndjson'
import { PROTOCOL_VERSION } from './types'
import type { SubprocessHandle } from './session'
type DaemonServerPrivate = {
pendingPtySpawnPreparations: Map<string, Set<unknown>>
clients: Map<string, { streamSocket: Socket | null }>
}
function createMockSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
pid: 55555,
getForegroundProcess: vi.fn(() => null),
confirmForegroundProcess: vi.fn(async () => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 0)),
forceKill: vi.fn(() => onExitCb?.(137)),
signal: vi.fn(),
onData: vi.fn(),
onExit: vi.fn((cb: (code: number) => void) => {
onExitCb = cb
}),
dispose: vi.fn()
}
}
describe('daemon preflight client replacement', () => {
let dir: string
let server: DaemonServer
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-preflight-replacement-'))
})
afterEach(async () => {
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
async function connectDaemonSocketPair(
socketPath: string,
tokenPath: string,
clientId: string
): Promise<{ control: Socket; stream: Socket }> {
const token = readFileSync(tokenPath, 'utf-8').trim()
const connectRole = async (role: 'control' | 'stream'): Promise<Socket> => {
const socket = connect(socketPath)
await new Promise<void>((resolve) => socket.once('connect', resolve))
socket.write(
encodeNdjson({ type: 'hello', version: PROTOCOL_VERSION, token, clientId, role })
)
await new Promise<void>((resolve) => socket.once('data', () => resolve()))
return socket
}
return { control: await connectRole('control'), stream: await connectRole('stream') }
}
it('cancels preparation when a reconnect replaces the owning control socket', async () => {
let finishPreparation!: () => void
const preparation = new Promise<void>((resolve) => {
finishPreparation = resolve
})
const preparePtySpawn = vi.fn(() => preparation)
const spawnSubprocess = vi.fn(() => createMockSubprocess())
const socketPath = join(dir, 'daemon.sock')
const tokenPath = join(dir, 'daemon.token')
server = new DaemonServer({ socketPath, tokenPath, preparePtySpawn, spawnSubprocess })
await server.start()
const original = new DaemonClient({ socketPath, tokenPath })
const replacement = new DaemonClient({ socketPath, tokenPath })
;(original as unknown as { clientId: string }).clientId = 'reused-client-id'
;(replacement as unknown as { clientId: string }).clientId = 'reused-client-id'
await original.ensureConnected()
original
.request('createOrAttach', {
sessionId: 'replacement-pending',
cols: 80,
rows: 24
})
.catch(() => {
/* replacement disconnects the original request */
})
await vi.waitFor(() => expect(preparePtySpawn).toHaveBeenCalledOnce())
await replacement.ensureConnected()
finishPreparation()
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
)
expect(spawnSubprocess).not.toHaveBeenCalled()
replacement.disconnect()
original.disconnect()
})
it('cancels only the preparation whose client loses its stream socket', async () => {
let finishPreparation!: () => void
const preparation = new Promise<void>((resolve) => {
finishPreparation = resolve
})
const preparePtySpawn = vi.fn(() => preparation)
const spawnSubprocess = vi.fn(() => createMockSubprocess())
const socketPath = join(dir, 'daemon.sock')
const tokenPath = join(dir, 'daemon.token')
server = new DaemonServer({ socketPath, tokenPath, preparePtySpawn, spawnSubprocess })
await server.start()
const disconnectedClientId = 'stream-loss-client'
const survivingClientId = 'stream-survivor-client'
const disconnected = await connectDaemonSocketPair(socketPath, tokenPath, disconnectedClientId)
const survivor = await connectDaemonSocketPair(socketPath, tokenPath, survivingClientId)
disconnected.control.write(
encodeNdjson({
id: 'req-stream-loss',
type: 'createOrAttach',
payload: { sessionId: 'stream-loss-pending', cols: 80, rows: 24 }
})
)
survivor.control.write(
encodeNdjson({
id: 'req-stream-survivor',
type: 'createOrAttach',
payload: { sessionId: 'stream-survivor-pending', cols: 80, rows: 24 }
})
)
await vi.waitFor(() => expect(preparePtySpawn).toHaveBeenCalledTimes(2))
disconnected.stream.destroy()
await vi.waitFor(() =>
expect(
(server as unknown as DaemonServerPrivate).clients.get(disconnectedClientId)?.streamSocket
).toBeNull()
)
finishPreparation()
await vi.waitFor(() =>
expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0)
)
expect(spawnSubprocess).toHaveBeenCalledOnce()
disconnected.control.destroy()
survivor.control.destroy()
survivor.stream.destroy()
})
})