mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b0f605ba | ||
|
|
286f5fd5a2 | ||
|
|
07951737cf |
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 });
|
||||
});
|
||||
Reference in new issue
Block a user