fix: harden managed OpenCode startup

This commit is contained in:
Bohdan Triapitsyn
2026-04-28 12:35:00 +03:00
parent ccab4f49df
commit 72e706972a
5 changed files with 201 additions and 19 deletions
+47 -8
View File
@@ -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<string, unknown> =>
!!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<string> => {
const now = new Date();
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
@@ -260,6 +284,12 @@ export const buildOpenCodeStatusReport = async (): Promise<string> => {
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<string> => {
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<string> => {
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}`);
}
+4
View File
@@ -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,
@@ -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;
+73 -8
View File
@@ -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) {
@@ -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();
});
});