From 72e706972a66a36a0b2f32a4cd90cee91a871f01 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 28 Apr 2026 12:35:00 +0300 Subject: [PATCH] fix: harden managed OpenCode startup --- packages/ui/src/lib/openCodeStatus.ts | 55 +++++++++++-- packages/web/server/index.js | 4 + .../web/server/lib/opencode/env-runtime.js | 27 ++++++- packages/web/server/lib/opencode/lifecycle.js | 81 +++++++++++++++++-- .../web/server/lib/opencode/lifecycle.test.js | 53 ++++++++++++ 5 files changed, 201 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts index 8a2e1eae..62e956da 100644 --- a/packages/ui/src/lib/openCodeStatus.ts +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -18,6 +18,7 @@ type OpenChamberHealthSnapshot = { openCodeAuthSource?: unknown; isOpenCodeReady?: unknown; lastOpenCodeError?: unknown; + lastOpenCodeLaunchDiagnostics?: unknown; opencodeBinaryResolved?: unknown; opencodeBinarySource?: unknown; opencodeLaunchBinary?: unknown; @@ -121,6 +122,29 @@ const normalizePort = (value: unknown): number | null => { return null; }; +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const formatUnknown = (value: unknown, fallback = '(n/a)'): string => { + if (typeof value === 'string') return value.trim() || fallback; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return fallback; +}; + +const formatLaunchRuntime = (wrapperType: string, node: string, bun: string): string => { + if (wrapperType === 'node-shebang' || wrapperType === 'node-launcher') { + return node ? `node (${node})` : 'node'; + } + if (wrapperType === 'bun-shebang') { + return bun ? `bun (${bun})` : 'bun'; + } + if (wrapperType) { + return wrapperType; + } + return 'direct executable'; +}; + export const buildOpenCodeStatusReport = async (): Promise => { const now = new Date(); const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)'; @@ -260,6 +284,12 @@ export const buildOpenCodeStatusReport = async (): Promise => { lines.push(''); lines.push('OpenCode CLI resolution:'); + const launchDiagnostics = isRecord(openChamberHealth?.lastOpenCodeLaunchDiagnostics) + ? openChamberHealth.lastOpenCodeLaunchDiagnostics + : null; + const actualLaunchArgs = launchDiagnostics && Array.isArray(launchDiagnostics.args) + ? launchDiagnostics.args.filter((value): value is string => typeof value === 'string') + : []; const openChamberOpencodeResolution = openChamberOpencodeResolutionResult.data; const configured = openChamberOpencodeResolution && typeof openChamberOpencodeResolution.configured === 'string' @@ -277,15 +307,15 @@ export const buildOpenCodeStatusReport = async (): Promise => { openChamberOpencodeResolution && typeof openChamberOpencodeResolution.source === 'string' ? openChamberOpencodeResolution.source : (openChamberHealth && typeof openChamberHealth.opencodeBinarySource === 'string' ? openChamberHealth.opencodeBinarySource : ''); - const launchBinary = + const configuredLaunchBinary = openChamberOpencodeResolution && typeof openChamberOpencodeResolution.launchBinary === 'string' ? openChamberOpencodeResolution.launchBinary : (openChamberHealth && typeof openChamberHealth.opencodeLaunchBinary === 'string' ? openChamberHealth.opencodeLaunchBinary : ''); - const launchWrapperType = + const configuredLaunchWrapperType = openChamberOpencodeResolution && typeof openChamberOpencodeResolution.launchWrapperType === 'string' ? openChamberOpencodeResolution.launchWrapperType : (openChamberHealth && typeof openChamberHealth.opencodeLaunchWrapperType === 'string' ? openChamberHealth.opencodeLaunchWrapperType : ''); - const launchArgs = + const configuredLaunchArgs = openChamberOpencodeResolution && Array.isArray(openChamberOpencodeResolution.launchArgs) ? openChamberOpencodeResolution.launchArgs.filter((value): value is string => typeof value === 'string') : (openChamberHealth && Array.isArray(openChamberHealth.opencodeLaunchArgs) @@ -324,11 +354,20 @@ export const buildOpenCodeStatusReport = async (): Promise => { lines.push(`- detected-now: ${detectedNow}`); lines.push(`- detected-source: ${detectedSourceNow || '(n/a)'}`); } - lines.push(`- launch-binary: ${launchBinary || '(n/a)'}`); - lines.push(`- launch-wrapper: ${launchWrapperType || '(n/a)'}`); - lines.push(`- launch-args: ${launchArgs.length ? launchArgs.join(' ') : '(none)'}`); - lines.push(`- node: ${node || '(n/a)'}`); - lines.push(`- bun: ${bun || '(n/a)'}`); + if (launchDiagnostics) { + lines.push(`- launched-at: ${formatUnknown(launchDiagnostics.launchedAt)}`); + lines.push(`- launch: ${formatUnknown(launchDiagnostics.binary)} ${actualLaunchArgs.join(' ')}`.trim()); + lines.push(`- cwd: ${formatUnknown(launchDiagnostics.cwd)}`); + lines.push(`- wrapper: ${formatUnknown(launchDiagnostics.wrapperType)}`); + lines.push(`- runtime: ${formatLaunchRuntime(formatUnknown(launchDiagnostics.wrapperType, ''), node, bun)}`); + lines.push(`- PATH entries: ${formatUnknown(launchDiagnostics.pathEntryCount, '(unknown)')}`); + lines.push(`- shell env: ${formatUnknown(launchDiagnostics.hasShellEnv, '(unknown)')} (${formatUnknown(launchDiagnostics.shellEnvKeysCount, '?')} keys)`); + } else { + lines.push(`- launch-binary: ${configuredLaunchBinary || '(n/a)'}`); + lines.push(`- launch-wrapper: ${configuredLaunchWrapperType || '(n/a)'}`); + lines.push(`- launch-args: ${configuredLaunchArgs.length ? configuredLaunchArgs.join(' ') : '(none)'}`); + lines.push(`- runtime: ${formatLaunchRuntime(configuredLaunchWrapperType || '', node, bun)}`); + } if (!openChamberOpencodeResolution && openChamberOpencodeResolutionResult.error) { lines.push(`- resolution-endpoint: ${openChamberOpencodeResolutionResult.error}`); } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index b5092c64..3f76c20a 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -406,6 +406,7 @@ let openCodeApiPrefix = ''; let openCodeApiPrefixDetected = true; let openCodeApiDetectionTimer = null; let lastOpenCodeError = null; +let lastOpenCodeLaunchDiagnostics = null; let isOpenCodeReady = false; let openCodeNotReadySince = 0; let isExternalOpenCode = false; @@ -836,6 +837,7 @@ Object.defineProperties(openCodeLifecycleState, { openCodeApiPrefixDetected: { get: () => openCodeApiPrefixDetected, set: (value) => { openCodeApiPrefixDetected = value; } }, openCodeApiDetectionTimer: { get: () => openCodeApiDetectionTimer, set: (value) => { openCodeApiDetectionTimer = value; } }, lastOpenCodeError: { get: () => lastOpenCodeError, set: (value) => { lastOpenCodeError = value; } }, + lastOpenCodeLaunchDiagnostics: { get: () => lastOpenCodeLaunchDiagnostics, set: (value) => { lastOpenCodeLaunchDiagnostics = value; } }, isOpenCodeReady: { get: () => isOpenCodeReady, set: (value) => { isOpenCodeReady = value; } }, openCodeNotReadySince: { get: () => openCodeNotReadySince, set: (value) => { openCodeNotReadySince = value; } }, isExternalOpenCode: { get: () => isExternalOpenCode, set: (value) => { isExternalOpenCode = value; } }, @@ -876,6 +878,7 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ clearResolvedOpenCodeBinary, buildAugmentedPath, buildManagedOpenCodePath, + getManagedOpenCodeShellEnvSnapshot: getLoginShellEnvSnapshot, }); const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args); @@ -1065,6 +1068,7 @@ async function main(options = {}) { openCodeApiPrefixDetected: true, isOpenCodeReady, lastOpenCodeError, + lastOpenCodeLaunchDiagnostics, opencodeBinaryResolved: resolvedOpencodeBinary || null, opencodeBinarySource: resolvedOpencodeBinarySource || null, opencodeLaunchBinary: launchSpec?.binary || null, diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 970bc8e6..af6c263b 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -52,12 +52,33 @@ export const createOpenCodeEnvRuntime = (deps) => { }; const searchPathFor = (binaryName) => { + const trimmed = typeof binaryName === 'string' ? binaryName.trim() : ''; + if (!trimmed) { + return null; + } + const current = process.env.PATH || ''; const parts = current.split(path.delimiter).filter(Boolean); + const candidateNames = [trimmed]; + + if (process.platform === 'win32' && !path.extname(trimmed)) { + const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD'; + for (const ext of pathExt.split(';')) { + const normalizedExt = ext.trim(); + if (!normalizedExt) continue; + const candidateName = `${trimmed}${normalizedExt.startsWith('.') ? normalizedExt : `.${normalizedExt}`}`; + if (!candidateNames.some((existing) => existing.toLowerCase() === candidateName.toLowerCase())) { + candidateNames.push(candidateName); + } + } + } + for (const dir of parts) { - const candidate = path.join(dir, binaryName); - if (isExecutable(candidate)) { - return candidate; + for (const candidateName of candidateNames) { + const candidate = path.join(dir, candidateName); + if (isExecutable(candidate)) { + return candidate; + } } } return null; diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index 0d2cb381..9b2519ac 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -24,6 +24,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { clearResolvedOpenCodeBinary, buildAugmentedPath, buildManagedOpenCodePath, + getManagedOpenCodeShellEnvSnapshot, } = deps; const killProcessOnPort = (port) => { @@ -178,9 +179,21 @@ export const createOpenCodeLifecycleRuntime = (deps) => { await waitForChildProcessClose(child, 1000); }; - const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv }) => { + const formatCapturedOutput = ({ stdout, stderr }) => { + const parts = []; + if (stdout.trim()) { + parts.push(`stdout:\n${stdout.trim()}`); + } + if (stderr.trim()) { + parts.push(`stderr:\n${stderr.trim()}`); + } + return parts.length > 0 ? parts.join('\n\n') : 'No stdout/stderr captured'; + }; + + const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => { let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; let args = ['serve', '--hostname', hostname, '--port', String(port)]; + let launchWrapperType = null; if (process.platform === 'win32' && state.useWslForOpencode) { const wslBinary = state.resolvedWslBinary || resolveWslExecutablePath(); @@ -210,11 +223,28 @@ export const createOpenCodeLifecycleRuntime = (deps) => { if (launchSpec.wrapperType) { console.log(`Launching OpenCode via ${launchSpec.wrapperType}: ${launchSpec.binary}`); } + launchWrapperType = launchSpec.wrapperType || null; binary = launchSpec.binary; args = [...(Array.isArray(launchSpec.args) ? launchSpec.args : []), ...args]; } } + const pathValue = typeof processEnv?.PATH === 'string' ? processEnv.PATH : ''; + const pathEntryCount = pathValue ? pathValue.split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length : 0; + state.lastOpenCodeLaunchDiagnostics = { + launchedAt: new Date().toISOString(), + binary, + args, + cwd, + hostname, + port, + wrapperType: launchWrapperType, + pathEntryCount, + hasShellEnv: shellEnvKeysCount > 0, + shellEnvKeysCount, + }; + console.log('[OpenCode] Launching managed server', state.lastOpenCodeLaunchDiagnostics); + const child = spawn(binary, args, { cwd, env: processEnv, @@ -223,7 +253,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => { }); const url = await new Promise((resolve, reject) => { - let output = ''; + let stdout = ''; + let stderr = ''; let done = false; const finish = (handler, value) => { if (done) return; @@ -237,8 +268,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => { }; const onStdout = (chunk) => { - output += chunk.toString(); - const lines = output.split('\n'); + stdout += chunk.toString(); + const lines = stdout.split('\n'); for (const line of lines) { if (!line.startsWith('opencode server listening')) continue; const match = line.match(/on\s+(https?:\/\/[^\s]+)/); @@ -252,11 +283,12 @@ export const createOpenCodeLifecycleRuntime = (deps) => { }; const onStderr = (chunk) => { - output += chunk.toString(); + stderr += chunk.toString(); }; - const onExit = (code) => { - finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`)); + const onExit = (code, signal) => { + const reason = signal ? `signal ${signal}` : `code ${code}`; + finish(reject, new Error(`OpenCode exited with ${reason}. ${formatCapturedOutput({ stdout, stderr })}`)); }; const onError = (error) => { @@ -379,7 +411,11 @@ export const createOpenCodeLifecycleRuntime = (deps) => { throw new Error('Timed out waiting for OpenCode port'); }; - const startOpenCode = async () => { + const START_OPEN_CODE_MAX_ATTEMPTS = 2; + + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const startOpenCodeOnce = async () => { const desiredPort = env.ENV_CONFIGURED_OPENCODE_PORT ?? 0; const spawnPort = await resolveManagedOpenCodePort(desiredPort, env.ENV_CONFIGURED_OPENCODE_HOSTNAME); console.log( @@ -396,6 +432,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => { : typeof buildAugmentedPath === 'function' ? buildAugmentedPath() : process.env.PATH; + const shellEnv = typeof getManagedOpenCodeShellEnvSnapshot === 'function' + ? getManagedOpenCodeShellEnvSnapshot() || {} + : {}; try { const serverInstance = await createManagedOpenCodeServerProcess({ @@ -403,7 +442,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => { port: spawnPort, timeout: 30000, cwd: state.openCodeWorkingDirectory, + shellEnvKeysCount: Object.keys(shellEnv).length, env: { + ...shellEnv, ...process.env, PATH: envPath, OPENCODE_SERVER_PASSWORD: openCodePassword, @@ -444,6 +485,30 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } }; + const startOpenCode = async () => { + let lastError = null; + for (let attempt = 1; attempt <= START_OPEN_CODE_MAX_ATTEMPTS; attempt += 1) { + try { + return await startOpenCodeOnce(); + } catch (error) { + lastError = error; + if (attempt >= START_OPEN_CODE_MAX_ATTEMPTS) { + break; + } + + const message = error instanceof Error ? error.message : String(error); + console.warn(`[OpenCode] Managed server startup failed on attempt ${attempt}/${START_OPEN_CODE_MAX_ATTEMPTS}; retrying: ${message}`); + state.openCodePort = null; + state.isOpenCodeReady = false; + state.openCodeNotReadySince = Date.now(); + syncToHmrState(); + await delay(750 * attempt); + } + } + + throw lastError; + }; + const restartOpenCode = async () => { if (state.isShuttingDown) return; if (state.currentRestartPromise) { diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index 78864c60..913c046a 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -90,6 +90,11 @@ const createRuntime = (overrides = {}) => { clearResolvedOpenCodeBinary: vi.fn(), buildAugmentedPath: vi.fn(() => '/home/user/.bun/bin:/usr/local/bin:/usr/bin'), buildManagedOpenCodePath: vi.fn(() => '/home/user/.bun/bin:/usr/local/bin:/usr/bin'), + getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({ + PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin', + SHELL_ONLY: 'yes', + OPENCODE_SERVER_PASSWORD: 'shell-password', + })), ...overrides, }); }; @@ -112,6 +117,7 @@ describe('OpenCode lifecycle', () => { expect(binary).toBe('opencode'); expect(args).toEqual(['serve', '--hostname', '127.0.0.1', '--port', '45678']); expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin'); + expect(options.env.SHELL_ONLY).toBe('yes'); expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password'); await server.close(); @@ -163,4 +169,51 @@ describe('OpenCode lifecycle', () => { await server.close(); }); + + it('reports the exit signal when managed OpenCode exits before becoming ready', async () => { + delete process.env.OPENCODE_BINARY; + const firstChild = createMockChild(); + const secondChild = createMockChild(); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + firstChild.emit('exit', null, 'SIGTERM'); + }); + return firstChild; + }); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + secondChild.emit('exit', null, 'SIGTERM'); + }); + return secondChild; + }); + + const runtime = createRuntime(); + + await expect(runtime.startOpenCode()).rejects.toThrow('OpenCode exited with signal SIGTERM. No stdout/stderr captured'); + expect(spawnMock).toHaveBeenCalledTimes(2); + }); + + it('retries managed OpenCode startup once after a pre-ready exit', async () => { + delete process.env.OPENCODE_BINARY; + const firstChild = createMockChild(); + const secondChild = createMockChild(); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + firstChild.emit('exit', null, 'SIGTERM'); + }); + return firstChild; + }); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + secondChild.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'); + }); + return secondChild; + }); + + const runtime = createRuntime(); + const server = await runtime.startOpenCode(); + + expect(spawnMock).toHaveBeenCalledTimes(2); + await server.close(); + }); });