mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(macos): route fresh PTYs away from skewed daemons
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user