Skip to content

Commit b8621fc

Browse files
authored
Merge pull request #7051 from galuis116/fix/miner-extension-sync-fetch-timeout
fix(miner-extension): bound the ranked-candidates sync fetch with a timeout
2 parents 8ba48bd + 4c0b19f commit b8621fc

2 files changed

Lines changed: 35 additions & 1 deletion

File tree

apps/loopover-miner-extension/background.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ const toolbarBadgeApi = globalThis.__loopoverMinerToolbarBadge;
77
const PING_MESSAGE = "loopover-miner:ping";
88
const ISSUE_CONTEXT_MESSAGE = "loopover-miner:issue-context";
99
const SYNC_RANKED_CANDIDATES_MESSAGE = "loopover-miner:sync-ranked-candidates";
10+
// Short: this is a same-machine localhost call, not a round-trip to a remote server -- a stalled connection
11+
// (miner-ui running but unresponsive) should fail fast and let the next 10-minute alarm retry, following the
12+
// timeout pattern established in review-enrichment/src/external-fetch.ts.
13+
const RANKED_CANDIDATES_FETCH_TIMEOUT_MS = 3000;
1014

1115
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1216
if (!message || typeof message.type !== "string") return false;
@@ -108,7 +112,9 @@ async function loadMinerUiUrl() {
108112
async function syncRankedCandidatesFromMinerUi() {
109113
const minerUiUrl = await loadMinerUiUrl();
110114
try {
111-
const response = await fetch(`${minerUiUrl}/api/ranked-candidates`);
115+
const response = await fetch(`${minerUiUrl}/api/ranked-candidates`, {
116+
signal: AbortSignal.timeout(RANKED_CANDIDATES_FETCH_TIMEOUT_MS),
117+
});
112118
if (!response.ok) {
113119
return { ok: false, error: `miner UI responded ${response.status}`, minerUiUrl };
114120
}

apps/loopover-miner-extension/test/background.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,34 @@ describe("background service worker", () => {
120120
expect(failed).toMatchObject({ ok: false, error: "connection refused" });
121121
});
122122

123+
it("REGRESSION (#7007): bounds the miner-ui fetch with a 3s AbortSignal timeout", async () => {
124+
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
125+
const { backgroundInternals } = await loadExtensionModules({
126+
fetchImpl: jsonFetch(200, { candidates: [] }),
127+
});
128+
await backgroundInternals.syncRankedCandidatesFromMinerUi();
129+
expect(timeoutSpy).toHaveBeenCalledWith(3000);
130+
timeoutSpy.mockRestore();
131+
});
132+
133+
it("REGRESSION (#7007): a stalled miner-ui connection times out instead of hanging the sync alarm forever", async () => {
134+
// Mirrors what a real fetch does under an aborted signal: the promise never resolves on its own, it only
135+
// rejects once the signal fires -- so this proves the timeout actually bounds a genuinely-hung connection,
136+
// not just a fast-failing one.
137+
const hangingFetch = (async (_url: string, init?: RequestInit) =>
138+
new Promise((_resolve, reject) => {
139+
init?.signal?.addEventListener("abort", () => reject(new DOMException("The operation was aborted.", "TimeoutError")));
140+
})) as typeof fetch;
141+
const { backgroundInternals } = await loadExtensionModules({ fetchImpl: hangingFetch });
142+
143+
const startedAt = Date.now();
144+
const result = await backgroundInternals.syncRankedCandidatesFromMinerUi();
145+
const elapsedMs = Date.now() - startedAt;
146+
147+
expect(result.ok).toBe(false);
148+
expect(elapsedMs).toBeLessThan(4000); // bounded well under what an unbounded hang would take
149+
}, 10_000);
150+
123151
it("falls back to the default miner UI URL when sync storage is empty or malformed", async () => {
124152
const empty = await loadExtensionModules({ minerUiUrl: "" });
125153
expect(await empty.backgroundInternals.loadMinerUiUrl()).toBe(

0 commit comments

Comments
 (0)