diff --git a/src/main/daemon/daemon-init-live-session-preservation.test.ts b/src/main/daemon/daemon-init-live-session-preservation.test.ts index c3d36f59799..f4831484967 100644 --- a/src/main/daemon/daemon-init-live-session-preservation.test.ts +++ b/src/main/daemon/daemon-init-live-session-preservation.test.ts @@ -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 }> + ) => Promise<{ + mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky' + shutdown(): Promise + }> 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 }> + 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. diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index 341fcd26df7..a71348ee88c 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -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 @@ -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 }> diff --git a/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts b/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts index f080083d370..2507eab5920 100644 --- a/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts +++ b/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts @@ -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 }> + ) => Promise<{ mode?: 'degraded-new-pty-fallback'; shutdown(): Promise }> 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 }> + ) => Promise<{ + mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky' + shutdown(): Promise + }> 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') }) }) diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index 423ee9ee34f..8d8d37726ed 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -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 + 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() diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index 21ff31918ac..586ed0f00b0 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -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 => { const connectedClient = adoptionClient ?? undefined adoptionClient = null diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index fa794257bda..ed337e46c77 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -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({ diff --git a/src/main/daemon/daemon-replacement-preflight.ts b/src/main/daemon/daemon-replacement-preflight.ts index 71e7303d174..8ad2c581f87 100644 --- a/src/main/daemon/daemon-replacement-preflight.ts +++ b/src/main/daemon/daemon-replacement-preflight.ts @@ -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 +type PreserveDaemon = ( + mode?: 'degraded-new-pty-fallback' | 'degraded-new-pty-fallback-sticky' +) => Promise 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 diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 8c50b764b05..b4d32290de2 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -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