fix: keep retrying a lost push subscription, at most every 15 minutes #1147

Recreating the subscription removed the account's stored id before the
replacement was created and verified, and the resync now requires that
id. One interrupted or timed-out attempt (up to 75 seconds of
verification) therefore ended recovery for that account for good. The
id now stays until a verified replacement overwrites it.

A failed resync also forgot its timestamp, so every return to the tab
reran the whole enable. It now waits 15 minutes before the next try.
This commit is contained in:
Linus Rath
2026-10-02 10:31:33 +02:00
parent 5d6bd51331
commit 7eced91f82
2 changed files with 29 additions and 4 deletions
+21 -1
View File
@@ -306,7 +306,27 @@ describe('resyncWebPush', () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
// Not on the very next tab focus...
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
// ...but a quarter of an hour later.
const later = Date.now() + 16 * 60_000;
vi.spyOn(Date, 'now').mockReturnValue(later);
try {
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
} finally {
vi.mocked(Date.now).mockRestore();
}
});
it('keeps the opt-in when recreating the subscription fails', async () => {
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
// The server lost the old subscription, and creating a new one fails.
const client = makeClient([]);
vi.mocked(client.createPushSubscription).mockRejectedValue(new Error('server down'));
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
expect(localStorage.getItem(SUB_KEY)).toBe('push-old');
});
it('re-syncs an enabled registration and installs the missing filter', async () => {
+8 -3
View File
@@ -526,7 +526,9 @@ export async function enableWebPush(
}
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
}
localStorage.removeItem(subIdKey);
// The stored id stays until the replacement is verified below: it is
// what tells resyncWebPush this account opted in, so dropping it here
// would end recovery for good after one interrupted attempt.
}
// Reap leftover subscriptions that would otherwise starve the new one's
@@ -719,6 +721,8 @@ export async function isWebPushEnabled(accountId: string): Promise<boolean> {
// clamps `expires` to 7 days, so a tab or installed app left open for a
// week would otherwise let the subscription lapse and push stop silently.
const RESYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
// After a failed attempt: soon again, but not on every return to the tab.
const RESYNC_RETRY_MS = 15 * 60 * 1000;
const lastResyncAt = new Map<string, number>();
export interface ResyncWebPushParams {
@@ -762,8 +766,9 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolea
});
return true;
} catch {
// A failed attempt must not wait a day for the next one.
lastResyncAt.delete(accountId);
// A failed attempt must not wait a day for the next one, nor rerun the
// whole enable on every tab focus.
lastResyncAt.set(accountId, Date.now() - RESYNC_INTERVAL_MS + RESYNC_RETRY_MS);
return false;
}
}