Compare commits

...
5 changed files with 212 additions and 6 deletions

No files matched your search

@@ -13,6 +13,12 @@ const {
const OPENCODE_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const DEFAULT_OPENCODE_PORT = 4096;
// The bundled @opencode-ai/sdk defaults `createOpencodeServer` to a 5000ms
// startup deadline, which is too tight for opencode server cold starts on
// Windows (antivirus scans / first-run autoupdate can push it past 8s, and
// community reports show 30s still missed there). Always pass an explicit,
// generous timeout so the SDK default never applies (#3579).
const OPENCODE_SERVER_START_TIMEOUT_MS = process.platform === "win32" ? 60000 : 30000;
function resolveUsableOpenCodeBinPath(binPath, env) {
const candidates = [];
@@ -461,8 +467,10 @@ function getAvailablePort(host = "127.0.0.1") {
}
async function withOpenCodeServerPort(options = {}) {
if (options.port != null) return options;
return { ...options, port: await getAvailablePort(options.hostname || "127.0.0.1") };
// Never let the SDK's hardcoded 5000ms startup deadline apply (#3579).
const timeout = options.timeout ?? OPENCODE_SERVER_START_TIMEOUT_MS;
if (options.port != null) return { ...options, timeout };
return { ...options, timeout, port: await getAvailablePort(options.hostname || "127.0.0.1") };
}
function closeOpenCodeInstance(opencode) {
@@ -491,7 +499,13 @@ async function createDefaultOpenCode(options, env, binPath) {
};
try {
const opencode = await withExclusiveProcessEnv(nextEnv, () => sdk.createOpencode(options));
// The SDK spawns synchronously before waiting for server readiness. Only
// that spawn needs the temporary environment; holding the gate through
// cold startup serializes unrelated profiles and exhausts their deadlines.
const { startup } = await withExclusiveProcessEnv(nextEnv, () => ({
startup: sdk.createOpencode(options),
}));
const opencode = await startup;
const originalClose = opencode.server?.close?.bind(opencode.server);
if (typeof originalClose === "function") {
opencode.server.close = () => {
@@ -837,7 +851,8 @@ async function acquireOpenCodeListServer({ env, binPath, openCodeFactory, signal
entry.ready = (async () => {
const options = await withOpenCodeServerPort({
config: { autoupdate: false },
timeout: 10000,
// Startup deadline comes from withOpenCodeServerPort; the SDK's 5000ms
// default is too tight for Windows cold starts (#3579).
signal: createAbort.signal,
});
const opencode = await factory(options);
@@ -923,6 +938,7 @@ module.exports = {
getOpenCodeDefaultModelId,
getOpenCodeSessionIdFromEvent,
withOpenCodeProcessEnv,
withOpenCodeServerPort,
listOpenCodeModels,
mapOpenCodeModels,
parseOpenCodeModel,
@@ -932,4 +948,5 @@ module.exports = {
toOpenCodeMcpConfig,
translateOpenCodeEvent,
OPENCODE_LIST_SERVER_IDLE_MS,
OPENCODE_SERVER_START_TIMEOUT_MS,
};
@@ -0,0 +1,83 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { getTempFilePath } = require('../../tempDirBridge.cjs');
const { listOpenCodeModels, resetOpenCodeListServerPool } = require('./opencodeDriver.cjs');
test('OpenCode releases the process-env gate after spawning separate catalog profiles', {
skip: process.platform === 'win32' && 'fixture executable uses a POSIX shebang',
timeout: 15000,
}, async (t) => {
try { await import('@opencode-ai/sdk'); } catch (error) {
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
t.skip('optional OpenCode SDK is not installed');
return;
}
const root = fs.mkdtempSync(`${getTempFilePath('opencode-startup-test')}-`);
const binPath = path.join(root, 'opencode');
const startedPath = path.join(root, 'started');
const readyPath = path.join(root, 'ready');
const originalHome = process.env.HOME;
fs.writeFileSync(binPath, `#!${process.execPath}
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const root = ${JSON.stringify(root)};
const profile = path.basename(process.env.HOME);
fs.appendFileSync(path.join(root, 'started'), profile + '\\n');
if (profile === 'profile-failed') process.exit(7);
const port = Number(process.argv.find(arg => arg.startsWith('--port=')).split('=')[1]);
const server = http.createServer((_req, res) => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ providers: [], default: { fixture: profile } }));
});
const poll = setInterval(() => {
if (!fs.existsSync(path.join(root, 'ready'))) return;
clearInterval(poll);
server.listen(port, '127.0.0.1', () => console.log('opencode server listening on http://127.0.0.1:' + port));
}, 10);
`, { mode: 0o755 });
const loads = ['profile-a', 'profile-b'].map(profile => listOpenCodeModels({
binPath,
env: { PATH: process.env.PATH, HOME: path.join(root, profile) },
}));
// Observe every rejection immediately, including failures during cleanup.
const settled = Promise.allSettled(loads);
try {
const deadline = Date.now() + 5000;
let profiles = [];
while (Date.now() < deadline) {
profiles = fs.existsSync(startedPath) ? fs.readFileSync(startedPath, 'utf8').trim().split('\n') : [];
if (profiles.length === 2) break;
await new Promise(resolve => setTimeout(resolve, 20));
}
assert.deepEqual(profiles.sort(), ['profile-a', 'profile-b'], 'both profiles must spawn without waiting for either server to become ready');
assert.equal(process.env.HOME, originalHome, 'the parent environment must be restored while servers start');
fs.writeFileSync(readyPath, 'ready');
const results = await settled;
assert.deepEqual(results.map(result => result.status), ['fulfilled', 'fulfilled']);
assert.deepEqual(results.map(result => result.value.currentModelId), ['fixture/profile-a', 'fixture/profile-b']);
// A rejected startup must restore the environment and release the gate
// as well, so a subsequent profile still starts with its own HOME.
resetOpenCodeListServerPool();
const failed = await listOpenCodeModels({
binPath, env: { PATH: process.env.PATH, HOME: path.join(root, 'profile-failed') },
}).catch(error => {
assert.match(error.message, /Server exited with code 7/);
return { models: [] };
});
assert.equal(failed.models.length, 0);
assert.equal(process.env.HOME, originalHome);
const retry = await listOpenCodeModels({
binPath, env: { PATH: process.env.PATH, HOME: path.join(root, 'profile-retry') },
});
assert.equal(retry.currentModelId, 'fixture/profile-retry');
assert.equal(process.env.HOME, originalHome);
} finally {
fs.writeFileSync(readyPath, 'ready');
await settled;
resetOpenCodeListServerPool();
fs.rmSync(root, { recursive: true, force: true });
}
});
@@ -11,12 +11,14 @@ const {
listOpenCodeModels,
mapOpenCodeModels,
OPENCODE_LIST_SERVER_IDLE_MS,
OPENCODE_SERVER_START_TIMEOUT_MS,
parseOpenCodeModel,
resetOpenCodeListServerPool,
resolveUsableOpenCodeBinPath,
runOpenCodeTurn,
translateOpenCodeEvent,
withOpenCodeProcessEnv,
withOpenCodeServerPort,
} = require("./opencodeDriver.cjs");
function collector() {
@@ -768,6 +770,52 @@ test("runOpenCodeTurn passes an explicit non-default port to the OpenCode SDK fa
assert.notEqual(capturedPort, 4096);
});
test("runOpenCodeTurn passes an explicit startup timeout to the OpenCode SDK factory (#3579)", async () => {
const { emitter } = collector();
const abortController = new AbortController();
let capturedTimeout;
const stream = {
async *[Symbol.asyncIterator]() {
yield { type: "message.part.updated", properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "ok" } } };
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ data: true }),
},
};
await runOpenCodeTurn({
prompt: "hello",
emitter,
abortController,
openCodeFactory: async (options) => {
capturedTimeout = options.timeout;
return { client, server: { close() {} } };
},
});
assert.equal(capturedTimeout, OPENCODE_SERVER_START_TIMEOUT_MS);
assert.ok(capturedTimeout > 5000, "startup deadline must exceed the SDK's 5000ms default");
});
test("withOpenCodeServerPort injects a generous startup timeout and preserves explicit values", async () => {
const defaulted = await withOpenCodeServerPort({});
assert.equal(defaulted.timeout, OPENCODE_SERVER_START_TIMEOUT_MS);
assert.ok(defaulted.timeout > 5000, "startup deadline must exceed the SDK's 5000ms default");
assert.equal(typeof defaulted.port, "number");
const withPort = await withOpenCodeServerPort({ port: 5555 });
assert.equal(withPort.port, 5555);
assert.equal(withPort.timeout, OPENCODE_SERVER_START_TIMEOUT_MS);
const withExplicitTimeout = await withOpenCodeServerPort({ port: 5555, timeout: 120000 });
assert.equal(withExplicitTimeout.timeout, 120000);
});
test("runOpenCodeTurn waits for the OpenCode event stream before prompting", async () => {
const { emitter } = collector();
const abortController = new AbortController();
@@ -13,6 +13,7 @@ const { CodexAppServerRuntime } = require("../codexAppServer/runtime.cjs");
const { probeCodexAppServer } = require("../codexAppServer/probe.cjs");
const { codebuddySessionManager } = require("./codebuddySessionManager.cjs");
const codebuddyDriver = require("./codebuddyDriver.cjs");
const { OPENCODE_SERVER_START_TIMEOUT_MS } = require("./opencodeDriver.cjs");
const VALID_BACKENDS = new Set(listBackends());
@@ -982,7 +983,11 @@ function registerSdkStreamHandlers(ctx) {
cursorCliBinPath: backendKey === "cursor" ? cursorCliBinPath : undefined,
codexAppServerRuntime,
}),
MODEL_LIST_TIMEOUT_MS,
// Include OpenCode cold startup in this single cancellable
// catalog budget, then allow the normal provider-query budget.
backendKey === "opencode"
? OPENCODE_SERVER_START_TIMEOUT_MS + MODEL_LIST_TIMEOUT_MS
: MODEL_LIST_TIMEOUT_MS,
abortController,
);
const { currentModelId, models } = normalizeSdkListModelsResult(raw);
@@ -25,7 +25,7 @@ const {
* (cleanup) can be invoked directly. registerSdkStreamHandlers exposes its
* request-scoped maps on ctx for exactly this kind of test.
*/
function registerWithStubbedCtx() {
function registerWithStubbedCtx(overrides = {}) {
const handlers = new Map();
const ctx = {
ipcMain: { handle: (channel, fn) => handlers.set(channel, fn) },
@@ -37,6 +37,7 @@ function registerWithStubbedCtx() {
cancelWorkerBackgroundJobsForSession: () => {},
cleanupScopedMetadata: async () => {},
},
...overrides,
};
registerSdkStreamHandlers(ctx);
return { handlers, ctx };
@@ -1014,3 +1015,55 @@ test("resolveSdkBackendBinPath does not fall back to Windows shell shims for non
});
assert.equal(out, undefined);
});
test("OpenCode catalog IPC allows cold startup past 10s and still aborts a stalled load", async (t) => {
const { getDriver } = require("./index.cjs");
const { OPENCODE_SERVER_START_TIMEOUT_MS } = require("./opencodeDriver.cjs");
const { handlers } = registerWithStubbedCtx({
getShellEnv: async () => ({}),
normalizeAgentEnv: (env) => env || {},
withCliDiscoveryEnv: (env) => env,
normalizeClaudeCodeExecutableEnvForSdk: (env) => env,
resolveCliFromPath: () => undefined,
normalizeCliPathForPlatform: (value) => value,
resolveSdkBinPath: () => undefined,
resolveClaudeCodeExecutableForSdk: undefined,
resolveCodexExecutableForSdk: undefined,
resolveCodebuddyExecutableForSdk: undefined,
});
const listModels = handlers.get("netcatty:ai:sdk-agent:list-models");
const catalog = { models: [{ id: "test/cold-start" }], currentModelId: "test/cold-start" };
let signal;
let complete;
let started;
let entered = new Promise((resolve) => { started = resolve; });
t.mock.method(getDriver("opencode"), "listModels", ({ abortController }) => {
signal = abortController.signal;
started();
return new Promise((resolve) => { complete = resolve; });
});
t.mock.timers.enable({ apis: ["setTimeout"] });
const slow = listModels({ sender: {} }, { sdkBackend: "opencode", agentCommand: "/test/cold-start" });
await entered;
t.mock.timers.tick(10001);
assert.equal(signal.aborted, false, "the old outer 10s deadline must not cancel OpenCode startup");
complete(catalog);
assert.deepEqual(await slow, { ok: true, ...catalog });
entered = new Promise((resolve) => { started = resolve; });
const stalled = listModels({ sender: {} }, { sdkBackend: "opencode", agentCommand: "/test/stalled-start" });
await entered;
t.mock.timers.tick(OPENCODE_SERVER_START_TIMEOUT_MS + 10000);
const result = await stalled;
assert.equal(signal.aborted, true, "the bounded catalog deadline must abort the driver");
assert.match(result.warning, /list-models timed out/);
assert.deepEqual(result.models, []);
// A timeout must leave neither an in-flight entry nor a cached failure.
entered = new Promise((resolve) => { started = resolve; });
const retried = listModels({ sender: {} }, { sdkBackend: "opencode", agentCommand: "/test/stalled-start" });
await entered;
complete(catalog);
assert.deepEqual(await retried, { ok: true, ...catalog });
});