[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) <noreply@anthropic.com>

* 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 <MatissJanis@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
This commit is contained in:
Matiss Janis Aboltins
2026-05-11 18:06:01 +01:00
committed by GitHub
parent 5d270340a5
commit 425db2d94d
6 changed files with 92 additions and 25 deletions

View File

@@ -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 => {

View File

@@ -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(<FatalError error={error} />, { 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(<FatalError error={error} />, { wrapper: TestProviders });
expect(
screen.getByText(/problem loading the app in this browser version/i),
).toBeInTheDocument();

View File

@@ -69,10 +69,17 @@ function RenderSimple({ error }: RenderSimpleProps) {
</Trans>
</Text>
);
} else if ('BackendInitFailure' in error && error.BackendInitFailure) {
msg = (
<Text>
<Trans>
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.
</Trans>
</Text>
);
} else {
// This indicates the backend failed to initialize. Show the
// user something at least so they aren't looking at a blank
// screen
msg = (
<Text>
<Trans>
@@ -92,19 +99,6 @@ function RenderSimple({ error }: RenderSimpleProps) {
}}
>
<Text>{msg}</Text>
<Text>
<Trans>
Please get{' '}
<Link
variant="external"
linkColor="muted"
to="https://actualbudget.org/contact"
>
in touch
</Link>{' '}
for support
</Trans>
</Text>
</SpaceBetween>
);
}

View File

@@ -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 ─────────────────────────────────────────────────────

View File

@@ -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 {

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Surface the backend init failures with a custom error screen and message.