Files
claude-code-haha/desktop/electron/services/serverRuntime.test.ts
T
程序员阿江(Relakkes) 814c02a786 feat(im): add scan-to-create Feishu bots plus WeCom, QQ and Slack adapters
Connecting Feishu meant creating a bot by hand on the open platform and
pasting an App ID and App Secret back. Feishu also exposes an RFC 8628
device-authorization flow, so the desktop can now render a QR code, and
confirming it in the app creates the bot and stores its credentials
directly. `adapters/feishu/registration.ts` implements that protocol
rather than importing `registerApp` from `@larksuiteoapi/node-sdk@1.73`:
the repository pins 1.60 for the chat client, and the SDK runs the whole
poll inside one un-cancellable promise where the desktop needs the
stateless begin/poll pair the DingTalk registration already uses. The
scan is create-only, so it can never rewrite the configuration of a bot
the user already runs, and it pre-fills exactly the scopes, events and
callbacks this adapter calls. International tenants finish on Lark's
domain, which is now persisted and honoured by the client.

WeCom, QQ and Slack join the same session model. WeCom and QQ bind by
scanning; Slack has no scan flow, so it uses an app manifest that
pre-fills the scopes and Socket Mode. All three run over long
connections, so no public callback URL is needed, and all three accept
private chats only — pairing authorizes one person, and answering in a
group would extend that authorization to everyone else in the room.

They are built on a new `adapters/common/chat-runtime.ts` instead of a
fourth copy of the loop the five existing adapters each carry. A platform
supplies a `ChatPort` — how to say something, how to open a streaming
reply, optionally how to send an image — and the runtime owns pairing,
command routing, session restore, permission bookkeeping and the
translation of the server's stream. The existing five are deliberately
left on their own copies; migrating them is a separate change with its
own regression surface.

Attachments are downloaded through a deferred loader that runs after the
pairing gate and inside the per-chat queue. Resolving them eagerly would
let an unpaired stranger make the adapter fetch bytes and write them
under ~/.claude/im-downloads — on Slack with the bot token attached —
and would let a slow attachment overtake a text message sent after it.

The sidecar launcher's per-adapter branches become one table. It is
declared above the mode dispatch on purpose: `runAdapters` is hoisted and
runs at module top level, so a table declared below it is still in its
temporal dead zone when the adapters mode reads it — which type checks,
lints and unit tests all miss, and only the compiled binary reveals.

Verified with the checks `check:impact` selects: adapters, server,
desktop, electron, policy, chat-contract, agent-flow, docs, native
(sidecar compile, packaging and an adapters-mode smoke against the real
binary) and coverage. The scan flows themselves are not verified against
live platforms — that needs real WeCom, QQ and Slack accounts and would
create real bots.

Claude-Session: https://claude.ai/code/session_01CCGoP316AK7wdQG3Ms6Uwq
2026-09-05 23:46:50 +08:00

