diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index bb30d596ba..a5a2216cf4 100644 --- a/src/signals/focus-manifest-loader.ts +++ b/src/signals/focus-manifest-loader.ts @@ -9,6 +9,10 @@ import type { LocalManifestLoadResult } from "../selfhost/private-config"; export const REPO_FOCUS_MANIFEST_SIGNAL = "repo-focus-manifest"; export const REPO_PUBLIC_FOCUS_MANIFEST_SIGNAL = "repo-public-focus-manifest"; export const REPO_FOCUS_MANIFEST_MAX_AGE_MS = 6 * 60 * 60 * 1000; +// Per-request ceiling for each raw-content candidate fetch (#7071), matching the bounded-fetch convention in +// src/review/** (e.g. alerts.ts's AbortSignal.timeout(10_000)) so a slow raw.githubusercontent.com response +// can't stall manifest resolution on the cold-cache path. +const MANIFEST_FETCH_TIMEOUT_MS = 10_000; export const REPO_FOCUS_MANIFEST_MAX_CONCURRENT_LOADS = 4; /** @@ -101,7 +105,10 @@ export async function fetchRepoFocusManifestFile(repoFullName: string): Promise< for (const path of MANIFEST_FILE_CANDIDATES) { const url = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/HEAD/${path}`; try { - const response = await fetch(url, { headers: { Accept: "application/json", "User-Agent": "loopover" } }); + const response = await fetch(url, { + headers: { Accept: "application/json", "User-Agent": "loopover" }, + signal: AbortSignal.timeout(MANIFEST_FETCH_TIMEOUT_MS), + }); if (response.ok) { const text = await readBoundedResponseText(response); if (text !== null) return text; diff --git a/test/unit/focus-manifest-loader.test.ts b/test/unit/focus-manifest-loader.test.ts index 4f74d7ec67..69cdd4cead 100644 --- a/test/unit/focus-manifest-loader.test.ts +++ b/test/unit/focus-manifest-loader.test.ts @@ -302,6 +302,19 @@ describe("focus-manifest loader", () => { expect(fetchSpy).toHaveBeenCalledTimes(1); // first candidate in MANIFEST_FILE_CANDIDATES is a 200, no fallback needed }); + it("bounds each candidate fetch with an AbortSignal timeout (#7071)", async () => { + // A slow raw.githubusercontent.com response can't stall manifest resolution: every candidate fetch must + // carry a time bound, matching the bounded-fetch convention elsewhere in src/review/**. + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => new Response("not found", { status: 404 })); + await fetchRepoFocusManifestFile("owner/repo"); + expect(fetchSpy).toHaveBeenCalled(); + for (const call of fetchSpy.mock.calls) { + expect((call[1] as RequestInit | undefined)?.signal).toBeInstanceOf(AbortSignal); + } + }); + it("does not read public manifest responses when Content-Length is too large", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { const stringUrl = String(url);