Fix macOS daemon DNS resolver reuse (#2835)
This commit is contained in:
parent
97ad9e5e11
commit
016b574fe9
|
|
@ -5,9 +5,16 @@ import { existsSync, readFileSync, unlinkSync } from 'fs'
|
|||
import { connect, type Socket } from 'net'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import { getDaemonPidPath } from './daemon-spawner'
|
||||
import { PROTOCOL_VERSION, type HelloMessage, type HelloResponse } from './types'
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
type HelloMessage,
|
||||
type HelloResponse,
|
||||
type SystemResolverHealth,
|
||||
type SystemResolverHealthResult
|
||||
} from './types'
|
||||
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 3_000
|
||||
const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000
|
||||
const KILL_WAIT_MS = 3_000
|
||||
const KILL_POLL_MS = 100
|
||||
const START_TIME_TOLERANCE_MS = 1_500
|
||||
|
|
@ -125,6 +132,109 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis
|
|||
})
|
||||
}
|
||||
|
||||
function isSystemResolverHealth(value: unknown): value is SystemResolverHealth {
|
||||
return value === 'healthy' || value === 'unhealthy' || value === 'unknown'
|
||||
}
|
||||
|
||||
export function getMacDaemonSystemResolverHealth(
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
protocolVersion = PROTOCOL_VERSION
|
||||
): Promise<SystemResolverHealth> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return Promise.resolve('unknown')
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!existsSync(socketPath)) {
|
||||
resolve('unknown')
|
||||
return
|
||||
}
|
||||
|
||||
let token: string
|
||||
try {
|
||||
token = readFileSync(tokenPath, 'utf8').trim()
|
||||
} catch {
|
||||
resolve('unknown')
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
let sock: Socket | null = null
|
||||
const settle = (result: SystemResolverHealth): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
sock?.destroy()
|
||||
resolve(result)
|
||||
}
|
||||
const timer = setTimeout(() => settle('unknown'), RESOLVER_HEALTH_CHECK_TIMEOUT_MS)
|
||||
|
||||
sock = connect({ path: socketPath })
|
||||
sock.on('error', () => settle('unknown'))
|
||||
sock.on('connect', () => {
|
||||
const hello: HelloMessage = {
|
||||
type: 'hello',
|
||||
version: protocolVersion,
|
||||
token,
|
||||
clientId: 'resolver-health-check',
|
||||
role: 'control'
|
||||
}
|
||||
sock?.write(encodeNdjson(hello))
|
||||
})
|
||||
|
||||
let buffer = ''
|
||||
sock.on('data', (chunk: Buffer) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
buffer += chunk.toString()
|
||||
for (;;) {
|
||||
const newlineIdx = buffer.indexOf('\n')
|
||||
if (newlineIdx === -1) {
|
||||
break
|
||||
}
|
||||
const line = buffer.slice(0, newlineIdx)
|
||||
buffer = buffer.slice(newlineIdx + 1)
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
|
||||
let message: Record<string, unknown>
|
||||
try {
|
||||
message = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
settle('unknown')
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'hello') {
|
||||
if (!(message as HelloResponse).ok) {
|
||||
settle('unknown')
|
||||
return
|
||||
}
|
||||
// Why: the daemon must report health from inside its own process;
|
||||
// external launchctl bsexec probes can misclassify healthy PTYs.
|
||||
sock?.write(encodeNdjson({ id: 'resolver-health-1', type: 'systemResolverHealth' }))
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.id === 'resolver-health-1') {
|
||||
if (!message.ok || typeof message.payload !== 'object' || message.payload === null) {
|
||||
settle('unknown')
|
||||
return
|
||||
}
|
||||
const payload = message.payload as Partial<SystemResolverHealthResult>
|
||||
settle(isSystemResolverHealth(payload.health) ? payload.health : 'unknown')
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function commandLineMatchesDaemon(
|
||||
commandLine: string,
|
||||
socketPath: string,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const {
|
|||
netConnectMock,
|
||||
forkMock,
|
||||
healthCheckDaemonMock,
|
||||
getMacDaemonSystemResolverHealthMock,
|
||||
getDaemonLaunchIdentityMock,
|
||||
killStaleDaemonMock,
|
||||
getProcessStartedAtMsMock,
|
||||
|
|
@ -58,6 +59,7 @@ const {
|
|||
})
|
||||
|
||||
const healthCheckDaemonMock = vi.fn(async () => true)
|
||||
const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy')
|
||||
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
|
||||
const killStaleDaemonMock = vi.fn(async () => true)
|
||||
const getProcessStartedAtMsMock = vi.fn(() => 1_000_000)
|
||||
|
|
@ -87,6 +89,7 @@ const {
|
|||
netConnectMock,
|
||||
forkMock,
|
||||
healthCheckDaemonMock,
|
||||
getMacDaemonSystemResolverHealthMock,
|
||||
getDaemonLaunchIdentityMock,
|
||||
killStaleDaemonMock,
|
||||
getProcessStartedAtMsMock,
|
||||
|
|
@ -154,6 +157,7 @@ vi.mock('net', () => ({ connect: netConnectMock }))
|
|||
|
||||
vi.mock('./daemon-health', () => ({
|
||||
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
|
||||
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
|
||||
healthCheckDaemon: healthCheckDaemonMock,
|
||||
killStaleDaemon: killStaleDaemonMock,
|
||||
getProcessStartedAtMs: getProcessStartedAtMsMock
|
||||
|
|
@ -243,6 +247,8 @@ async function importFresh() {
|
|||
unbindLocalProviderListenersMock.mockClear()
|
||||
rebindLocalProviderListenersMock.mockClear()
|
||||
healthCheckDaemonMock.mockClear()
|
||||
getMacDaemonSystemResolverHealthMock.mockReset()
|
||||
getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy')
|
||||
getDaemonLaunchIdentityMock.mockClear()
|
||||
killStaleDaemonMock.mockClear()
|
||||
getAppPathMock.mockReset()
|
||||
|
|
@ -675,6 +681,51 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('respawns instead of reusing a protocol-healthy daemon with broken macOS resolver state', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('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
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
unref: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token')
|
||||
expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled()
|
||||
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('uses the direct daemon entry when Electron app path is already out/main', async () => {
|
||||
probeSocketExistsMock.mockImplementation(
|
||||
(p?: string) => p === '/fake/app/out/main/daemon-entry.js'
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
type ListSessionsResult
|
||||
} from './types'
|
||||
import {
|
||||
getMacDaemonSystemResolverHealth,
|
||||
getDaemonLaunchIdentity,
|
||||
getProcessStartedAtMs,
|
||||
healthCheckDaemon,
|
||||
|
|
@ -103,22 +104,28 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
|||
const entryPath = getDaemonEntryPath()
|
||||
const healthy = await healthCheckDaemon(socketPath, tokenPath)
|
||||
if (healthy) {
|
||||
// Why: dev worktrees share the same orca-dev userData, so a daemon from
|
||||
// a deleted sibling checkout can pass protocol health checks while still
|
||||
// pointing at missing native modules. Packaged app paths are stable and
|
||||
// should preserve existing warm daemon reuse semantics.
|
||||
const identity = app.isPackaged
|
||||
? 'match'
|
||||
: getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
|
||||
if (identity === 'mismatch') {
|
||||
console.warn('[daemon] Replacing daemon launched from a different app path')
|
||||
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
|
||||
if (resolverHealth === 'unhealthy') {
|
||||
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 {
|
||||
shutdown: async () => {
|
||||
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
|
||||
// Why: dev worktrees share the same orca-dev userData, so a daemon from
|
||||
// a deleted sibling checkout can pass protocol health checks while still
|
||||
// pointing at missing native modules. Packaged app paths are stable and
|
||||
// should preserve existing warm daemon reuse semantics.
|
||||
const identity = app.isPackaged
|
||||
? 'match'
|
||||
: getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
|
||||
if (identity === 'mismatch') {
|
||||
console.warn('[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 {
|
||||
shutdown: async () => {
|
||||
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,19 @@ import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
|||
import { DaemonServer } from './daemon-server'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import type * as DaemonHealthModule from './daemon-health'
|
||||
|
||||
const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({
|
||||
getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown')
|
||||
}))
|
||||
|
||||
vi.mock('./daemon-health', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof DaemonHealthModule>()
|
||||
return {
|
||||
...actual,
|
||||
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock
|
||||
}
|
||||
})
|
||||
|
||||
function createTestDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'daemon-adapter-test-'))
|
||||
|
|
@ -85,6 +98,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
|||
|
||||
adapter = new DaemonPtyAdapter({ socketPath, tokenPath })
|
||||
lastSpawnOpts = null
|
||||
getMacDaemonSystemResolverHealthMock.mockReset()
|
||||
getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -837,6 +852,57 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
|||
await respawnServer?.shutdown()
|
||||
})
|
||||
|
||||
it('replaces an unhealthy macOS resolver daemon before creating a fresh session', async () => {
|
||||
let respawnServer: DaemonServer | undefined
|
||||
const respawnFn = vi.fn(async () => {
|
||||
await server.shutdown()
|
||||
rmSync(socketPath, { force: true })
|
||||
respawnServer = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await respawnServer.start()
|
||||
})
|
||||
const exits: { id: string; code: number }[] = []
|
||||
const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn })
|
||||
respawnAdapter.onExit((payload) => exits.push(payload))
|
||||
const existing = await respawnAdapter.spawn({ cols: 80, rows: 24 })
|
||||
getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy')
|
||||
|
||||
const replacement = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
|
||||
|
||||
expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith(
|
||||
socketPath,
|
||||
tokenPath,
|
||||
respawnAdapter.protocolVersion
|
||||
)
|
||||
expect(respawnFn).toHaveBeenCalledOnce()
|
||||
expect(exits).toContainEqual({ id: existing.id, code: -1 })
|
||||
expect(replacement.id).toBeDefined()
|
||||
|
||||
respawnAdapter.dispose()
|
||||
await respawnServer?.shutdown()
|
||||
})
|
||||
|
||||
it('does not resolver-health restart attach-style spawns', async () => {
|
||||
const respawnFn = vi.fn()
|
||||
const respawnAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn: respawnFn })
|
||||
getMacDaemonSystemResolverHealthMock.mockResolvedValueOnce('unhealthy')
|
||||
|
||||
const result = await respawnAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'caller-owned-session'
|
||||
})
|
||||
|
||||
expect(result.id).toBe('caller-owned-session')
|
||||
expect(getMacDaemonSystemResolverHealthMock).not.toHaveBeenCalled()
|
||||
expect(respawnFn).not.toHaveBeenCalled()
|
||||
|
||||
respawnAdapter.dispose()
|
||||
})
|
||||
|
||||
it('propagates respawn failure to the caller', async () => {
|
||||
const respawnFn = vi.fn(async () => {
|
||||
throw new Error('Daemon entry file missing')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ adapter ↔ history lifecycle logic. */
|
|||
import { basename } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { DaemonClient } from './client'
|
||||
import { getMacDaemonSystemResolverHealth } from './daemon-health'
|
||||
import { HistoryManager } from './history-manager'
|
||||
import { HistoryReader } from './history-reader'
|
||||
import { mintPtySessionId, parsePtySessionId } from './pty-session-id'
|
||||
|
|
@ -41,6 +42,8 @@ export class TerminalKilledError extends Error {
|
|||
|
||||
export class DaemonPtyAdapter implements IPtyProvider {
|
||||
readonly protocolVersion: number
|
||||
private socketPath: string
|
||||
private tokenPath: string
|
||||
private client: DaemonClient
|
||||
private historyManager: HistoryManager | null
|
||||
private historyReader: HistoryReader | null
|
||||
|
|
@ -75,6 +78,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
|
||||
constructor(opts: DaemonPtyAdapterOptions) {
|
||||
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
||||
this.socketPath = opts.socketPath
|
||||
this.tokenPath = opts.tokenPath
|
||||
this.client = new DaemonClient({
|
||||
socketPath: opts.socketPath,
|
||||
tokenPath: opts.tokenPath,
|
||||
|
|
@ -95,14 +100,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
}
|
||||
|
||||
private async doSpawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
await this.ensureConnected()
|
||||
|
||||
const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId)
|
||||
|
||||
if (this.killedSessionTombstones.has(sessionId)) {
|
||||
throw new TerminalKilledError(sessionId)
|
||||
}
|
||||
|
||||
if (opts.isNewSession) {
|
||||
await this.replaceUnhealthyMacResolverDaemonBeforeNewPty()
|
||||
}
|
||||
|
||||
// Why: detect crash-recovery history before spawning a replacement PTY so
|
||||
// the revived shell inherits the recovered cwd and dimensions instead of
|
||||
// whatever the current renderer happened to request on mount.
|
||||
|
|
@ -111,6 +118,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
const effectiveCols = restoreInfo?.cols ?? opts.cols
|
||||
const effectiveRows = restoreInfo?.rows ?? opts.rows
|
||||
|
||||
await this.ensureConnected()
|
||||
|
||||
const result = await this.client.request<CreateOrAttachResult>('createOrAttach', {
|
||||
sessionId,
|
||||
cols: effectiveCols,
|
||||
|
|
@ -647,8 +656,35 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
}
|
||||
}
|
||||
|
||||
private async doRespawn(): Promise<void> {
|
||||
console.warn('[daemon] Daemon died — respawning')
|
||||
private async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise<void> {
|
||||
if (!this.respawnFn) {
|
||||
return
|
||||
}
|
||||
|
||||
const health = await getMacDaemonSystemResolverHealth(
|
||||
this.socketPath,
|
||||
this.tokenPath,
|
||||
this.protocolVersion
|
||||
)
|
||||
if (health !== 'unhealthy') {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: replacing the daemon kills its sessions without daemon-side exit
|
||||
// fanout. Emit exits first so renderer panes do not write to dead PTYs.
|
||||
this.fanoutSyntheticExits(-1)
|
||||
if (!this.respawnPromise) {
|
||||
this.respawnPromise = this.doRespawn(
|
||||
'[daemon] macOS system resolver unavailable - respawning daemon'
|
||||
).finally(() => {
|
||||
this.respawnPromise = null
|
||||
})
|
||||
}
|
||||
await this.respawnPromise
|
||||
}
|
||||
|
||||
private async doRespawn(message = '[daemon] Daemon died — respawning'): Promise<void> {
|
||||
console.warn(message)
|
||||
this.removeEventListener?.()
|
||||
this.removeEventListener = null
|
||||
this.client.disconnect()
|
||||
|
|
|
|||
|
|
@ -145,6 +145,15 @@ describe('DaemonServer', () => {
|
|||
expect(result).toEqual({ pong: true })
|
||||
})
|
||||
|
||||
it('handles systemResolverHealth', async () => {
|
||||
await startServer()
|
||||
const c = await connectClient()
|
||||
|
||||
const result = await c.request<{ health: unknown }>('systemResolverHealth', undefined)
|
||||
|
||||
expect(['healthy', 'unhealthy', 'unknown']).toContain(result.health)
|
||||
})
|
||||
|
||||
it('handles write (fire-and-forget)', async () => {
|
||||
await startServer()
|
||||
const c = await connectClient()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { writeFileSync, chmodSync, unlinkSync } from 'fs'
|
|||
import { encodeNdjson, createNdjsonParser } from './ndjson'
|
||||
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 {
|
||||
PROTOCOL_VERSION,
|
||||
|
|
@ -309,6 +310,9 @@ export class DaemonServer {
|
|||
case 'ping':
|
||||
return { pong: true }
|
||||
|
||||
case 'systemResolverHealth':
|
||||
return { health: readCurrentProcessMacSystemResolverHealth() }
|
||||
|
||||
case 'shutdown':
|
||||
if (request.payload.killSessions) {
|
||||
this.host.dispose()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// ─── Protocol Version ────────────────────────────────────────────────
|
||||
// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or
|
||||
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
|
||||
// Why: bumped from 7 -> 8 so existing daemons restart with envToDelete support;
|
||||
// older daemons re-merge process.env and can leak host CODEX_HOME into WSL PTYs.
|
||||
export const PROTOCOL_VERSION = 8
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7] as const
|
||||
// Why: bumped from 8 -> 9 so new app launches do not reuse daemon processes
|
||||
// that cannot report their own macOS system resolver health.
|
||||
export const PROTOCOL_VERSION = 9
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8] as const
|
||||
|
||||
// ─── Session State Machine ──────────────────────────────────────────
|
||||
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
|
||||
|
|
@ -166,6 +166,11 @@ export type PingRequest = {
|
|||
type: 'ping'
|
||||
}
|
||||
|
||||
export type SystemResolverHealthRequest = {
|
||||
id: string
|
||||
type: 'systemResolverHealth'
|
||||
}
|
||||
|
||||
export type GetSnapshotRequest = {
|
||||
id: string
|
||||
type: 'getSnapshot'
|
||||
|
|
@ -187,6 +192,7 @@ export type DaemonRequest =
|
|||
| ClearScrollbackRequest
|
||||
| ShutdownRequest
|
||||
| PingRequest
|
||||
| SystemResolverHealthRequest
|
||||
| GetSnapshotRequest
|
||||
|
||||
// ─── RPC Responses (Daemon → Client, on control socket) ────────────
|
||||
|
|
@ -220,6 +226,12 @@ export type ListSessionsResult = {
|
|||
sessions: SessionInfo[]
|
||||
}
|
||||
|
||||
export type SystemResolverHealth = 'healthy' | 'unhealthy' | 'unknown'
|
||||
|
||||
export type SystemResolverHealthResult = {
|
||||
health: SystemResolverHealth
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
sessionId: string
|
||||
state: SessionState
|
||||
|
|
|
|||
|
|
@ -865,7 +865,11 @@ describe('registerPtyHandlers', () => {
|
|||
|
||||
function setupDaemonAdapter() {
|
||||
const daemonSpawn = vi.fn(
|
||||
async (options: { env: Record<string, string>; sessionId?: string }) => ({
|
||||
async (options: {
|
||||
env: Record<string, string>
|
||||
sessionId?: string
|
||||
isNewSession?: boolean
|
||||
}) => ({
|
||||
id: options.sessionId ?? 'daemon-pty'
|
||||
})
|
||||
)
|
||||
|
|
@ -886,6 +890,7 @@ describe('registerPtyHandlers', () => {
|
|||
type DaemonSpawnCall = {
|
||||
env: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
isNewSession?: boolean
|
||||
}
|
||||
|
||||
async function daemonSpawnAndGetOptions(
|
||||
|
|
@ -1223,6 +1228,7 @@ describe('registerPtyHandlers', () => {
|
|||
const sessionId = spawnOpts.sessionId
|
||||
expect(sessionId).toEqual(expect.any(String))
|
||||
expect((sessionId ?? '').length).toBeGreaterThan(0)
|
||||
expect(spawnOpts.isNewSession).toBe(true)
|
||||
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(sessionId, undefined, 'pi')
|
||||
})
|
||||
|
||||
|
|
@ -1237,6 +1243,7 @@ describe('registerPtyHandlers', () => {
|
|||
sessionId: 'user-session-42'
|
||||
})
|
||||
expect(daemonSpawn.mock.calls.at(-1)![0].sessionId).toBe('user-session-42')
|
||||
expect(daemonSpawn.mock.calls.at(-1)![0].isNewSession).toBeUndefined()
|
||||
expect(piBuildPtyEnvMock).toHaveBeenCalledWith('user-session-42', undefined, 'pi')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1167,7 +1167,8 @@ export function registerPtyHandlers(
|
|||
cols: args.cols,
|
||||
rows: args.rows,
|
||||
cwd: args.cwd,
|
||||
env
|
||||
env,
|
||||
...(isDaemonHostSpawn ? { isNewSession: true } : {})
|
||||
}
|
||||
spawnOptions.envToDelete = mergePtyEnvDeletions(
|
||||
claudeAuth?.stripAuthEnv
|
||||
|
|
@ -1586,7 +1587,8 @@ export function registerPtyHandlers(
|
|||
cols: args.cols,
|
||||
rows: args.rows,
|
||||
cwd: args.cwd,
|
||||
env: spawnEnv
|
||||
env: spawnEnv,
|
||||
...(isMintedSessionId ? { isNewSession: true } : {})
|
||||
}
|
||||
if (combinedEnvToDelete) {
|
||||
spawnOptions.envToDelete = combinedEnvToDelete
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyMacSystemResolverHealth } from './macos-system-resolver-health'
|
||||
|
||||
describe('classifyMacSystemResolverHealth', () => {
|
||||
it('treats the macOS no-resolver output as unhealthy', () => {
|
||||
expect(classifyMacSystemResolverHealth('No DNS configuration available\n')).toBe('unhealthy')
|
||||
})
|
||||
|
||||
it('treats scutil DNS output with nameservers as healthy', () => {
|
||||
expect(
|
||||
classifyMacSystemResolverHealth(`
|
||||
DNS configuration
|
||||
|
||||
resolver #1
|
||||
nameserver[0] : 1.1.1.1
|
||||
flags : Request A records
|
||||
`)
|
||||
).toBe('healthy')
|
||||
})
|
||||
|
||||
it('fails open when the resolver output is inconclusive', () => {
|
||||
expect(classifyMacSystemResolverHealth('')).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { spawnSync } from 'child_process'
|
||||
import type { SystemResolverHealth } from '../daemon/types'
|
||||
|
||||
const MAC_RESOLVER_CHECK_TIMEOUT_MS = 1_500
|
||||
const MAC_NO_DNS_CONFIGURATION_RE = /\bNo DNS configuration available\b/i
|
||||
const MAC_DNS_CONFIGURATION_RE = /^DNS configuration\b/m
|
||||
const MAC_NAMESERVER_RE = /nameserver\[\d+\]\s*:/m
|
||||
|
||||
export function classifyMacSystemResolverHealth(scutilOutput: string): SystemResolverHealth {
|
||||
if (MAC_NO_DNS_CONFIGURATION_RE.test(scutilOutput)) {
|
||||
return 'unhealthy'
|
||||
}
|
||||
if (MAC_DNS_CONFIGURATION_RE.test(scutilOutput) && MAC_NAMESERVER_RE.test(scutilOutput)) {
|
||||
return 'healthy'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function readCurrentProcessMacSystemResolverHealth(): SystemResolverHealth {
|
||||
if (process.platform !== 'darwin') {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const result = spawnSync('/usr/sbin/scutil', ['--dns'], {
|
||||
encoding: 'utf8',
|
||||
timeout: MAC_RESOLVER_CHECK_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
const output = `${typeof result.stdout === 'string' ? result.stdout : ''}\n${
|
||||
typeof result.stderr === 'string' ? result.stderr : ''
|
||||
}`
|
||||
return classifyMacSystemResolverHealth(output)
|
||||
}
|
||||
|
|
@ -28,9 +28,14 @@ export type PtySpawnOptions = {
|
|||
/** Orca worktree identity. When present, the local provider scopes shell
|
||||
* history to this worktree so ArrowUp only surfaces local commands. */
|
||||
worktreeId?: string
|
||||
/** Daemon session ID for reattach. When provided, the daemon reconnects
|
||||
* to an existing session instead of creating a new one. */
|
||||
/** Daemon session ID. A caller-provided ID is treated as an attach request;
|
||||
* daemon hosts also pass minted IDs for fresh sessions that need stable
|
||||
* per-PTY state before provider.spawn returns. */
|
||||
sessionId?: string
|
||||
/** True when the caller minted this daemon session for a fresh terminal.
|
||||
* Existing-session attach paths must stay false so recovery checks do not
|
||||
* replace the daemon out from under a still-live PTY. */
|
||||
isNewSession?: boolean
|
||||
/** Why: allows the renderer to request a specific shell for a single new
|
||||
* terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without
|
||||
* changing the user's persistent default shell setting. Only consulted on
|
||||
|
|
|
|||
Loading…
Reference in New Issue