mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
741 lines
30 KiB
TypeScript
741 lines
30 KiB
TypeScript
/* eslint-disable max-lines -- Why: this file covers the entire restart flow
|
|
of daemon-init — construction, the 7-step sequence from
|
|
docs/daemon-staleness-ux.md §Phase 1, and the concurrency coalescer. A
|
|
single describe block with shared mocks keeps setup in one place; splitting
|
|
across files would duplicate the vi.hoisted boundary mocks with no cleaner
|
|
ownership seam. */
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { PROTOCOL_VERSION } from './types'
|
|
|
|
// Why: the restart flow touches many boundary modules (electron app paths, fs
|
|
// for dir creation, net for socket probe, DaemonClient over that socket, the
|
|
// spawner's launcher, ipc/pty listener binders). We only care here about the
|
|
// observable sequencing and identity invariants of runRestartDaemon, so every
|
|
// non-daemon-init dependency is replaced by a minimal stub that records calls.
|
|
const {
|
|
getPathMock,
|
|
getAppPathMock,
|
|
isPackagedMock,
|
|
probeSocketExistsMock,
|
|
netConnectMock,
|
|
forkMock,
|
|
healthCheckDaemonMock,
|
|
getDaemonLaunchIdentityMock,
|
|
killStaleDaemonMock,
|
|
getProcessStartedAtMsMock,
|
|
daemonClientMock,
|
|
spawnerInstances,
|
|
adapterInstances,
|
|
setLocalPtyProviderMock,
|
|
unbindLocalProviderListenersMock,
|
|
rebindLocalProviderListenersMock
|
|
} = 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 forkMock = vi.fn()
|
|
const netConnectMock = vi.fn(() => {
|
|
// Why: the real probeSocket() in daemon-init connects to the socket and
|
|
// resolves true on 'connect', false on 'error'. Our launcher never runs
|
|
// in these tests (healthCheckDaemon short-circuits), but probeSocket is
|
|
// also invoked by cleanupDaemonForProtocol — stub the socket object so
|
|
// the 'error' path fires synchronously 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
|
|
},
|
|
destroy() {}
|
|
}
|
|
})
|
|
|
|
const healthCheckDaemonMock = vi.fn(async () => true)
|
|
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
|
|
const killStaleDaemonMock = vi.fn(async () => true)
|
|
const getProcessStartedAtMsMock = vi.fn(() => 1_000_000)
|
|
|
|
const daemonClientMock = vi.fn().mockImplementation(() => ({
|
|
ensureConnected: vi.fn(async () => {}),
|
|
request: vi.fn(async () => ({ sessions: [] })),
|
|
disconnect: vi.fn()
|
|
}))
|
|
|
|
// Why: every DaemonSpawner constructed under test pushes into this array so
|
|
// assertions can check "was the *same* spawner reused across restart?".
|
|
const spawnerInstances: MockSpawner[] = []
|
|
// Same for DaemonPtyAdapter. The test asserts the replacement adapter is a
|
|
// fresh instance whose respawn closure targets the *original* spawner.
|
|
const adapterInstances: MockAdapter[] = []
|
|
|
|
const setLocalPtyProviderMock = vi.fn()
|
|
const unbindLocalProviderListenersMock = vi.fn()
|
|
const rebindLocalProviderListenersMock = vi.fn()
|
|
|
|
return {
|
|
getPathMock,
|
|
getAppPathMock,
|
|
isPackagedMock,
|
|
probeSocketExistsMock,
|
|
netConnectMock,
|
|
forkMock,
|
|
healthCheckDaemonMock,
|
|
getDaemonLaunchIdentityMock,
|
|
killStaleDaemonMock,
|
|
getProcessStartedAtMsMock,
|
|
daemonClientMock,
|
|
spawnerInstances,
|
|
adapterInstances,
|
|
setLocalPtyProviderMock,
|
|
unbindLocalProviderListenersMock,
|
|
rebindLocalProviderListenersMock
|
|
}
|
|
})
|
|
|
|
type MockSpawner = {
|
|
ensureRunning: ReturnType<typeof vi.fn>
|
|
resetHandle: ReturnType<typeof vi.fn>
|
|
shutdown: ReturnType<typeof vi.fn>
|
|
launcher: unknown
|
|
}
|
|
|
|
type MockAdapter = {
|
|
protocolVersion: number
|
|
options: {
|
|
socketPath: string
|
|
tokenPath: string
|
|
historyPath?: string
|
|
respawn?: () => Promise<void>
|
|
protocolVersion?: number
|
|
}
|
|
getActiveSessionIds: ReturnType<typeof vi.fn>
|
|
fanoutSyntheticExits: ReturnType<typeof vi.fn>
|
|
listProcesses: ReturnType<typeof vi.fn>
|
|
listSessions: 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: MockAdapter is fed through `new DaemonPtyRouter({ current, legacy })`
|
|
// during the "legacy preservation" test. The real router calls onData/onExit
|
|
// on each adapter; our stub returns a no-op unsubscribe so the router can
|
|
// subscribe without exploding.
|
|
callOrder: string[]
|
|
}
|
|
|
|
vi.mock('electron', () => ({
|
|
app: {
|
|
get isPackaged() {
|
|
return isPackagedMock()
|
|
},
|
|
getPath: getPathMock,
|
|
getAppPath: getAppPathMock
|
|
}
|
|
}))
|
|
|
|
vi.mock('fs', () => ({
|
|
mkdirSync: vi.fn(),
|
|
existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'),
|
|
unlinkSync: vi.fn(),
|
|
writeFileSync: vi.fn()
|
|
}))
|
|
|
|
vi.mock('child_process', () => ({ fork: forkMock }))
|
|
|
|
vi.mock('net', () => ({ connect: netConnectMock }))
|
|
|
|
vi.mock('./daemon-health', () => ({
|
|
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
|
|
healthCheckDaemon: healthCheckDaemonMock,
|
|
killStaleDaemon: killStaleDaemonMock,
|
|
getProcessStartedAtMs: getProcessStartedAtMsMock
|
|
}))
|
|
|
|
vi.mock('./client', () => ({ DaemonClient: daemonClientMock }))
|
|
|
|
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>
|
|
private socketCounter: number
|
|
constructor(opts: { runtimeDir: string; launcher: unknown }) {
|
|
this.launcher = opts.launcher
|
|
this.socketCounter = 0
|
|
// Why: each ensureRunning bumps a counter into the returned socketPath
|
|
// so the test can verify the *replacement* adapter is constructed with
|
|
// info from the second ensureRunning call, not stale info from the first.
|
|
this.ensureRunning = vi.fn(async () => {
|
|
this.socketCounter += 1
|
|
return {
|
|
socketPath: `/fake/socket-${this.socketCounter}`,
|
|
tokenPath: `/fake/token-${this.socketCounter}`
|
|
}
|
|
})
|
|
this.resetHandle = vi.fn()
|
|
this.shutdown = vi.fn(async () => {})
|
|
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)
|
|
}))
|
|
|
|
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 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 () => [])
|
|
this.listSessions = vi.fn(async () => [])
|
|
this.shutdown = vi.fn(async () => {})
|
|
this.dispose = vi.fn()
|
|
this.disconnectOnly = vi.fn(async () => {})
|
|
this.onData = vi.fn(() => () => {})
|
|
this.onExit = vi.fn(() => () => {})
|
|
adapterInstances.push(this as unknown as MockAdapter)
|
|
}
|
|
}
|
|
}))
|
|
|
|
vi.mock('../ipc/pty', () => ({
|
|
setLocalPtyProvider: setLocalPtyProviderMock,
|
|
unbindLocalProviderListeners: unbindLocalProviderListenersMock,
|
|
rebindLocalProviderListeners: rebindLocalProviderListenersMock
|
|
}))
|
|
|
|
async function importFresh() {
|
|
vi.resetModules()
|
|
spawnerInstances.length = 0
|
|
adapterInstances.length = 0
|
|
setLocalPtyProviderMock.mockClear()
|
|
unbindLocalProviderListenersMock.mockClear()
|
|
rebindLocalProviderListenersMock.mockClear()
|
|
healthCheckDaemonMock.mockClear()
|
|
getDaemonLaunchIdentityMock.mockClear()
|
|
killStaleDaemonMock.mockClear()
|
|
getAppPathMock.mockReset()
|
|
getAppPathMock.mockReturnValue('/fake/app')
|
|
forkMock.mockReset()
|
|
isPackagedMock.mockReset()
|
|
isPackagedMock.mockReturnValue(false)
|
|
daemonClientMock.mockClear()
|
|
probeSocketExistsMock.mockClear()
|
|
// Why: importing daemon-init *after* resetModules means the module-level
|
|
// `spawner`/`adapter`/`restartInFlight` start fresh for every test, which is
|
|
// the only way to reliably exercise the "first-time init" path and the
|
|
// coalescer independently.
|
|
return import('./daemon-init')
|
|
}
|
|
|
|
describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
|
beforeEach(() => {
|
|
probeSocketExistsMock.mockReturnValue(false)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
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 the original adapter with active sessions so
|
|
// fanoutSyntheticExits has something to do. The real adapter snapshots
|
|
// activeSessionIds then clears it; the mock emulates that so a regression
|
|
// that measures killedCount *after* fanout (when the set is empty) would
|
|
// surface as `killedCount === 0` here.
|
|
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* the fanout
|
|
// cleared the set. A bug that swapped these two lines in source would
|
|
// report 0 here.
|
|
expect(result.killedCount).toBe(3)
|
|
expect(originalAdapter.fanoutSyntheticExits).toHaveBeenCalledWith(-1)
|
|
// The load-bearing ordering invariant: the synthetic exits must reach
|
|
// the renderer *before* listeners are torn down. Step 1 before Step 2.
|
|
expect(order).toEqual(['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')
|
|
|
|
// Invoking the replacement adapter's respawn closure must drive the
|
|
// *same* original spawner (matches the crash-respawn closure baked into
|
|
// the first adapter — see daemon-init.ts step 5 comment).
|
|
originalSpawner.resetHandle.mockClear()
|
|
originalSpawner.ensureRunning.mockClear()
|
|
await replacementAdapter.options.respawn?.()
|
|
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1)
|
|
expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(1)
|
|
// 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 last rebind call index
|
|
// must be greater than the last setLocalPtyProvider call index.
|
|
const rebindOrder = rebindLocalProviderListenersMock.mock.invocationCallOrder.at(-1) ?? -1
|
|
const swapOrder = setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) ?? -1
|
|
expect(rebindOrder).toBeGreaterThan(swapOrder)
|
|
})
|
|
|
|
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: initDaemonPtyProvider only constructs legacy adapters when
|
|
// probeSocket returns true for a legacy socket path. The real flow runs
|
|
// createLegacyDaemonAdapters which calls probeSocket per previous version.
|
|
// Simplest seam for this test: directly construct a router with a legacy
|
|
// adapter and install it via replaceDaemonProvider, bypassing
|
|
// createLegacyDaemonAdapters' socket-probe machinery.
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
const { DaemonPtyRouter } = await import('./daemon-pty-router')
|
|
const { DaemonPtyAdapter } = await import('./daemon-pty-adapter')
|
|
const currentAtConstruction = adapterInstances[0]
|
|
// Construct a legacy adapter using the mocked constructor (pushes into
|
|
// adapterInstances) — index 1.
|
|
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 so we can prove it
|
|
// was invoked (not just that legacy adapters survived — a no-op
|
|
// disposeRouterOnly would leak listeners but still leave adapters alive).
|
|
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 instance is preserved by identity — not reconstructed,
|
|
// not defensively copied, not disposed.
|
|
const legacies = newRouter.getLegacyAdapters()
|
|
expect(legacies).toHaveLength(1)
|
|
expect(legacies[0]).toBe(legacyAdapter)
|
|
expect(legacyAdapter.dispose).not.toHaveBeenCalled()
|
|
// The outgoing router was drained via disposeRouterOnly (router-only
|
|
// teardown), so legacy adapters' underlying connections are untouched.
|
|
expect(legacyAdapter.disconnectOnly).not.toHaveBeenCalled()
|
|
// The outgoing router's subscriptions were drained but the adapters
|
|
// behind it were NOT disposed — that's the whole point of disposeRouterOnly.
|
|
expect(disposeRouterOnlySpy).toHaveBeenCalledTimes(1)
|
|
expect(oldRouterDispose).not.toHaveBeenCalled()
|
|
|
|
// The replacement router must re-run discovery so spawns targeting a
|
|
// surviving legacy sessionId still route to the legacy adapter.
|
|
expect(legacyAdapter.listProcesses).toHaveBeenCalled()
|
|
})
|
|
|
|
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 sets up a bare adapter when createLegacyDaemonAdapters
|
|
// finds nothing — 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 as it fires. Cleanup is
|
|
// the tricky one — it's the `cleanupDaemonForProtocol` call, observable
|
|
// via the probeSocket→healthCheckDaemon→killStaleDaemon branch. We use
|
|
// healthCheckDaemon as the cleanup-start marker because it's the first
|
|
// call inside createOutOfProcessLauncher → and also fires inside
|
|
// cleanupDaemonForProtocol's alive branch… actually in the default
|
|
// (probeSocket=false) path cleanup is a no-op beyond pid-unlink. The
|
|
// load-bearing observable is `resetHandle` — it fires *after* cleanup
|
|
// returns. So we instrument the spawner 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
|
|
|
|
// The full 7-step sequence in order. Step 3 (cleanupDaemonForProtocol)
|
|
// has no unique observable in the dead-socket branch, so it's implicitly
|
|
// ordered by the fact that resetHandle runs after unbind; if cleanup
|
|
// ever moved *after* resetHandle, we'd see `resetHandle` precede its
|
|
// expected position.
|
|
expect(trace).toEqual([
|
|
'fanout',
|
|
'unbind',
|
|
'resetHandle',
|
|
'ensureRunning',
|
|
'replaceProvider',
|
|
'rebind'
|
|
])
|
|
|
|
// A fresh adapter must have been constructed *between* ensureRunning
|
|
// and replaceProvider (Step 5 before Step 6). adapterInstances[1] is
|
|
// the replacement — its socketPath comes from the Step-4 ensureRunning
|
|
// result, so its existence proves the ordering.
|
|
expect(adapterInstances).toHaveLength(2)
|
|
expect(adapterInstances[1].options.socketPath).toBe('/fake/socket-2')
|
|
})
|
|
|
|
it('exercises the alive-daemon cleanup path: issues shutdown RPC via DaemonClient before spawning a replacement', async () => {
|
|
// Why: the default mock has probeSocket returning false, so Step 3's
|
|
// DaemonClient-based shutdown path is normally skipped. This test flips
|
|
// the socket to "alive" so cleanupDaemonForProtocol takes the
|
|
// client.ensureConnected → listSessions → shutdown RPC branch. Without
|
|
// this, the design doc's Risks section ("verify under both 'shutdown
|
|
// RPC succeeded' and 'fell back to killStaleDaemon' paths") is uncovered.
|
|
|
|
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()
|
|
// Why: DaemonClient is invoked via `new DaemonClient(...)`, so the mock
|
|
// factory must return a constructor-compatible function. Capture the
|
|
// existing impl so later tests aren't affected.
|
|
daemonClientMock.mockImplementationOnce(function MockDaemonClientForShutdown() {
|
|
return {
|
|
ensureConnected: ensureConnectedMock,
|
|
request: requestMock,
|
|
disconnect: disconnectMock
|
|
}
|
|
})
|
|
|
|
const mod = await importFresh()
|
|
await mod.initDaemonPtyProvider()
|
|
|
|
// Make probeSocket return true for the current-version path by toggling
|
|
// 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
|
|
},
|
|
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('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: without an explicit gate, `Promise.all([restartDaemon(),
|
|
// restartDaemon()])` evaluates arguments left-to-right *synchronously* —
|
|
// the first call's promise could resolve before the second invocation
|
|
// begins if all internal awaits resolved in a single microtask, which
|
|
// would leave the coalescer untested. The deferred gate holds the first
|
|
// restart inside `ensureRunning` until we release it, guaranteeing the
|
|
// second call enters while the first is genuinely mid-flight.
|
|
let releaseEnsureRunning: (() => void) | undefined
|
|
const ensureRunningBarrier = new Promise<void>((resolve) => {
|
|
releaseEnsureRunning = resolve
|
|
})
|
|
originalSpawner.ensureRunning.mockImplementationOnce(async () => {
|
|
await ensureRunningBarrier
|
|
return { socketPath: '/fake/socket-2', tokenPath: '/fake/token-2' }
|
|
})
|
|
|
|
const call1 = mod.restartDaemon()
|
|
// Yield microtasks so call1 progresses into runRestartDaemon and is
|
|
// definitely blocked on the barrier.
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
const call2 = mod.restartDaemon()
|
|
|
|
// Why: `async function restartDaemon` wraps each return in a fresh
|
|
// Promise, so `call1 === call2` does *not* hold even when the coalescer
|
|
// is working. The load-bearing proof is behavioral: while call1 is
|
|
// blocked on the ensureRunning barrier, call2 must NOT fork a parallel
|
|
// run of the 7-step sequence. If the coalescer fails, a second
|
|
// `runRestartDaemon` starts, which means a second `resetHandle` fires
|
|
// before we release the barrier. Check that counter at this exact
|
|
// moment — a non-coalescing implementation would already be at 2.
|
|
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1)
|
|
expect(adapterInstances).toHaveLength(1)
|
|
|
|
releaseEnsureRunning?.()
|
|
const [r1, r2] = await Promise.all([call1, call2])
|
|
// Both resolved values must be structurally identical (same result
|
|
// object bubbled up through the shared runRestartDaemon promise).
|
|
expect(r1).toEqual(r2)
|
|
|
|
// resetHandle fires once per restart; ensureRunning fires once during
|
|
// init + once during restart. A second, un-coalesced restart would push
|
|
// these counters to 2 and 3 respectively.
|
|
expect(originalSpawner.resetHandle).toHaveBeenCalledTimes(1)
|
|
expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(2)
|
|
expect(adapterInstances).toHaveLength(2)
|
|
|
|
// After the in-flight promise settles, a fresh restart is allowed to run
|
|
// — proves `.finally(() => restartInFlight = null)` actually cleared the
|
|
// slot. A stale restartInFlight would skip the work entirely.
|
|
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()
|
|
|
|
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' }))
|
|
}
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
})
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith(
|
|
'/fake/userData/daemon',
|
|
'/fake/socket',
|
|
'/fake/token',
|
|
'/fake/app/out/main/daemon-entry.js'
|
|
)
|
|
expect(killStaleDaemonMock).toHaveBeenCalledWith(
|
|
'/fake/userData/daemon',
|
|
'/fake/socket',
|
|
'/fake/token'
|
|
)
|
|
expect(forkMock).toHaveBeenCalledWith(
|
|
'/fake/app/out/main/daemon-entry.js',
|
|
['--socket', '/fake/socket', '--token', '/fake/token'],
|
|
expect.objectContaining({ detached: true })
|
|
)
|
|
})
|
|
|
|
it('uses the direct daemon entry when Electron app path is already out/main', async () => {
|
|
probeSocketExistsMock.mockImplementation(
|
|
(p?: string) => p === '/fake/app/out/main/daemon-entry.js'
|
|
)
|
|
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
|
const mod = await importFresh()
|
|
getAppPathMock.mockReturnValue('/fake/app/out/main')
|
|
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' }))
|
|
}
|
|
return this
|
|
},
|
|
disconnect: vi.fn(),
|
|
unref: vi.fn()
|
|
}
|
|
})
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(forkMock).toHaveBeenCalledWith(
|
|
'/fake/app/out/main/daemon-entry.js',
|
|
['--socket', '/fake/socket', '--token', '/fake/token'],
|
|
expect.objectContaining({ detached: true })
|
|
)
|
|
})
|
|
|
|
it('keeps packaged healthy-daemon reuse independent of dev app-path identity', 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)
|
|
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
|
|
|
|
await launcher('/fake/socket', '/fake/token')
|
|
|
|
expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled()
|
|
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
|
expect(forkMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|