fix: detect OpenCode readiness by health

GoodBuddy previously treated one exact OpenCode stdout line as the startup signal, so harmless log formatting differences could report a timeout even when the server was already listening. It now polls the authenticated health endpoint on the loopback port that GoodBuddy selected, with bounded attempts and the original ten-second startup limit.

Release note: 修复 OpenCode 已启动但因监听日志格式未被识别而误报超时的问题;启动检测现在直接使用本机健康接口。
This commit is contained in:
mesalogo
2026-08-30 15:30:43 +08:00
parent acc5a8e2d8
commit 6dc219f3a3
2 changed files with 105 additions and 93 deletions
+44 -27
View File
@@ -91,6 +91,7 @@ function dependencies(
spawnMock: ReturnType<typeof vi.fn>
detectBinary: ReturnType<typeof vi.fn>
createClient: ReturnType<typeof vi.fn>
checkServerHealth: ReturnType<typeof vi.fn>
} {
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')
+61 -66
View File
@@ -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<boolean>
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<boolean> {
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<void> => {
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, {