diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index f6d3670c8..d83272bfb 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -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 { + 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 + try { + message = JSON.parse(line) as Record + } 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 + settle(isSystemResolverHealth(payload.health) ? payload.health : 'unknown') + return + } + } + }) + }) +} + function commandLineMatchesDaemon( commandLine: string, socketPath: string, diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index f750369cc..552d3bab4 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -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 }> + getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy') + forkMock.mockImplementationOnce(() => { + const handlers: Record 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' diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 2e1833078..45a9c9e13 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -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) + } } } } diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index e201d466c..57f8feb93 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -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() + 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') diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index b4b5b9149..ce7778d3a 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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 { - 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('createOrAttach', { sessionId, cols: effectiveCols, @@ -647,8 +656,35 @@ export class DaemonPtyAdapter implements IPtyProvider { } } - private async doRespawn(): Promise { - console.warn('[daemon] Daemon died — respawning') + private async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { + 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 { + console.warn(message) this.removeEventListener?.() this.removeEventListener = null this.client.disconnect() diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index df547bad8..7cecebd92 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -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() diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 222711fa0..8b17bf15a 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -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() diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index ef3519a4d..34a3153c4 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -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 diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 2d57027b3..22fed6c7c 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -865,7 +865,11 @@ describe('registerPtyHandlers', () => { function setupDaemonAdapter() { const daemonSpawn = vi.fn( - async (options: { env: Record; sessionId?: string }) => ({ + async (options: { + env: Record + sessionId?: string + isNewSession?: boolean + }) => ({ id: options.sessionId ?? 'daemon-pty' }) ) @@ -886,6 +890,7 @@ describe('registerPtyHandlers', () => { type DaemonSpawnCall = { env: Record 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') }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index b01be7ae2..358b284a0 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/main/network/macos-system-resolver-health.test.ts b/src/main/network/macos-system-resolver-health.test.ts new file mode 100644 index 000000000..508f28c4c --- /dev/null +++ b/src/main/network/macos-system-resolver-health.test.ts @@ -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') + }) +}) diff --git a/src/main/network/macos-system-resolver-health.ts b/src/main/network/macos-system-resolver-health.ts new file mode 100644 index 000000000..846eb1f3e --- /dev/null +++ b/src/main/network/macos-system-resolver-health.ts @@ -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) +} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 3bf122ec3..4d36807e5 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -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