Fix macOS daemon DNS resolver reuse (#2835)

This commit is contained in:
Neil 2026-05-26 13:13:08 -07:00 committed by GitHub
parent 97ad9e5e11
commit 016b574fe9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 394 additions and 28 deletions

View File

@ -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,

View File

@ -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'

View File

@ -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)
}
}
}
}

View File

@ -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')

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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')
})

View File

@ -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

View File

@ -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')
})
})

View File

@ -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)
}

View File

@ -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