diff --git a/packages/clawhub/src/discovery.timeout.test.ts b/packages/clawhub/src/discovery.timeout.test.ts new file mode 100644 index 00000000..e3ff4352 --- /dev/null +++ b/packages/clawhub/src/discovery.timeout.test.ts @@ -0,0 +1,119 @@ +/* @vitest-environment node */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createGlobalStubRegistry } from "../test/runtimeStubs.js"; +import { discoverRegistryFromSite } from "./discovery"; + +const globalStubs = createGlobalStubRegistry(); + +function stubNeverResolvingFetch() { + globalStubs.stub( + "fetch", + vi.fn( + (_input: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) return; // hangs forever, like an endpoint that never answers + signal.addEventListener("abort", () => { + reject(signal.reason instanceof Error ? signal.reason : new Error("aborted")); + }); + }), + ) as unknown as typeof fetch, + ); +} + +function stubJsonFetch(status: number, body: unknown) { + globalStubs.stub( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as typeof fetch, + ); +} + +function stubRejectingFetch(error: Error) { + globalStubs.stub("fetch", vi.fn(async () => Promise.reject(error)) as unknown as typeof fetch); +} + +function stubStalledBodyFetch() { + globalStubs.stub( + "fetch", + vi.fn(async (_input: unknown, init?: RequestInit) => { + const signal = init?.signal; + // Return a response with headers immediately, but body never completes + const response = new Response(null, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + // Override .json() to hang forever (or abort if signal fires) + response.json = vi.fn( + () => + new Promise((_resolve, reject) => { + if (!signal) return; // hangs forever + signal.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted", "AbortError")); + }); + }), + ); + return response; + }) as unknown as typeof fetch, + ); +} + +describe("discoverRegistryFromSite timeout", () => { + afterEach(() => { + vi.useRealTimers(); + globalStubs.restoreAll(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it( + "rejects with a timeout error when the endpoint never responds", + { timeout: 5_000 }, + async () => { + stubNeverResolvingFetch(); + await expect(discoverRegistryFromSite("https://example.com", 50)).rejects.toThrow( + /Request timed out after \d+s/, + ); + }, + ); + + it("clears the timeout when fetch rejects before returning headers", async () => { + vi.useFakeTimers(); + const error = new Error("connection refused"); + stubRejectingFetch(error); + + await expect(discoverRegistryFromSite("https://example.com")).rejects.toBe(error); + expect(vi.getTimerCount()).toBe(0); + }); + + it( + "rejects with a timeout error when the response body never completes", + { timeout: 5_000 }, + async () => { + stubStalledBodyFetch(); + await expect(discoverRegistryFromSite("https://example.com", 50)).rejects.toThrow( + /Request timed out after \d+s/, + ); + }, + ); + + it("still parses a valid well-known config", async () => { + stubJsonFetch(200, { registry: "https://example.convex.site" }); + await expect(discoverRegistryFromSite("https://example.com")).resolves.toEqual({ + apiBase: "https://example.convex.site", + authBase: undefined, + minCliVersion: undefined, + }); + }); + + it("still returns null when both well-known paths 404", async () => { + stubJsonFetch(404, { error: "nope" }); + await expect(discoverRegistryFromSite("https://example.com")).resolves.toBeNull(); + }); +}); diff --git a/packages/clawhub/src/discovery.ts b/packages/clawhub/src/discovery.ts index 1a9964eb..807e2298 100644 --- a/packages/clawhub/src/discovery.ts +++ b/packages/clawhub/src/discovery.ts @@ -1,23 +1,74 @@ import { parseArk, WellKnownConfigSchema } from "./schema/index.js"; -export async function discoverRegistryFromSite(siteUrl: string) { +const DISCOVERY_TIMEOUT_MS = 15_000; + +// Mirrors the fetchWithTimeout pattern from ./http.js (not imported) so an +// endpoint that accepts but never answers cannot hang discovery forever. +// Returns both the response and the timeout handle so the caller can extend +// the timeout to cover body parsing. +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise<{ + response: Response; + clearTimer: () => void; + normalizeError: (error: unknown) => unknown; +}> { + const controller = new AbortController(); + const timeoutSeconds = Math.ceil(timeoutMs / 1000); + let timeoutError: Error | null = null; + const timeout = setTimeout(() => { + timeoutError = new Error(`Request timed out after ${timeoutSeconds}s`); + controller.abort(timeoutError); + }, timeoutMs); + const normalizeError = (error: unknown) => timeoutError ?? error; + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + return { + response, + clearTimer: () => clearTimeout(timeout), + normalizeError, + }; + } catch (error) { + clearTimeout(timeout); + throw normalizeError(error); + } +} + +export async function discoverRegistryFromSite(siteUrl: string, timeoutMs = DISCOVERY_TIMEOUT_MS) { const paths = ["/.well-known/clawhub.json", "/.well-known/clawdhub.json"]; for (const path of paths) { const url = new URL(path, siteUrl); - const response = await fetch(url.toString(), { - method: "GET", - headers: { Accept: "application/json" }, - }); - if (!response.ok) continue; - const raw = (await response.json()) as unknown; - const parsed = parseArk(WellKnownConfigSchema, raw, "WellKnown config"); - const apiBase = "apiBase" in parsed ? parsed.apiBase : parsed.registry; - if (!apiBase) return null; - return { - apiBase, - authBase: parsed.authBase, - minCliVersion: parsed.minCliVersion, - }; + const { response, clearTimer, normalizeError } = await fetchWithTimeout( + url.toString(), + { + method: "GET", + headers: { Accept: "application/json" }, + }, + timeoutMs, + ); + if (!response.ok) { + clearTimer(); + continue; + } + // Keep timeout active through JSON body parsing to guard against + // peers that send headers but never complete the response body + try { + const raw = (await response.json()) as unknown; + const parsed = parseArk(WellKnownConfigSchema, raw, "WellKnown config"); + const apiBase = "apiBase" in parsed ? parsed.apiBase : parsed.registry; + if (!apiBase) return null; + return { + apiBase, + authBase: parsed.authBase, + minCliVersion: parsed.minCliVersion, + }; + } catch (error) { + throw normalizeError(error); + } finally { + clearTimer(); + } } return null; }