mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
The patient ask was not patient. With twelve seconds reserved for the evidence read, `max(CONNECT, probeBudget - REQUEST)` resolved to exactly CONNECT — the tolerant ask got the cheap ask's four seconds, and the grace loop's gate needed 19s of a budget that only ever held 11s, so it never ran at all. Both shipped green because mocked probes consume no wall clock, so the budget never binds in a test. Two arithmetic tests now compute against realistic elapsed time, which is where the defect actually lived. The reservation was backwards anyway. Evidence can only raise 'unknown' to an uncounted 'occupied', and both now hold the daemon, so the read changes a log line and nothing else — while starving the one probe whose counted answer still reaches preserveDaemon() and full daemon mode. It is opportunistic now: if the probes spent the clock, it is skipped and the verdict stays 'unknown', which holds exactly as an evidence-raised 'occupied' would have. Recovery was a one-way flip on a two-way condition. Once a health check promoted fresh spawns back to the daemon, nothing ever demoted them, so a daemon that wedged again cost a hello timeout plus a full launcher re-classification for every new terminal, for the rest of the session. A failed spawn now routes back and re-arms the cooldown. The class had no tests at all; it has six. The notice was wrong twice. It claimed terminals already open keep working — in held mode discovery runs over the same IPC the daemon is failing, so its sessions are never routed and attach refuses rather than answering on its behalf. They are running, but unreachable. And it named half the cost of Restart: runRestartDaemon shuts down the local fallback sessions too, so the terminals it had just called safe die as well. Also fixed: the amber-on-amber body text failed AA at 3.94:1, the keys were in a namespace no sibling uses, and the scale did not match the notice it renders beside. The banner armed a destructive button with state that never refreshed. It now refetches on focus, like the sibling notice that solved this first. Also: the launch mode type in the test harness omitted 'held', so no test could describe the launch the hold produces; held mode's routing through the degraded provider was unpinned, and deleting it left every test green; and pty:signal kept a try/catch for a synchronous throw that async routing cannot produce.
4005 lines
146 KiB
TypeScript
4005 lines
146 KiB
TypeScript
/* eslint-disable max-lines -- Why: covers daemon-init's full restart flow (7-step sequence per docs/daemon-staleness-ux.md §Phase 1 + coalescer); one describe block keeps shared mocks in one place. */
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { DaemonLaunchMode } from './daemon-spawner'
|
|
import { join } from 'node:path'
|
|
import { PROTOCOL_VERSION } from './types'
|
|
import { WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS, WEDGED_DAEMON_GRACE_RETRIES } from './daemon-init'
|
|
|
|
const FAKE_USER_DATA_PATH = '/fake/userData'
|
|
const FAKE_RUNTIME_DIR = join(FAKE_USER_DATA_PATH, 'daemon')
|
|
const FAKE_APP_PATH = '/fake/app'
|
|
const FAKE_APP_OUT_MAIN_PATH = join(FAKE_APP_PATH, 'out', 'main')
|
|
const FAKE_DAEMON_ENTRY_PATH = join(FAKE_APP_OUT_MAIN_PATH, 'daemon-entry.js')
|
|
|
|
// Why: we only care about runRestartDaemon's observable sequencing/identity invariants, so every non-daemon-init dependency is a minimal stub.
|
|
const {
|
|
getPathMock,
|
|
getAppPathMock,
|
|
isPackagedMock,
|
|
probeSocketExistsMock,
|
|
writeFileSyncMock,
|
|
readFileSyncMock,
|
|
unlinkSyncMock,
|
|
netConnectMock,
|
|
forkMock,
|
|
checkDaemonHealthMock,
|
|
healthCheckDaemonMock,
|
|
getMacDaemonSystemResolverHealthMock,
|
|
getMacDaemonTccAttributionHealthMock,
|
|
getDaemonLaunchIdentityMock,
|
|
isDaemonStaleForCurrentBundleMock,
|
|
killStaleDaemonMock,
|
|
readVerifiedDaemonPidMock,
|
|
inspectDaemonPtyOwnershipMock,
|
|
getProcessStartedAtMsMock,
|
|
parseDaemonPidFileMock,
|
|
replaceDaemonPidFileMock,
|
|
getDaemonCommandLineMock,
|
|
unlinkOwnedDaemonPidFileMock,
|
|
launchedStartedAtMs,
|
|
readLaunchedDaemonIdentity,
|
|
daemonClientMock,
|
|
spawnerInstances,
|
|
ensureRunningOverrides,
|
|
adoptionLeaseReleases,
|
|
lifecycleLeaseErrors,
|
|
disconnectOnlyErrors,
|
|
routerSubscriptionError,
|
|
adapterInstances,
|
|
defaultListSessionsSessions,
|
|
listProcessesControl,
|
|
getLocalPtyProviderMock,
|
|
localFallbackProvider,
|
|
setLocalPtyProviderMock,
|
|
unbindLocalProviderListenersMock,
|
|
rebindLocalProviderListenersMock,
|
|
trackDaemonReplacedMock,
|
|
trackDaemonRetiredMock
|
|
} = vi.hoisted(() => {
|
|
const getPathMock = vi.fn(() => '/fake/userData')
|
|
const getAppPathMock = vi.fn(() => '/fake/app')
|
|
const isPackagedMock = vi.fn(() => false)
|
|
|
|
const probeSocketExistsMock = vi.fn((_path?: string) => false)
|
|
const writeFileSyncMock = vi.fn()
|
|
// Why: readFileSync throws by default so legacyDaemonProcessMayBeAlive treats every legacy pid file as unreadable (pre-fix cleanup behavior).
|
|
const readFileSyncMock = vi.fn((): string => {
|
|
throw new Error('ENOENT')
|
|
})
|
|
const unlinkSyncMock = vi.fn()
|
|
const forkMock = vi.fn()
|
|
const netConnectMock = vi.fn(() => {
|
|
// Why: stub the socket so probeSocket's 'error' path fires and cleanupDaemonForProtocol's alive=false branch runs without side effects.
|
|
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
|
return {
|
|
on(event: string, cb: () => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'error') {
|
|
// Fire after microtask so destroy()/resolve ordering matches real net
|
|
queueMicrotask(() => cb())
|
|
}
|
|
return this
|
|
},
|
|
removeListener(event: string, cb: () => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
destroy() {}
|
|
}
|
|
})
|
|
|
|
const checkDaemonHealthMock = vi.fn(async () => 'healthy')
|
|
const healthCheckDaemonMock = vi.fn(async () => true)
|
|
const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy')
|
|
const getMacDaemonTccAttributionHealthMock = vi.fn(async () => 'unknown')
|
|
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
|
|
const isDaemonStaleForCurrentBundleMock = vi.fn(() => false)
|
|
const inspectDaemonPtyOwnershipMock = vi.fn(
|
|
async (): Promise<'owns-live-ptys' | 'no-live-ptys' | 'unknown'> => 'unknown'
|
|
)
|
|
const readVerifiedDaemonPidMock = vi.fn(async (): Promise<{ pid: number } | null> => null)
|
|
const killStaleDaemonMock = vi.fn(async () => ({
|
|
killed: true,
|
|
liveOwnerSurvived: false
|
|
}))
|
|
const getProcessStartedAtMsMock = vi.fn((): number | null => 1_000_000)
|
|
const parseDaemonPidFileMock = vi.fn(
|
|
(): { pid: number; startedAtMs: number | null } | null => null
|
|
)
|
|
const replaceDaemonPidFileMock = vi.fn(() => true)
|
|
const getDaemonCommandLineMock = vi.fn(async (_pid: number): Promise<string | null> => null)
|
|
const unlinkOwnedDaemonPidFileMock = vi.fn(() => true)
|
|
const launchedStartedAtMs = { current: 1_000_000 }
|
|
|
|
const readLaunchedDaemonIdentity = () => {
|
|
const args = forkMock.mock.calls.at(-1)?.[1]
|
|
const child = forkMock.mock.results.at(-1)?.value as { pid?: unknown } | undefined
|
|
const launchNonceIndex = Array.isArray(args) ? args.indexOf('--launch-nonce') : -1
|
|
const launchNonce = launchNonceIndex >= 0 ? args[launchNonceIndex + 1] : undefined
|
|
return typeof child?.pid === 'number' && typeof launchNonce === 'string'
|
|
? {
|
|
pid: child.pid,
|
|
startedAtMs: launchedStartedAtMs.current,
|
|
launchNonce
|
|
}
|
|
: null
|
|
}
|
|
|
|
const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
// Why: every DaemonSpawner pushes here so assertions can check the *same* spawner was reused across restart.
|
|
const spawnerInstances: MockSpawner[] = []
|
|
// Mirrors DaemonLaunchMode rather than restating one of its members: the type used to omit
|
|
// 'held', so no test could describe the launch the hold produces.
|
|
const ensureRunningOverrides: (() => Promise<{
|
|
socketPath: string
|
|
tokenPath: string
|
|
mode?: DaemonLaunchMode
|
|
}>)[] = []
|
|
const adoptionLeaseReleases: ReturnType<typeof vi.fn>[] = []
|
|
const lifecycleLeaseErrors: Error[] = []
|
|
const disconnectOnlyErrors: Error[] = []
|
|
const routerSubscriptionError: { current: Error | null } = { current: null }
|
|
// Same for DaemonPtyAdapter — tests assert the replacement adapter is fresh but its respawn closure targets the *original* spawner.
|
|
const adapterInstances: MockAdapter[] = []
|
|
// Why: adapters are built inside initDaemonPtyProvider, so tests set this before init to make listSessions report live sessions.
|
|
const defaultListSessionsSessions: { sessionId: string }[] = []
|
|
const listProcessesControl: {
|
|
current: null | (() => Promise<{ sessionId: string }[]>)
|
|
} = { current: null }
|
|
|
|
const localFallbackProvider = {
|
|
routesFreshSpawnsToLocalProvider: undefined,
|
|
spawn: vi.fn(async (opts: { sessionId?: string }) => ({
|
|
id: opts.sessionId ?? 'local-fallback-pty'
|
|
})),
|
|
attach: vi.fn(async () => {}),
|
|
hasPty: vi.fn(() => false),
|
|
write: vi.fn(),
|
|
resize: vi.fn(),
|
|
shutdown: vi.fn(async () => {}),
|
|
sendSignal: vi.fn(async () => {}),
|
|
getCwd: vi.fn(async () => ''),
|
|
getInitialCwd: vi.fn(async () => ''),
|
|
clearBuffer: vi.fn(async () => {}),
|
|
acknowledgeDataEvent: vi.fn(),
|
|
hasChildProcesses: vi.fn(async () => false),
|
|
getForegroundProcess: vi.fn(async () => null),
|
|
serialize: vi.fn(async () => '{}'),
|
|
revive: vi.fn(async () => {}),
|
|
listProcesses: vi.fn(async () => []),
|
|
getDefaultShell: vi.fn(async () => '/bin/zsh'),
|
|
getProfiles: vi.fn(async () => []),
|
|
onData: vi.fn(() => () => {}),
|
|
onReplay: vi.fn(() => () => {}),
|
|
onExit: vi.fn(() => () => {})
|
|
}
|
|
const getLocalPtyProviderMock = vi.fn(() => localFallbackProvider)
|
|
const setLocalPtyProviderMock = vi.fn()
|
|
const unbindLocalProviderListenersMock = vi.fn()
|
|
const rebindLocalProviderListenersMock = vi.fn()
|
|
const trackDaemonReplacedMock = vi.fn()
|
|
const trackDaemonRetiredMock = vi.fn()
|
|
|
|
return {
|
|
getPathMock,
|
|
getAppPathMock,
|
|
isPackagedMock,
|
|
probeSocketExistsMock,
|
|
writeFileSyncMock,
|
|
readFileSyncMock,
|
|
unlinkSyncMock,
|
|
netConnectMock,
|
|
forkMock,
|
|
checkDaemonHealthMock,
|
|
healthCheckDaemonMock,
|
|
getMacDaemonSystemResolverHealthMock,
|
|
getMacDaemonTccAttributionHealthMock,
|
|
getDaemonLaunchIdentityMock,
|
|
isDaemonStaleForCurrentBundleMock,
|
|
killStaleDaemonMock,
|
|
readVerifiedDaemonPidMock,
|
|
inspectDaemonPtyOwnershipMock,
|
|
getProcessStartedAtMsMock,
|
|
parseDaemonPidFileMock,
|
|
replaceDaemonPidFileMock,
|
|
getDaemonCommandLineMock,
|
|
unlinkOwnedDaemonPidFileMock,
|
|
launchedStartedAtMs,
|
|
readLaunchedDaemonIdentity,
|
|
daemonClientMock,
|
|
spawnerInstances,
|
|
ensureRunningOverrides,
|
|
adoptionLeaseReleases,
|
|
lifecycleLeaseErrors,
|
|
disconnectOnlyErrors,
|
|
routerSubscriptionError,
|
|
adapterInstances,
|
|
defaultListSessionsSessions,
|
|
listProcessesControl,
|
|
getLocalPtyProviderMock,
|
|
localFallbackProvider,
|
|
setLocalPtyProviderMock,
|
|
unbindLocalProviderListenersMock,
|
|
rebindLocalProviderListenersMock,
|
|
trackDaemonReplacedMock,
|
|
trackDaemonRetiredMock
|
|
}
|
|
})
|
|
|
|
type MockSpawner = {
|
|
ensureRunning: ReturnType<typeof vi.fn>
|
|
resetHandle: ReturnType<typeof vi.fn>
|
|
shutdown: ReturnType<typeof vi.fn>
|
|
getHandle: ReturnType<typeof vi.fn>
|
|
launcher: unknown
|
|
}
|
|
|
|
type MockAdapter = {
|
|
protocolVersion: number
|
|
options: {
|
|
socketPath: string
|
|
tokenPath: string
|
|
historyPath?: string
|
|
respawn?: (reason: 'daemon_died' | 'unhealthy_resolver') => Promise<void>
|
|
protocolVersion?: number
|
|
}
|
|
getActiveSessionIds: ReturnType<typeof vi.fn>
|
|
fanoutSyntheticExits: ReturnType<typeof vi.fn>
|
|
listProcesses: ReturnType<typeof vi.fn>
|
|
listSessions: ReturnType<typeof vi.fn>
|
|
establishLifecycleLease: ReturnType<typeof vi.fn>
|
|
shutdown: ReturnType<typeof vi.fn>
|
|
dispose: ReturnType<typeof vi.fn>
|
|
disconnectOnly: ReturnType<typeof vi.fn>
|
|
onData: ReturnType<typeof vi.fn>
|
|
onExit: ReturnType<typeof vi.fn>
|
|
// Why: the router calls onData/onExit on each adapter; the stub returns a no-op unsubscribe so router subscription doesn't explode.
|
|
callOrder: string[]
|
|
}
|
|
|
|
vi.mock('electron', () => ({
|
|
app: {
|
|
get isPackaged() {
|
|
return isPackagedMock()
|
|
},
|
|
getPath: getPathMock,
|
|
getAppPath: getAppPathMock,
|
|
getVersion: () => '1.2.3'
|
|
}
|
|
}))
|
|
|
|
// Map the existing boolean socket double onto the canonical three-valued probe:
|
|
// present ⇒ something is serving, absent ⇒ positively dead.
|
|
vi.mock('./daemon-live-pty-evidence', () => ({
|
|
inspectDaemonPtyOwnership: inspectDaemonPtyOwnershipMock
|
|
}))
|
|
|
|
vi.mock('./daemon-endpoint-probe', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
probeSocketConnect: async (p: string) => (probeSocketExistsMock(p) ? 'connected' : 'missing')
|
|
}))
|
|
|
|
vi.mock('fs', () => ({
|
|
mkdirSync: vi.fn(),
|
|
existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'),
|
|
unlinkSync: unlinkSyncMock,
|
|
readFileSync: readFileSyncMock,
|
|
writeFileSync: writeFileSyncMock
|
|
}))
|
|
|
|
vi.mock('child_process', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
fork: forkMock
|
|
}))
|
|
|
|
vi.mock('net', () => ({ connect: netConnectMock }))
|
|
|
|
vi.mock('./daemon-health', () => ({
|
|
checkDaemonHealth: checkDaemonHealthMock,
|
|
getDaemonCommandLine: getDaemonCommandLineMock,
|
|
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
|
|
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
|
|
getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock,
|
|
healthCheckDaemon: healthCheckDaemonMock,
|
|
isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock,
|
|
killStaleDaemon: killStaleDaemonMock,
|
|
readVerifiedDaemonPid: readVerifiedDaemonPidMock,
|
|
getProcessStartedAtMs: getProcessStartedAtMsMock,
|
|
parseDaemonPidFile: parseDaemonPidFileMock
|
|
}))
|
|
|
|
vi.mock('./client', () => ({
|
|
// Mirror ensureConnected onto the bounded variant so every existing double keeps its
|
|
// behaviour — including the ones whose whole point is that connecting throws. The budget is
|
|
// forwarded rather than dropped so a test can see what the launcher was willing to wait.
|
|
DaemonClient: function DaemonClientDouble(...args: unknown[]) {
|
|
const instance = (daemonClientMock as unknown as (...a: unknown[]) => Record<string, unknown>)(
|
|
...args
|
|
)
|
|
if (instance && typeof instance === 'object' && !('ensureConnectedWithin' in instance)) {
|
|
instance.ensureConnectedWithin = (budgetMs?: number) =>
|
|
(instance.ensureConnected as (ms?: number) => unknown)(budgetMs)
|
|
}
|
|
return instance
|
|
}
|
|
}))
|
|
|
|
vi.mock('./daemon-lifecycle-event', () => ({
|
|
trackDaemonReplaced: trackDaemonReplacedMock,
|
|
trackDaemonRetired: trackDaemonRetiredMock
|
|
}))
|
|
|
|
vi.mock('./daemon-spawner', () => ({
|
|
DaemonSpawner: class MockDaemonSpawner {
|
|
readonly launcher: unknown
|
|
readonly ensureRunning: ReturnType<typeof vi.fn>
|
|
readonly resetHandle: ReturnType<typeof vi.fn>
|
|
readonly shutdown: ReturnType<typeof vi.fn>
|
|
readonly getHandle: ReturnType<typeof vi.fn>
|
|
private socketCounter: number
|
|
private handle: {
|
|
mode?: DaemonLaunchMode
|
|
releaseAdoptionLease?: () => void
|
|
shutdown: () => Promise<void>
|
|
} | null
|
|
constructor(opts: { runtimeDir: string; launcher: unknown }) {
|
|
this.launcher = opts.launcher
|
|
this.socketCounter = 0
|
|
this.handle = null
|
|
// Why: each ensureRunning bumps a counter into socketPath so tests can tell the replacement adapter used the second call, not the first.
|
|
this.ensureRunning = vi.fn(async () => {
|
|
const override = ensureRunningOverrides.shift()
|
|
if (override) {
|
|
const result = await override()
|
|
const releaseAdoptionLease = vi.fn()
|
|
adoptionLeaseReleases.push(releaseAdoptionLease)
|
|
this.handle = {
|
|
releaseAdoptionLease,
|
|
shutdown: vi.fn(async () => {})
|
|
}
|
|
if (result.mode) {
|
|
this.handle.mode = result.mode
|
|
}
|
|
return {
|
|
socketPath: result.socketPath,
|
|
tokenPath: result.tokenPath
|
|
}
|
|
}
|
|
this.socketCounter += 1
|
|
const releaseAdoptionLease = vi.fn()
|
|
adoptionLeaseReleases.push(releaseAdoptionLease)
|
|
this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) }
|
|
return {
|
|
socketPath: `/fake/socket-${this.socketCounter}`,
|
|
tokenPath: `/fake/token-${this.socketCounter}`
|
|
}
|
|
})
|
|
this.resetHandle = vi.fn()
|
|
this.shutdown = vi.fn(async () => {})
|
|
this.getHandle = vi.fn(() => this.handle)
|
|
spawnerInstances.push(this as unknown as MockSpawner)
|
|
}
|
|
},
|
|
getDaemonSocketPath: (_dir: string, version?: number) =>
|
|
`/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.sock`,
|
|
getDaemonTokenPath: (_dir: string, version?: number) =>
|
|
`/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.token`,
|
|
getDaemonPidPath: (_dir: string, version?: number) =>
|
|
`/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.pid`,
|
|
serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj),
|
|
replaceDaemonPidFile: replaceDaemonPidFileMock,
|
|
unlinkOwnedDaemonPidFile: unlinkOwnedDaemonPidFileMock
|
|
}))
|
|
|
|
vi.mock('./daemon-pty-adapter', () => ({
|
|
DaemonPtyAdapter: class MockDaemonPtyAdapter {
|
|
readonly protocolVersion: number
|
|
readonly options: MockAdapter['options']
|
|
readonly getActiveSessionIds: ReturnType<typeof vi.fn>
|
|
readonly fanoutSyntheticExits: ReturnType<typeof vi.fn>
|
|
readonly listProcesses: ReturnType<typeof vi.fn>
|
|
readonly listSessions: ReturnType<typeof vi.fn>
|
|
readonly establishLifecycleLease: ReturnType<typeof vi.fn>
|
|
readonly shutdown: ReturnType<typeof vi.fn>
|
|
readonly dispose: ReturnType<typeof vi.fn>
|
|
readonly disconnectOnly: ReturnType<typeof vi.fn>
|
|
readonly onData: ReturnType<typeof vi.fn>
|
|
readonly onExit: ReturnType<typeof vi.fn>
|
|
readonly callOrder: string[]
|
|
constructor(opts: MockAdapter['options']) {
|
|
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
|
this.options = opts
|
|
this.callOrder = []
|
|
this.getActiveSessionIds = vi.fn(() => [] as string[])
|
|
this.fanoutSyntheticExits = vi.fn(() => {
|
|
this.callOrder.push('fanoutSyntheticExits')
|
|
})
|
|
this.listProcesses = vi.fn(async () =>
|
|
listProcessesControl.current ? listProcessesControl.current() : []
|
|
)
|
|
this.listSessions = vi.fn(async () => [...defaultListSessionsSessions])
|
|
const lifecycleLeaseError = lifecycleLeaseErrors.shift()
|
|
this.establishLifecycleLease = vi.fn(async () => {
|
|
if (lifecycleLeaseError) {
|
|
throw lifecycleLeaseError
|
|
}
|
|
})
|
|
this.shutdown = vi.fn(async () => {})
|
|
this.dispose = vi.fn()
|
|
const disconnectOnlyError = disconnectOnlyErrors.shift()
|
|
this.disconnectOnly = vi.fn(async () => {
|
|
if (disconnectOnlyError) {
|
|
throw disconnectOnlyError
|
|
}
|
|
})
|
|
this.onData = vi.fn(() => {
|
|
if (routerSubscriptionError.current) {
|
|
const error = routerSubscriptionError.current
|
|
routerSubscriptionError.current = null
|
|
throw error
|
|
}
|
|
return () => {}
|
|
})
|
|
this.onExit = vi.fn(() => () => {})
|
|
adapterInstances.push(this as unknown as MockAdapter)
|
|
}
|
|
}
|
|
}))
|
|
|
|
vi.mock('../ipc/pty', () => ({
|
|
getLocalPtyProvider: getLocalPtyProviderMock,
|
|
setLocalPtyProvider: setLocalPtyProviderMock,
|
|
unbindLocalProviderListeners: unbindLocalProviderListenersMock,
|
|
rebindLocalProviderListeners: rebindLocalProviderListenersMock
|
|
}))
|
|
|
|
async function importFresh() {
|
|
vi.resetModules()
|
|
spawnerInstances.length = 0
|
|
ensureRunningOverrides.length = 0
|
|
adoptionLeaseReleases.length = 0
|
|
lifecycleLeaseErrors.length = 0
|
|
disconnectOnlyErrors.length = 0
|
|
routerSubscriptionError.current = null
|
|
adapterInstances.length = 0
|
|
defaultListSessionsSessions.length = 0
|
|
listProcessesControl.current = null
|
|
getLocalPtyProviderMock.mockClear()
|
|
localFallbackProvider.spawn.mockClear()
|
|
localFallbackProvider.write.mockClear()
|
|
localFallbackProvider.onData.mockClear()
|
|
localFallbackProvider.onExit.mockClear()
|
|
setLocalPtyProviderMock.mockClear()
|
|
unbindLocalProviderListenersMock.mockClear()
|
|
rebindLocalProviderListenersMock.mockClear()
|
|
trackDaemonReplacedMock.mockClear()
|
|
trackDaemonRetiredMock.mockClear()
|
|
checkDaemonHealthMock.mockClear()
|
|
checkDaemonHealthMock.mockResolvedValue('healthy')
|
|
healthCheckDaemonMock.mockClear()
|
|
healthCheckDaemonMock.mockResolvedValue(true)
|
|
getMacDaemonSystemResolverHealthMock.mockReset()
|
|
getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy')
|
|
getMacDaemonTccAttributionHealthMock.mockReset()
|
|
getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown')
|
|
getDaemonLaunchIdentityMock.mockClear()
|
|
isDaemonStaleForCurrentBundleMock.mockReset()
|
|
isDaemonStaleForCurrentBundleMock.mockReturnValue(false)
|
|
// mockReset (not mockClear) also drops an unconsumed *Once queue, so a test that bails early
|
|
// can't leak a queued false into the next test's confirmedReplacement gate.
|
|
killStaleDaemonMock.mockReset()
|
|
killStaleDaemonMock.mockResolvedValue({
|
|
killed: true,
|
|
liveOwnerSurvived: false
|
|
})
|
|
getAppPathMock.mockReset()
|
|
getAppPathMock.mockReturnValue('/fake/app')
|
|
forkMock.mockReset()
|
|
isPackagedMock.mockReset()
|
|
isPackagedMock.mockReturnValue(false)
|
|
daemonClientMock.mockReset()
|
|
daemonClientMock.mockImplementation(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
probeSocketExistsMock.mockClear()
|
|
writeFileSyncMock.mockClear()
|
|
readFileSyncMock.mockReset()
|
|
readFileSyncMock.mockImplementation(() => {
|
|
throw new Error('ENOENT')
|
|
})
|
|
unlinkSyncMock.mockClear()
|
|
parseDaemonPidFileMock.mockReset()
|
|
parseDaemonPidFileMock.mockReturnValue(null)
|
|
unlinkOwnedDaemonPidFileMock.mockReset()
|
|
unlinkOwnedDaemonPidFileMock.mockReturnValue(true)
|
|
launchedStartedAtMs.current = 1_000_000
|
|
getProcessStartedAtMsMock.mockReset()
|
|
getProcessStartedAtMsMock.mockReturnValue(1_000_000)
|
|
// Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer.
|
|
return import('./daemon-init')
|
|
}
|
|
|
|
function mockConnectedAdoptionClientOnce(): void {
|
|
daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
}
|
|
|
|
function mockOnlyDaemonSocketAlive(socketSuffix: string): void {
|
|
netConnectMock.mockImplementation((options?: { path?: string }) => {
|
|
const live = options?.path?.endsWith(socketSuffix) ?? false
|
|
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
|
return {
|
|
on(event: string, callback: () => void) {
|
|
handlers[event]?.push(callback)
|
|
if ((live && event === 'connect') || (!live && event === 'error')) {
|
|
queueMicrotask(() => callback())
|
|
}
|
|
return this
|
|
},
|
|
removeListener(event: string, callback: () => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
|
|
return this
|
|
},
|
|
destroy() {}
|
|
}
|
|
})
|
|
}
|
|
|
|
describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
|
beforeEach(() => {
|
|
probeSocketExistsMock.mockReturnValue(false)
|
|
netConnectMock.mockReset()
|
|
netConnectMock.mockImplementation(() => {
|
|
const handlers: Record<string, (() => void)[]> = {
|
|
connect: [],
|
|
error: []
|
|
}
|
|
return {
|
|
on(event: string, cb: () => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'error') {
|
|
queueMicrotask(() => cb())
|
|
}
|
|
return this
|
|
},
|
|
removeListener(event: string, cb: () => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
destroy() {}
|
|
}
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
// Why restore too: tests here spy on Date.now, and clearAllMocks keeps the fake
|
|
// implementation. A failure before an inline restore would freeze the clock for every
|
|
// later test in the file, turning one red into a cascade.
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('re-binds listeners after the first daemon provider is installed', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
expect(setLocalPtyProviderMock).toHaveBeenCalledTimes(1)
|
|
expect(rebindLocalProviderListenersMock).toHaveBeenCalledTimes(1)
|
|
expect(rebindLocalProviderListenersMock.mock.invocationCallOrder[0]).toBeGreaterThan(
|
|
setLocalPtyProviderMock.mock.invocationCallOrder[0]
|
|
)
|
|
expect(adapterInstances[0].establishLifecycleLease).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan(
|
|
setLocalPtyProviderMock.mock.invocationCallOrder[0]
|
|
)
|
|
expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan(
|
|
adoptionLeaseReleases[0].mock.invocationCallOrder[0]
|
|
)
|
|
})
|
|
|
|
it('uses daemon-owned idle retirement when a fresh launch fails permanent adoption', async () => {
|
|
const mod = await importFresh()
|
|
ensureRunningOverrides.push(async () => ({
|
|
socketPath: '/fake/launched-socket',
|
|
tokenPath: '/fake/launched-token'
|
|
}))
|
|
lifecycleLeaseErrors.push(new Error('lease identity mismatch'))
|
|
|
|
await expect(mod.initDaemonPtyProvider()).rejects.toThrow('lease identity mismatch')
|
|
|
|
expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[0].dispose).not.toHaveBeenCalled()
|
|
expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled()
|
|
expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan(
|
|
adoptionLeaseReleases[0].mock.invocationCallOrder[0]
|
|
)
|
|
expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not kill a preserved daemon when startup lease acquisition fails', async () => {
|
|
const mod = await importFresh()
|
|
ensureRunningOverrides.push(async () => ({
|
|
socketPath: '/fake/preserved-socket',
|
|
tokenPath: '/fake/preserved-token'
|
|
}))
|
|
lifecycleLeaseErrors.push(new Error('preserved lease failed'))
|
|
|
|
await expect(mod.initDaemonPtyProvider()).rejects.toThrow('preserved lease failed')
|
|
|
|
expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
|
|
expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled()
|
|
expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => {
|
|
const mod = await importFresh()
|
|
// Why: live-pty-gate is intentionally unmocked — import from the same fresh registry so gate state matches daemon-init's.
|
|
const gate = await import('../claude-accounts/live-pty-gate')
|
|
defaultListSessionsSessions.push({ sessionId: 'claude-alive' })
|
|
gate.seedLiveClaudePtysFromPersistence(['claude-alive', 'claude-dead'])
|
|
try {
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
expect(gate.hasLiveClaudePtys()).toBe(true)
|
|
|
|
gate.markClaudePtyExited('claude-alive')
|
|
// Why: proves 'claude-dead' was released by the daemon reconcile — the surviving session held the gate alone.
|
|
expect(gate.hasLiveClaudePtys()).toBe(false)
|
|
} finally {
|
|
gate.markClaudePtyExited('claude-alive')
|
|
gate.markClaudePtyExited('claude-dead')
|
|
}
|
|
})
|
|
|
|
it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => {
|
|
const mod = await importFresh()
|
|
let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void
|
|
ensureRunningOverrides.push(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveEnsureRunning = resolve
|
|
})
|
|
)
|
|
const abortController = new AbortController()
|
|
|
|
const started = mod.initDaemonPtyProvider(abortController.signal)
|
|
await Promise.resolve()
|
|
|
|
expect(spawnerInstances).toHaveLength(1)
|
|
expect(spawnerInstances[0].ensureRunning).toHaveBeenCalledTimes(1)
|
|
|
|
abortController.abort()
|
|
resolveEnsureRunning({
|
|
socketPath: '/fake/socket-late',
|
|
tokenPath: '/fake/token-late'
|
|
})
|
|
await started
|
|
|
|
expect(adapterInstances).toHaveLength(1)
|
|
expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[0].establishLifecycleLease).not.toHaveBeenCalled()
|
|
expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
|
|
expect(rebindLocalProviderListenersMock).not.toHaveBeenCalled()
|
|
expect(mod.getDaemonProvider()).toBeNull()
|
|
})
|
|
|
|
it('disconnects uninstalled adapter leases when startup aborts during legacy discovery', async () => {
|
|
const mod = await importFresh()
|
|
probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false)
|
|
mockOnlyDaemonSocketAlive('daemon-v9.sock')
|
|
let resolveDiscovery!: (sessions: { sessionId: string }[]) => void
|
|
const discovery = new Promise<{ sessionId: string }[]>((resolve) => {
|
|
resolveDiscovery = resolve
|
|
})
|
|
listProcessesControl.current = () => discovery
|
|
const abortController = new AbortController()
|
|
|
|
const started = mod.initDaemonPtyProvider(abortController.signal)
|
|
await vi.waitFor(() => {
|
|
expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true)
|
|
expect(
|
|
adapterInstances.some((instance) => instance.listProcesses.mock.calls.length > 0)
|
|
).toBe(true)
|
|
})
|
|
abortController.abort()
|
|
resolveDiscovery([])
|
|
await started
|
|
|
|
expect(adapterInstances).toHaveLength(2)
|
|
expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
|
|
expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
|
|
expect(mod.getDaemonProvider()).toBeNull()
|
|
})
|
|
|
|
it('retains every adapter cleanup failure when legacy router setup aborts', async () => {
|
|
const mod = await importFresh()
|
|
probeSocketExistsMock.mockImplementation(
|
|
(path?: string) => path?.endsWith('daemon-v9.sock') ?? false
|
|
)
|
|
mockOnlyDaemonSocketAlive('daemon-v9.sock')
|
|
const discoveryError = new Error('router subscription failed')
|
|
const currentCleanupError = new Error('current cleanup failed')
|
|
const legacyCleanupError = new Error('legacy cleanup failed')
|
|
routerSubscriptionError.current = discoveryError
|
|
disconnectOnlyErrors.push(currentCleanupError, legacyCleanupError)
|
|
|
|
const error = await mod.initDaemonPtyProvider().catch((caught: unknown) => caught)
|
|
expect(error).toBeInstanceOf(AggregateError)
|
|
const topLevelErrors = (error as AggregateError).errors
|
|
expect(topLevelErrors[0]).toBe(discoveryError)
|
|
expect(topLevelErrors[1]).toBeInstanceOf(AggregateError)
|
|
expect((topLevelErrors[1] as AggregateError).errors).toEqual([
|
|
legacyCleanupError,
|
|
currentCleanupError
|
|
])
|
|
expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('routes a held daemon through the degraded provider, not a bare adapter', async () => {
|
|
// The wiring the hold depends on. Without it a held daemon gets a plain DaemonPtyAdapter:
|
|
// every fresh spawn hangs on the wedged socket instead of falling back locally, and
|
|
// isDaemonDegraded() — an instanceof check — reports false, so the notice telling the user
|
|
// how to recover never renders.
|
|
const mod = await importFresh()
|
|
ensureRunningOverrides.push(async () => ({
|
|
socketPath: '/fake/held-socket',
|
|
tokenPath: '/fake/held-token',
|
|
mode: 'held'
|
|
}))
|
|
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider')
|
|
const provider = mod.getDaemonProvider()
|
|
expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider)
|
|
expect(
|
|
(provider as InstanceType<typeof DegradedDaemonPtyProvider>).routesFreshSpawnsToLocalProvider
|
|
).toBe(true)
|
|
})
|
|
|
|
it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => {
|
|
const mod = await importFresh()
|
|
ensureRunningOverrides.push(async () => ({
|
|
socketPath: '/fake/degraded-socket',
|
|
tokenPath: '/fake/degraded-token',
|
|
mode: 'degraded-new-pty-fallback'
|
|
}))
|
|
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider')
|
|
const provider = mod.getDaemonProvider()
|
|
expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider)
|
|
expect(getLocalPtyProviderMock).toHaveBeenCalledOnce()
|
|
expect(setLocalPtyProviderMock).toHaveBeenCalledWith(provider)
|
|
|
|
const result = await provider!.spawn({ cols: 80, rows: 24 })
|
|
|
|
expect(result.id).toBe('local-fallback-pty')
|
|
expect(localFallbackProvider.spawn).toHaveBeenCalledWith({
|
|
cols: 80,
|
|
rows: 24
|
|
})
|
|
expect(adapterInstances[0].listProcesses).toHaveBeenCalled()
|
|
})
|
|
|
|
it('rechecks the preserved daemon endpoint before recovering fresh-spawn routing', async () => {
|
|
const mod = await importFresh()
|
|
ensureRunningOverrides.push(async () => ({
|
|
socketPath: '/fake/degraded-socket',
|
|
tokenPath: '/fake/degraded-token',
|
|
mode: 'degraded-new-pty-fallback'
|
|
}))
|
|
await mod.initDaemonPtyProvider()
|
|
checkDaemonHealthMock.mockClear()
|
|
|
|
const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider')
|
|
const provider = mod.getDaemonProvider()
|
|
expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider)
|
|
const degradedProvider = provider as InstanceType<typeof DegradedDaemonPtyProvider>
|
|
|
|
await expect(degradedProvider.recoverFreshSpawnRouting()).resolves.toBe(true)
|
|
expect(checkDaemonHealthMock).toHaveBeenCalledWith(
|
|
'/fake/degraded-socket',
|
|
'/fake/degraded-token'
|
|
)
|
|
expect(degradedProvider.routesFreshSpawnsToLocalProvider).toBeUndefined()
|
|
})
|
|
|
|
it('fans pty:exit for every active session *before* unbinding listeners, and killedCount is captured pre-fanout', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
// Why: seed active sessions and mock the snapshot-then-clear so a regression measuring killedCount *after* fanout surfaces as 0.
|
|
const originalAdapter = adapterInstances[0]
|
|
let activeIds = ['sess-a', 'sess-b', 'sess-c']
|
|
originalAdapter.getActiveSessionIds.mockImplementation(() => [...activeIds])
|
|
|
|
const order: string[] = []
|
|
originalAdapter.fanoutSyntheticExits.mockImplementation(() => {
|
|
order.push('fanout')
|
|
activeIds = []
|
|
})
|
|
unbindLocalProviderListenersMock.mockImplementation(() => {
|
|
order.push('unbind')
|
|
})
|
|
|
|
const result = await mod.restartDaemon()
|
|
|
|
// killedCount must be 3 — proves the count was taken *before* fanout cleared the set (a swapped-order bug reports 0).
|
|
expect(result.killedCount).toBe(3)
|
|
expect(originalAdapter.fanoutSyntheticExits).toHaveBeenCalledWith(-1)
|
|
// Ordering invariant: synthetic exits must reach the renderer *before* listeners are torn down (Step 1 before 2).
|
|
expect(order).toEqual(['fanout', 'unbind'])
|
|
})
|
|
|
|
it('uses daemon-owned idle retirement after a failed manual-restart adoption', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
const originalProvider = mod.getDaemonProvider()
|
|
ensureRunningOverrides.push(async () => ({
|
|
socketPath: '/fake/restart-failure-socket',
|
|
tokenPath: '/fake/restart-failure-token'
|
|
}))
|
|
lifecycleLeaseErrors.push(new Error('restart lease failed'))
|
|
|
|
await expect(mod.restartDaemon()).rejects.toThrow('restart lease failed')
|
|
|
|
expect(adoptionLeaseReleases[1]).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
|
|
expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled()
|
|
expect(mod.getDaemonProvider()).toBe(originalProvider)
|
|
expect(unbindLocalProviderListenersMock).toHaveBeenCalledOnce()
|
|
expect(rebindLocalProviderListenersMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('fans exits for preserved degraded current-daemon sessions during restart', async () => {
|
|
const mod = await importFresh()
|
|
ensureRunningOverrides.push(async () => ({
|
|
socketPath: '/fake/degraded-socket',
|
|
tokenPath: '/fake/degraded-token',
|
|
mode: 'degraded-new-pty-fallback'
|
|
}))
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider')
|
|
const provider = mod.getDaemonProvider()
|
|
expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider)
|
|
const degradedProvider = provider as InstanceType<typeof DegradedDaemonPtyProvider>
|
|
|
|
const originalAdapter = adapterInstances[0]
|
|
originalAdapter.listProcesses.mockResolvedValueOnce([
|
|
{ id: 'preserved-current-session', cwd: '/repo', title: 'shell' }
|
|
])
|
|
await degradedProvider.discoverDaemonSessions()
|
|
|
|
const order: string[] = []
|
|
degradedProvider.onExit((payload) => {
|
|
if (payload.id === 'preserved-current-session') {
|
|
order.push('degraded-fanout')
|
|
}
|
|
})
|
|
originalAdapter.fanoutSyntheticExits.mockImplementation(() => {
|
|
order.push('adapter-fanout')
|
|
})
|
|
unbindLocalProviderListenersMock.mockImplementation(() => {
|
|
order.push('unbind')
|
|
})
|
|
|
|
const result = await mod.restartDaemon()
|
|
|
|
expect(result.killedCount).toBe(1)
|
|
expect(order).toEqual(['adapter-fanout', 'degraded-fanout', 'unbind'])
|
|
})
|
|
|
|
it('reuses the existing DaemonSpawner across restart (resetHandle + ensureRunning on same instance)', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
expect(spawnerInstances).toHaveLength(1)
|
|
const originalSpawner = spawnerInstances[0]
|
|
expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(1)
|
|
|
|
await mod.restartDaemon()
|
|
|
|
// No second DaemonSpawner was constructed — restart uses the one from init.
|
|
expect(spawnerInstances).toHaveLength(1)
|
|
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1)
|
|
expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('builds a fresh adapter whose respawn callback closes over the same spawner', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const originalSpawner = spawnerInstances[0]
|
|
const originalAdapter = adapterInstances[0]
|
|
|
|
await mod.restartDaemon()
|
|
|
|
// A new adapter was constructed against the replacement daemon's socket.
|
|
expect(adapterInstances).toHaveLength(2)
|
|
const replacementAdapter = adapterInstances[1]
|
|
expect(replacementAdapter).not.toBe(originalAdapter)
|
|
expect(replacementAdapter.options.socketPath).toBe('/fake/socket-2')
|
|
expect(replacementAdapter.options.tokenPath).toBe('/fake/token-2')
|
|
|
|
// The replacement adapter's respawn closure must drive the *same* original spawner (see daemon-init.ts step 5).
|
|
originalSpawner.resetHandle.mockClear()
|
|
originalSpawner.ensureRunning.mockClear()
|
|
await replacementAdapter.options.respawn?.('daemon_died')
|
|
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1)
|
|
expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(1)
|
|
// STA-2376: death → respawn retires, exactly once.
|
|
expect(trackDaemonRetiredMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonRetiredMock).toHaveBeenCalledWith('died_respawn')
|
|
trackDaemonRetiredMock.mockClear()
|
|
trackDaemonReplacedMock.mockClear()
|
|
// STA-2376: the resolver respawn attributes rather than emits — the launch it triggers reports it.
|
|
// Emitting here too would double-count, and would fire before the outcome is known.
|
|
await replacementAdapter.options.respawn?.('unhealthy_resolver')
|
|
expect(trackDaemonRetiredMock).not.toHaveBeenCalled()
|
|
expect(trackDaemonReplacedMock).not.toHaveBeenCalled()
|
|
// Still only one spawner in the whole test — nobody new was constructed.
|
|
expect(spawnerInstances).toHaveLength(1)
|
|
})
|
|
|
|
it('swaps the module-level adapter and re-binds listeners after the new provider is installed', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
// initDaemonPtyProvider calls setLocalPtyProvider once with the original.
|
|
expect(setLocalPtyProviderMock).toHaveBeenCalledTimes(1)
|
|
const originalProvider = setLocalPtyProviderMock.mock.calls[0][0]
|
|
expect(originalProvider).toBe(adapterInstances[0])
|
|
expect(mod.getDaemonProvider()).toBe(originalProvider)
|
|
|
|
await mod.restartDaemon()
|
|
|
|
const replacementAdapter = adapterInstances[1]
|
|
// Second call: swap to the replacement provider (Step 6).
|
|
expect(setLocalPtyProviderMock).toHaveBeenCalledTimes(2)
|
|
expect(setLocalPtyProviderMock.mock.calls[1][0]).toBe(replacementAdapter)
|
|
expect(mod.getDaemonProvider()).toBe(replacementAdapter)
|
|
|
|
// Step 7: rebind must run *after* Step 6 (the provider swap).
|
|
const rebindOrder = rebindLocalProviderListenersMock.mock.invocationCallOrder.at(-1) ?? -1
|
|
const swapOrder = setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) ?? -1
|
|
expect(rebindOrder).toBeGreaterThan(swapOrder)
|
|
})
|
|
|
|
// STA-2376: a manual restart kills the daemon while the outgoing adapter is still live, so a pane
|
|
// respawning on its synthetic exit reaches the death path for a user action. That must not land in
|
|
// the crash bucket. Driven from inside the restart's ensureRunning so restartInFlight is genuinely
|
|
// set, rather than asserting the guard against a flag the test poked itself.
|
|
it('does not report a retirement for a death observed during a manual restart', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
const outgoingRespawn = adapterInstances[0].options.respawn
|
|
trackDaemonRetiredMock.mockClear()
|
|
|
|
let respawnedMidRestart = false
|
|
ensureRunningOverrides.push(async () => {
|
|
await outgoingRespawn?.('daemon_died')
|
|
respawnedMidRestart = true
|
|
return {
|
|
socketPath: '/fake/restarted-socket',
|
|
tokenPath: '/fake/restarted-token'
|
|
}
|
|
})
|
|
|
|
await mod.restartDaemon()
|
|
|
|
expect(respawnedMidRestart).toBe(true)
|
|
expect(trackDaemonRetiredMock).not.toHaveBeenCalled()
|
|
|
|
// The same closure still retires once the restart has settled, so the guard is scoped, not permanent.
|
|
await outgoingRespawn?.('daemon_died')
|
|
expect(trackDaemonRetiredMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonRetiredMock).toHaveBeenCalledWith('died_respawn')
|
|
|
|
// The restart installs its own adapter, whose closure is a second copy of the guard — and the one
|
|
// that actually runs in the field from the second restart onward, since the first adapter is gone.
|
|
const restartedRespawn = adapterInstances[1].options.respawn
|
|
trackDaemonRetiredMock.mockClear()
|
|
let respawnedMidSecondRestart = false
|
|
ensureRunningOverrides.push(async () => {
|
|
await restartedRespawn?.('daemon_died')
|
|
respawnedMidSecondRestart = true
|
|
return {
|
|
socketPath: '/fake/restarted-socket-2',
|
|
tokenPath: '/fake/restarted-token-2'
|
|
}
|
|
})
|
|
|
|
await mod.restartDaemon()
|
|
|
|
expect(respawnedMidSecondRestart).toBe(true)
|
|
expect(trackDaemonRetiredMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves legacy adapter instances by identity, drains outgoing router via disposeRouterOnly, and re-discovers legacy sessions on the new router', async () => {
|
|
const mod = await importFresh()
|
|
|
|
// Why: bypass createLegacyDaemonAdapters' socket-probe machinery — directly construct a router with a legacy adapter and install it.
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const { DaemonPtyRouter } = await import('./daemon-pty-router')
|
|
const { DaemonPtyAdapter } = await import('./daemon-pty-adapter')
|
|
const currentAtConstruction = adapterInstances[0]
|
|
const legacyAdapter = new DaemonPtyAdapter({
|
|
socketPath: '/fake/legacy.sock',
|
|
tokenPath: '/fake/legacy.token',
|
|
protocolVersion: 3
|
|
})
|
|
const routerWithLegacy = new DaemonPtyRouter({
|
|
current: currentAtConstruction as unknown as InstanceType<typeof DaemonPtyAdapter>,
|
|
legacy: [legacyAdapter as unknown as InstanceType<typeof DaemonPtyAdapter>]
|
|
})
|
|
// Why: spy on the outgoing router's disposeRouterOnly — adapter survival alone wouldn't catch a no-op that leaks listeners.
|
|
const disposeRouterOnlySpy = vi.spyOn(routerWithLegacy, 'disposeRouterOnly')
|
|
const oldRouterDispose = vi.spyOn(routerWithLegacy, 'dispose')
|
|
mod.replaceDaemonProvider(routerWithLegacy)
|
|
|
|
await mod.restartDaemon()
|
|
|
|
const provider = mod.getDaemonProvider()
|
|
expect(provider).toBeInstanceOf(DaemonPtyRouter)
|
|
const newRouter = provider as InstanceType<typeof DaemonPtyRouter>
|
|
expect(newRouter).not.toBe(routerWithLegacy)
|
|
|
|
// Legacy adapter is preserved by identity — not reconstructed, copied, or disposed.
|
|
const legacies = newRouter.getLegacyAdapters()
|
|
expect(legacies).toHaveLength(1)
|
|
expect(legacies[0]).toBe(legacyAdapter)
|
|
expect(legacyAdapter.dispose).not.toHaveBeenCalled()
|
|
// Router drained via disposeRouterOnly (router-only teardown), so legacy adapters' connections are untouched.
|
|
expect(legacyAdapter.disconnectOnly).not.toHaveBeenCalled()
|
|
// disposeRouterOnly drained subscriptions but did NOT dispose the adapters behind it.
|
|
expect(disposeRouterOnlySpy).toHaveBeenCalledTimes(1)
|
|
expect(oldRouterDispose).not.toHaveBeenCalled()
|
|
|
|
// The replacement router re-runs discovery so spawns for a surviving legacy sessionId still route to the legacy adapter.
|
|
expect(legacyAdapter.listProcesses).toHaveBeenCalled()
|
|
})
|
|
|
|
it('routes affected v9 daemon sessions through a legacy adapter on launch', async () => {
|
|
const mod = await importFresh()
|
|
probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false)
|
|
mockOnlyDaemonSocketAlive('daemon-v9.sock')
|
|
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const { DaemonPtyRouter } = await import('./daemon-pty-router')
|
|
expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyRouter)
|
|
expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true)
|
|
})
|
|
|
|
it('restart path with no legacy adapters yields a bare DaemonPtyAdapter (not wrapped in a router)', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
// initDaemonPtyProvider yields a bare adapter when no legacy adapters exist — confirm that shape persists across restart.
|
|
const { DaemonPtyAdapter } = await import('./daemon-pty-adapter')
|
|
const { DaemonPtyRouter } = await import('./daemon-pty-router')
|
|
expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyAdapter)
|
|
|
|
await mod.restartDaemon()
|
|
|
|
expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyAdapter)
|
|
expect(mod.getDaemonProvider()).not.toBeInstanceOf(DaemonPtyRouter)
|
|
})
|
|
|
|
it('orders Step 3 (cleanup) → Step 4 (resetHandle + ensureRunning) → Step 5 (new adapter) → Step 6 (replaceProvider) → Step 7 (rebind)', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const originalSpawner = spawnerInstances[0]
|
|
const originalAdapter = adapterInstances[0]
|
|
|
|
// Build an ordered trace by stamping each step; cleanup has no observable in the default probeSocket=false path, so instrument resetHandle instead.
|
|
const trace: string[] = []
|
|
originalAdapter.fanoutSyntheticExits.mockImplementation(() => trace.push('fanout'))
|
|
unbindLocalProviderListenersMock.mockImplementation(() => trace.push('unbind'))
|
|
originalSpawner.resetHandle.mockImplementation(() => trace.push('resetHandle'))
|
|
const originalEnsureRunning = originalSpawner.ensureRunning
|
|
originalSpawner.ensureRunning.mockImplementation(async () => {
|
|
trace.push('ensureRunning')
|
|
return {
|
|
socketPath: '/fake/socket-2',
|
|
tokenPath: '/fake/token-2'
|
|
}
|
|
})
|
|
setLocalPtyProviderMock.mockImplementation(() => trace.push('replaceProvider'))
|
|
rebindLocalProviderListenersMock.mockImplementation(() => trace.push('rebind'))
|
|
|
|
await mod.restartDaemon()
|
|
void originalEnsureRunning // keep ref so tslint doesn't complain
|
|
|
|
// Full 7-step order; Step 3 (cleanup) has no observable in the dead-socket branch, so it's pinned implicitly by resetHandle running after unbind.
|
|
expect(trace).toEqual([
|
|
'fanout',
|
|
'unbind',
|
|
'resetHandle',
|
|
'ensureRunning',
|
|
'replaceProvider',
|
|
'rebind'
|
|
])
|
|
|
|
// A fresh adapter built between ensureRunning and replaceProvider (Step 5 before 6); its Step-4 socketPath proves the ordering.
|
|
expect(adapterInstances).toHaveLength(2)
|
|
expect(adapterInstances[1].options.socketPath).toBe('/fake/socket-2')
|
|
expect(adapterInstances[1].establishLifecycleLease).toHaveBeenCalledOnce()
|
|
expect(adapterInstances[1].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan(
|
|
setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) as number
|
|
)
|
|
})
|
|
|
|
it('exercises the alive-daemon cleanup path: issues shutdown RPC via DaemonClient before spawning a replacement', async () => {
|
|
// Why: default probeSocket=false skips Step 3's shutdown RPC; flip the socket "alive" to cover the shutdown-RPC-succeeded branch.
|
|
|
|
const requestMock = vi.fn(async (method: string) => {
|
|
if (method === 'listSessions') {
|
|
return { sessions: [{ sessionId: 'live-1', isAlive: true }] }
|
|
}
|
|
// `shutdown` RPC — daemon exits before reply lands; return undefined.
|
|
return undefined
|
|
})
|
|
const ensureConnectedMock = vi.fn(async () => {})
|
|
const disconnectMock = vi.fn()
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClientForShutdown() {
|
|
return {
|
|
ensureConnected: ensureConnectedMock,
|
|
request: requestMock,
|
|
disconnect: disconnectMock
|
|
}
|
|
})
|
|
|
|
// Make probeSocket return true: needs both the fs.existsSync proxy AND net.connect resolving "alive".
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementationOnce(() => {
|
|
const handlers: Record<string, (() => void)[]> = {
|
|
connect: [],
|
|
error: []
|
|
}
|
|
return {
|
|
on(event: string, cb: () => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'connect') {
|
|
queueMicrotask(() => cb())
|
|
}
|
|
return this
|
|
},
|
|
removeListener(event: string, cb: () => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
destroy() {}
|
|
}
|
|
})
|
|
|
|
await mod.restartDaemon()
|
|
|
|
// The shutdown RPC must have been issued with killSessions=true.
|
|
expect(ensureConnectedMock).toHaveBeenCalled()
|
|
expect(requestMock).toHaveBeenCalledWith('shutdown', {
|
|
killSessions: true
|
|
})
|
|
// The fallback killStaleDaemon must NOT fire when the RPC path worked.
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('cleans up daemon socket probe listeners when the probe times out', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const handlers: Record<string, Set<() => void>> = {
|
|
connect: new Set(),
|
|
error: new Set()
|
|
}
|
|
const socket = {
|
|
on(event: string, cb: () => void) {
|
|
handlers[event]?.add(cb)
|
|
return this
|
|
},
|
|
removeListener(event: string, cb: () => void) {
|
|
handlers[event]?.delete(cb)
|
|
return this
|
|
},
|
|
destroy: vi.fn(),
|
|
listenerCount(event: string) {
|
|
return handlers[event]?.size ?? 0
|
|
}
|
|
}
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockReturnValueOnce(socket)
|
|
const mod = await importFresh()
|
|
|
|
const cleanup = mod.cleanupDaemonForProtocol('/fake/daemon', PROTOCOL_VERSION)
|
|
await Promise.resolve()
|
|
|
|
expect(socket.listenerCount('connect')).toBe(1)
|
|
expect(socket.listenerCount('error')).toBe(1)
|
|
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
|
|
await expect(cleanup).resolves.toEqual({
|
|
cleaned: false,
|
|
killedCount: 0
|
|
})
|
|
expect(socket.destroy).toHaveBeenCalledTimes(1)
|
|
expect(socket.listenerCount('connect')).toBe(0)
|
|
expect(socket.listenerCount('error')).toBe(0)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('falls back to killStaleDaemon without the live-PTY veto, so an explicit restart always wins', async () => {
|
|
// Why: this is the documented escape hatch (Settings → Manage Sessions → Restart). Opting
|
|
// into the veto here would report liveOwnerSurvived and throw, leaving the user no daemon.
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(() => {
|
|
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
|
return {
|
|
on(event: string, cb: () => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'connect') {
|
|
queueMicrotask(() => cb())
|
|
}
|
|
return this
|
|
},
|
|
removeListener(event: string, cb: () => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
destroy() {}
|
|
}
|
|
})
|
|
const mod = await importFresh()
|
|
daemonClientMock.mockImplementationOnce(function MockWedgedDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('Hello response timed out')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
await expect(
|
|
mod.cleanupDaemonForProtocol('/fake/daemon', PROTOCOL_VERSION)
|
|
).resolves.toMatchObject({ cleaned: true })
|
|
|
|
expect(killStaleDaemonMock).toHaveBeenCalledWith(
|
|
'/fake/daemon',
|
|
`/fake/daemon/daemon-v${PROTOCOL_VERSION}.sock`,
|
|
`/fake/daemon/daemon-v${PROTOCOL_VERSION}.token`,
|
|
PROTOCOL_VERSION
|
|
)
|
|
})
|
|
|
|
it('coalesces concurrent restartDaemon() calls so the 7-step sequence runs exactly once', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const originalSpawner = spawnerInstances[0]
|
|
|
|
// Why: the deferred gate holds the first restart inside ensureRunning so the second call provably enters while the first is mid-flight.
|
|
let markEnsureRunningEntered: (() => void) | undefined
|
|
const ensureRunningEntered = new Promise<void>((resolve) => {
|
|
markEnsureRunningEntered = resolve
|
|
})
|
|
let releaseEnsureRunning: (() => void) | undefined
|
|
const ensureRunningBarrier = new Promise<void>((resolve) => {
|
|
releaseEnsureRunning = resolve
|
|
})
|
|
originalSpawner.ensureRunning.mockImplementationOnce(async () => {
|
|
markEnsureRunningEntered?.()
|
|
await ensureRunningBarrier
|
|
return { socketPath: '/fake/socket-2', tokenPath: '/fake/token-2' }
|
|
})
|
|
|
|
const call1 = mod.restartDaemon()
|
|
await ensureRunningEntered
|
|
const call2 = mod.restartDaemon()
|
|
|
|
// Why: restartDaemon wraps each return in a fresh Promise, so call1===call2 can't prove coalescing; instead assert resetHandle stayed at 1 mid-flight.
|
|
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1)
|
|
expect(adapterInstances).toHaveLength(1)
|
|
|
|
releaseEnsureRunning?.()
|
|
const [r1, r2] = await Promise.all([call1, call2])
|
|
// Both resolved values are structurally identical — same result bubbled up through the shared runRestartDaemon promise.
|
|
expect(r1).toEqual(r2)
|
|
|
|
// resetHandle=1/restart, ensureRunning=1 init+1 restart; an un-coalesced second restart would push these to 2 and 3.
|
|
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1)
|
|
expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(2)
|
|
expect(adapterInstances).toHaveLength(2)
|
|
|
|
// After the in-flight promise settles, a fresh restart runs — proves .finally cleared restartInFlight (a stale slot would skip work).
|
|
await mod.restartDaemon()
|
|
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(2)
|
|
expect(adapterInstances).toHaveLength(3)
|
|
})
|
|
|
|
it('throws when restartDaemon is called before initDaemonPtyProvider', async () => {
|
|
const mod = await importFresh()
|
|
await expect(mod.restartDaemon()).rejects.toThrow(
|
|
'restartDaemon called before initDaemonPtyProvider'
|
|
)
|
|
})
|
|
|
|
it('respawns instead of reusing a healthy daemon launched from another app path', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider(undefined, {
|
|
macosLoginSessionWatch: true
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
|
|
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', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
})
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token',
|
|
FAKE_DAEMON_ENTRY_PATH
|
|
)
|
|
expect(killStaleDaemonMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token'
|
|
)
|
|
expect(forkMock).toHaveBeenCalledWith(
|
|
FAKE_DAEMON_ENTRY_PATH,
|
|
expect.arrayContaining([
|
|
'--socket',
|
|
'/fake/socket',
|
|
'--token',
|
|
'/fake/token',
|
|
'--login-session-watch',
|
|
'--log-file',
|
|
join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log')
|
|
]),
|
|
expect.objectContaining({ cwd: '/fake/userData', detached: true })
|
|
)
|
|
// STA-2376: different-app-path replacement, emitted exactly once.
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('different_app_path', 0)
|
|
})
|
|
|
|
it('adopts the winner when a launched daemon loses the endpoint race', async () => {
|
|
// Why: losing the publish race is an expected outcome, not a crash. Reporting it as a
|
|
// startup failure strands this app on local non-persistent PTYs beside a healthy daemon.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true })
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
// Force the replace-and-launch path; otherwise the launcher adopts the healthy daemon and
|
|
// never forks, and this test would pass without exercising anything.
|
|
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
|
|
forkMock.mockImplementationOnce(() => {
|
|
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
|
message: [],
|
|
error: [],
|
|
exit: []
|
|
}
|
|
return {
|
|
pid: 24680,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event]?.push(cb)
|
|
// Why the exit code and not the message: the launcher settles on exit, so keying
|
|
// adoption off the notification alone could lose that race.
|
|
if (event === 'exit') {
|
|
queueMicrotask(() => cb(20))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
kill: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
})
|
|
|
|
// A handle rather than a rejection means the incumbent was adopted.
|
|
await expect(launcher('/fake/socket', '/fake/token')).resolves.toMatchObject({
|
|
shutdown: expect.any(Function)
|
|
})
|
|
// Guard against passing for the wrong reason: the adoption must follow a real launch.
|
|
expect(forkMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('replaces a healthy daemon whose macOS TCC attribution is severed when it has no live sessions', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true })
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
|
|
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', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
})
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(forkMock).toHaveBeenCalledTimes(1)
|
|
// STA-3491: attribution-severed replacement is billed to its own reason, exactly once.
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('severed_tcc_attribution', 0)
|
|
})
|
|
|
|
it('preserves a severed-attribution daemon that owns live sessions', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true })
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
|
|
// Why: live sessions must veto replacement — the Settings surface owns the remedy instead.
|
|
daemonClientMock.mockImplementation(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [{ sessionId: 's1', isAlive: true }] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const handle = await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(handle).toBeDefined()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(trackDaemonReplacedMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('holds a full adoption pair before a healthy launcher resolves', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
const events: string[] = []
|
|
const disconnect = vi.fn()
|
|
daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
events.push('full-pair')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect
|
|
}
|
|
})
|
|
checkDaemonHealthMock.mockImplementationOnce(async () => {
|
|
events.push('health')
|
|
return 'healthy'
|
|
})
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{
|
|
releaseAdoptionLease?(): void
|
|
shutdown(): Promise<void>
|
|
}>
|
|
|
|
const handle = await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(events[0]).toBe('full-pair')
|
|
expect(events.indexOf('full-pair')).toBeLessThan(events.indexOf('health'))
|
|
expect(disconnect).not.toHaveBeenCalled()
|
|
handle.releaseAdoptionLease?.()
|
|
expect(disconnect).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('repairs a stale PID record to the authenticated socket owner before adoption', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
const endpointIdentity = {
|
|
pid: 101,
|
|
startedAtMs: 1_000_000,
|
|
launchNonce: 'socket-owner'
|
|
}
|
|
daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
getDaemonIdentity: vi.fn(() => endpointIdentity),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
readFileSyncMock.mockReturnValueOnce(
|
|
JSON.stringify({
|
|
pid: 202,
|
|
startedAtMs: 2_000_000,
|
|
launchNonce: 'stale-owner'
|
|
})
|
|
)
|
|
readFileSyncMock.mockReturnValueOnce(JSON.stringify(endpointIdentity))
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string,
|
|
pidPath?: string,
|
|
launchNonce?: string
|
|
) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise<void> }>
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
try {
|
|
const handle = await launcher(
|
|
'/fake/socket',
|
|
'/fake/token',
|
|
'/fake/daemon.pid',
|
|
'unused-new-launch'
|
|
)
|
|
|
|
expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', endpointIdentity)
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'[daemon] Repaired daemon PID ownership to match the authenticated endpoint'
|
|
)
|
|
handle.releaseAdoptionLease?.()
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('republishes the endpoint owner launch metadata into a repaired PID record', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
// Why: the mismatched record's metadata belongs to another daemon, so freshness and
|
|
// host-pinning fields must come from the authenticated owner — a record without
|
|
// appVersion reads as a permanently stale bundle and gets needlessly replaced. The
|
|
// install path deliberately contains a space: re-parsing it out of a space-joined
|
|
// command line truncated it and made a healthy daemon look like a different app path.
|
|
const endpointIdentity = {
|
|
pid: 101,
|
|
startedAtMs: 1_000_000,
|
|
launchNonce: 'socket-owner',
|
|
entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js',
|
|
appVersion: '9.9.9',
|
|
spawnerExecPath: '/Applications/Orca 2.app/Contents/MacOS/Orca'
|
|
}
|
|
daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
getDaemonIdentity: vi.fn(() => endpointIdentity),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
readFileSyncMock.mockReturnValueOnce(
|
|
JSON.stringify({
|
|
pid: 202,
|
|
startedAtMs: 2_000_000,
|
|
launchNonce: 'stale-owner'
|
|
})
|
|
)
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string,
|
|
pidPath?: string,
|
|
launchNonce?: string
|
|
) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise<void> }>
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
try {
|
|
const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused')
|
|
|
|
expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', {
|
|
pid: 101,
|
|
startedAtMs: 1_000_000,
|
|
launchNonce: 'socket-owner',
|
|
entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js',
|
|
appVersion: '9.9.9',
|
|
spawnerExecPath: '/Applications/Orca 2.app/Contents/MacOS/Orca'
|
|
})
|
|
handle.releaseAdoptionLease?.()
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('adopts the authenticated endpoint even when the PID record cannot be repaired', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
const endpointIdentity = {
|
|
pid: 101,
|
|
startedAtMs: 1_000_000,
|
|
launchNonce: 'socket-owner'
|
|
}
|
|
daemonClientMock.mockImplementationOnce(function MockAdoptionClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
getDaemonIdentity: vi.fn(() => endpointIdentity),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
readFileSyncMock.mockReturnValueOnce(
|
|
JSON.stringify({
|
|
pid: 202,
|
|
startedAtMs: 2_000_000,
|
|
launchNonce: 'stale-owner'
|
|
})
|
|
)
|
|
// Why: fail open. Losing every persistent terminal because a pid file write failed is a
|
|
// far worse outcome than a record that disagrees with the endpoint.
|
|
replaceDaemonPidFileMock.mockReturnValueOnce(false)
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string,
|
|
pidPath?: string,
|
|
launchNonce?: string
|
|
) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise<void> }>
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const forkCallsBefore = forkMock.mock.calls.length
|
|
|
|
try {
|
|
const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused')
|
|
|
|
expect(handle).toBeDefined()
|
|
// The healthy daemon is adopted, not replaced.
|
|
expect(forkMock.mock.calls.length).toBe(forkCallsBefore)
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'[daemon] Could not repair daemon PID ownership; adopting the authenticated endpoint anyway'
|
|
)
|
|
handle.releaseAdoptionLease?.()
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('degrades instead of forking beside a daemon that could not be confirmed stopped', async () => {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
const forkCallsBefore = forkMock.mock.calls.length
|
|
// Why: forking beside a survivor is exactly how the endpoint owner and the session host
|
|
// diverge — one daemon answers the socket while another hosts the visible terminals. But
|
|
// refusing outright would leave the user with no daemon at all, and something demonstrably
|
|
// still answers the endpoint, so adopt it degraded: live sessions keep working.
|
|
killStaleDaemonMock.mockResolvedValueOnce({
|
|
killed: false,
|
|
liveOwnerSurvived: true
|
|
})
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string,
|
|
pidPath?: string,
|
|
launchNonce?: string
|
|
) => Promise<{ mode?: string; releaseAdoptionLease?(): void }>
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
try {
|
|
const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new')
|
|
|
|
expect(handle.mode).toBe('degraded-new-pty-fallback')
|
|
expect(forkMock.mock.calls.length).toBe(forkCallsBefore)
|
|
handle.releaseAdoptionLease?.()
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('disconnects every temporary client when healthy adoption fails', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
const initialDisconnect = vi.fn()
|
|
const replacementDisconnect = vi.fn()
|
|
daemonClientMock
|
|
.mockImplementationOnce(function MockInitialAdoptionClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('initial adoption failed')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: initialDisconnect
|
|
}
|
|
})
|
|
.mockImplementationOnce(function MockReplacementAdoptionClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('replacement adoption failed')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: replacementDisconnect
|
|
}
|
|
})
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'replacement adoption failed'
|
|
)
|
|
|
|
expect(initialDisconnect).toHaveBeenCalledOnce()
|
|
expect(replacementDisconnect).toHaveBeenCalledOnce()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves a daemon launched from another app path when it owns live sessions', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const requestMock = vi.fn(async (method: string) => {
|
|
if (method === 'listSessions') {
|
|
return {
|
|
sessions: [
|
|
{ sessionId: 'wt-1@@live', isAlive: true },
|
|
{ sessionId: 'wt-1@@dead', isAlive: false }
|
|
]
|
|
}
|
|
}
|
|
return {}
|
|
})
|
|
const disconnectMock = vi.fn()
|
|
mockConnectedAdoptionClientOnce()
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: requestMock,
|
|
disconnect: disconnectMock
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token',
|
|
FAKE_DAEMON_ENTRY_PATH
|
|
)
|
|
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number))
|
|
expect(disconnectMock).toHaveBeenCalledOnce()
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves a daemon launched from another app path when live session state cannot be verified', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const requestMock = vi.fn(async (method: string) => {
|
|
if (method === 'listSessions') {
|
|
throw new Error('listSessions failed')
|
|
}
|
|
return {}
|
|
})
|
|
const disconnectMock = vi.fn()
|
|
mockConnectedAdoptionClientOnce()
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: requestMock,
|
|
disconnect: disconnectMock
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number))
|
|
expect(disconnectMock).toHaveBeenCalledOnce()
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
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', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
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_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token'
|
|
)
|
|
expect(forkMock).toHaveBeenCalledWith(
|
|
FAKE_DAEMON_ENTRY_PATH,
|
|
expect.arrayContaining([
|
|
'--socket',
|
|
'/fake/socket',
|
|
'--token',
|
|
'/fake/token',
|
|
'--log-file',
|
|
join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log')
|
|
]),
|
|
expect.objectContaining({ cwd: '/fake/userData', detached: true })
|
|
)
|
|
// STA-2376: the launcher is the sole emitter for a resolver replace, and fires exactly once.
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('unhealthy_resolver', 0)
|
|
})
|
|
|
|
it('preserves a resolver-unhealthy daemon when it owns live sessions', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const requestMock = vi.fn(async (method: string) => {
|
|
if (method === 'listSessions') {
|
|
return {
|
|
sessions: [
|
|
{ sessionId: 'wt-1@@live', isAlive: true },
|
|
{ sessionId: 'wt-1@@dead', isAlive: false }
|
|
]
|
|
}
|
|
}
|
|
return {}
|
|
})
|
|
const disconnectMock = vi.fn()
|
|
mockConnectedAdoptionClientOnce()
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: requestMock,
|
|
disconnect: disconnectMock
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy')
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token')
|
|
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number))
|
|
expect(disconnectMock).toHaveBeenCalledOnce()
|
|
expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled()
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
// STA-2376: preserving a daemon is not a lifecycle transition — no event.
|
|
expect(trackDaemonReplacedMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves a resolver-unhealthy daemon when live session state cannot be verified', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const requestMock = vi.fn(async (method: string) => {
|
|
if (method === 'listSessions') {
|
|
throw new Error('listSessions failed')
|
|
}
|
|
return {}
|
|
})
|
|
const disconnectMock = vi.fn()
|
|
mockConnectedAdoptionClientOnce()
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: requestMock,
|
|
disconnect: disconnectMock
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
getMacDaemonSystemResolverHealthMock.mockReturnValueOnce('unhealthy')
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number))
|
|
expect(disconnectMock).toHaveBeenCalledOnce()
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the direct daemon entry when Electron app path is already out/main', async () => {
|
|
probeSocketExistsMock.mockImplementation((p?: string) => p === FAKE_DAEMON_ENTRY_PATH)
|
|
const mod = await importFresh()
|
|
getAppPathMock.mockReturnValue(FAKE_APP_OUT_MAIN_PATH)
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
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', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
})
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(forkMock).toHaveBeenCalledWith(
|
|
FAKE_DAEMON_ENTRY_PATH,
|
|
expect.arrayContaining([
|
|
'--socket',
|
|
'/fake/socket',
|
|
'--token',
|
|
'/fake/token',
|
|
'--log-file',
|
|
join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log')
|
|
]),
|
|
expect.objectContaining({ detached: true })
|
|
)
|
|
// STA-2376: an unreachable daemon with no live sessions is replaced via the failed-health path, once.
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('failed_health_check', 0)
|
|
})
|
|
|
|
it('does not report a replacement when startup finds no daemon to remove', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
killStaleDaemonMock.mockResolvedValueOnce({
|
|
killed: false,
|
|
liveOwnerSurvived: false
|
|
})
|
|
forkMock.mockImplementationOnce(() => {
|
|
throw new Error('stop after replacement decision')
|
|
})
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'stop after replacement decision'
|
|
)
|
|
|
|
expect(trackDaemonReplacedMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// STA-2376 regression: dropping the adapter's last authenticated client is enough to make an idle
|
|
// daemon self-retire, so by the time the launcher runs there is nothing to kill and its own
|
|
// confirmed-kill gate reports nothing. The attributed reason is what keeps the runtime resolver
|
|
// replacement on the wire — and keeps it off the failed_health_check bucket it would otherwise land in.
|
|
it('reports the runtime resolver replacement even after the daemon self-retired', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
const adapterOptions = adapterInstances[0].options
|
|
trackDaemonReplacedMock.mockClear()
|
|
|
|
// The daemon is gone before the launcher looks: nothing answers, nothing left to kill.
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
killStaleDaemonMock
|
|
.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false })
|
|
.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false })
|
|
forkMock.mockImplementationOnce(() => {
|
|
throw new Error('stop after replacement decision')
|
|
})
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
await adapterOptions.respawn?.('unhealthy_resolver')
|
|
expect(trackDaemonReplacedMock).not.toHaveBeenCalled()
|
|
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'stop after replacement decision'
|
|
)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('unhealthy_resolver', 0)
|
|
|
|
// One-shot: a later unrelated launch must not inherit the attribution.
|
|
trackDaemonReplacedMock.mockClear()
|
|
forkMock.mockImplementationOnce(() => {
|
|
throw new Error('stop after replacement decision')
|
|
})
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'stop after replacement decision'
|
|
)
|
|
expect(trackDaemonReplacedMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// STA-2376: the attribution covers the case the confirmed-kill gate cannot see; it must not
|
|
// overwrite a reason this launch proved against the daemon it actually removed. Otherwise a
|
|
// resolver that recovers mid-flight bills a real stale-bundle replacement to the resolver bucket.
|
|
it('prefers a proven replacement reason over the attributed one', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
const adapterOptions = adapterInstances[0].options
|
|
trackDaemonReplacedMock.mockClear()
|
|
|
|
// Resolver recovered by the time the launcher looks, but the daemon is genuinely from another path.
|
|
getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy')
|
|
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
|
|
forkMock.mockImplementationOnce(() => {
|
|
throw new Error('stop after replacement decision')
|
|
})
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
await adapterOptions.respawn?.('unhealthy_resolver')
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'stop after replacement decision'
|
|
)
|
|
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('different_app_path', 0)
|
|
})
|
|
|
|
// STA-2376: failed_health_check is the residual bucket, not an identification, so it must not
|
|
// absorb the attribution. The same dead login session that fails the resolver also fails the PTY
|
|
// spawn probe, and with zero live sessions that lands here instead of the degraded preserve —
|
|
// so this is the likely shape of the incident, not a corner case.
|
|
it('keeps the attributed reason when the launcher only reaches failed_health_check', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
const adapterOptions = adapterInstances[0].options
|
|
trackDaemonReplacedMock.mockClear()
|
|
|
|
// Daemon survived the disconnect (non-alive sessions keep it non-idle) but fails the spawn probe.
|
|
checkDaemonHealthMock.mockResolvedValue('pty-spawn-unhealthy')
|
|
forkMock.mockImplementationOnce(() => {
|
|
throw new Error('stop after replacement decision')
|
|
})
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
await adapterOptions.respawn?.('unhealthy_resolver')
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'stop after replacement decision'
|
|
)
|
|
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('unhealthy_resolver', 0)
|
|
})
|
|
|
|
// STA-2376: in the field the identified reasons confirm via cleanupDaemonForProtocol().cleaned, not
|
|
// via killStaleDaemon — the daemon is healthy, so cleanup shuts it down over RPC and unlinks its pid,
|
|
// leaving nothing for the kill to find. The other tests reach confirmedReplacement through the kill,
|
|
// so without this one the `.cleaned` half could be dropped and every identified reason would go
|
|
// silent in production with the suite still green.
|
|
it('reports a replacement confirmed by cleanup alone, with no stale daemon left to kill', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
trackDaemonReplacedMock.mockClear()
|
|
|
|
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
|
|
killStaleDaemonMock.mockResolvedValueOnce({
|
|
killed: false,
|
|
liveOwnerSurvived: false
|
|
})
|
|
// The daemon answers cleanup's liveness probe, then the endpoint goes away so the self-shutdown
|
|
// wait succeeds and cleanup reports cleaned:true.
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementationOnce(() => {
|
|
const handlers: Record<string, (() => void)[]> = {
|
|
connect: [],
|
|
error: []
|
|
}
|
|
return {
|
|
on(event: string, cb: () => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'connect') {
|
|
queueMicrotask(() => cb())
|
|
}
|
|
return this
|
|
},
|
|
removeListener(event: string, cb: () => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
destroy() {}
|
|
}
|
|
})
|
|
forkMock.mockImplementationOnce(() => {
|
|
throw new Error('stop after replacement decision')
|
|
})
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'stop after replacement decision'
|
|
)
|
|
|
|
expect(killStaleDaemonMock).toHaveBeenCalled()
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('different_app_path', 0)
|
|
|
|
// beforeEach only mockClear()s this one, so hand it back rather than leaving later tests probing a live endpoint.
|
|
probeSocketExistsMock.mockReturnValue(false)
|
|
})
|
|
|
|
it('removes detached daemon startup listeners after readiness', async () => {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
|
message: [],
|
|
error: [],
|
|
exit: []
|
|
}
|
|
const offMock = vi.fn((event: string, cb: (arg?: unknown) => void) => {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return child
|
|
})
|
|
const child = {
|
|
pid: 12345,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'message') {
|
|
queueMicrotask(() =>
|
|
cb({
|
|
type: 'ready',
|
|
startedAtMs: 1_000_000,
|
|
linuxStartTicks: '4242',
|
|
bootId: 'boot-a'
|
|
})
|
|
)
|
|
}
|
|
return this
|
|
},
|
|
off: offMock,
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
const launchedArgsWithoutWatch = forkMock.mock.calls.at(-1)?.[1] as string[]
|
|
expect(launchedArgsWithoutWatch).not.toContain('--login-session-watch')
|
|
|
|
expect(offMock).toHaveBeenCalledWith('message', expect.any(Function))
|
|
expect(offMock).toHaveBeenCalledWith('error', expect.any(Function))
|
|
expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function))
|
|
expect(handlers.message).toHaveLength(0)
|
|
expect(handlers.error).toHaveLength(0)
|
|
expect(handlers.exit).toHaveLength(0)
|
|
expect(child.disconnect).toHaveBeenCalledOnce()
|
|
expect(child.unref).toHaveBeenCalledOnce()
|
|
expect(writeFileSyncMock).not.toHaveBeenCalled()
|
|
const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[]
|
|
const launchNonceIndex = launchArgs.indexOf('--launch-nonce')
|
|
expect(launchArgs).toEqual(
|
|
expect.arrayContaining([
|
|
'--pid-record',
|
|
`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`,
|
|
'--launch-nonce',
|
|
expect.stringMatching(/^[0-9a-f-]{36}$/),
|
|
'--entry-path',
|
|
FAKE_DAEMON_ENTRY_PATH,
|
|
'--app-version',
|
|
'1.2.3',
|
|
'--spawner-exec-path',
|
|
process.execPath
|
|
])
|
|
)
|
|
expect(launchArgs[launchNonceIndex + 1]).toMatch(/^[0-9a-f-]{36}$/)
|
|
})
|
|
|
|
it.each([
|
|
[
|
|
'changed endpoint identity',
|
|
() => ({
|
|
getDaemonIdentity: vi.fn(() => ({
|
|
pid: 999,
|
|
startedAtMs: 900_000,
|
|
launchNonce: 'stale-launch'
|
|
}))
|
|
})
|
|
],
|
|
['missing endpoint identity reader', () => ({})]
|
|
])('rejects and reaps a child with %s before adoption', async (_case, identityMethods) => {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
function basicClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
daemonClientMock.mockImplementationOnce(basicClient)
|
|
daemonClientMock.mockImplementationOnce(basicClient)
|
|
daemonClientMock.mockImplementationOnce(function postReadyClient() {
|
|
return {
|
|
...basicClient(),
|
|
...identityMethods()
|
|
}
|
|
})
|
|
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
|
message: [],
|
|
error: [],
|
|
exit: []
|
|
}
|
|
const child = {
|
|
pid: 12345,
|
|
connected: true,
|
|
exitCode: null as number | null,
|
|
signalCode: null as NodeJS.Signals | null,
|
|
on(event: string, callback: (arg?: unknown) => void) {
|
|
handlers[event]?.push(callback)
|
|
if (event === 'message') {
|
|
queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, callback: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
|
|
return this
|
|
},
|
|
kill: vi.fn(() => true),
|
|
disconnect: vi.fn(() => {
|
|
child.connected = false
|
|
}),
|
|
unref: vi.fn()
|
|
}
|
|
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
queueMicrotask(() => {
|
|
child.exitCode = 0
|
|
for (const callback of handlers.exit.slice()) {
|
|
callback(0)
|
|
}
|
|
})
|
|
return true
|
|
})
|
|
forkMock.mockReturnValueOnce(child)
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string,
|
|
pidPath?: string,
|
|
launchNonce?: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
try {
|
|
await expect(
|
|
launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new')
|
|
).rejects.toThrow('Daemon endpoint ownership changed during startup')
|
|
|
|
expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM')
|
|
expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith(
|
|
'/fake/daemon.pid',
|
|
12345,
|
|
'launch-new'
|
|
)
|
|
} finally {
|
|
kill.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('keeps a live PID record after adoption failure and removes it on exact child exit', async () => {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
const adoptionDisconnects: ReturnType<typeof vi.fn>[] = []
|
|
function MockFailingAdoptionClient() {
|
|
const disconnect = vi.fn()
|
|
adoptionDisconnects.push(disconnect)
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('adoption unavailable')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect
|
|
}
|
|
}
|
|
for (let index = 0; index < 3; index++) {
|
|
daemonClientMock.mockImplementationOnce(MockFailingAdoptionClient)
|
|
}
|
|
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
|
message: [],
|
|
error: [],
|
|
exit: []
|
|
}
|
|
const child = {
|
|
pid: 12345,
|
|
connected: true,
|
|
exitCode: null as number | null,
|
|
signalCode: null as NodeJS.Signals | null,
|
|
on(event: string, callback: (arg?: unknown) => void) {
|
|
handlers[event]?.push(callback)
|
|
if (event === 'message') {
|
|
queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
once(event: string, callback: (arg?: unknown) => void) {
|
|
handlers[event]?.push(callback)
|
|
return this
|
|
},
|
|
off(event: string, callback: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
|
|
return this
|
|
},
|
|
disconnect: vi.fn(() => {
|
|
child.connected = false
|
|
}),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string,
|
|
pidPath?: string,
|
|
launchNonce?: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
await expect(
|
|
launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-delayed')
|
|
).rejects.toThrow('adoption unavailable')
|
|
|
|
expect(writeFileSyncMock).not.toHaveBeenCalled()
|
|
expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled()
|
|
expect(adoptionDisconnects.at(-1)).toHaveBeenCalledOnce()
|
|
|
|
child.exitCode = 0
|
|
for (const callback of handlers.exit.slice()) {
|
|
callback(0)
|
|
}
|
|
expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith(
|
|
'/fake/daemon.pid',
|
|
12345,
|
|
'launch-delayed'
|
|
)
|
|
})
|
|
|
|
it('kills and rejects a daemon whose readiness message omits its start time', async () => {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
throw Object.assign(new Error('already exited'), { code: 'ESRCH' })
|
|
})
|
|
const child = {
|
|
pid: 12345,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
if (event === 'message') {
|
|
queueMicrotask(() => cb({ type: 'ready' }))
|
|
}
|
|
return this
|
|
},
|
|
off: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
|
|
try {
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'Daemon readiness identity is incomplete'
|
|
)
|
|
expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM')
|
|
expect(writeFileSyncMock).not.toHaveBeenCalled()
|
|
expect(child.disconnect).not.toHaveBeenCalled()
|
|
expect(child.unref).toHaveBeenCalledOnce()
|
|
} finally {
|
|
kill.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('rejects startup cleanup when SIGKILL never produces child exit', async () => {
|
|
vi.useFakeTimers()
|
|
const kill = vi.spyOn(process, 'kill').mockReturnValue(true)
|
|
try {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
|
message: [],
|
|
error: [],
|
|
exit: []
|
|
}
|
|
const child = {
|
|
pid: 12345,
|
|
connected: true,
|
|
exitCode: null,
|
|
signalCode: null,
|
|
on(event: string, callback: (arg?: unknown) => void) {
|
|
handlers[event]?.push(callback)
|
|
if (event === 'message') {
|
|
queueMicrotask(() => callback({ type: 'ready' }))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, callback: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
|
|
return this
|
|
},
|
|
disconnect: vi.fn(() => {
|
|
child.connected = false
|
|
}),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
const launch = launcher('/fake/socket', '/fake/token')
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
const rejection = expect(launch).rejects.toThrow('startup and child cleanup both failed')
|
|
await vi.advanceTimersByTimeAsync(6_000)
|
|
await rejection
|
|
|
|
expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM')
|
|
expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL')
|
|
expect(child.disconnect).toHaveBeenCalledOnce()
|
|
expect(child.unref).toHaveBeenCalledOnce()
|
|
expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
kill.mockRestore()
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('surfaces non-ESRCH startup termination errors and releases IPC', async () => {
|
|
const signalError = Object.assign(new Error('operation not permitted'), {
|
|
code: 'EPERM'
|
|
})
|
|
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
throw signalError
|
|
})
|
|
try {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
const child = {
|
|
pid: 12345,
|
|
connected: true,
|
|
exitCode: null,
|
|
signalCode: null,
|
|
on(event: string, callback: (arg?: unknown) => void) {
|
|
if (event === 'message') {
|
|
queueMicrotask(() => callback({ type: 'ready' }))
|
|
}
|
|
return this
|
|
},
|
|
off: vi.fn(),
|
|
disconnect: vi.fn(() => {
|
|
child.connected = false
|
|
}),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
const error = await launcher('/fake/socket', '/fake/token').catch((caught: unknown) => caught)
|
|
|
|
expect(error).toBeInstanceOf(AggregateError)
|
|
expect((error as AggregateError).errors).toEqual([
|
|
expect.objectContaining({
|
|
message: 'Daemon readiness identity is incomplete'
|
|
}),
|
|
signalError
|
|
])
|
|
expect(child.disconnect).toHaveBeenCalledOnce()
|
|
expect(child.unref).toHaveBeenCalledOnce()
|
|
} finally {
|
|
kill.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('settles startup with both errors when a malformed-ready child ignores termination', async () => {
|
|
vi.useFakeTimers()
|
|
const kill = vi.spyOn(process, 'kill').mockReturnValue(true)
|
|
try {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
|
message: [],
|
|
error: [],
|
|
exit: []
|
|
}
|
|
const child = {
|
|
pid: 12345,
|
|
connected: true,
|
|
exitCode: null,
|
|
signalCode: null,
|
|
on(event: string, callback: (arg?: unknown) => void) {
|
|
handlers[event]?.push(callback)
|
|
if (event === 'message') {
|
|
queueMicrotask(() => callback({ type: 'ready' }))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, callback: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? []
|
|
return this
|
|
},
|
|
disconnect: vi.fn(() => {
|
|
child.connected = false
|
|
}),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
|
|
const launch = launcher('/fake/socket', '/fake/token').catch((error: unknown) => error)
|
|
await vi.advanceTimersByTimeAsync(6_000)
|
|
const error = await launch
|
|
|
|
expect(error).toBeInstanceOf(AggregateError)
|
|
expect((error as AggregateError).errors).toEqual([
|
|
expect.objectContaining({
|
|
message: 'Daemon readiness identity is incomplete'
|
|
}),
|
|
expect.objectContaining({
|
|
message: 'Daemon did not exit after SIGKILL'
|
|
})
|
|
])
|
|
expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM')
|
|
expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL')
|
|
expect(handlers.message).toHaveLength(0)
|
|
expect(handlers.error).toHaveLength(0)
|
|
expect(handlers.exit).toHaveLength(0)
|
|
expect(child.disconnect).toHaveBeenCalledOnce()
|
|
expect(child.unref).toHaveBeenCalledOnce()
|
|
} finally {
|
|
kill.mockRestore()
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('leaves exclusive PID publication to the daemon child', async () => {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
const child = {
|
|
pid: 12345,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
if (event === 'message') {
|
|
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(writeFileSyncMock).not.toHaveBeenCalled()
|
|
expect(forkMock.mock.calls.at(-1)?.[1]).toEqual(
|
|
expect.arrayContaining([
|
|
'--pid-record',
|
|
`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`,
|
|
'--entry-path',
|
|
FAKE_DAEMON_ENTRY_PATH,
|
|
'--app-version',
|
|
'1.2.3',
|
|
'--spawner-exec-path',
|
|
process.execPath
|
|
])
|
|
)
|
|
})
|
|
|
|
it('removes detached daemon startup listeners after startup error', async () => {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
|
message: [],
|
|
error: [],
|
|
exit: []
|
|
}
|
|
const offMock = vi.fn((event: string, cb: (arg?: unknown) => void) => {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return child
|
|
})
|
|
const child = {
|
|
pid: undefined,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'error') {
|
|
queueMicrotask(() => cb(new Error('startup failed')))
|
|
}
|
|
return this
|
|
},
|
|
off: offMock,
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow('startup failed')
|
|
|
|
expect(offMock).toHaveBeenCalledWith('message', expect.any(Function))
|
|
expect(offMock).toHaveBeenCalledWith('error', expect.any(Function))
|
|
expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function))
|
|
expect(handlers.message).toHaveLength(0)
|
|
expect(handlers.error).toHaveLength(0)
|
|
expect(handlers.exit).toHaveLength(0)
|
|
expect(child.disconnect).not.toHaveBeenCalled()
|
|
expect(child.unref).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('captures daemon startup stderr into the failure error', async () => {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string,
|
|
pidPath?: string,
|
|
launchNonce?: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
|
message: [],
|
|
error: [],
|
|
exit: []
|
|
}
|
|
const stderrDataCbs: ((chunk: Buffer) => void)[] = []
|
|
const stderrDestroy = vi.fn()
|
|
const stderr = {
|
|
on(event: string, cb: (chunk: Buffer) => void) {
|
|
if (event === 'data') {
|
|
stderrDataCbs.push(cb)
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, cb: (chunk: Buffer) => void) {
|
|
if (event === 'data') {
|
|
const idx = stderrDataCbs.indexOf(cb)
|
|
if (idx !== -1) {
|
|
stderrDataCbs.splice(idx, 1)
|
|
}
|
|
}
|
|
return this
|
|
},
|
|
destroy: stderrDestroy
|
|
}
|
|
const child = {
|
|
pid: 4321,
|
|
exitCode: null as number | null,
|
|
stderr,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'exit') {
|
|
// Why: deliver the stderr tail before exit so the failure path sees the crash reason (mirrors a module-load crash).
|
|
queueMicrotask(() => {
|
|
for (const dataCb of stderrDataCbs.slice()) {
|
|
dataCb(Buffer.from("Error: Cannot find module 'electron'\n"))
|
|
}
|
|
child.exitCode = 1
|
|
cb(1)
|
|
})
|
|
}
|
|
return this
|
|
},
|
|
off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return child
|
|
}),
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
|
|
const error = await launcher(
|
|
'/fake/socket',
|
|
'/fake/token',
|
|
'/fake/daemon.pid',
|
|
'failed-launch'
|
|
).catch((err: Error) => err)
|
|
|
|
expect(error).toBeInstanceOf(Error)
|
|
expect((error as Error).message).toMatch(/Cannot find module 'electron'/)
|
|
expect((error as Error).message).toMatch(/Daemon stderr \(tail\)/)
|
|
expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith(
|
|
'/fake/daemon.pid',
|
|
4321,
|
|
'failed-launch'
|
|
)
|
|
// Why: release the piped stderr so the detached daemon can't keep the parent event loop alive after failure.
|
|
expect(stderrDestroy).toHaveBeenCalled()
|
|
})
|
|
|
|
it('destroys the daemon stderr pipe once the daemon signals ready', async () => {
|
|
const mod = await importFresh()
|
|
checkDaemonHealthMock.mockResolvedValue('unreachable')
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
|
message: [],
|
|
error: [],
|
|
exit: []
|
|
}
|
|
const stderrOff = vi.fn()
|
|
const stderrDestroy = vi.fn()
|
|
const stderr = {
|
|
on() {
|
|
return this
|
|
},
|
|
off: stderrOff,
|
|
destroy: stderrDestroy
|
|
}
|
|
const child = {
|
|
pid: 12345,
|
|
stderr,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'message') {
|
|
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
once(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event]?.push(cb)
|
|
return this
|
|
},
|
|
off: vi.fn(() => child),
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
forkMock.mockReturnValueOnce(child)
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(stderrOff).toHaveBeenCalledWith('data', expect.any(Function))
|
|
expect(stderrDestroy).toHaveBeenCalledOnce()
|
|
expect(child.disconnect).toHaveBeenCalledOnce()
|
|
expect(child.unref).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('preserves a health-check-failing daemon when it owns live sessions', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const requestMock = vi.fn(async (method: string) => {
|
|
if (method === 'listSessions') {
|
|
return {
|
|
sessions: [{ sessionId: 'wt-1@@live', isAlive: true }]
|
|
}
|
|
}
|
|
return {}
|
|
})
|
|
const disconnectMock = vi.fn()
|
|
mockConnectedAdoptionClientOnce()
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: requestMock,
|
|
disconnect: disconnectMock
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number))
|
|
expect(disconnectMock).toHaveBeenCalledOnce()
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('marks a preserved daemon as degraded when its PTY spawn health check fails', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const requestMock = vi.fn(async (method: string) => {
|
|
if (method === 'listSessions') {
|
|
return {
|
|
sessions: [{ sessionId: 'wt-1@@live', isAlive: true }]
|
|
}
|
|
}
|
|
return {}
|
|
})
|
|
mockConnectedAdoptionClientOnce()
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: requestMock,
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{
|
|
mode?: DaemonLaunchMode
|
|
shutdown(): Promise<void>
|
|
}>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
|
|
|
|
const handle = await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number))
|
|
expect(handle.mode).toBe('degraded-new-pty-fallback')
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// Note: only the adoption client is wedged here; the session probe answers, so this is the
|
|
// verified-zero case and the live-PTY veto stays off. The unverifiable path is covered by
|
|
// the grace-retry tests below.
|
|
it('replaces a health-check-failing daemon that reports no sessions when the pipe is dead', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('daemon is wedged')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
forkMock.mockImplementationOnce(() => ({
|
|
pid: 12345,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
if (event === 'message') {
|
|
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
once() {
|
|
return this
|
|
},
|
|
off() {
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}))
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(killStaleDaemonMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token'
|
|
)
|
|
expect(forkMock).toHaveBeenCalled()
|
|
})
|
|
|
|
it('stays silent about replacing a daemon on a cold start, where there is none', async () => {
|
|
// Why: a first launch reaches the same replace fall-through (unreachable health,
|
|
// no socket, nothing to probe); announcing a replacement there reports killing a
|
|
// daemon that never existed, on the most common path there is.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
// Both pre-spawn probes fail: nothing ever answers, so no session count is observed.
|
|
const unreachableClient = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('connect ENOENT')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
daemonClientMock
|
|
.mockImplementationOnce(unreachableClient)
|
|
.mockImplementationOnce(unreachableClient)
|
|
// A cold start has no pid record to act on, so nothing is ever killed.
|
|
killStaleDaemonMock.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false })
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
probeSocketExistsMock.mockReturnValue(false)
|
|
forkMock.mockImplementationOnce(() => ({
|
|
pid: 12345,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
if (event === 'message') {
|
|
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
once() {
|
|
return this
|
|
},
|
|
off() {
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}))
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
try {
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(forkMock).toHaveBeenCalled()
|
|
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('Replacing daemon that failed the health check')
|
|
)
|
|
} finally {
|
|
warnSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
// Why: net.connect stub whose 'connect' fires, so probeSocket() reports the pipe alive on every grace re-check.
|
|
function stubAliveSocketConnect() {
|
|
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
|
|
return {
|
|
on(event: string, cb: () => void) {
|
|
handlers[event]?.push(cb)
|
|
if (event === 'connect') {
|
|
queueMicrotask(() => cb())
|
|
}
|
|
return this
|
|
},
|
|
removeListener(event: string, cb: () => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
destroy() {}
|
|
}
|
|
}
|
|
|
|
it('adopts a transiently wedged daemon that drains and reports live sessions within the grace window', async () => {
|
|
// Why: Windows update-relaunch — post-install load wedges the daemon briefly; it still owns live sessions, so grace-adopt not kill.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
// First probe times out (still draining); the retry within grace succeeds with a live session.
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('Hello response timed out')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({
|
|
sessions: [{ sessionId: 'wt-1@@live', isAlive: true }]
|
|
})),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('holds a permanently wedged daemon rather than killing what it might be hosting', async () => {
|
|
// The trade this makes, deliberately: a socket that accepts connections but never answers
|
|
// hello can no longer be replaced at launch, so a wedged-but-empty daemon stays until the
|
|
// user restarts it (#8689 regresses to degraded mode). The alternative was killing a daemon
|
|
// whose live agents we had simply failed to observe, and that loss is unrecoverable while
|
|
// this one is one click from repaired.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
// Permanent wedge: every probe times out, then the freshly spawned daemon accepts the temporary adoption lease.
|
|
let daemonClientConstructionCount = 0
|
|
daemonClientMock.mockImplementation(function MockDaemonClient() {
|
|
daemonClientConstructionCount++
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
if (daemonClientConstructionCount <= 2 + WEDGED_DAEMON_GRACE_RETRIES) {
|
|
throw new Error('Hello response timed out')
|
|
}
|
|
}),
|
|
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
forkMock.mockImplementationOnce(() => ({
|
|
pid: 12345,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
if (event === 'message') {
|
|
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off() {
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}))
|
|
|
|
// Count only the launcher's own session-count probes.
|
|
daemonClientMock.mockClear()
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
try {
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
// 1 adoption + 1 patient probe + the cheap retries. No post-fork adoption, because
|
|
// nothing was forked. The patient probe comes first and is the point: retrying a
|
|
// four-second handshake never reaches a daemon that consistently needs longer, and
|
|
// spending the clock on the cheap asks first left the tolerant one unable to fund
|
|
// an answer.
|
|
expect(daemonClientMock).toHaveBeenCalledTimes(2 + WEDGED_DAEMON_GRACE_RETRIES)
|
|
// The verdict must still be recorded: holding without a log is indistinguishable from
|
|
// a successful adoption to anyone reading the log afterwards.
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('holding a daemon that failed the health check')
|
|
)
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Manage Sessions'))
|
|
} finally {
|
|
warnSpy.mockRestore()
|
|
// Restore the answering default: clearAllMocks clears calls not impls, so the throwing impl would leak into later tests.
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
}
|
|
})
|
|
|
|
it('holds a wedged daemon that still owns live terminals instead of killing its agents', async () => {
|
|
// Why hold and not adopt: a daemon that cannot answer listSessions cannot answer a hello,
|
|
// so there is nothing to adopt. The kill must never be attempted — its agents are alive.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
daemonClientMock.mockImplementation(function MockWedgedDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('Hello response timed out')
|
|
}),
|
|
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
// The daemon is identity-verified and its process still owns live terminals.
|
|
readVerifiedDaemonPidMock.mockResolvedValue({ pid: 4242 })
|
|
inspectDaemonPtyOwnershipMock.mockResolvedValue('owns-live-ptys')
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void>; mode?: string }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
|
|
try {
|
|
const handle = await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(handle.mode).toBe('held')
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
readVerifiedDaemonPidMock.mockResolvedValue(null)
|
|
inspectDaemonPtyOwnershipMock.mockResolvedValue('unknown')
|
|
}
|
|
})
|
|
|
|
it('adopts a PTY-spawn-unhealthy daemon in degraded mode when it can still answer', async () => {
|
|
// Why the count must come from IPC: an answered listSessions is what proves the daemon
|
|
// can still complete a handshake, and adoption opens one.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
daemonClientMock.mockImplementation(function MockAnsweringDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [{ isAlive: true }] })),
|
|
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void>; mode?: string }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
|
|
try {
|
|
const handle = await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(handle.mode).toBe('degraded-new-pty-fallback')
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
}
|
|
})
|
|
|
|
it('holds a PTY-spawn-unhealthy daemon that has since stopped answering', async () => {
|
|
// Why: `health` is a reading from before the grace window. If listSessions went
|
|
// unanswered across all of it, adoption would open a hello the daemon can no longer
|
|
// complete — and that throw costs the app its daemon.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
daemonClientMock.mockImplementation(function MockSilentDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => {
|
|
throw new Error('listSessions timed out')
|
|
}),
|
|
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
readVerifiedDaemonPidMock.mockResolvedValue({ pid: 4242 })
|
|
inspectDaemonPtyOwnershipMock.mockResolvedValue('owns-live-ptys')
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void>; mode?: string }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
|
|
try {
|
|
const handle = await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(handle.mode).toBe('held')
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
readVerifiedDaemonPidMock.mockResolvedValue(null)
|
|
inspectDaemonPtyOwnershipMock.mockResolvedValue('unknown')
|
|
}
|
|
})
|
|
|
|
it('keeps a daemon handle when the preserved daemon is too wedged to be adopted', async () => {
|
|
// Why: adoption needs a hello, which is exactly what a daemon wedged enough to be
|
|
// preserved cannot answer. Throwing here would abort initDaemonPtyProvider, leaving no
|
|
// spawner — and restartDaemon() throws without one, so the user loses the documented
|
|
// Manage Sessions → Restart remedy on top of having no daemon.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
// Permanently wedged: every client, including the adoption client, fails its hello.
|
|
daemonClientMock.mockImplementation(function MockWedgedDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('Hello response timed out')
|
|
}),
|
|
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
killStaleDaemonMock.mockResolvedValueOnce({ killed: false, liveOwnerSurvived: true })
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void>; mode?: string }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
// The endpoint still listens, so the daemon is wedged rather than gone.
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
|
|
try {
|
|
const handle = await launcher('/fake/socket', '/fake/token')
|
|
|
|
// 'held', not merely degraded: init must not attempt a lease on a daemon whose
|
|
// adoption hello just failed — that throw would cost the app its daemon entirely.
|
|
expect(handle.mode).toBe('held')
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
}
|
|
})
|
|
|
|
it('holds a hello-rejected daemon that owns live terminals rather than adopting it', async () => {
|
|
// Why: 'rejected' means it answered and refused the handshake, so adoption can never
|
|
// succeed. Falling through to preserveDaemon() would throw and cost the app its daemon
|
|
// entirely — and killing it would end agents that are still running.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
daemonClientMock.mockImplementation(function MockRejectingDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('hello refused')
|
|
}),
|
|
getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
readVerifiedDaemonPidMock.mockResolvedValue({ pid: 4242 })
|
|
inspectDaemonPtyOwnershipMock.mockResolvedValue('owns-live-ptys')
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void>; mode?: string }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('rejected')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
|
|
try {
|
|
const handle = await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(handle.mode).toBe('held')
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
readVerifiedDaemonPidMock.mockResolvedValue(null)
|
|
inspectDaemonPtyOwnershipMock.mockResolvedValue('unknown')
|
|
}
|
|
})
|
|
|
|
it('stops grace-retrying when the classification clock runs out, with retries still left', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
// Permanently wedged: without the budget this drains all WEDGED_DAEMON_GRACE_RETRIES probes.
|
|
daemonClientMock.mockImplementation(function MockWedgedDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('Hello response timed out')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
|
|
// Stand in for the hello timeout each probe really waits out: mocked probes return instantly,
|
|
// so only a moving clock can exercise the budget without a slow (or flaky) real wait.
|
|
const startMs = Date.now()
|
|
let elapsedMs = 0
|
|
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
|
|
const value = startMs + elapsedMs
|
|
elapsedMs += WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS / 3
|
|
return value
|
|
})
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
try {
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
const verdict = warnSpy.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.find((message) => message.includes('graceRetries='))
|
|
expect(verdict).toBeDefined()
|
|
const graceRetries = Number(/graceRetries=(\d+)/.exec(verdict as string)?.[1])
|
|
expect(graceRetries).toBeLessThan(WEDGED_DAEMON_GRACE_RETRIES)
|
|
} finally {
|
|
nowSpy.mockRestore()
|
|
warnSpy.mockRestore()
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
}
|
|
})
|
|
|
|
it('still replaces when the endpoint is proven dead, so a cold start is not held', async () => {
|
|
// The regression holding most risks: 'unknown' is also what a cold start looks like, since
|
|
// nothing answers when nothing is there. Holding then would hand every first launch a
|
|
// provider pointed at no daemon. A missing socket is the discriminator.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
daemonClientMock.mockImplementation(function MockWedgedDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('Hello response timed out')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
probeSocketExistsMock.mockReturnValue(false)
|
|
// Reaching the fork IS the assertion; throwing there stops before the spawn plumbing.
|
|
forkMock.mockImplementationOnce(() => {
|
|
throw new Error('reached the replacement fork')
|
|
})
|
|
|
|
try {
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'reached the replacement fork'
|
|
)
|
|
} finally {
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
}
|
|
})
|
|
|
|
it('still replaces a daemon that refused the handshake', async () => {
|
|
// 'rejected' answered and refused — bad token or foreign protocol — so it can never be
|
|
// adopted and its sessions can never be reattached. Holding one would be permanent
|
|
// degradation buying nothing, which is the opposite of the trade holding exists to make.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
daemonClientMock.mockImplementation(function MockRejectingDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('Hello response timed out')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('rejected')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
forkMock.mockImplementationOnce(() => {
|
|
throw new Error('reached the replacement fork')
|
|
})
|
|
|
|
try {
|
|
await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow(
|
|
'reached the replacement fork'
|
|
)
|
|
} finally {
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
}
|
|
})
|
|
|
|
it('preserves a daemon that drains on the last probe the budget allows', async () => {
|
|
// Why the clock is frozen: the probes here are instantaneous, so without pinning time the
|
|
// wall-clock ceiling would never bind and this would assert a retry depth the real code
|
|
// cannot reach. Freezing it lets the loop run to its retry ceiling deliberately, which is
|
|
// the thing under test — that a daemon draining on the final allowed probe is preserved.
|
|
const frozenNow = Date.now()
|
|
vi.spyOn(Date, 'now').mockReturnValue(frozenNow)
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
let probe = 0
|
|
const answeringDefault = function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}
|
|
}
|
|
daemonClientMock.mockImplementation(function MockDaemonClient() {
|
|
probe += 1
|
|
const drainsNow = probe >= 1 + WEDGED_DAEMON_GRACE_RETRIES
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
if (!drainsNow) {
|
|
throw new Error('Hello response timed out')
|
|
}
|
|
}),
|
|
request: vi.fn(async () => ({
|
|
sessions: drainsNow ? [{ sessionId: 'wt-1@@live', isAlive: true }] : []
|
|
})),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
|
|
try {
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
daemonClientMock.mockImplementation(answeringDefault)
|
|
}
|
|
})
|
|
|
|
it('replaces a hello-rejected daemon even though its pipe accepts connections', async () => {
|
|
// Why: 'rejected' = daemon refused the handshake; it can never be adopted, so replacement is the only recovery.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {
|
|
throw new Error('Hello rejected')
|
|
}),
|
|
request: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('rejected')
|
|
probeSocketExistsMock.mockReturnValue(true)
|
|
netConnectMock.mockImplementation(stubAliveSocketConnect)
|
|
forkMock.mockImplementationOnce(() => ({
|
|
pid: 12345,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
if (event === 'message') {
|
|
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off() {
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}))
|
|
daemonClientMock.mockClear()
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(killStaleDaemonMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token'
|
|
)
|
|
expect(forkMock).toHaveBeenCalled()
|
|
// 'rejected' gets no grace window (probed once): count = initial adoption + rejected probe + fresh daemon lease.
|
|
expect(daemonClientMock).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => {
|
|
// Why: startedAtMs null (all pre-fix Windows pid files) → identity 'unknown'; a live daemon must ADOPT, not replace.
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
getDaemonLaunchIdentityMock.mockReturnValueOnce('unknown')
|
|
isPackagedMock.mockReturnValue(true)
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('accepts the daemon self-reported start time when the OS query returns null', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
getProcessStartedAtMsMock.mockReturnValue(null)
|
|
launchedStartedAtMs.current = 1_700_000_123_456
|
|
forkMock.mockImplementationOnce(() => ({
|
|
pid: 12345,
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
if (event === 'message') {
|
|
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_700_000_123_456 }))
|
|
}
|
|
return this
|
|
},
|
|
off() {
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}))
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(writeFileSyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
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()
|
|
readFileSyncMock.mockReturnValue('{"pid":123}')
|
|
// process.pid is guaranteed alive, so the liveness probe succeeds.
|
|
parseDaemonPidFileMock.mockReturnValue({
|
|
pid: process.pid,
|
|
startedAtMs: null
|
|
})
|
|
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const legacyUnlinks = unlinkSyncMock.mock.calls.filter(
|
|
([p]) => typeof p === 'string' && (p.includes('.token') || p.includes('.pid'))
|
|
)
|
|
expect(legacyUnlinks).toEqual([])
|
|
})
|
|
|
|
it('cleans up legacy daemon pid/token files when the probe fails and the process is gone', async () => {
|
|
const mod = await importFresh()
|
|
readFileSyncMock.mockReturnValue('{"pid":123}')
|
|
// Why: spy process.kill to force a deterministic ESRCH instead of relying on an unallocated real pid.
|
|
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
throw new Error('ESRCH')
|
|
})
|
|
parseDaemonPidFileMock.mockReturnValue({ pid: 999_999, startedAtMs: null })
|
|
|
|
try {
|
|
await mod.initDaemonPtyProvider()
|
|
} finally {
|
|
killSpy.mockRestore()
|
|
}
|
|
|
|
const tokenUnlinks = unlinkSyncMock.mock.calls.filter(
|
|
([p]) => typeof p === 'string' && p.includes('.token')
|
|
)
|
|
expect(tokenUnlinks.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('replaces a health-check-failing daemon when no live sessions would be lost', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
|
|
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', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
})
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(killStaleDaemonMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token'
|
|
)
|
|
expect(forkMock).toHaveBeenCalledWith(
|
|
FAKE_DAEMON_ENTRY_PATH,
|
|
expect.arrayContaining([
|
|
'--socket',
|
|
'/fake/socket',
|
|
'--token',
|
|
'/fake/token',
|
|
'--log-file',
|
|
join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log')
|
|
]),
|
|
expect.objectContaining({ detached: true })
|
|
)
|
|
})
|
|
|
|
it('preserves a packaged healthy daemon when its app bundle is current', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
getDaemonLaunchIdentityMock.mockClear()
|
|
killStaleDaemonMock.mockClear()
|
|
forkMock.mockClear()
|
|
isPackagedMock.mockReturnValue(true)
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token',
|
|
FAKE_DAEMON_ENTRY_PATH
|
|
)
|
|
expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token',
|
|
'1.2.3'
|
|
)
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('respawns a packaged daemon that predates the current app bundle', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
isPackagedMock.mockReturnValue(true)
|
|
isDaemonStaleForCurrentBundleMock.mockReturnValueOnce(true)
|
|
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', startedAtMs: 1_000_000 }))
|
|
}
|
|
return this
|
|
},
|
|
off(event: string, cb: (arg?: unknown) => void) {
|
|
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
})
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token',
|
|
'1.2.3'
|
|
)
|
|
expect(killStaleDaemonMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token'
|
|
)
|
|
expect(forkMock).toHaveBeenCalledWith(
|
|
FAKE_DAEMON_ENTRY_PATH,
|
|
expect.arrayContaining([
|
|
'--socket',
|
|
'/fake/socket',
|
|
'--token',
|
|
'/fake/token',
|
|
'--log-file',
|
|
join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log')
|
|
]),
|
|
expect.objectContaining({ detached: true })
|
|
)
|
|
// STA-2376: stale-bundle replacement, emitted exactly once.
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
|
|
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('stale_bundle', 0)
|
|
})
|
|
|
|
it('preserves a packaged daemon that predates the current app bundle when it owns live sessions', async () => {
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const requestMock = vi.fn(async (method: string) => {
|
|
if (method === 'listSessions') {
|
|
return {
|
|
sessions: [{ sessionId: 'wt-1@@live', isAlive: true }]
|
|
}
|
|
}
|
|
return {}
|
|
})
|
|
const disconnectMock = vi.fn()
|
|
mockConnectedAdoptionClientOnce()
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
|
return {
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: requestMock,
|
|
disconnect: disconnectMock
|
|
}
|
|
})
|
|
|
|
const launcher = spawnerInstances[0].launcher as (
|
|
socketPath: string,
|
|
tokenPath: string
|
|
) => Promise<{ shutdown(): Promise<void> }>
|
|
isPackagedMock.mockReturnValue(true)
|
|
isDaemonStaleForCurrentBundleMock.mockReturnValueOnce(true)
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith(
|
|
FAKE_RUNTIME_DIR,
|
|
'/fake/socket',
|
|
'/fake/token',
|
|
'1.2.3'
|
|
)
|
|
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number))
|
|
expect(disconnectMock).toHaveBeenCalledOnce()
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|