diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index d3b8d953..fce3998b 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -91,6 +91,7 @@ function dependencies( spawnMock: ReturnType detectBinary: ReturnType createClient: ReturnType + checkServerHealth: ReturnType } { const spawnMock = vi.fn(() => child) const detectBinary = vi.fn().mockResolvedValue({ @@ -98,18 +99,21 @@ function dependencies( detail: 'OpenCode CLI 已就绪' }) const createClient = vi.fn(() => fakeClient()) + const checkServerHealth = vi.fn().mockResolvedValue(true) return { deps: { spawn: spawnMock as unknown as typeof spawn, detectBinary, createClient: createClient as unknown as typeof createOpencodeClient, + checkServerHealth, platform: 'linux', startupTimeoutMs: 100, ...overrides }, spawnMock, detectBinary, - createClient + createClient, + checkServerHealth } } @@ -301,6 +305,7 @@ describe('OpenCodeRuntime embedded launcher', () => { detail: 'OpenCode CLI 已就绪' }) const createClient = vi.fn(() => fakeClient()) + const checkServerHealth = vi.fn().mockResolvedValue(true) const spawnMock = vi.fn((command: string) => { if (command === 'taskkill.exe') { queueMicrotask(() => { @@ -308,11 +313,6 @@ describe('OpenCodeRuntime embedded launcher', () => { }) return killerChild } - setTimeout(() => { - stdoutOf(serverChild).write( - 'opencode server listening securely on http://127.0.0.1:43210\n' - ) - }, 0) return serverChild }) const configPath = './private/opencode.json' @@ -325,6 +325,7 @@ describe('OpenCodeRuntime embedded launcher', () => { spawn: spawnMock as unknown as typeof spawn, detectBinary, createClient: createClient as unknown as typeof createOpencodeClient, + checkServerHealth, platform: 'win32' } ) @@ -357,6 +358,14 @@ describe('OpenCodeRuntime embedded launcher', () => { }) }) ) + const firstSpawn = spawnMock.mock.calls[0] as unknown as + | [string, string[]] + | undefined + const serverPort = firstSpawn?.[1] + ?.find((argument) => argument.startsWith('--port=')) + ?.slice('--port='.length) + expect(serverPort).toMatch(/^\d+$/u) + const serverUrl = `http://127.0.0.1:${serverPort}` const clientOptions = ( createClient.mock.calls as unknown as Array< [ @@ -375,12 +384,17 @@ describe('OpenCodeRuntime embedded launcher', () => { } | undefined expect(clientOptions).toMatchObject({ - baseUrl: 'http://127.0.0.1:43210', + baseUrl: serverUrl, directory: process.cwd(), headers: { Authorization: expect.stringMatching(/^Basic /u) } }) + expect(checkServerHealth).toHaveBeenCalledWith( + serverUrl, + clientOptions?.headers?.Authorization, + expect.any(AbortSignal) + ) const spawnOptions = ( spawnMock.mock.calls as unknown as Array< [string, string[], { env?: NodeJS.ProcessEnv }] @@ -960,36 +974,39 @@ describe('OpenCodeRuntime embedded launcher', () => { } }) - it.each([ - 'https://127.0.0.1:4321', - 'http://0.0.0.0:4321', - 'http://example.com:4321', - 'http://127.0.0.1', - 'http://127.0.0.1:4321/admin' - ])('rejects an unsafe listening URL: %s', async (url) => { + it('polls the known loopback URL without waiting for stdout', async () => { const child = fakeChild() - const { deps, createClient } = dependencies(child, { - spawn: vi.fn(() => { - queueMicrotask(() => { - stdoutOf(child).write(`opencode server listening on ${url}\n`) - closeChild(child, 7) - }) - return child - }) as unknown as typeof spawn + const { deps, checkServerHealth, createClient } = dependencies(child, { + startupTimeoutMs: 1_000 }) + checkServerHealth + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) const runtime = new OpenCodeRuntime(options(), deps) await expect(runtime.getStatus()).resolves.toMatchObject({ - available: false, - detail: 'OpenCode Server 启动前退出(code 7)' + available: true + }) + expect(checkServerHealth).toHaveBeenCalledTimes(2) + const [url, authorization, signal] = + checkServerHealth.mock.calls[0] ?? [] + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + expect(authorization).toMatch(/^Basic /) + expect(signal).toBeInstanceOf(AbortSignal) + expect(createClient).toHaveBeenCalledWith({ + baseUrl: url, + directory: process.cwd(), + headers: { Authorization: authorization } }) - expect(createClient).not.toHaveBeenCalled() }) it('times out, terminates the process tree, and does not expose stderr', async () => { const child = fakeChild() const secret = 'private-config-token' - const { deps } = dependencies(child, { startupTimeoutMs: 5 }) + const { deps } = dependencies(child, { + startupTimeoutMs: 5, + checkServerHealth: vi.fn().mockResolvedValue(false) + }) stderrOf(child).write(secret) const runtime = new OpenCodeRuntime(options(), deps) @@ -997,7 +1014,7 @@ describe('OpenCodeRuntime embedded launcher', () => { expect(status).toMatchObject({ available: false, - detail: 'OpenCode Server 启动超时(30 秒)' + detail: 'OpenCode Server 启动超时(10 秒)' }) expect(status.detail).not.toContain(secret) expect(child.kill).toHaveBeenCalledWith('SIGTERM') diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 55b1bbf5..32b04deb 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -61,8 +61,9 @@ import { waitForProcessExit } from './child-process-termination' -const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024 -const STARTUP_TIMEOUT_MS = 30_000 +const STARTUP_TIMEOUT_MS = 10_000 +const STARTUP_POLL_INTERVAL_MS = 100 +const STARTUP_PROBE_TIMEOUT_MS = 500 const MAX_PERMISSION_NAME_LENGTH = 128 const MAX_PERMISSION_PATTERNS = 32 const MAX_PERMISSION_PATTERN_LENGTH = 1_024 @@ -458,6 +459,11 @@ export type OpenCodeRuntimeDependencies = { bundledPath?: string ) => Promise<{ path?: string; detail: string }> createClient: typeof createOpencodeClient + checkServerHealth: ( + url: string, + authorization: string, + signal: AbortSignal + ) => Promise platform: NodeJS.Platform startupTimeoutMs: number executionTimeoutMs: number @@ -667,41 +673,17 @@ async function defaultDetectBinary( }) } -function parseListeningUrl(output: string): string | undefined { - for (const line of output.split(/\r?\n/)) { - const match = line.match( - /^opencode server listening\b.*\bon\s+(http:\/\/\S+)\s*$/ - ) - const candidate = match?.[1] - if (!candidate) { - continue - } - - try { - const url = new URL(candidate) - const hostname = url.hostname.toLowerCase() - const port = Number(url.port) - if ( - url.protocol !== 'http:' || - !['127.0.0.1', '[::1]'].includes(hostname) || - !/^\d+$/.test(url.port) || - !Number.isInteger(port) || - port < 1 || - port > 65_535 || - url.username || - url.password || - url.search || - url.hash || - (url.pathname !== '' && url.pathname !== '/') - ) { - continue - } - return url.origin - } catch { - continue - } - } - return undefined +async function defaultCheckServerHealth( + url: string, + authorization: string, + signal: AbortSignal +): Promise { + const client = createOpencodeClient({ + baseUrl: url, + headers: { Authorization: authorization } + }) + const response = await client.global.health({ signal }) + return response.error === undefined && response.data?.healthy === true } export class OpenCodeRuntime implements AgentRuntime { @@ -738,6 +720,7 @@ export class OpenCodeRuntime implements AgentRuntime { spawn, detectBinary: defaultDetectBinary, createClient: createOpencodeClient, + checkServerHealth: defaultCheckServerHealth, platform: process.platform, startupTimeoutMs: STARTUP_TIMEOUT_MS, executionTimeoutMs: MAX_EXECUTION_MILLISECONDS, @@ -987,16 +970,21 @@ export class OpenCodeRuntime implements AgentRuntime { ) this.startingChild = child const { stdout, stderr } = child - let stdoutText = '' - let stdoutBytes = 0 - let stderrBytes = 0 let settled = false + let pollTimer: NodeJS.Timeout | undefined + const probeController = new AbortController() + const probeSignal = signal + ? AbortSignal.any([signal, probeController.signal]) + : probeController.signal + const url = `http://127.0.0.1:${port}` const cleanupStartupListeners = (): void => { clearTimeout(timeout) + if (pollTimer) { + clearTimeout(pollTimer) + } + probeController.abort() signal?.removeEventListener('abort', abort) - stdout?.removeListener('data', onStdout) - stderr?.removeListener('data', onStderr) child.removeListener('error', onError) child.removeListener('close', onClose) } @@ -1048,27 +1036,6 @@ export class OpenCodeRuntime implements AgentRuntime { } }) } - const onStdout = (chunk: string | Buffer): void => { - const text = chunk.toString() - stdoutBytes += Buffer.isBuffer(chunk) - ? chunk.byteLength - : Buffer.byteLength(chunk) - if (stdoutBytes > MAX_STARTUP_OUTPUT_BYTES) { - fail('OpenCode Server stdout 超过 64KB 安全限制') - return - } - stdoutText += text - const url = parseListeningUrl(stdoutText) - if (url) { - succeed(url) - } - } - const onStderr = (chunk: string | Buffer): void => { - stderrBytes += Buffer.byteLength(chunk) - if (stderrBytes > MAX_STARTUP_OUTPUT_BYTES) { - fail('OpenCode Server stderr 超过 64KB 安全限制') - } - } const onError = (): void => { fail('OpenCode Server 启动失败') } @@ -1078,22 +1045,50 @@ export class OpenCodeRuntime implements AgentRuntime { const abort = (): void => { fail('OpenCode Server 启动已取消') } + const probe = async (): Promise => { + try { + const attemptSignal = AbortSignal.any([ + probeSignal, + AbortSignal.timeout(STARTUP_PROBE_TIMEOUT_MS) + ]) + if ( + await this.dependencies.checkServerHealth( + url, + authorization, + attemptSignal + ) + ) { + succeed(url) + return + } + } catch { + // The server is not ready yet. + } + if (!settled) { + pollTimer = setTimeout( + () => void probe(), + STARTUP_POLL_INTERVAL_MS + ) + } + } const timeout = setTimeout(() => { - fail('OpenCode Server 启动超时(30 秒)') + fail('OpenCode Server 启动超时(10 秒)') }, this.dependencies.startupTimeoutMs) if (!stdout || !stderr) { fail('OpenCode Server 管道初始化失败') return } - stdout.on('data', onStdout) - stderr.on('data', onStderr) + stdout.resume() + stderr.resume() child.once('error', onError) child.once('close', onClose) signal?.addEventListener('abort', abort, { once: true }) if (signal?.aborted) { abort() + return } + void probe() }) } catch (error) { await rm(registration.root, {