Compare commits

..

1 Commits

Author SHA1 Message Date
Markus Hartung
214aec3f97 fix(executors): report WS readyState in Meta AI timeout error (#10727)
The muse-spark-web wsChat timeout previously reported a flat 'Meta AI WebSocket timed out' with no way to tell whether the socket never opened or opened and then went silent. Report readyState at the moment the 30s timeout fires so the next occurrence of #10727 is diagnosable from logs alone.

This does not fix the underlying protocol drift (Meta's reverse-engineered private WS gateway silently dropping frames) -- that requires a live meta.ai session + fresh DevTools capture per the triage plan-file's needs-vps verdict, which is not achievable in this sandbox.
2026-08-20 20:34:47 -03:00
6 changed files with 168 additions and 62 deletions

View File

@@ -0,0 +1 @@
- **fix(executors):** the Meta AI (muse-spark-web) WebSocket send-message timeout now reports the socket's `readyState` at the moment it fires, so a "Meta AI WS timed out" failure can be told apart as either the connection never opening (`readyState=0`) or opening successfully and then going silent (`readyState=1`) — the exact ambiguity that made #10727 undiagnosable from logs alone (#10727).

View File

@@ -1 +0,0 @@
- **fix(open-sse):** declare `supportedThinkingEfforts` (`low`/`medium`/`high`/`max`) on Ollama Cloud's `glm-5.1`, `glm-5.2`, `deepseek-v4-pro` and `deepseek-v4-flash` registry entries so the catalog's `appendSyncedEffortVariants()` pass — which only synthesizes selectable `-low`/`-high`/`-max` model ids from an already-populated `capabilities.effort_tiers` — can expose an effort selector for these reasoning-capable models, matching what `gpt-oss:20b`/`gpt-oss:120b` already had (#10788)

View File

@@ -24,24 +24,8 @@ export const ollama_cloudProvider: RegistryEntry = {
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high"],
},
// #10788: Ollama Cloud accepts low|medium|high|max|none uniformly across
// its reasoning-capable models (see supportsMaxEffortForProvider's
// isOllamaCloud comment in open-sse/executors/base/reasoningEffort.ts) —
// declare supportedThinkingEfforts so appendSyncedEffortVariants() (which
// runs before static-model capability enrichment) can synthesize the
// catalog's selectable -low/-high/-max variant ids for these models.
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
},
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
{ id: "kimi-k2.6", name: "Kimi K2.6" },
// Ollama Cloud accepts low|medium|high|max|none and rejects xhigh, so the
// explicit supportsXHighEffort:false makes the sanitizer map xhigh → max.
@@ -50,14 +34,12 @@ export const ollama_cloudProvider: RegistryEntry = {
name: "GLM 5.1",
supportsReasoning: true,
supportsXHighEffort: false,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
},
{
id: "glm-5.2",
name: "GLM 5.2",
supportsReasoning: true,
supportsXHighEffort: false,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
},
// #3110: MiniMax M3 via Ollama
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },

View File

@@ -1070,7 +1070,7 @@ async function wsChat(
const fail = (error: string) => finish({ content: "", deltas: [], error });
timeout = setTimeout(() => fail("Meta AI WebSocket timed out"), 30000);
timeout = setTimeout(() => fail(`Meta AI WS timed out (readyState=${ws.readyState})`), 30000);
abortHandler = () => fail("Request aborted");
signal?.addEventListener("abort", abortHandler, { once: true });

View File

@@ -0,0 +1,164 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
MuseSparkWebExecutor,
__resetMuseSparkConversationCacheForTesting,
__setMuseSparkWebSocketForTesting,
} from "../../open-sse/executors/muse-spark-web.ts";
import { WebSocket } from "ws";
// #10727: Meta AI (muse-spark-web) times out on the WS send-message step at
// exactly the executor's hardcoded 30s timeout, with no onerror/onclose
// firing first. The reporter's log shows the flow reaching wsChat and
// hanging until the timeout fires, meaning Meta's gateway either never
// truly opens the socket or silently drops frames after opening — but the
// old flat "Meta AI WebSocket timed out" message could not distinguish
// those two failure modes for whoever debugs the next occurrence.
//
// Root-causing (and fixing) the reverse-engineered private WS protocol
// itself requires a live meta.ai session + a fresh DevTools capture (see
// the plan-file's `needs-vps` verdict) — not achievable in this sandbox.
// This regression test locks in the diagnosability improvement that *is*
// verifiable here: the timeout error now reports the socket's readyState
// at the moment it fires, so a future report can tell "never opened"
// (readyState 0) apart from "opened but Meta went silent" (readyState 1).
/**
* Intercepts only the wsChat 30000ms timeout registration and lets every
* other setTimeout (including the mock WebSocket's own onopen scheduling)
* run for real. Firing the captured callback directly — instead of
* advancing a fake clock — keeps the test fast and avoids interleaving
* bugs between fake timers and the executor's real async/await chain.
*/
function interceptWsTimeout(): { fire: () => void; restore: () => void } {
const original = globalThis.setTimeout;
let captured: (() => void) | null = null;
globalThis.setTimeout = ((cb: (...a: unknown[]) => void, ms?: number, ...args: unknown[]) => {
if (ms === 30000 && captured === null) {
captured = cb as () => void;
return 0 as unknown as ReturnType<typeof setTimeout>;
}
return original(cb as () => void, ms, ...args);
}) as typeof setTimeout;
return {
fire: () => {
assert.ok(captured, "the 30000ms wsChat timeout was never registered");
captured?.();
},
restore: () => {
globalThis.setTimeout = original;
},
};
}
class NeverOpensWebSocket {
onopen: (() => void) | null = null;
onmessage: ((evt: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: ((evt: Error) => void) | null = null;
readyState = WebSocket.CONNECTING;
url: string;
constructor(url: string) {
this.url = url;
// Never calls onopen, onmessage, onerror, or onclose — mirrors the
// reported symptom exactly: the socket just hangs until the timeout.
}
send(_data: Uint8Array | string) {}
close() {}
}
class OpensThenSilentWebSocket {
onopen: (() => void) | null = null;
onmessage: ((evt: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: ((evt: Error) => void) | null = null;
readyState = WebSocket.CONNECTING;
url: string;
constructor(url: string) {
this.url = url;
setTimeout(() => {
this.readyState = WebSocket.OPEN;
this.onopen?.();
}, 0);
}
send(_data: Uint8Array | string) {}
close() {}
}
function baseInput(connectionId: string): Parameters<MuseSparkWebExecutor["execute"]>[0] {
return {
model: "muse-spark",
body: { messages: [{ role: "user", content: "ping" }] },
stream: false,
credentials: {
apiKey: "ecto_1_sess=test123",
connectionId,
providerSpecificData: { authorization: "ecto1:test-auth-token" },
},
signal: null,
log: null,
upstreamExtraHeaders: undefined,
} as Parameters<MuseSparkWebExecutor["execute"]>[0];
}
test("#10727: WS timeout while still CONNECTING reports readyState=0 (never opened)", async () => {
__resetMuseSparkConversationCacheForTesting();
const executor = new MuseSparkWebExecutor();
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response("{}", { status: 200 });
const restore = __setMuseSparkWebSocketForTesting(
NeverOpensWebSocket as unknown as typeof WebSocket
);
const timeoutHook = interceptWsTimeout();
try {
const resultPromise = executor.execute(baseInput("conn-10727-never-opens"));
// Let the GraphQL warmup/mode-switch awaits and the WS constructor run
// before the 30s timeout is registered.
await new Promise((r) => setTimeout(r, 20));
timeoutHook.fire();
const result = await resultPromise;
assert.equal(result.response.status, 502);
const body = await result.response.json();
assert.match(
body.error.message,
/readyState=0/,
"timeout while the socket never left CONNECTING must report readyState=0"
);
} finally {
globalThis.fetch = originalFetch;
restore();
timeoutHook.restore();
}
});
test("#10727: WS timeout after a successful open reports readyState=1 (opened, then silent)", async () => {
__resetMuseSparkConversationCacheForTesting();
const executor = new MuseSparkWebExecutor();
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response("{}", { status: 200 });
const restore = __setMuseSparkWebSocketForTesting(
OpensThenSilentWebSocket as unknown as typeof WebSocket
);
const timeoutHook = interceptWsTimeout();
try {
const resultPromise = executor.execute(baseInput("conn-10727-opens-silent"));
// Let the GraphQL awaits run, the WS open (its own real setTimeout(...,0)),
// and the intro/prompt frames send before the 30s timeout is registered.
await new Promise((r) => setTimeout(r, 20));
timeoutHook.fire();
const result = await resultPromise;
assert.equal(result.response.status, 502);
const body = await result.response.json();
assert.match(
body.error.message,
/readyState=1/,
"timeout after the socket reached OPEN must report readyState=1, not the never-opened case"
);
} finally {
globalThis.fetch = originalFetch;
restore();
timeoutHook.restore();
}
});

View File

@@ -1,40 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { ollama_cloudProvider } from "../../open-sse/config/providers/registry/ollama-cloud/index.ts";
// #10788: ollama-cloud declared supportsReasoning:true on several models
// (glm-5.1/5.2, deepseek-v4-pro/flash) but never declared
// supportedThinkingEfforts. appendSyncedEffortVariants() (open-sse/utils/
// syncedEffortVariants.ts) only synthesizes catalog `<model>-<tier>` ids from
// an already-populated capabilities.effort_tiers, and for static registry
// models that population only happens from a non-empty
// supportedThinkingEfforts — so these models never got a selectable
// -low/-high/-max catalog id. gpt-oss:20b/120b already declared it as the
// control case.
test("#10788: ollama-cloud reasoning-capable models declare supportedThinkingEfforts", () => {
const byId = new Map(ollama_cloudProvider.models.map((m) => [m.id, m]));
const control = byId.get("gpt-oss:20b");
assert.ok(
Array.isArray(control?.supportedThinkingEfforts) && control.supportedThinkingEfforts.length > 0,
"control: gpt-oss:20b should already declare supportedThinkingEfforts"
);
const reasoningModelIds = ["glm-5.1", "glm-5.2", "deepseek-v4-pro", "deepseek-v4-flash"];
for (const id of reasoningModelIds) {
const model = byId.get(id);
assert.ok(model?.supportsReasoning, `${id} should be flagged as a reasoning model`);
assert.ok(
Array.isArray(model?.supportedThinkingEfforts) && model.supportedThinkingEfforts.length > 0,
`${id} supports reasoning but declares no supportedThinkingEfforts`
);
// Ollama Cloud's documented vocabulary (see supportsMaxEffortForProvider's
// isOllamaCloud comment in open-sse/executors/base/reasoningEffort.ts):
// low|medium|high|max|none — xhigh is rejected and mapped to max.
assert.deepEqual(
[...(model?.supportedThinkingEfforts ?? [])],
["low", "medium", "high", "max"],
`${id} should declare Ollama Cloud's documented low/medium/high/max vocabulary`
);
}
});