fix: harden managed OpenCode startup
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user