From 7eced91f82834e20271a5940e41eab74943f91ae Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:31:33 +0200 Subject: [PATCH] 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. --- lib/__tests__/web-push.test.ts | 22 +++++++++++++++++++++- lib/web-push.ts | 11 ++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index a6884a579..2ec102592 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -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 () => { diff --git a/lib/web-push.ts b/lib/web-push.ts index 90280a5cd..8135ef75f 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -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 { // 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(); export interface ResyncWebPushParams { @@ -762,8 +766,9 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise