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:
@@ -91,6 +91,7 @@ function dependencies(
|
|||||||
spawnMock: ReturnType<typeof vi.fn>
|
spawnMock: ReturnType<typeof vi.fn>
|
||||||
detectBinary: ReturnType<typeof vi.fn>
|
detectBinary: ReturnType<typeof vi.fn>
|
||||||
createClient: ReturnType<typeof vi.fn>
|
createClient: ReturnType<typeof vi.fn>
|
||||||
|
checkServerHealth: ReturnType<typeof vi.fn>
|
||||||
} {
|
} {
|
||||||
const spawnMock = vi.fn(() => child)
|
const spawnMock = vi.fn(() => child)
|
||||||
const detectBinary = vi.fn().mockResolvedValue({
|
const detectBinary = vi.fn().mockResolvedValue({
|
||||||
@@ -98,18 +99,21 @@ function dependencies(
|
|||||||
detail: 'OpenCode CLI 已就绪'
|
detail: 'OpenCode CLI 已就绪'
|
||||||
})
|
})
|
||||||
const createClient = vi.fn(() => fakeClient())
|
const createClient = vi.fn(() => fakeClient())
|
||||||
|
const checkServerHealth = vi.fn().mockResolvedValue(true)
|
||||||
return {
|
return {
|
||||||
deps: {
|
deps: {
|
||||||
spawn: spawnMock as unknown as typeof spawn,
|
spawn: spawnMock as unknown as typeof spawn,
|
||||||
detectBinary,
|
detectBinary,
|
||||||
createClient: createClient as unknown as typeof createOpencodeClient,
|
createClient: createClient as unknown as typeof createOpencodeClient,
|
||||||
|
checkServerHealth,
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
startupTimeoutMs: 100,
|
startupTimeoutMs: 100,
|
||||||
...overrides
|
...overrides
|
||||||
},
|
},
|
||||||
spawnMock,
|
spawnMock,
|
||||||
detectBinary,
|
detectBinary,
|
||||||
createClient
|
createClient,
|
||||||
|
checkServerHealth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +305,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
detail: 'OpenCode CLI 已就绪'
|
detail: 'OpenCode CLI 已就绪'
|
||||||
})
|
})
|
||||||
const createClient = vi.fn(() => fakeClient())
|
const createClient = vi.fn(() => fakeClient())
|
||||||
|
const checkServerHealth = vi.fn().mockResolvedValue(true)
|
||||||
const spawnMock = vi.fn((command: string) => {
|
const spawnMock = vi.fn((command: string) => {
|
||||||
if (command === 'taskkill.exe') {
|
if (command === 'taskkill.exe') {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -308,11 +313,6 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
})
|
})
|
||||||
return killerChild
|
return killerChild
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
|
||||||
stdoutOf(serverChild).write(
|
|
||||||
'opencode server listening securely on http://127.0.0.1:43210\n'
|
|
||||||
)
|
|
||||||
}, 0)
|
|
||||||
return serverChild
|
return serverChild
|
||||||
})
|
})
|
||||||
const configPath = './private/opencode.json'
|
const configPath = './private/opencode.json'
|
||||||
@@ -325,6 +325,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
spawn: spawnMock as unknown as typeof spawn,
|
spawn: spawnMock as unknown as typeof spawn,
|
||||||
detectBinary,
|
detectBinary,
|
||||||
createClient: createClient as unknown as typeof createOpencodeClient,
|
createClient: createClient as unknown as typeof createOpencodeClient,
|
||||||
|
checkServerHealth,
|
||||||
platform: 'win32'
|
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 = (
|
const clientOptions = (
|
||||||
createClient.mock.calls as unknown as Array<
|
createClient.mock.calls as unknown as Array<
|
||||||
[
|
[
|
||||||
@@ -375,12 +384,17 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
expect(clientOptions).toMatchObject({
|
expect(clientOptions).toMatchObject({
|
||||||
baseUrl: 'http://127.0.0.1:43210',
|
baseUrl: serverUrl,
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: expect.stringMatching(/^Basic /u)
|
Authorization: expect.stringMatching(/^Basic /u)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
expect(checkServerHealth).toHaveBeenCalledWith(
|
||||||
|
serverUrl,
|
||||||
|
clientOptions?.headers?.Authorization,
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
)
|
||||||
const spawnOptions = (
|
const spawnOptions = (
|
||||||
spawnMock.mock.calls as unknown as Array<
|
spawnMock.mock.calls as unknown as Array<
|
||||||
[string, string[], { env?: NodeJS.ProcessEnv }]
|
[string, string[], { env?: NodeJS.ProcessEnv }]
|
||||||
@@ -960,36 +974,39 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it('polls the known loopback URL without waiting for stdout', async () => {
|
||||||
'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) => {
|
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
const { deps, createClient } = dependencies(child, {
|
const { deps, checkServerHealth, createClient } = dependencies(child, {
|
||||||
spawn: vi.fn(() => {
|
startupTimeoutMs: 1_000
|
||||||
queueMicrotask(() => {
|
|
||||||
stdoutOf(child).write(`opencode server listening on ${url}\n`)
|
|
||||||
closeChild(child, 7)
|
|
||||||
})
|
|
||||||
return child
|
|
||||||
}) as unknown as typeof spawn
|
|
||||||
})
|
})
|
||||||
|
checkServerHealth
|
||||||
|
.mockResolvedValueOnce(false)
|
||||||
|
.mockResolvedValueOnce(true)
|
||||||
const runtime = new OpenCodeRuntime(options(), deps)
|
const runtime = new OpenCodeRuntime(options(), deps)
|
||||||
|
|
||||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||||
available: false,
|
available: true
|
||||||
detail: 'OpenCode Server 启动前退出(code 7)'
|
})
|
||||||
|
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 () => {
|
it('times out, terminates the process tree, and does not expose stderr', async () => {
|
||||||
const child = fakeChild()
|
const child = fakeChild()
|
||||||
const secret = 'private-config-token'
|
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)
|
stderrOf(child).write(secret)
|
||||||
const runtime = new OpenCodeRuntime(options(), deps)
|
const runtime = new OpenCodeRuntime(options(), deps)
|
||||||
|
|
||||||
@@ -997,7 +1014,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
|||||||
|
|
||||||
expect(status).toMatchObject({
|
expect(status).toMatchObject({
|
||||||
available: false,
|
available: false,
|
||||||
detail: 'OpenCode Server 启动超时(30 秒)'
|
detail: 'OpenCode Server 启动超时(10 秒)'
|
||||||
})
|
})
|
||||||
expect(status.detail).not.toContain(secret)
|
expect(status.detail).not.toContain(secret)
|
||||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ import {
|
|||||||
waitForProcessExit
|
waitForProcessExit
|
||||||
} from './child-process-termination'
|
} from './child-process-termination'
|
||||||
|
|
||||||
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
|
const STARTUP_TIMEOUT_MS = 10_000
|
||||||
const STARTUP_TIMEOUT_MS = 30_000
|
const STARTUP_POLL_INTERVAL_MS = 100
|
||||||
|
const STARTUP_PROBE_TIMEOUT_MS = 500
|
||||||
const MAX_PERMISSION_NAME_LENGTH = 128
|
const MAX_PERMISSION_NAME_LENGTH = 128
|
||||||
const MAX_PERMISSION_PATTERNS = 32
|
const MAX_PERMISSION_PATTERNS = 32
|
||||||
const MAX_PERMISSION_PATTERN_LENGTH = 1_024
|
const MAX_PERMISSION_PATTERN_LENGTH = 1_024
|
||||||
@@ -458,6 +459,11 @@ export type OpenCodeRuntimeDependencies = {
|
|||||||
bundledPath?: string
|
bundledPath?: string
|
||||||
) => Promise<{ path?: string; detail: string }>
|
) => Promise<{ path?: string; detail: string }>
|
||||||
createClient: typeof createOpencodeClient
|
createClient: typeof createOpencodeClient
|
||||||
|
checkServerHealth: (
|
||||||
|
url: string,
|
||||||
|
authorization: string,
|
||||||
|
signal: AbortSignal
|
||||||
|
) => Promise<boolean>
|
||||||
platform: NodeJS.Platform
|
platform: NodeJS.Platform
|
||||||
startupTimeoutMs: number
|
startupTimeoutMs: number
|
||||||
executionTimeoutMs: number
|
executionTimeoutMs: number
|
||||||
@@ -667,41 +673,17 @@ async function defaultDetectBinary(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseListeningUrl(output: string): string | undefined {
|
async function defaultCheckServerHealth(
|
||||||
for (const line of output.split(/\r?\n/)) {
|
url: string,
|
||||||
const match = line.match(
|
authorization: string,
|
||||||
/^opencode server listening\b.*\bon\s+(http:\/\/\S+)\s*$/
|
signal: AbortSignal
|
||||||
)
|
): Promise<boolean> {
|
||||||
const candidate = match?.[1]
|
const client = createOpencodeClient({
|
||||||
if (!candidate) {
|
baseUrl: url,
|
||||||
continue
|
headers: { Authorization: authorization }
|
||||||
}
|
})
|
||||||
|
const response = await client.global.health({ signal })
|
||||||
try {
|
return response.error === undefined && response.data?.healthy === true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenCodeRuntime implements AgentRuntime {
|
export class OpenCodeRuntime implements AgentRuntime {
|
||||||
@@ -738,6 +720,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
spawn,
|
spawn,
|
||||||
detectBinary: defaultDetectBinary,
|
detectBinary: defaultDetectBinary,
|
||||||
createClient: createOpencodeClient,
|
createClient: createOpencodeClient,
|
||||||
|
checkServerHealth: defaultCheckServerHealth,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
startupTimeoutMs: STARTUP_TIMEOUT_MS,
|
startupTimeoutMs: STARTUP_TIMEOUT_MS,
|
||||||
executionTimeoutMs: MAX_EXECUTION_MILLISECONDS,
|
executionTimeoutMs: MAX_EXECUTION_MILLISECONDS,
|
||||||
@@ -987,16 +970,21 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
)
|
)
|
||||||
this.startingChild = child
|
this.startingChild = child
|
||||||
const { stdout, stderr } = child
|
const { stdout, stderr } = child
|
||||||
let stdoutText = ''
|
|
||||||
let stdoutBytes = 0
|
|
||||||
let stderrBytes = 0
|
|
||||||
let settled = false
|
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 => {
|
const cleanupStartupListeners = (): void => {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
}
|
||||||
|
probeController.abort()
|
||||||
signal?.removeEventListener('abort', abort)
|
signal?.removeEventListener('abort', abort)
|
||||||
stdout?.removeListener('data', onStdout)
|
|
||||||
stderr?.removeListener('data', onStderr)
|
|
||||||
child.removeListener('error', onError)
|
child.removeListener('error', onError)
|
||||||
child.removeListener('close', onClose)
|
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 => {
|
const onError = (): void => {
|
||||||
fail('OpenCode Server 启动失败')
|
fail('OpenCode Server 启动失败')
|
||||||
}
|
}
|
||||||
@@ -1078,22 +1045,50 @@ export class OpenCodeRuntime implements AgentRuntime {
|
|||||||
const abort = (): void => {
|
const abort = (): void => {
|
||||||
fail('OpenCode Server 启动已取消')
|
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(() => {
|
const timeout = setTimeout(() => {
|
||||||
fail('OpenCode Server 启动超时(30 秒)')
|
fail('OpenCode Server 启动超时(10 秒)')
|
||||||
}, this.dependencies.startupTimeoutMs)
|
}, this.dependencies.startupTimeoutMs)
|
||||||
|
|
||||||
if (!stdout || !stderr) {
|
if (!stdout || !stderr) {
|
||||||
fail('OpenCode Server 管道初始化失败')
|
fail('OpenCode Server 管道初始化失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stdout.on('data', onStdout)
|
stdout.resume()
|
||||||
stderr.on('data', onStderr)
|
stderr.resume()
|
||||||
child.once('error', onError)
|
child.once('error', onError)
|
||||||
child.once('close', onClose)
|
child.once('close', onClose)
|
||||||
signal?.addEventListener('abort', abort, { once: true })
|
signal?.addEventListener('abort', abort, { once: true })
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
abort()
|
abort()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
void probe()
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await rm(registration.root, {
|
await rm(registration.root, {
|
||||||
|
|||||||
Reference in New Issue
Block a user