diff --git a/apps/loopover-miner-extension/background.js b/apps/loopover-miner-extension/background.js index 0c29bf9f8d..307ea7c432 100644 --- a/apps/loopover-miner-extension/background.js +++ b/apps/loopover-miner-extension/background.js @@ -7,6 +7,10 @@ const toolbarBadgeApi = globalThis.__loopoverMinerToolbarBadge; const PING_MESSAGE = "loopover-miner:ping"; const ISSUE_CONTEXT_MESSAGE = "loopover-miner:issue-context"; const SYNC_RANKED_CANDIDATES_MESSAGE = "loopover-miner:sync-ranked-candidates"; +// Short: this is a same-machine localhost call, not a round-trip to a remote server -- a stalled connection +// (miner-ui running but unresponsive) should fail fast and let the next 10-minute alarm retry, following the +// timeout pattern established in review-enrichment/src/external-fetch.ts. +const RANKED_CANDIDATES_FETCH_TIMEOUT_MS = 3000; chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (!message || typeof message.type !== "string") return false; @@ -108,7 +112,9 @@ async function loadMinerUiUrl() { async function syncRankedCandidatesFromMinerUi() { const minerUiUrl = await loadMinerUiUrl(); try { - const response = await fetch(`${minerUiUrl}/api/ranked-candidates`); + const response = await fetch(`${minerUiUrl}/api/ranked-candidates`, { + signal: AbortSignal.timeout(RANKED_CANDIDATES_FETCH_TIMEOUT_MS), + }); if (!response.ok) { return { ok: false, error: `miner UI responded ${response.status}`, minerUiUrl }; } diff --git a/apps/loopover-miner-extension/test/background.test.ts b/apps/loopover-miner-extension/test/background.test.ts index 0009483d82..0ca224e5f9 100644 --- a/apps/loopover-miner-extension/test/background.test.ts +++ b/apps/loopover-miner-extension/test/background.test.ts @@ -120,6 +120,34 @@ describe("background service worker", () => { expect(failed).toMatchObject({ ok: false, error: "connection refused" }); }); + it("REGRESSION (#7007): bounds the miner-ui fetch with a 3s AbortSignal timeout", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + const { backgroundInternals } = await loadExtensionModules({ + fetchImpl: jsonFetch(200, { candidates: [] }), + }); + await backgroundInternals.syncRankedCandidatesFromMinerUi(); + expect(timeoutSpy).toHaveBeenCalledWith(3000); + timeoutSpy.mockRestore(); + }); + + it("REGRESSION (#7007): a stalled miner-ui connection times out instead of hanging the sync alarm forever", async () => { + // Mirrors what a real fetch does under an aborted signal: the promise never resolves on its own, it only + // rejects once the signal fires -- so this proves the timeout actually bounds a genuinely-hung connection, + // not just a fast-failing one. + const hangingFetch = (async (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("The operation was aborted.", "TimeoutError"))); + })) as typeof fetch; + const { backgroundInternals } = await loadExtensionModules({ fetchImpl: hangingFetch }); + + const startedAt = Date.now(); + const result = await backgroundInternals.syncRankedCandidatesFromMinerUi(); + const elapsedMs = Date.now() - startedAt; + + expect(result.ok).toBe(false); + expect(elapsedMs).toBeLessThan(4000); // bounded well under what an unbounded hang would take + }, 10_000); + it("falls back to the default miner UI URL when sync storage is empty or malformed", async () => { const empty = await loadExtensionModules({ minerUiUrl: "" }); expect(await empty.backgroundInternals.loadMinerUiUrl()).toBe(