fix(macos): route fresh PTYs away from skewed daemons

This commit is contained in:
Jinwoo-H
2026-09-03 06:09:00 -04:00
parent f70f580627
commit 48042ea90a
8 changed files with 144 additions and 58 deletions
@@ -48,9 +48,11 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
afterEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
it('preserves a daemon launched from another app path when it owns live sessions', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -79,10 +81,13 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
) => Promise<{
mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky'
shutdown(): Promise<void>
}>
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
await launcher('/fake/socket', '/fake/token')
const handle = await launcher('/fake/socket', '/fake/token')
expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith(
FAKE_RUNTIME_DIR,
@@ -94,6 +99,40 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(disconnectMock).toHaveBeenCalledOnce()
expect(killStaleDaemonMock).not.toHaveBeenCalled()
expect(forkMock).not.toHaveBeenCalled()
expect(handle.mode).toBe('degraded-new-pty-fallback-sticky')
})
it('degrades a mismatched daemon even when its macOS resolver is unhealthy', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const mod = await importFresh()
await mod.initDaemonPtyProvider()
const requestMock = vi.fn(async (method: string) =>
method === 'listSessions' ? { sessions: [{ sessionId: 'wt-1@@live', isAlive: true }] } : {}
)
const disconnectMock = vi.fn()
mockConnectedAdoptionClientOnce()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
ensureConnectedWithin: vi.fn(async () => {}),
request: requestMock,
disconnect: disconnectMock
}
})
getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy')
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ mode?: string; shutdown(): Promise<void> }>
const handle = await launcher('/fake/socket', '/fake/token')
expect(handle.mode).toBe('degraded-new-pty-fallback-sticky')
expect(getMacDaemonSystemResolverHealthMock).not.toHaveBeenCalled()
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number))
expect(disconnectMock).toHaveBeenCalledOnce()
expect(killStaleDaemonMock).not.toHaveBeenCalled()
})
it('preserves a daemon launched from another app path when live session state cannot be verified', async () => {
@@ -167,7 +206,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
await launcher('/fake/socket', '/fake/token')
expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token')
expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled()
expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith(
FAKE_RUNTIME_DIR,
'/fake/socket',
'/fake/token',
FAKE_DAEMON_ENTRY_PATH
)
expect(killStaleDaemonMock).toHaveBeenCalledWith(
FAKE_RUNTIME_DIR,
'/fake/socket',
@@ -227,7 +271,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token')
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number))
expect(disconnectMock).toHaveBeenCalledOnce()
expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled()
expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith(
FAKE_RUNTIME_DIR,
'/fake/socket',
'/fake/token',
FAKE_DAEMON_ENTRY_PATH
)
expect(killStaleDaemonMock).not.toHaveBeenCalled()
expect(forkMock).not.toHaveBeenCalled()
// STA-2376: preserving a daemon is not a lifecycle transition — no event.
+2 -2
View File
@@ -46,7 +46,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA
/** Handle the fake spawner hands back from ensureRunning/getHandle. */
export type MockSpawnerHandle = {
mode?: 'degraded-new-pty-fallback'
mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky'
adopted?: true
releaseAdoptionLease?: () => void
shutdown: () => Promise<void>
@@ -95,7 +95,7 @@ export type MockLocalPtyProvider = {
export type EnsureRunningOverride = () => Promise<{
socketPath: string
tokenPath: string
mode?: 'degraded-new-pty-fallback'
mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky'
adopted?: true
}>
@@ -49,6 +49,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
afterEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
it('preserves a packaged healthy daemon when its app bundle is current', async () => {
@@ -58,7 +59,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
) => Promise<{ mode?: 'degraded-new-pty-fallback'; shutdown(): Promise<void> }>
getDaemonLaunchIdentityMock.mockClear()
killStaleDaemonMock.mockClear()
forkMock.mockClear()
@@ -148,6 +149,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('preserves a packaged daemon that predates the current app bundle when it owns live sessions', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -173,11 +175,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
) => Promise<{
mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky'
shutdown(): Promise<void>
}>
isPackagedMock.mockReturnValue(true)
isDaemonStaleForCurrentBundleMock.mockReturnValueOnce(true)
await launcher('/fake/socket', '/fake/token')
const handle = await launcher('/fake/socket', '/fake/token')
expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith(
FAKE_RUNTIME_DIR,
@@ -189,5 +194,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(disconnectMock).toHaveBeenCalledOnce()
expect(killStaleDaemonMock).not.toHaveBeenCalled()
expect(forkMock).not.toHaveBeenCalled()
expect(handle.mode).toBe('degraded-new-pty-fallback-sticky')
})
})
@@ -344,6 +344,25 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(degradedProvider.routesFreshSpawnsToLocalProvider).toBeUndefined()
})
it('keeps macOS version-skew fallback local until the daemon is restarted', async () => {
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
socketPath: '/fake/stale-socket',
tokenPath: '/fake/stale-token',
mode: 'degraded-new-pty-fallback-sticky'
}))
await mod.initDaemonPtyProvider()
const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider')
const provider = mod.getDaemonProvider() as InstanceType<typeof DegradedDaemonPtyProvider>
expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider)
checkDaemonHealthMock.mockClear()
await expect(provider.recoverFreshSpawnRouting()).resolves.toBe(false)
expect(checkDaemonHealthMock).not.toHaveBeenCalled()
expect(provider.routesFreshSpawnsToLocalProvider).toBe(true)
})
it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => {
// Why: deleting a live legacy daemon's token file makes its sessions permanently unadoptable.
const mod = await importFresh()
@@ -38,7 +38,7 @@ export function attributeNextDaemonReplacement(reason: DaemonReplaceReason): voi
function createPreservedDaemonHandle(
runtimeDir: string,
protocolVersion = PROTOCOL_VERSION,
mode?: 'degraded-new-pty-fallback'
mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky'
): DaemonProcessHandle {
const handle: DaemonProcessHandle = {
adopted: true,
@@ -87,7 +87,7 @@ export function createOutOfProcessLauncher(
adoptionClient = null
}
const preserveDaemon = async (
mode?: 'degraded-new-pty-fallback'
mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky'
): Promise<DaemonProcessHandle> => {
const connectedClient = adoptionClient ?? undefined
adoptionClient = null
+8 -3
View File
@@ -120,13 +120,18 @@ export async function initDaemonPtyProvider(
legacyAdapters = await createLegacyDaemonAdapters(runtimeDir)
routedAdapter =
launchMode === 'degraded-new-pty-fallback'
launchMode === 'degraded-new-pty-fallback' ||
launchMode === 'degraded-new-pty-fallback-sticky'
? new DegradedDaemonPtyProvider({
current: newAdapter,
legacy: legacyAdapters,
fallback: getLocalPtyProvider(),
probeCurrentDaemonSpawn: async () =>
(await checkDaemonHealth(info.socketPath, info.tokenPath)) === 'healthy'
...(launchMode === 'degraded-new-pty-fallback'
? {
probeCurrentDaemonSpawn: async () =>
(await checkDaemonHealth(info.socketPath, info.tokenPath)) === 'healthy'
}
: {})
})
: legacyAdapters.length > 0
? new DaemonPtyRouter({
+50 -43
View File
@@ -21,7 +21,9 @@ import { PROTOCOL_VERSION } from './types'
// would spin hot for the whole budget.
export const WEDGED_DAEMON_GRACE_RETRIES = 11
type PreserveDaemon = (mode?: 'degraded-new-pty-fallback') => Promise<DaemonProcessHandle>
type PreserveDaemon = (
mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky'
) => Promise<DaemonProcessHandle>
type ReplacementPreflightOptions = {
runtimeDir: string
@@ -57,62 +59,67 @@ export async function prepareDaemonReplacement(
let confirmedReplacement = false
const health = await checkDaemonHealth(socketPath, tokenPath)
if (health === 'healthy') {
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
if (resolverHealth === 'unhealthy') {
const liveSessionCount = await getAliveDaemonSessionCount(
// Why: a protocol-healthy daemon can outlive its launching app bundle (dev worktree rebuild,
// or packaged update replacing the app path). Classify that before resolver/TCC checks so a
// mismatched daemon never remains the fresh-PTY authority on macOS.
const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
const stalePackagedBundle =
getAppEnvironment().isPackaged() &&
(await isDaemonStaleForCurrentBundle(
runtimeDir,
socketPath,
tokenPath,
recoveryDeadlineMs
)
if (liveSessionCount !== 0) {
console.warn(
liveSessionCount === null
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
getAppEnvironment().getVersion()
))
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code freshness until no live sessions would be lost.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
if (
await shouldPreserveDaemonWithLiveSessions(
socketPath,
tokenPath,
recoveryDeadlineMs,
replacementLabel
)
) {
// Keep live sessions on the old daemon, but localize fresh PTYs on macOS so TCC uses this app's attribution.
return preserveDaemon(
process.platform === 'darwin' ? 'degraded-new-pty-fallback-sticky' : undefined
)
return preserveDaemon()
}
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
console.warn(
stalePackagedBundle
? '[daemon] Replacing daemon launched before the current app bundle was installed'
: '[daemon] Replacing daemon launched from a different app path'
)
// liveSessionCount is 0: shouldPreserveDaemonWithLiveSessions() only falls through at exactly 0.
pendingReplacement = {
reason: 'unhealthy_resolver',
liveSessionCount
reason: stalePackagedBundle ? 'stale_bundle' : 'different_app_path',
liveSessionCount: 0
}
confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)).cleaned
} else {
// Why: a protocol-healthy daemon can outlive its launching app bundle (dev worktree rebuild, or packaged update replacing the app path).
const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
const stalePackagedBundle =
getAppEnvironment().isPackaged() &&
(await isDaemonStaleForCurrentBundle(
runtimeDir,
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
if (resolverHealth === 'unhealthy') {
const liveSessionCount = await getAliveDaemonSessionCount(
socketPath,
tokenPath,
getAppEnvironment().getVersion()
))
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code freshness until no live sessions would be lost.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
if (
await shouldPreserveDaemonWithLiveSessions(
socketPath,
tokenPath,
recoveryDeadlineMs,
replacementLabel
recoveryDeadlineMs
)
if (liveSessionCount !== 0) {
console.warn(
liveSessionCount === null
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
) {
return preserveDaemon()
}
console.warn(
stalePackagedBundle
? '[daemon] Replacing daemon launched before the current app bundle was installed'
: '[daemon] Replacing daemon launched from a different app path'
)
// liveSessionCount is 0: shouldPreserveDaemonWithLiveSessions() only falls through at exactly 0.
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
pendingReplacement = {
reason: stalePackagedBundle ? 'stale_bundle' : 'different_app_path',
liveSessionCount: 0
reason: 'unhealthy_resolver',
liveSessionCount
}
confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION))
.cleaned
+1 -1
View File
@@ -30,7 +30,7 @@ export type DaemonPidFile = {
}
export type DaemonProcessHandle = {
mode?: 'degraded-new-pty-fallback'
mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky'
/** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */
adopted?: true
releaseAdoptionLease?(): void