A Host startup failure reached the crash report as a fresh Error built from a message string three hops away from the original: the Host sent only error.message over the fatal IPC event, DesktopHostProcess wrapped it in a new Error, desktopErrorState flattened it to text, and main.ts wrapped that text again. Stack, cause, code, and path were gone, and the Host's stderr tail arriving with the later exit was blocked by the first-failure latch. The Host now sends its complete inspected error alongside the message (bounded to 64 KiB; carried over IPC rather than stderr because the two race and the shell reports the first failure it sees). DesktopHostProcess keeps it on DesktopHostFatalError.diagnostic, the backend error state carries the original failure object, main.ts reports that object, and the crash report prints the Host diagnostic verbatim in its own section.
176 lines
6.9 KiB
TypeScript
176 lines
6.9 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { DesktopBackendController, type DesktopBackendState } from '../src/backend-controller.ts'
|
|
|
|
function deferred() {
|
|
let resolve!: () => void
|
|
let reject!: (error: Error) => void
|
|
const promise = new Promise<void>((accept, decline) => { resolve = accept; reject = decline })
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
function fixture() {
|
|
const started = deferred()
|
|
const ready = deferred()
|
|
const stopping = deferred()
|
|
const exited = deferred()
|
|
const states: DesktopBackendState[] = []
|
|
let fail!: (error: Error) => void
|
|
const host = {
|
|
start: vi.fn(() => { started.resolve(); return ready.promise }),
|
|
stop: vi.fn(() => { stopping.resolve(); return exited.promise }),
|
|
}
|
|
const create = vi.fn((onFailure: (error: Error) => void) => { fail = onFailure; return host })
|
|
const controller = new DesktopBackendController(create, state => states.push(state))
|
|
return { controller, host, create, states, started, ready, stopping, exited, fail: (error: Error) => { fail(error) } }
|
|
}
|
|
|
|
describe('desktop backend controller', () => {
|
|
it('shares preparation and startup between concurrent retries', async () => {
|
|
const f = fixture()
|
|
const prepare = vi.fn(async () => {})
|
|
const first = f.controller.start(prepare)
|
|
expect(f.controller.start(prepare)).toBe(first)
|
|
await f.started.promise
|
|
expect(f.controller.host).toBeUndefined()
|
|
expect(f.states).toEqual([{ phase: 'starting' }])
|
|
f.ready.resolve()
|
|
await first
|
|
expect(f.controller.host).toBe(f.host)
|
|
await f.controller.start(prepare)
|
|
expect(prepare).toHaveBeenCalledTimes(1)
|
|
expect(f.host.start).toHaveBeenCalledTimes(1)
|
|
f.exited.resolve()
|
|
await f.controller.close()
|
|
})
|
|
|
|
it('waits for pending preparation on close and never spawns afterward', async () => {
|
|
const f = fixture()
|
|
const preparing = deferred()
|
|
const prepared = deferred()
|
|
const start = f.controller.start(() => { preparing.resolve(); return prepared.promise })
|
|
await preparing.promise
|
|
let closed = false
|
|
const close = f.controller.close().then(() => { closed = true })
|
|
await Promise.resolve()
|
|
expect(closed).toBe(false)
|
|
prepared.resolve()
|
|
await Promise.all([start, close])
|
|
expect(f.create).not.toHaveBeenCalled()
|
|
expect(f.states).toEqual([{ phase: 'starting' }])
|
|
await expect(f.controller.start(async () => {})).rejects.toThrow('closed')
|
|
})
|
|
|
|
it('stops a pending child once and waits for startup and child exit on close', async () => {
|
|
const f = fixture()
|
|
const start = f.controller.start(async () => {})
|
|
const rejected = expect(start).rejects.toThrow('stopped')
|
|
await f.started.promise
|
|
const close = f.controller.close()
|
|
expect(f.controller.close()).toBe(close)
|
|
await f.stopping.promise
|
|
let closed = false
|
|
void close.then(() => { closed = true })
|
|
f.ready.reject(new Error('stopped'))
|
|
await Promise.resolve()
|
|
expect(closed).toBe(false)
|
|
f.exited.resolve()
|
|
await Promise.all([rejected, close])
|
|
expect(f.host.stop).toHaveBeenCalledTimes(1)
|
|
expect(f.states).toEqual([{ phase: 'starting' }])
|
|
expect(f.controller.host).toBeUndefined()
|
|
})
|
|
|
|
it('publishes preparation errors and permits retry', async () => {
|
|
const f = fixture()
|
|
const invalid = new Error('invalid profile')
|
|
await expect(f.controller.start(async () => { throw invalid })).rejects.toThrow('invalid profile')
|
|
expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile', failure: invalid })
|
|
expect(f.create).not.toHaveBeenCalled()
|
|
f.ready.resolve()
|
|
await f.controller.start(async () => {})
|
|
expect(f.controller.state).toEqual({ phase: 'ready' })
|
|
f.exited.resolve()
|
|
await f.controller.close()
|
|
})
|
|
|
|
it('cleans up a failed startup before rejecting and displays the actual failure', async () => {
|
|
const f = fixture()
|
|
const start = f.controller.start(async () => {})
|
|
const rejected = expect(start).rejects.toThrow('plugin failed')
|
|
await f.started.promise
|
|
const pluginFailure = new Error('plugin failed')
|
|
f.ready.reject(pluginFailure)
|
|
await f.stopping.promise
|
|
f.exited.resolve()
|
|
await rejected
|
|
// The original error object travels with the state so a crash report keeps its stack and properties.
|
|
expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed', failure: pluginFailure })
|
|
expect(f.host.stop).toHaveBeenCalledTimes(1)
|
|
await f.controller.close()
|
|
})
|
|
|
|
it('waits for failed ready child cleanup before preparing a retry', async () => {
|
|
const f = fixture()
|
|
f.ready.resolve()
|
|
await f.controller.start(async () => {})
|
|
const transportFailure = new Error('transport failed')
|
|
f.fail(transportFailure)
|
|
expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed', failure: transportFailure })
|
|
expect(f.controller.host).toBeUndefined()
|
|
await f.stopping.promise
|
|
const prepare = vi.fn(async () => {})
|
|
const retry = f.controller.start(prepare)
|
|
await Promise.resolve()
|
|
expect(prepare).not.toHaveBeenCalled()
|
|
f.exited.resolve()
|
|
await retry
|
|
expect(prepare).toHaveBeenCalledTimes(1)
|
|
expect(f.create).toHaveBeenCalledTimes(2)
|
|
await f.controller.close()
|
|
})
|
|
|
|
it('does not publish ready when the child fails as readiness settles', async () => {
|
|
const f = fixture()
|
|
const start = f.controller.start(async () => {})
|
|
const rejected = expect(start).rejects.toThrow('immediate failure')
|
|
await f.started.promise
|
|
f.ready.resolve()
|
|
const immediate = new Error('immediate failure')
|
|
f.fail(immediate)
|
|
await f.stopping.promise
|
|
f.exited.resolve()
|
|
await rejected
|
|
expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure', failure: immediate }])
|
|
await f.controller.close()
|
|
})
|
|
|
|
it('ignores failure callbacks caused by intentional stop and permits subsequent start', async () => {
|
|
const f = fixture()
|
|
f.ready.resolve()
|
|
await f.controller.start(async () => {})
|
|
const stop = f.controller.stop()
|
|
await f.stopping.promise
|
|
f.fail(new Error('stopped'))
|
|
await expect(f.controller.start(async () => {})).rejects.toThrow('stopping')
|
|
expect(f.controller.state).toEqual({ phase: 'starting' })
|
|
f.exited.resolve()
|
|
await stop
|
|
await f.controller.start(async () => {})
|
|
expect(f.create).toHaveBeenCalledTimes(2)
|
|
await f.controller.close()
|
|
})
|
|
|
|
it('propagates cleanup failure and prevents retry from overlapping the remaining child', async () => {
|
|
const f = fixture()
|
|
f.ready.resolve()
|
|
await f.controller.start(async () => {})
|
|
f.fail(new Error('fatal'))
|
|
await f.stopping.promise
|
|
f.exited.reject(new Error('child did not exit'))
|
|
await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit')
|
|
await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit')
|
|
expect(f.create).toHaveBeenCalledTimes(1)
|
|
await expect(f.controller.close()).rejects.toThrow('child did not exit')
|
|
})
|
|
})
|