From 425db2d94dbc8e01dc83b82ea39322a12baebf1a Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Mon, 11 May 2026 18:06:01 +0100 Subject: [PATCH] [AI] Recover from BackendInitFailure and show a meaningful error (#7761) * [AI] Recover from BackendInitFailure and show a meaningful error When the backend Worker fails to load (e.g., the hashed kcab.worker asset can't be fetched), the SharedWorker would cache the app-init-failure and replay it to every subsequent tab forever, while the FatalError modal showed a misleading "browser version" message. - Retry importScripts in production (3 attempts) so a transient blip doesn't brick the SharedWorker. - Clear lastAppInitFailure when the client acknowledges the failure, when a backend later connects successfully (centralized in broadcastConnect), and when a fresh init arrives with no active groups (the failed leader is gone). - Add a BackendInitFailure branch to FatalError's RenderSimple with a message that points the user at reload / hard refresh. Co-Authored-By: Claude Opus 4.7 (1M context) * Remove support contact message from FatalError Removed support contact message from FatalError component. * [AI] Fix error propagation in importScriptsWithRetry - Change Promise executor to accept both resolve and reject - Properly propagate errors using .then(resolve).catch(reject) - Fixes issue where errors from recursive retry calls were swallowed Co-authored-by: Matiss Janis Aboltins --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Cursor Agent Co-authored-by: Matiss Janis Aboltins --- packages/desktop-client/src/browser-server.js | 19 +++++---- .../src/components/FatalError.test.tsx | 12 +++++- .../src/components/FatalError.tsx | 26 +++++------- .../src/shared-browser-server-core.test.ts | 41 +++++++++++++++++++ .../src/shared-browser-server-core.ts | 13 ++++++ upcoming-release-notes/7761.md | 6 +++ 6 files changed, 92 insertions(+), 25 deletions(-) create mode 100644 upcoming-release-notes/7761.md diff --git a/packages/desktop-client/src/browser-server.js b/packages/desktop-client/src/browser-server.js index e573a665a2..7b254ff180 100644 --- a/packages/desktop-client/src/browser-server.js +++ b/packages/desktop-client/src/browser-server.js @@ -25,14 +25,15 @@ const importScriptsWithRetry = async (script, { maxRetries = 5 } = {}) => { } // Attempt to retry after a small delay - await new Promise(resolve => - setTimeout(async () => { - await importScriptsWithRetry(script, { + await new Promise((resolve, reject) => { + setTimeout(() => { + importScriptsWithRetry(script, { maxRetries: maxRetries - 1, - }); - resolve(); - }, 5000), - ); + }) + .then(resolve) + .catch(reject); + }, 5000); + }); } }; @@ -76,9 +77,11 @@ self.addEventListener('message', async event => { return; } + // A single failed importScripts bricks the SharedWorker until + // it's evicted, so retry in production too. await importScriptsWithRetry( `${msg.publicUrl}/kcab/kcab.worker.${hash}.js`, - { maxRetries: isDev ? 5 : 0 }, + { maxRetries: isDev ? 5 : 3 }, ); backend.initApp(isDev, self).catch(err => { diff --git a/packages/desktop-client/src/components/FatalError.test.tsx b/packages/desktop-client/src/components/FatalError.test.tsx index dabe658cf0..a7f3107b3a 100644 --- a/packages/desktop-client/src/components/FatalError.test.tsx +++ b/packages/desktop-client/src/components/FatalError.test.tsx @@ -30,7 +30,7 @@ describe('FatalError', () => { expect(screen.getByText(/IndexedDB/)).toBeInTheDocument(); }); - it('renders the generic simple message for an app-init-failure without a specific cause', () => { + it('renders a backend-worker message for a BackendInitFailure', () => { const error = { type: 'app-init-failure', BackendInitFailure: true, @@ -38,6 +38,16 @@ describe('FatalError', () => { render(, { wrapper: TestProviders }); + expect( + screen.getByText(/couldn't load a critical backend worker/i), + ).toBeInTheDocument(); + }); + + it('renders the generic simple message for an app-init-failure without a specific cause', () => { + const error = { type: 'app-init-failure' }; + + render(, { wrapper: TestProviders }); + expect( screen.getByText(/problem loading the app in this browser version/i), ).toBeInTheDocument(); diff --git a/packages/desktop-client/src/components/FatalError.tsx b/packages/desktop-client/src/components/FatalError.tsx index 465d726773..5d741a4752 100644 --- a/packages/desktop-client/src/components/FatalError.tsx +++ b/packages/desktop-client/src/components/FatalError.tsx @@ -69,10 +69,17 @@ function RenderSimple({ error }: RenderSimpleProps) { ); + } else if ('BackendInitFailure' in error && error.BackendInitFailure) { + msg = ( + + + Actual couldn't load a critical backend worker. Reload the page to try + again; if the problem persists, do a hard refresh to clear any stale + cached assets. + + + ); } else { - // This indicates the backend failed to initialize. Show the - // user something at least so they aren't looking at a blank - // screen msg = ( @@ -92,19 +99,6 @@ function RenderSimple({ error }: RenderSimpleProps) { }} > {msg} - - - Please get{' '} - - in touch - {' '} - for support - - ); } diff --git a/packages/desktop-client/src/shared-browser-server-core.test.ts b/packages/desktop-client/src/shared-browser-server-core.test.ts index a0b0e1f42f..aa04a04d37 100644 --- a/packages/desktop-client/src/shared-browser-server-core.test.ts +++ b/packages/desktop-client/src/shared-browser-server-core.test.ts @@ -172,6 +172,47 @@ describe('SharedWorker coordinator', () => { expect.objectContaining({ type: 'app-init-failure', error: 'boom' }), ); }); + + it('clears the cached init failure when the client acknowledges it', () => { + const leader = connectTab(coordinator); + sendInit(leader); + simulateWorkerConnect(leader); + + sendMsg(leader, { + type: '__from-worker', + msg: { type: 'app-init-failure', error: 'boom' }, + }); + expect(coordinator.getState().lastAppInitFailure).toEqual( + expect.objectContaining({ type: 'app-init-failure' }), + ); + + sendMsg(leader, { name: '__app-init-failure-acknowledged' }); + expect(coordinator.getState().lastAppInitFailure).toBeNull(); + + const port2 = connectTab(coordinator); + sendInit(port2); + expect(port2.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'app-init-failure' }), + ); + }); + + it('clears the cached init failure when a backend later connects successfully', () => { + const failedLeader = connectTab(coordinator); + sendInit(failedLeader); + sendMsg(failedLeader, { + type: '__from-worker', + msg: { type: 'app-init-failure', error: 'boom' }, + }); + expect(coordinator.getState().lastAppInitFailure).not.toBeNull(); + + sendMsg(failedLeader, { type: 'tab-closing' }); + + const newLeader = connectTab(coordinator); + sendInit(newLeader); + simulateWorkerConnect(newLeader); + + expect(coordinator.getState().lastAppInitFailure).toBeNull(); + }); }); // ── Load budget ───────────────────────────────────────────────────── diff --git a/packages/desktop-client/src/shared-browser-server-core.ts b/packages/desktop-client/src/shared-browser-server-core.ts index 3c81c78a89..47707f63ad 100644 --- a/packages/desktop-client/src/shared-browser-server-core.ts +++ b/packages/desktop-client/src/shared-browser-server-core.ts @@ -100,6 +100,7 @@ export function createCoordinator({ } function broadcastConnect(budgetId: string) { + lastAppInitFailure = null; const connectMsg = { type: 'connect' }; broadcastToAllInGroup(budgetId, connectMsg); for (const port of unassignedPorts) { @@ -544,10 +545,22 @@ export function createCoordinator({ return; } + // Drop the cache once the client has surfaced the failure, so a + // subsequent init can re-attempt. Falls through to the leader so + // the Worker still stops its retry interval. + if (msg.name === '__app-init-failure-acknowledged') { + lastAppInitFailure = null; + } + // ── Initialization ───────────────────────────────────────── if (msg.type === 'init') { cachedInitMsg = msg; + // The leader that produced the failure is gone, so the cache + // can't recover — drop it and let this tab attempt a fresh init. + if (lastAppInitFailure && budgetGroups.size === 0) { + lastAppInitFailure = null; + } if (lastAppInitFailure) { port.postMessage(lastAppInitFailure); } else { diff --git a/upcoming-release-notes/7761.md b/upcoming-release-notes/7761.md new file mode 100644 index 0000000000..f92e205901 --- /dev/null +++ b/upcoming-release-notes/7761.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [MatissJanis] +--- + +Surface the backend init failures with a custom error screen and message.