Files
OmniRoute/tests/unit/combo-silent-stop-gaps.test.ts
Hernan Javier Ardila Sanchez d098114fe5 fix(combo): guarantee combo loops terminate with an actionable error instead of hanging silently (#10463)
Guarantees combo/target loops always terminate with an actionable error instead of hanging when an upstream hangs — fixes 10 silent-stop gaps (G1–G10): combo-loop safety timer + abort on hang → 504, unexpected task throw → 502, round-robin safety net, chaos all-panel-failure visibility, ReDoS-safe eval regex, autoRefreshDaemon swallowed-error logging, batch-item wall-clock timeout.

Validated in an isolated worktree boarded onto origin/release/v3.8.50 (2 real conflicts in combo.ts and chaosEngine.ts, both additive features from concurrently-merged PRs landing at the same insertion point — resolved by combining both, verified no variable-shadowing/scoping issues):
- 10/10 new regression tests pass (combo-silent-stop-gaps.test.ts): hung upstream → 504, unexpected throw → 502, chaos all-fail logged, ReDoS regex rejected, batch timeout.
- 163/163 broader focused tests pass (combo-routing-engine, combo-target-timeout-runner, pipeline-router, chaos-executor, batch-processor ×2, service-batch-processor, evalrunner-builtinsuites-split).
- check-file-size, check-changelog-integrity: OK.
- typecheck:core: clean.
- check-complexity / check-cognitive-complexity: OK, both under baseline.

Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
2026-08-20 23:07:25 -03:00

306 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Isolate DATA_DIR before any DB-touching import (combo.ts pulls in the
// SQLite layer) — mirrors tests/unit/combo-routing-engine.test.ts.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-silent-stop-gaps-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
process.env.DATA_DIR = TEST_DATA_DIR;
// ---------------------------------------------------------------------------
// Silent-stop gap fixes — regression tests
// ---------------------------------------------------------------------------
// These lock the G1G10 fixes for loops that could terminate silently:
// G1 combo.ts globalPromise safety timer (hung target → 504, never hang)
// G2 combo.ts task wrapper catch resolves globalPromise (unexpected throw → 502)
// G3 targetTimeoutRunner warns when per-model timeout is disabled
// G4 round-robin loop safety timer (hung model → 504)
// G5 chaosEngine logs all-panel failures with per-model errors
// G7 evalRunner rejects catastrophic regex (ReDoS) via safe-regex
// G8 autoRefreshDaemon logs network errors per provider
// G10 batchProcessor item dispatch timeout (hung item fails fast)
// ---------------------------------------------------------------------------
const { buildTargetTimeoutRunner } =
await import("../../open-sse/services/combo/targetTimeoutRunner.ts");
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const { handleChaosChat } = await import("../../open-sse/services/autoCombo/chaosEngine.ts");
const { evaluateCase } = await import("../../src/lib/evals/evalRunner.ts");
const { withItemDispatchTimeout } = await import("../../open-sse/services/batchProcessor.ts");
const { saveModelsDevCapabilities } = await import("../../src/lib/modelsDevSync.ts");
function capabilityEntry(limitContext: unknown, overrides: Record<string, unknown> = {}) {
return {
tool_call: true,
reasoning: false,
attachment: false,
structured_output: true,
temperature: true,
modalities_input: JSON.stringify(["text"]),
modalities_output: JSON.stringify(["text"]),
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: null,
open_weights: false,
limit_context: limitContext,
limit_input: limitContext,
limit_output: 4096,
interleaved_field: null,
...overrides,
};
}
function createLog() {
const entries: Array<{ level: string; tag: string; msg: string }> = [];
return {
info: (tag: string, msg: string) => entries.push({ level: "info", tag, msg }),
warn: (tag: string, msg: string) => entries.push({ level: "warn", tag, msg }),
error: (tag: string, msg: string) => entries.push({ level: "error", tag, msg }),
debug: (tag: string, msg: string) => entries.push({ level: "debug", tag, msg }),
entries,
};
}
function okResponse(body: Record<string, unknown> = { choices: [{ message: { content: "ok" } }] }) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}
function errorResponse(status: number, message: string = `Error ${status}`) {
return new Response(JSON.stringify({ error: { message } }), {
status,
headers: { "content-type": "application/json" },
});
}
// ── G1: combo loop safety timer ─────────────────────────────────────────────
// A hung target (per-model timeout disabled / upstream never settles) must NOT
// freeze the request forever: the safety timer force-resolves with 504.
test.after(async () => {
if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
/* best effort */
}
});
test("G1: hung target with per-model timeout disabled → 504, not a silent hang", async () => {
const log = createLog();
const combo = {
name: "g1-hang",
models: ["openai/gpt-4o-mini"],
config: {
// Per-model timeout off + tiny global budget so the test runs in ms.
targetTimeoutMs: 0,
comboTimeoutMs: 100,
},
};
saveModelsDevCapabilities({ openai: { "gpt-4o-mini": capabilityEntry(128000) } });
const startedAt = Date.now();
const result = await handleComboChat({
body: {},
combo,
// Never settles — simulates an upstream that accepts the connection and
// then stalls forever.
handleSingleModel: () => new Promise<Response>(() => {}),
isModelAvailable: async () => true,
log,
settings: null,
allCombos: null,
});
const elapsed = Date.now() - startedAt;
assert.equal(result.status, 504);
assert.ok(elapsed < 10_000, `safety timeout took ${elapsed}ms — too slow`);
assert.ok(
log.entries.some((e) => e.level === "warn" && /safety timeout/i.test(String(e.msg))),
"expected a warn about the combo loop safety timeout"
);
});
// ── G2: task wrapper catch resolves globalPromise ───────────────────────────
// An unexpected throw inside executeTarget (e.g. isModelAvailable exploding)
// must surface as 502, not leave the request hanging.
test("G2: unexpected throw in a target surfaces 502 instead of hanging", async () => {
const log = createLog();
const combo = {
name: "g2-throw",
models: ["openai/gpt-4o-mini"],
};
const result = await handleComboChat({
body: {},
combo,
handleSingleModel: async () => okResponse(),
// Throw inside executeTarget before dispatch — the wrapper catch must
// resolve globalPromise so the loop terminates.
isModelAvailable: async () => {
throw new Error("unexpected availability explosion");
},
log,
settings: null,
allCombos: null,
});
assert.equal(result.status, 502);
const body = await result.text();
assert.match(body, /unexpected error/i);
assert.ok(
log.entries.some((e) => e.level === "error" && /Speculative task error/i.test(String(e.msg))),
"expected the speculative task error to be logged"
);
});
// ── G3: targetTimeoutRunner warns when per-model timeout is disabled ────────
test("G3: targetTimeoutRunner warns when comboTargetTimeoutMs <= 0", async () => {
const log = createLog();
const runner = buildTargetTimeoutRunner({
handleSingleModel: async () => new Response("ok"),
comboTargetTimeoutMs: 0,
log,
});
const res = await runner({}, "m");
assert.equal(await res.text(), "ok");
assert.ok(
log.entries.some((e) => e.level === "warn" && /DISABLED|disabled/i.test(String(e.msg))),
"expected a warn about the disabled per-model timeout"
);
});
// ── G4: round-robin loop safety timer ───────────────────────────────────────
test("G4: round-robin hung model → 504 via loop safety timer", async () => {
const log = createLog();
const combo = {
name: "g4-rr-hang",
models: ["openai/gpt-4o-mini", "claude/sonnet"],
strategy: "round-robin",
config: {
targetTimeoutMs: 0,
comboTimeoutMs: 100,
},
};
saveModelsDevCapabilities({
openai: { "gpt-4o-mini": capabilityEntry(128000) },
claude: { sonnet: capabilityEntry(200000) },
});
const startedAt = Date.now();
const result = await handleComboChat({
body: {},
combo,
handleSingleModel: () => new Promise<Response>(() => {}),
isModelAvailable: async () => true,
log,
settings: null,
allCombos: null,
});
const elapsed = Date.now() - startedAt;
assert.equal(result.status, 504);
assert.ok(elapsed < 10_000, `RR safety timeout took ${elapsed}ms — too slow`);
assert.ok(
log.entries.some((e) => e.level === "warn" && /Round-robin/i.test(String(e.msg))),
"expected a warn about the round-robin safety timeout"
);
});
// ── G5: chaosEngine logs all-panel failures ─────────────────────────────────
test("G5: chaos all-panel failure is logged with per-model errors", async () => {
const log = createLog();
const res = await handleChaosChat({
body: {},
models: ["openai/gpt-4o-mini", "claude/sonnet"],
handleSingleModel: async () => errorResponse(503, "upstream down"),
log,
comboName: "g5-chaos",
});
assert.equal(res.status, 200); // SSE envelope stays well-formed
const body = await res.text();
assert.match(body, /All chaos panel models failed/);
assert.match(body, /upstream down/);
assert.ok(
log.entries.some(
(e) => e.level === "warn" && /All chaos panel models failed/i.test(String(e.msg))
),
"expected the all-failed warn with model errors"
);
});
// ── G7: evalRunner rejects catastrophic regex (ReDoS guard) ─────────────────
test("G7: catastrophic regex is rejected instead of hanging the eval loop", () => {
const startedAt = Date.now();
const result = evaluateCase(
{ id: "redos", name: "redos", expected: { strategy: "regex", value: "(a+)+$" } },
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
);
const elapsed = Date.now() - startedAt;
assert.equal(result.passed, false);
assert.match(String(result.details?.error ?? ""), /unsafe|backtracking/i);
assert.ok(elapsed < 1000, `regex eval took ${elapsed}ms — ReDoS guard failed`);
});
test("G7: benign regex still evaluates normally", () => {
const result = evaluateCase(
{ id: "ok-regex", name: "ok-regex", expected: { strategy: "regex", value: "^hello" } },
"hello world"
);
assert.equal(result.passed, true);
});
// ── G8: autoRefreshDaemon logs network errors per provider ──────────────────
test("G8: autoRefreshDaemon logs network errors instead of swallowing them", async () => {
const { autoRefreshDaemon } = await import("../../open-sse/services/autoRefreshDaemon.ts");
const originalFetch = globalThis.fetch;
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" "));
// Simulate a provider whose validation request blows up (network error).
globalThis.fetch = (async () => {
throw new Error("ECONNRESET");
}) as typeof fetch;
try {
autoRefreshDaemon.registerCredential("claude-web", "cookie-value");
await autoRefreshDaemon.check();
assert.ok(
warnings.some((w) => w.includes("claude-web") && w.includes("ECONNRESET")),
"expected a warn naming the provider and the network error, got: " + warnings.join(" | ")
);
} finally {
globalThis.fetch = originalFetch;
console.warn = originalWarn;
autoRefreshDaemon.unregisterCredential("claude-web");
}
});
// ── G10: batch item dispatch timeout ────────────────────────────────────────
test("G10: hung batch item dispatch fails fast via wall-clock timeout", async () => {
const startedAt = Date.now();
await assert.rejects(
withItemDispatchTimeout(
new Promise<Response>(() => {}), // never settles
50,
"Batch item dispatch (/v1/chat/completions)"
),
/timed out after 50ms/
);
const elapsed = Date.now() - startedAt;
assert.ok(elapsed < 5000, `timeout fired after ${elapsed}ms — too slow`);
});
test("G10: fast dispatch wins the race untouched", async () => {
const res = await withItemDispatchTimeout(
Promise.resolve(new Response("ok", { status: 200 })),
1000,
"Batch item dispatch"
);
assert.equal(res.status, 200);
assert.equal(await res.text(), "ok");
});