From fba5eaee20e753a201915595f0fe834984a6e0fd Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:10:06 +1000 Subject: [PATCH] fix(signals): bound fetchRepoFocusManifestFile's raw-content fetch with a timeout fetchRepoFocusManifestFile fetches a repo's manifest from GitHub's raw-content CDN across up to 4 candidate paths with no AbortSignal.timeout, unlike the bounded-fetch convention in src/review/** (alerts.ts, enrichment-wire.ts, visual/capture.ts). It's on the cold-cache path for resolving a repo's gate/review policy on webhook/sweep processing, so a slow raw.githubusercontent.com response could stall manifest resolution. Add a named MANIFEST_FETCH_TIMEOUT_MS (10s) and pass AbortSignal.timeout(...) to each candidate fetch. A TimeoutError is already handled by the existing catch that falls through to the next candidate. Closes #7071 --- src/signals/focus-manifest-loader.ts | 9 ++++++++- test/unit/focus-manifest-loader.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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);