665 lines
25 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import path from 'node:path'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidecarChild, SidecarPlan } from './sidecarManager'
import { ADAPTER_FLAGS, SYSTEM_PROXY_ERROR_ENV } from './sidecarManager'
import { ElectronServerRuntime } from './serverRuntime'
import type { SystemProxyBridgeLike } from './systemProxyBridge'
const sidecarMocks = {
nextPort: 49321,
spawnError: null as Error | null,
serverChildren: [] as FakeSidecarChild[],
adapterChildren: [] as FakeSidecarChild[],
serverPlans: [] as SidecarPlan[],
appendHostDiagnostic: vi.fn(),
waitForServerImpl: () => Promise.resolve(),
onAdapterSpawn: null as (() => void) | null,
spawnSidecar: vi.fn((plan: SidecarPlan) => {
if (plan.args[0] === 'server' && sidecarMocks.spawnError) throw sidecarMocks.spawnError
const child = new FakeSidecarChild()
if (plan.args[0] === 'server') {
sidecarMocks.serverChildren.push(child)
sidecarMocks.serverPlans.push(plan)
} else {
sidecarMocks.adapterChildren.push(child)
sidecarMocks.onAdapterSpawn?.()
}
return child as unknown as SidecarChild
}),
}
/** One sidecar per IM adapter, so the counts below track the flag list
* rather than a number that has to be edited whenever a platform is added. */
const ADAPTER_COUNT = ADAPTER_FLAGS.length
let isolatedConfigDir = ''
class FakeSidecarChild extends EventEmitter {
readonly stdout = new PassThrough()
readonly stderr = new PassThrough()
readonly kill = vi.fn()
}
function createRuntime(options: {
appRoot?: string
diagnosticsFile?: string
env?: NodeJS.ProcessEnv
now?: () => number
resolveSystemProxy?: (url: string) => Promise<string>
sleep?: (delayMs: number) => Promise<void>
proxyBridge?: SystemProxyBridgeLike
} = {}) {
return new ElectronServerRuntime({
desktopRoot: '/isolated/desktop',
appRoot: options.appRoot,
diagnosticsFile: options.diagnosticsFile,
env: { CLAUDE_CONFIG_DIR: isolatedConfigDir, ...options.env },
resolveSystemProxy: options.resolveSystemProxy,
deps: {
appendHostDiagnostic: sidecarMocks.appendHostDiagnostic,
...(options.now ? { now: options.now } : {}),
preferredServerPorts: () => [],
reserveServerPort: async () => sidecarMocks.nextPort++,
...(options.sleep ? { sleep: options.sleep } : {}),
spawnSidecar: sidecarMocks.spawnSidecar,
waitForServer: async () => await sidecarMocks.waitForServerImpl(),
writeLastServerPort: () => undefined,
...(options.proxyBridge
? { createSystemProxyBridge: () => options.proxyBridge! }
: {}),
},
})
}
async function waitForServerChildren(count: number): Promise<void> {
for (let attempt = 0; attempt < 20 && sidecarMocks.serverChildren.length !== count; attempt++) {
await new Promise(resolve => setTimeout(resolve, 0))
}
expect(sidecarMocks.serverChildren).toHaveLength(count)
}
async function waitForMockCalls(mock: ReturnType<typeof vi.fn>, count: number): Promise<void> {
for (let attempt = 0; attempt < 20 && mock.mock.calls.length !== count; attempt++) {
await new Promise(resolve => setTimeout(resolve, 0))
}
expect(mock).toHaveBeenCalledTimes(count)
}
describe('ElectronServerRuntime', () => {
beforeEach(() => {
isolatedConfigDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-runtime-'))
sidecarMocks.nextPort = 49321
sidecarMocks.spawnError = null
sidecarMocks.serverChildren.length = 0
sidecarMocks.adapterChildren.length = 0
sidecarMocks.serverPlans.length = 0
sidecarMocks.appendHostDiagnostic.mockClear()
sidecarMocks.waitForServerImpl = () => Promise.resolve()
sidecarMocks.onAdapterSpawn = null
sidecarMocks.spawnSidecar.mockClear()
})
afterEach(() => {
rmSync(isolatedConfigDir, { recursive: true, force: true })
})
it('restarts after the active healthy server exits and ignores its late exit', async () => {
const runtime = createRuntime({
appRoot: '/isolated/app',
})
const firstUrl = await runtime.getServerUrl()
const firstChild = sidecarMocks.serverChildren[0]!
const firstAdapters = [...sidecarMocks.adapterChildren]
expect(firstAdapters).toHaveLength(ADAPTER_COUNT)
firstChild.emit('exit', 7, null)
const [secondUrl, coalescedUrl] = await Promise.all([
runtime.getServerUrl(),
runtime.getServerUrl(),
])
const secondChild = sidecarMocks.serverChildren[1]!
firstChild.emit('exit', 9, 'SIGTERM')
expect(firstUrl).toBe('http://127.0.0.1:49321')
expect(secondUrl).toBe('http://127.0.0.1:49322')
expect(coalescedUrl).toBe(secondUrl)
expect(sidecarMocks.serverChildren).toHaveLength(2)
expect(sidecarMocks.adapterChildren).toHaveLength(ADAPTER_COUNT * 2)
for (const adapter of firstAdapters) expect(adapter.kill).toHaveBeenCalledTimes(1)
for (const adapter of sidecarMocks.adapterChildren.slice(ADAPTER_COUNT)) {
expect(adapter.kill).not.toHaveBeenCalled()
}
expect(await runtime.getServerUrl()).toBe(secondUrl)
expect(secondChild).toBeDefined()
})
it('passes the isolated Electron host diagnostics file to the server sidecar', async () => {
const runtime = createRuntime({
diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log',
})
await runtime.startServer()
expect(sidecarMocks.serverPlans[0]!.env.CC_HAHA_ELECTRON_DIAGNOSTICS_FILE)
.toBe('/isolated/user-data/diagnostics/electron-host.log')
expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR).toBe(isolatedConfigDir)
expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR)
.not.toBe(path.join(homedir(), '.claude'))
})
it('keeps the pet capability independent and exposes it only to the server sidecar', async () => {
const runtime = createRuntime()
await runtime.startServer()
const localToken = runtime.getLocalAccessToken()
const petToken = runtime.getPetAccessToken()
expect(localToken.length).toBeGreaterThanOrEqual(32)
expect(petToken.length).toBeGreaterThanOrEqual(32)
expect(petToken).not.toBe(localToken)
expect(sidecarMocks.serverPlans[0]!.env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe(localToken)
expect(sidecarMocks.serverPlans[0]!.env.CC_HAHA_PET_ACCESS_TOKEN).toBe(petToken)
for (const adapter of sidecarMocks.spawnSidecar.mock.calls
.map(([plan]) => plan)
.filter(plan => plan.args[0] === 'adapters')) {
expect(adapter.env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe(localToken)
expect(adapter.env.CC_HAHA_PET_ACCESS_TOKEN).toBeUndefined()
}
})
it('gives the server only the dynamic bridge URL while adapters explicitly inherit it', async () => {
const bridge = {
start: vi.fn(async () => 'http://127.0.0.1:49123'),
stop: vi.fn(async () => undefined),
}
const runtime = createRuntime({
env: {
HTTP_PROXY: 'http://stale.example:8080',
HTTPS_PROXY: 'http://stale.example:8080',
ALL_PROXY: 'socks5://stale.example:1080',
all_proxy: 'socks5://stale.example:1080',
},
resolveSystemProxy: async () => 'DIRECT',
proxyBridge: bridge,
})
await runtime.startServer()
const serverEnv = sidecarMocks.serverPlans[0]!.env
expect(serverEnv.CC_HAHA_SYSTEM_PROXY_URL).toBe('http://127.0.0.1:49123')
expect(serverEnv.HTTP_PROXY).toBeUndefined()
expect(serverEnv.HTTPS_PROXY).toBeUndefined()
expect(serverEnv.ALL_PROXY).toBeUndefined()
expect(serverEnv.all_proxy).toBeUndefined()
const adapterPlans = sidecarMocks.spawnSidecar.mock.calls
.map(([plan]) => plan)
.filter(plan => plan.args[0] === 'adapters')
expect(adapterPlans).toHaveLength(ADAPTER_COUNT)
for (const plan of adapterPlans) {
expect(plan.env.HTTP_PROXY).toBe('http://127.0.0.1:49123')
expect(plan.env.HTTPS_PROXY).toBe('http://127.0.0.1:49123')
expect(plan.env.ALL_PROXY).toBe('http://127.0.0.1:49123')
expect(plan.env.all_proxy).toBe('http://127.0.0.1:49123')
}
runtime.stopAll()
expect(bridge.stop).toHaveBeenCalledTimes(1)
})
it('does not spawn a server when stopAll races with proxy bridge startup', async () => {
let releaseBridge!: (url: string) => void
const bridge = {
start: vi.fn(() => new Promise<string>(resolve => { releaseBridge = resolve })),
stop: vi.fn(async () => undefined),
}
const runtime = createRuntime({
resolveSystemProxy: async () => 'DIRECT',
proxyBridge: bridge,
})
const starting = runtime.startServer()
for (let attempt = 0; attempt < 10 && bridge.start.mock.calls.length === 0; attempt++) {
await Promise.resolve()
}
expect(bridge.start).toHaveBeenCalledTimes(1)
runtime.stopAll()
releaseBridge('http://127.0.0.1:49123')
await expect(starting).rejects.toThrow('server startup stopped')
expect(sidecarMocks.spawnSidecar).not.toHaveBeenCalled()
expect(bridge.stop).toHaveBeenCalledTimes(1)
})
it('waits for real server shutdown cleanup before the first restart attempt', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-restart-'))
const activeTurn = path.join(root, 'active-turn')
const children: ChildProcess[] = []
const readyFiles: string[] = []
let serverStarts = 0
const fixture = String.raw`
const fs = require('node:fs')
const activeTurn = process.argv[1]
const readyFile = process.argv[2]
let owned = false
process.on('SIGTERM', () => {
setTimeout(() => {
if (owned) fs.rmSync(activeTurn, { force: true })
process.exit(0)
}, 150)
})
try {
const fd = fs.openSync(activeTurn, 'wx')
fs.closeSync(fd)
owned = true
fs.writeFileSync(readyFile, 'ready')
} catch {
process.exit(17)
}
setInterval(() => {}, 1_000)
`
const runtime = new ElectronServerRuntime({
desktopRoot: '/isolated/desktop',
env: { CLAUDE_CONFIG_DIR: root },
deps: {
appendHostDiagnostic: () => undefined,
preferredServerPorts: () => [],
reserveServerPort: async () => 49321 + serverStarts,
spawnSidecar: plan => {
if (plan.args[0] !== 'server') {
return new FakeSidecarChild() as unknown as SidecarChild
}
const readyFile = path.join(root, `ready-${++serverStarts}`)
readyFiles.push(readyFile)
const child = spawn(process.execPath, ['-e', fixture, activeTurn, readyFile], {
stdio: ['ignore', 'pipe', 'pipe'],
})
children.push(child)
return child as SidecarChild
},
waitForServer: async () => {
const readyFile = readyFiles.at(-1)!
for (let attempt = 0; attempt < 100 && !existsSync(readyFile); attempt++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
if (!existsSync(readyFile)) throw new Error('fixture server did not become ready')
},
writeLastServerPort: () => undefined,
},
})
try {
await runtime.startServer()
expect(existsSync(activeTurn)).toBe(true)
await runtime.stopAllAndWait(2_000)
expect(existsSync(activeTurn)).toBe(false)
await runtime.startServer()
expect(serverStarts).toBe(2)
expect(children[1]!.exitCode).toBeNull()
} finally {
await runtime.stopAllAndWait(2_000).catch(() => undefined)
for (const child of children) {
if (child.exitCode === null) child.kill('SIGKILL')
}
rmSync(root, { recursive: true, force: true })
}
})
it('passes a sanitized bridge startup failure to the server without silently using direct mode', async () => {
const bridge = {
start: vi.fn(async () => {
throw new Error('failed via https://user:password@proxy.example/path with sk-secret12345678')
}),
stop: vi.fn(async () => undefined),
}
const runtime = createRuntime({
env: {
HTTP_PROXY: 'http://stale.example:8080',
HTTPS_PROXY: 'http://stale.example:8080',
ALL_PROXY: 'socks5://stale.example:1080',
},
resolveSystemProxy: async () => 'DIRECT',
proxyBridge: bridge,
})
await runtime.startServer()
const serverEnv = sidecarMocks.serverPlans[0]!.env
expect(serverEnv.HTTP_PROXY).toBeUndefined()
expect(serverEnv.HTTPS_PROXY).toBeUndefined()
expect(serverEnv.ALL_PROXY).toBeUndefined()
expect(serverEnv.CC_HAHA_SYSTEM_PROXY_URL).toBeUndefined()
expect(serverEnv[SYSTEM_PROXY_ERROR_ENV]).toContain('System proxy bridge unavailable: failed via')
expect(serverEnv[SYSTEM_PROXY_ERROR_ENV]).not.toContain('password')
expect(serverEnv[SYSTEM_PROXY_ERROR_ENV]).not.toContain('sk-secret')
expect(sidecarMocks.serverChildren).toHaveLength(1)
})
it('persists a server startup failure through the sanitized host-log boundary', async () => {
sidecarMocks.spawnError = new Error('spawn failed')
const runtime = createRuntime({
diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log',
})
await expect(runtime.startServer()).rejects.toThrow('spawn failed')
expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith(
'/isolated/user-data/diagnostics/electron-host.log',
expect.stringContaining('[startup-error] spawn failed'),
)
})
it('rejects an in-flight start when the child exits before health publication', async () => {
sidecarMocks.waitForServerImpl = () => new Promise(() => undefined)
const runtime = createRuntime()
const starting = runtime.startServer()
await waitForServerChildren(1)
sidecarMocks.serverChildren[0]!.emit('exit', 17, null)
await expect(starting).rejects.toThrow('code=17, signal=null')
sidecarMocks.waitForServerImpl = () => Promise.resolve()
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322')
expect(sidecarMocks.serverChildren).toHaveLength(2)
})
it('kills the attempted server child when the health wait rejects', async () => {
sidecarMocks.waitForServerImpl = () => Promise.reject(new Error('health wait timed out'))
const runtime = createRuntime()
await expect(runtime.startServer()).rejects.toThrow('health wait timed out')
expect(sidecarMocks.serverChildren).toHaveLength(1)
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
expect(sidecarMocks.adapterChildren).toHaveLength(0)
})
it('kills an unpublished server exactly once when stopAll runs during health wait', async () => {
let releaseHealth!: () => void
sidecarMocks.waitForServerImpl = () => new Promise<void>(resolve => {
releaseHealth = resolve
})
const runtime = createRuntime()
const starting = runtime.startServer()
await waitForServerChildren(1)
runtime.stopAll(true)
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
await expect(starting).rejects.toThrow('stopped')
releaseHealth()
await new Promise(resolve => setTimeout(resolve, 0))
expect(sidecarMocks.serverChildren).toHaveLength(1)
expect(sidecarMocks.adapterChildren).toHaveLength(0)
expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1)
})
it('stops active adapters and waits for the replacement server to become healthy', async () => {
const runtime = createRuntime()
await runtime.startServer()
const activeAdapters = [...sidecarMocks.adapterChildren]
let releaseReplacementHealth!: () => void
sidecarMocks.waitForServerImpl = () => new Promise<void>(resolve => {
releaseReplacementHealth = resolve
})
sidecarMocks.serverChildren[0]!.emit('exit', 19, null)
await waitForServerChildren(2)
for (const adapter of activeAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
let recoveredUrl: string | null = null
const recovery = runtime.getServerUrl().then((url) => {
recoveredUrl = url
})
await Promise.resolve()
expect(recoveredUrl).toBeNull()
releaseReplacementHealth()
await recovery
expect(recoveredUrl).toBe('http://127.0.0.1:49322')
expect(sidecarMocks.adapterChildren).toHaveLength(ADAPTER_COUNT * 2)
})
it('keeps demand recovery available after an immediate restart fails transiently', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const runtime = createRuntime()
await runtime.startServer()
let replacementAttempts = 0
sidecarMocks.waitForServerImpl = () => {
replacementAttempts += 1
return replacementAttempts === 1
? Promise.reject(new Error('port release race'))
: Promise.resolve()
}
sidecarMocks.serverChildren[0]!.emit('exit', 24, null)
await waitForServerChildren(2)
await waitForMockCalls(sidecarMocks.serverChildren[1]!.kill, 1)
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49323')
expect(sidecarMocks.serverChildren).toHaveLength(3)
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('failed to restart server sidecar after exit'),
)
})
it('opens a circuit after three consecutive automatic restarts', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
let now = 0
const restartDelays: number[] = []
const runtime = createRuntime({
now: () => now,
sleep: async (delayMs) => {
restartDelays.push(delayMs)
now += delayMs
},
})
await runtime.startServer()
for (let crash = 0; crash < 3; crash++) {
sidecarMocks.serverChildren[crash]!.emit('exit', 30 + crash, null)
await waitForServerChildren(crash + 2)
}
sidecarMocks.serverChildren[3]!.emit('exit', 33, null)
await new Promise(resolve => setTimeout(resolve, 0))
expect(sidecarMocks.serverChildren).toHaveLength(4)
await expect(runtime.getServerUrl()).rejects.toThrow('automatic restart paused')
expect(restartDelays).toEqual([250, 1_000])
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('automatic restart paused after 3 consecutive crashes'),
)
now += 60_000
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49325')
expect(sidecarMocks.serverChildren).toHaveLength(5)
})
it('resets the automatic restart budget after a stable server window', async () => {
let now = 0
const restartDelays: number[] = []
const runtime = createRuntime({
now: () => now,
sleep: async (delayMs) => {
restartDelays.push(delayMs)
},
})
await runtime.startServer()
sidecarMocks.serverChildren[0]!.emit('exit', 40, null)
await waitForServerChildren(2)
now = 60_000
sidecarMocks.serverChildren[1]!.emit('exit', 41, null)
await waitForServerChildren(3)
expect(restartDelays).toEqual([])
})
it('cancels a delayed automatic restart when the runtime stops', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
let releaseBackoff!: () => void
const sleep = vi.fn(() => new Promise<void>(resolve => {
releaseBackoff = resolve
}))
const runtime = createRuntime({ now: () => 0, sleep })
await runtime.startServer()
sidecarMocks.serverChildren[0]!.emit('exit', 42, null)
await waitForServerChildren(2)
sidecarMocks.serverChildren[1]!.emit('exit', 43, null)
await waitForMockCalls(sleep, 1)
runtime.stopAll()
releaseBackoff()
await new Promise(resolve => setTimeout(resolve, 0))
expect(sidecarMocks.serverChildren).toHaveLength(2)
})
it('stops active adapters immediately when the server emits a process error', async () => {
const runtime = createRuntime()
await runtime.startServer()
const activeAdapters = [...sidecarMocks.adapterChildren]
sidecarMocks.serverChildren[0]!.emit('error', new Error('active server failed'))
await waitForServerChildren(2)
for (const adapter of activeAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
})
it('does not let a stale server exit stop replacement adapters', async () => {
const runtime = createRuntime()
await runtime.startServer()
const firstServer = sidecarMocks.serverChildren[0]!
firstServer.emit('exit', 20, null)
await runtime.getServerUrl()
const replacementAdapters = sidecarMocks.adapterChildren.slice(ADAPTER_COUNT)
firstServer.emit('exit', 21, 'SIGTERM')
expect(replacementAdapters).toHaveLength(ADAPTER_COUNT)
for (const adapter of replacementAdapters) {
expect(adapter.kill).not.toHaveBeenCalled()
}
})
it('stops the current adapter generation after an explicit adapter restart', async () => {
const runtime = createRuntime()
await runtime.startServer()
const firstAdapters = [...sidecarMocks.adapterChildren]
await runtime.restartAdaptersSidecars()
const restartedAdapters = sidecarMocks.adapterChildren.slice(ADAPTER_COUNT)
sidecarMocks.serverChildren[0]!.emit('exit', 22, null)
await waitForServerChildren(2)
for (const adapter of firstAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
for (const adapter of restartedAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
})
it('coalesces overlapping manual adapter restarts into one live generation', async () => {
const runtime = createRuntime()
await runtime.startServer()
const originalAdapters = [...sidecarMocks.adapterChildren]
const firstRestart = runtime.restartAdaptersSidecars()
const secondRestart = runtime.restartAdaptersSidecars()
expect(secondRestart).toBe(firstRestart)
await Promise.all([firstRestart, secondRestart])
expect(sidecarMocks.adapterChildren).toHaveLength(ADAPTER_COUNT * 2)
for (const adapter of originalAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
for (const adapter of sidecarMocks.adapterChildren.slice(ADAPTER_COUNT)) {
expect(adapter.kill).not.toHaveBeenCalled()
}
})
it('cancels a manual adapter restart when its server exits after the first spawn', async () => {
const runtime = createRuntime()
await runtime.startServer()
const firstServer = sidecarMocks.serverChildren[0]!
const originalAdapters = [...sidecarMocks.adapterChildren]
sidecarMocks.onAdapterSpawn = () => {
sidecarMocks.onAdapterSpawn = null
firstServer.emit('exit', 23, null)
}
await runtime.restartAdaptersSidecars()
expect(sidecarMocks.adapterChildren).toHaveLength(ADAPTER_COUNT + 1)
for (const adapter of originalAdapters) {
expect(adapter.kill).toHaveBeenCalledTimes(1)
}
expect(sidecarMocks.adapterChildren[ADAPTER_COUNT]!.kill).toHaveBeenCalledTimes(1)
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322')
expect(sidecarMocks.serverChildren).toHaveLength(2)
expect(sidecarMocks.adapterChildren).toHaveLength(ADAPTER_COUNT * 2 + 1)
for (const adapter of sidecarMocks.adapterChildren.slice(ADAPTER_COUNT + 1)) {
expect(adapter.kill).not.toHaveBeenCalled()
}
})
it('rejects when the published child exits during adapter startup', async () => {
const runtime = createRuntime()
sidecarMocks.onAdapterSpawn = () => {
sidecarMocks.onAdapterSpawn = null
sidecarMocks.serverChildren[0]!.emit('exit', 18, 'SIGTERM')
}
await expect(runtime.startServer()).rejects.toThrow('code=18, signal=SIGTERM')
expect(sidecarMocks.adapterChildren).toHaveLength(1)
expect(sidecarMocks.adapterChildren[0]!.kill).toHaveBeenCalledTimes(1)
await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322')
expect(sidecarMocks.serverChildren).toHaveLength(2)
expect(sidecarMocks.adapterChildren).toHaveLength(ADAPTER_COUNT + 1)
for (const adapter of sidecarMocks.adapterChildren.slice(1)) {
expect(adapter.kill).not.toHaveBeenCalled()
}
})
it('handles an asynchronous child process error without crashing Electron', async () => {
sidecarMocks.waitForServerImpl = () => new Promise(() => undefined)
const runtime = createRuntime({
diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log',
})
const starting = runtime.startServer()
await waitForServerChildren(1)
expect(() => sidecarMocks.serverChildren[0]!.emit(
'error',
new Error('spawn error OPENAI_API_KEY=unsafe-value'),
)).not.toThrow()
const rejection = await starting.then(
() => null,
error => error as Error,
)
expect(rejection?.message).toContain('spawn error')
expect(rejection?.message).not.toContain('unsafe-value')
expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith(
'/isolated/user-data/diagnostics/electron-host.log',
expect.stringContaining('[process-error] sidecar process error: spawn error'),
)
})
})