mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
* 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
155 lines
5.4 KiB
TypeScript
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()
|
|
})
|
|
})
|