From 4d31f6773f58b4803f30b80ccfe7dd73369a32fc Mon Sep 17 00:00:00 2001 From: Shahid Shamim Shah Date: Mon, 2 Nov 2026 03:22:18 +0530 Subject: [PATCH] fix: stop queued sections after summary failure --- background.js | 11 +++++-- docs/DEVELOPMENT.md | 3 +- tests/core/concurrency.test.js | 20 +++++++++++++ tests/integration/extension-workflow.test.js | 31 ++++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/background.js b/background.js index cf7c8a2..48c4dcf 100644 --- a/background.js +++ b/background.js @@ -786,13 +786,20 @@ export async function mapWithConcurrency(items, limit, fn) { /** @type {R[]} */ const results = new Array(items.length); let next = 0; + let failed = false; const workers = Array.from( { length: Math.min(limit, items.length) }, async () => { - while (next < items.length) { + while (!failed && next < items.length) { const index = next++; - results[index] = await fn(/** @type {T} */ (items[index]), index); + try { + results[index] = await fn(/** @type {T} */ (items[index]), index); + } catch (error) { + // eslint-disable-next-line require-atomic-updates -- Failure only transitions to true. + failed = true; + throw error; + } } } ); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 52e37b1..70f09ab 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -22,7 +22,8 @@ flowchart LR A summary extracts page text, checks the saved-result key, then either makes one request or summarizes up to eight 24,000-character sections (three concurrently) and merges the notes. Any failed section fails the task; partial results are not -presented as complete. Errors retain their code and message. +presented as complete. A failed section stops queued sections from starting; +already-started requests may settle. Errors retain their code and message. ## Source map diff --git a/tests/core/concurrency.test.js b/tests/core/concurrency.test.js index 99b2c94..601c56c 100644 --- a/tests/core/concurrency.test.js +++ b/tests/core/concurrency.test.js @@ -47,6 +47,26 @@ describe('mapWithConcurrency', () => { await expect(mapWithConcurrency([1, 2], 8, async n => n * 2)).resolves.toEqual([2, 4]); }); + it('does not claim queued items after a sibling fails', async () => { + let release; + const pending = new Promise(resolve => { + release = resolve; + }); + const seen = []; + const failure = new Error('section failed'); + const result = mapWithConcurrency([0, 1, 2, 3, 4, 5], 3, async item => { + seen.push(item); + if (item === 0) throw failure; + await pending; + return item; + }); + await expect(result).rejects.toBe(failure); + expect(seen).toEqual([0, 1, 2]); + release(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(seen).toEqual([0, 1, 2]); + }); + it('propagates a rejection', async () => { await expect( mapWithConcurrency([1, 2, 3], 2, async n => { diff --git a/tests/integration/extension-workflow.test.js b/tests/integration/extension-workflow.test.js index 6a15522..8f2988c 100644 --- a/tests/integration/extension-workflow.test.js +++ b/tests/integration/extension-workflow.test.js @@ -96,6 +96,37 @@ describe('Extension workflow', () => { ); }); + it('stops queued section requests after a terminal provider error', async () => { + let release; + const pending = new Promise(resolve => { + release = resolve; + }); + global.fetch + .mockReset() + .mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ error: { message: 'section rejected' } }), + }) + .mockImplementation(async () => { + await pending; + return CLAUDE_REPLY; + }); + + const response = await dispatch('GENERATE_CONTENT_SUMMARY', { + content: 'word '.repeat(38000), + summaryType: 'key-points', + }); + expect(response.success).toBe(false); + expect(response.errorCode).toBe('PROVIDER_ERROR'); + expect(global.fetch).toHaveBeenCalledTimes(3); + release(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(global.fetch).toHaveBeenCalledTimes(3); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + }); + it('persists the summary to local history', async () => { global.fetch.mockResolvedValue(CLAUDE_REPLY);