commit fc2c12942df1b362b0b37ae86b5a7ad08a0fc841
parent ff900508518f15ce9284ceb80512b4bf639f8334
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 14:45:19 -0400
mcp: fetch_clip bounds every request at 15 s and says why a request failed (review S3, N1)
Each call — the POST and every poll — carries its own
AbortSignal.timeout(REQUEST_TIMEOUT_MS = 15 s), so a POST that stats a
hung mount or an editor with a stalled event loop can no longer hold the
call until undici's 300 s headers timeout: wait_seconds is the bound again,
give or take one request. A timeout reads "no answer within 15 s (request
timed out)"; a timed-out POST adds that the editor may have queued the
fetch anyway, so check /jobs before asking again. Node's bare
"fetch failed" now carries its cause (ECONNREFUSED, …). requestTimeoutMs
is injectable for the tests only. Tests: a signal per request, the cause,
a fake TimeoutError, and three over a real socket (a POST never answered,
a poll never answered → the job id comes back, a refused connection).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 186 insertions(+), 10 deletions(-)
diff --git a/mcp/src/fetchClip.test.ts b/mcp/src/fetchClip.test.ts
@@ -1,5 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
+import http from "node:http";
+import type { AddressInfo } from "node:net";
import {
fetchClip,
parseSeconds,
@@ -18,7 +20,13 @@ import {
// time and the poll count is exact.
type Answer = { status: number; body: unknown } | Error;
-type Call = { url: string; method: string; headers: Record<string, string>; body?: unknown };
+type Call = {
+ url: string;
+ method: string;
+ headers: Record<string, string>;
+ body?: unknown;
+ signal?: AbortSignal;
+};
function editor(
respond: (call: Call, n: number) => Answer,
@@ -38,6 +46,7 @@ function editor(
method: init?.method ?? "GET",
headers: init?.headers ?? {},
body: init?.body !== undefined ? JSON.parse(init.body) : undefined,
+ signal: init?.signal,
};
calls.push(call);
const a = respond(call, calls.length - 1);
@@ -599,3 +608,118 @@ test("the editor dropping out WHILE POLLING hands back the job, and says not to
assert.deepEqual(again.calls.map((c) => c.method), ["GET"]);
assert.match(resumed.text, /^Fetched the whole recording \(job j3, 0s waited\)\./);
});
+
+// ─── Every request is bounded, and a failure says why ───
+
+test("every request — the POST and each poll — carries its own timeout signal", async () => {
+ const { deps, calls } = editor(
+ seq(
+ { status: 202, body: { cached: false, jobId: "j1", file: CLIP_FILE, from: 7, to: 23 } },
+ { status: 200, body: { status: "running", jobId: "j1" } },
+ { status: 200, body: { status: "done", jobId: "j1", file: CLIP_FILE, from: 7, to: 23, bytes: 1 } },
+ ),
+ );
+ await fetchClip(windowRequest(), deps);
+ assert.equal(calls.length, 3);
+ for (const c of calls) assert.ok(c.signal instanceof AbortSignal, `${c.method} has a signal`);
+ // One signal per request, not one shared deadline for the whole call.
+ assert.equal(new Set(calls.map((c) => c.signal)).size, 3);
+});
+
+test("Node's bare 'fetch failed' gets its cause appended", async () => {
+ const { deps } = editor(
+ seq(new TypeError("fetch failed", { cause: new Error("connect ECONNREFUSED 127.0.0.1:3001") })),
+ );
+ assert.equal(
+ renderFetchClip(await fetchClip(windowRequest(), deps), CTX).text,
+ "Could not reach the editor at http://editor.test: fetch failed (connect ECONNREFUSED " +
+ "127.0.0.1:3001). Is it running?",
+ );
+});
+
+test("a timed-out POST says so, and that the fetch may have been queued anyway", async () => {
+ const { deps } = editor(
+ seq(new DOMException("The operation was aborted due to timeout", "TimeoutError")),
+ );
+ assert.deepEqual(renderFetchClip(await fetchClip(fullRequest(), deps), CTX), {
+ text:
+ "Could not reach the editor at http://editor.test: no answer within 15 s (request " +
+ "timed out). Is it running? It may have queued the fetch anyway: check the editor's " +
+ "/jobs page before asking again.",
+ isError: true,
+ });
+});
+
+// A real socket, a real fetch: an editor that accepts the connection and never
+// answers — a POST stuck on a hung mount — must not hold the call.
+async function silentEditor(answerPost?: unknown) {
+ const server = http.createServer((req, res) => {
+ if (answerPost && req.method === "POST") {
+ res.writeHead(202, { "content-type": "application/json" });
+ res.end(JSON.stringify(answerPost));
+ }
+ // Anything else: never answer.
+ });
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
+ const { port } = server.address() as AddressInfo;
+ const close = () => {
+ server.closeAllConnections();
+ return new Promise<void>((r) => server.close(() => r()));
+ };
+ return { url: `http://127.0.0.1:${port}`, port, close };
+}
+
+function realDeps(url: string, requestTimeoutMs: number): FetchClipDeps {
+ let clock = 0;
+ return {
+ env: { WORKER_TOKEN: "tok", ARCHILYZER_EDITOR_URL: url },
+ fetch: (u, init) => globalThis.fetch(u, init),
+ sleep: async (ms) => {
+ clock += ms;
+ },
+ now: () => clock,
+ requestTimeoutMs,
+ };
+}
+
+test("a real editor that never answers the POST is given up on at the request timeout", async () => {
+ const ed = await silentEditor();
+ try {
+ const t0 = Date.now();
+ const r = renderFetchClip(await fetchClip(windowRequest(), realDeps(ed.url, 150)), CTX);
+ const took = Date.now() - t0;
+ assert.ok(took < 5000, `gave up after ${took} ms`);
+ assert.equal(r.isError, true);
+ assert.equal(
+ r.text,
+ `Could not reach the editor at ${ed.url}: no answer within 0.15 s (request timed out). ` +
+ "Is it running? It may have queued the fetch anyway: check the editor's /jobs page " +
+ "before asking again.",
+ );
+ } finally {
+ await ed.close();
+ }
+});
+
+test("a real editor that stops answering a poll hands back the job", async () => {
+ const ed = await silentEditor({ cached: false, jobId: "j4", file: null, from: 0, to: 0 });
+ try {
+ const r = renderFetchClip(await fetchClip(fullRequest(), realDeps(ed.url, 150)), CTX);
+ assert.equal(
+ r.text,
+ `Could not reach the editor at ${ed.url} while polling job j4: no answer within 0.15 s ` +
+ '(request timed out). Is it running? Call fetch_clip again with job: "j4" — do not ' +
+ "repeat the original request, the fetch may still be running.",
+ );
+ } finally {
+ await ed.close();
+ }
+});
+
+test("a real refused connection names its cause", async () => {
+ const ed = await silentEditor();
+ const url = ed.url;
+ await ed.close(); // nothing listens there now
+ const r = renderFetchClip(await fetchClip(windowRequest(), realDeps(url, 5000)), CTX);
+ assert.match(r.text, /: fetch failed \(connect ECONNREFUSED 127\.0\.0\.1:\d+\)\. Is it running\?$/);
+});
diff --git a/mcp/src/fetchClip.ts b/mcp/src/fetchClip.ts
@@ -20,6 +20,7 @@ export type HttpInit = {
method?: string;
headers?: Record<string, string>;
body?: string;
+ signal?: AbortSignal;
};
export type HttpResponse = { status: number; json(): Promise<unknown> };
export type FetchLike = (url: string, init?: HttpInit) => Promise<HttpResponse>;
@@ -29,12 +30,20 @@ export type FetchClipDeps = {
fetch: FetchLike;
sleep: (ms: number) => Promise<void>;
now: () => number;
+ // Per-request bound (default REQUEST_TIMEOUT_MS). Only tests shorten it.
+ requestTimeoutMs?: number;
};
// The client family's names and defaults (fetch-via-editor.mjs,
// scripts/archilyzer-ops.mjs): one editor URL, one shared token.
export const DEFAULT_EDITOR_URL = "http://localhost:3001";
export const POLL_MS = 1000;
+// EVERY request — the POST and each poll — gives up after this. Without it a
+// POST that stats a hung mount, or an editor whose event loop has stalled,
+// holds the call until undici's own 300 s headers timeout, and wait_seconds
+// bounds nothing. A healthy editor answers both in milliseconds: the POST only
+// checks the cache and queues, the poll only reads the job's state.
+export const REQUEST_TIMEOUT_MS = 15_000;
export const DEFAULT_PAD_SECONDS = 3;
export const DEFAULT_WAIT_SECONDS = 90;
export const MAX_WAIT_SECONDS = 300;
@@ -284,10 +293,39 @@ export type FetchClipOutcome =
// queued and may still be running, so the answer must hand back the id to
// resume with. Repeating the original request instead would queue a second
// fetch — for full: true, a second whole-recording download.
- | { kind: "unreachable"; url: string; message: string; jobId?: string };
+ | {
+ kind: "unreachable";
+ url: string;
+ message: string;
+ jobId?: string;
+ // The POST timed out: the editor may have queued the fetch anyway.
+ postTimedOut?: boolean;
+ };
type Json = Record<string, unknown>;
+function isTimeout(e: unknown): boolean {
+ return typeof e === "object" && e !== null && (e as { name?: unknown }).name === "TimeoutError";
+}
+
+// What went wrong, in words the operator can act on. Node's fetch throws a
+// bare `TypeError: fetch failed` and keeps the reason (ECONNREFUSED, ENOTFOUND,
+// a reset) in `cause`, so the cause is appended; a timeout says how long.
+export function describeFetchError(e: unknown, timeoutMs: number): string {
+ if (isTimeout(e)) return `no answer within ${timeoutMs / 1000} s (request timed out)`;
+ const obj = typeof e === "object" && e !== null ? (e as { message?: unknown; cause?: unknown }) : null;
+ const message = obj && typeof obj.message === "string" ? obj.message : String(e);
+ const cause = obj?.cause;
+ const causeText =
+ typeof cause === "string"
+ ? cause
+ : typeof cause === "object" && cause !== null &&
+ typeof (cause as { message?: unknown }).message === "string"
+ ? (cause as { message: string }).message
+ : "";
+ return causeText && causeText !== message ? `${message} (${causeText})` : message;
+}
+
async function readJson(res: HttpResponse): Promise<Json> {
try {
const body = await res.json();
@@ -319,11 +357,15 @@ export async function fetchClip(
const deadline = started + request.waitSeconds * 1000;
const waited = () => Math.round((deps.now() - started) / 1000);
- async function call(url: string, init: HttpInit): Promise<HttpResponse | Error> {
+ const timeoutMs = deps.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
+ async function call(
+ url: string,
+ init: HttpInit,
+ ): Promise<HttpResponse | { failed: string; timedOut: boolean }> {
try {
- return await deps.fetch(url, init);
+ return await deps.fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
} catch (e) {
- return e instanceof Error ? e : new Error(String(e));
+ return { failed: describeFetchError(e, timeoutMs), timedOut: isTimeout(e) };
}
}
@@ -348,8 +390,8 @@ export async function fetchClip(
`${editor!.url}/api/media/fetch-window/${encodeURIComponent(jobId)}`,
{ method: "GET", headers },
);
- if (res instanceof Error) {
- return { kind: "unreachable", url: editor!.url, message: res.message, jobId };
+ if ("failed" in res) {
+ return { kind: "unreachable", url: editor!.url, message: res.failed, jobId };
}
const body = await readJson(res);
if (res.status < 200 || res.status >= 300) {
@@ -421,8 +463,13 @@ export async function fetchClip(
headers,
body: JSON.stringify(body),
});
- if (res instanceof Error) {
- return { kind: "unreachable", url: editor.url, message: res.message };
+ if ("failed" in res) {
+ return {
+ kind: "unreachable",
+ url: editor.url,
+ message: res.failed,
+ postTimedOut: res.timedOut,
+ };
}
const answer = await readJson(res);
const reqFrom = target.kind === "window" ? target.from : 0;
@@ -628,7 +675,12 @@ export function renderFetchClip(
};
}
return {
- text: `Could not reach the editor at ${outcome.url}: ${outcome.message}. Is it running?`,
+ text:
+ `Could not reach the editor at ${outcome.url}: ${outcome.message}. Is it running?` +
+ (outcome.postTimedOut
+ ? " It may have queued the fetch anyway: check the editor's /jobs page " +
+ "before asking again."
+ : ""),
isError: true,
};
}