Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mmcp/src/fetchClip.test.ts | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mmcp/src/fetchClip.ts | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
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, }; }