Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/loopover-miner-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}
Expand Down
28 changes: 28 additions & 0 deletions apps/loopover-miner-extension/test/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading