fix: harden managed OpenCode startup
This commit is contained in:
@@ -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