mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Make ACP model detection timeout configurable (#2072)
Adds OD_ACP_TIMEOUT_MS for the ACP model-detection probe while preserving the default timeout, supporting the existing 0-disables-timeout convention, and capping oversized values before scheduling timers. Validation: CI and nix-check were green on PR head 8009c98d17.
This commit is contained in:
parent
098bbd55f4
commit
24fe033f85
2 changed files with 143 additions and 5 deletions
|
|
@ -4,6 +4,7 @@ import path from 'node:path';
|
|||
|
||||
const ACP_PROTOCOL_VERSION = 1;
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
||||
// Gap-between-chunks watchdog for an ACP session stage. The timer resets on
|
||||
// every line received from the agent, so this bounds *silent* periods, not
|
||||
// total runtime. Default kept in line with the outer chat-run inactivity
|
||||
|
|
@ -75,6 +76,12 @@ function errorMessage(err: unknown): string {
|
|||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function resolveAcpTimeoutMs(env: NodeJS.ProcessEnv, fallbackMs: number): number {
|
||||
const raw = Number(env.OD_ACP_TIMEOUT_MS);
|
||||
if (!Number.isFinite(raw)) return fallbackMs;
|
||||
return Math.min(MAX_TIMEOUT_MS, Math.max(0, Math.floor(raw)));
|
||||
}
|
||||
|
||||
function asObject(value: unknown): JsonObject | null {
|
||||
return value && typeof value === 'object' ? value as JsonObject : null;
|
||||
}
|
||||
|
|
@ -308,6 +315,7 @@ export async function detectAcpModels({
|
|||
clientVersion = 'runtime-adapter',
|
||||
defaultModelOption = { id: 'default', label: 'Default (CLI config)' },
|
||||
}: DetectAcpModelsOptions): Promise<ModelOption[]> {
|
||||
const effectiveTimeoutMs = resolveAcpTimeoutMs(env, timeoutMs);
|
||||
return await new Promise<ModelOption[]>((resolve, reject) => {
|
||||
const child = spawn(bin, args, {
|
||||
cwd,
|
||||
|
|
@ -322,11 +330,11 @@ export async function detectAcpModels({
|
|||
let expectedId = 1;
|
||||
let nextId = 2;
|
||||
|
||||
let timer: TimerHandle;
|
||||
let timer: TimerHandle | null = null;
|
||||
const finish = <T extends ModelOption[] | Error>(fn: (value: T) => void, value: T) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
try {
|
||||
child.stdin.end();
|
||||
} catch {}
|
||||
|
|
@ -394,9 +402,11 @@ export async function detectAcpModels({
|
|||
}
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
fail(`ACP model detection timed out after ${timeoutMs}ms`);
|
||||
}, timeoutMs);
|
||||
if (effectiveTimeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
fail(`ACP model detection timed out after ${effectiveTimeoutMs}ms`);
|
||||
}, effectiveTimeoutMs);
|
||||
}
|
||||
|
||||
writeRpc(1, 'initialize', {
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
|
|
|
|||
128
apps/daemon/tests/acp-timeout-env.test.ts
Normal file
128
apps/daemon/tests/acp-timeout-env.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { test, vi } from 'vitest';
|
||||
import { detectAcpModels } from '../src/acp.js';
|
||||
|
||||
function writeStallingProbe(): { dir: string; bin: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-acp-timeout-'));
|
||||
const bin = join(dir, 'stall-acp-probe.mjs');
|
||||
writeFileSync(
|
||||
bin,
|
||||
'process.stdin.resume();\nsetTimeout(() => {}, 60_000);\n',
|
||||
'utf8',
|
||||
);
|
||||
chmodSync(bin, 0o755);
|
||||
return { dir, bin };
|
||||
}
|
||||
|
||||
function writeDelayedSuccessProbe(delayMs: number): { dir: string; bin: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-acp-timeout-'));
|
||||
const bin = join(dir, 'delayed-acp-probe.mjs');
|
||||
writeFileSync(
|
||||
bin,
|
||||
[
|
||||
'process.stdin.setEncoding("utf8");',
|
||||
'let buffer = "";',
|
||||
'process.stdin.on("data", (chunk) => {',
|
||||
' buffer += chunk;',
|
||||
' for (;;) {',
|
||||
' const idx = buffer.indexOf("\\n");',
|
||||
' if (idx === -1) break;',
|
||||
' const line = buffer.slice(0, idx).trim();',
|
||||
' buffer = buffer.slice(idx + 1);',
|
||||
' if (!line) continue;',
|
||||
' const message = JSON.parse(line);',
|
||||
' setTimeout(() => {',
|
||||
' process.stdout.write(JSON.stringify({ id: message.id, result: message.method === "session/new" ? { sessionId: "s1" } : {} }) + "\\n");',
|
||||
` }, ${delayMs});`,
|
||||
' }',
|
||||
'});',
|
||||
'process.stdin.resume();',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
chmodSync(bin, 0o755);
|
||||
return { dir, bin };
|
||||
}
|
||||
|
||||
test('detectAcpModels uses OD_ACP_TIMEOUT_MS from the ACP probe environment', async () => {
|
||||
const { dir, bin } = writeStallingProbe();
|
||||
try {
|
||||
const started = Date.now();
|
||||
await assert.rejects(
|
||||
detectAcpModels({
|
||||
bin: process.execPath,
|
||||
args: [bin],
|
||||
env: { OD_ACP_TIMEOUT_MS: '123' },
|
||||
timeoutMs: 15_000,
|
||||
}),
|
||||
/ACP model detection timed out after 123ms/,
|
||||
);
|
||||
assert.ok(
|
||||
Date.now() - started < 5_000,
|
||||
'expected OD_ACP_TIMEOUT_MS to bound the probe instead of waiting for the 15s caller timeout',
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detectAcpModels ignores invalid OD_ACP_TIMEOUT_MS values', async () => {
|
||||
const { dir, bin } = writeStallingProbe();
|
||||
try {
|
||||
await assert.rejects(
|
||||
detectAcpModels({
|
||||
bin: process.execPath,
|
||||
args: [bin],
|
||||
env: { OD_ACP_TIMEOUT_MS: 'not-a-number' },
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
/ACP model detection timed out after 50ms/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detectAcpModels caps oversized OD_ACP_TIMEOUT_MS values before scheduling timers', async () => {
|
||||
vi.useFakeTimers();
|
||||
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
try {
|
||||
const probe = detectAcpModels({
|
||||
bin: process.execPath,
|
||||
args: ['-e', 'process.stdin.resume()'],
|
||||
env: { OD_ACP_TIMEOUT_MS: '10000000000' },
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
probe.catch(() => {});
|
||||
|
||||
const scheduledDelay = timeoutSpy.mock.calls
|
||||
.map((call) => call[1])
|
||||
.find((delay) => delay === 24 * 60 * 60 * 1000);
|
||||
|
||||
assert.equal(scheduledDelay, 24 * 60 * 60 * 1000);
|
||||
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
|
||||
await assert.rejects(probe, /ACP model detection timed out after 86400000ms/);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('detectAcpModels treats OD_ACP_TIMEOUT_MS=0 as disabling the ACP probe timeout', async () => {
|
||||
const { dir, bin } = writeDelayedSuccessProbe(120);
|
||||
try {
|
||||
const models = await detectAcpModels({
|
||||
bin: process.execPath,
|
||||
args: [bin],
|
||||
env: { OD_ACP_TIMEOUT_MS: '0' },
|
||||
timeoutMs: 50,
|
||||
});
|
||||
|
||||
assert.deepEqual(models, [{ id: 'default', label: 'Default (CLI config)' }]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue