From 55484453a12efaa026e70b2e3c634269312db808 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:39:20 +0200 Subject: [PATCH] fix: coalesce concurrent push setups to prevent subscription swarm --- src/lib/__tests__/push-notifications.test.ts | 18 +++++++++++++ src/lib/push-notifications.ts | 28 ++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 2435e7bb..138a9da3 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -39,6 +39,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { setupPushNotifications, deviceClientIdKey } from '../push-notifications'; import { listPushSubscriptions, + createPushSubscription, destroyPushSubscription, } from '../../api/push'; import { generateAccountId } from '../account-utils'; @@ -74,6 +75,7 @@ function installFetch(states: Record): void { const destroyMock = destroyPushSubscription as ReturnType; const listMock = listPushSubscriptions as ReturnType; +const createMock = createPushSubscription as ReturnType; function sub(id: string, deviceClientId: string) { return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] }; @@ -128,4 +130,20 @@ describe('setupPushNotifications leftover reaping', () => { expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('foreign-x'); }); + + it('coalesces concurrent setups into a single flow (no subscription swarm)', async () => { + listMock.mockResolvedValue([]); + installFetch({}); + + // Fire several overlapping setups, as App.tsx does while auth settles and + // on FCM token refresh. Only one underlying JMAP subscription must be made. + const results = await Promise.all([ + setupPushNotifications({ relayBaseUrl: RELAY }), + setupPushNotifications({ relayBaseUrl: RELAY }), + setupPushNotifications({ relayBaseUrl: RELAY }), + ]); + + expect(createMock).toHaveBeenCalledTimes(1); + expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1); + }); }); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index eaafec23..12850b8c 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -268,12 +268,36 @@ async function pollVerificationCode( throw new Error('Timed out waiting for PushVerification from JMAP server'); } +// Coalesce concurrent setup attempts per account. App.tsx re-runs its push +// effect as auth state settles during startup and again on every FCM token +// refresh; because a failing attempt blocks for up to 75s polling for the +// verification code, those re-fires overlap. Without this guard each +// overlapping run mints its OWN deviceClientId and JMAP subscription, and the +// resulting swarm starves Stalwart's one-PushVerification-per-60s slot so none +// of them ever verifies (the symptom is a perpetual "Timed out waiting for +// PushVerification"). Callers share the first in-flight run instead. +const inFlightSetups = new Map>(); + /** * Full setup flow: ask permission, fetch the device's FCM token, register * with the relay, create a JMAP PushSubscription, poll for the verification - * code, and finalise the subscription. + * code, and finalise the subscription. Concurrent calls for the same account + * are coalesced onto a single in-flight run. */ -export async function setupPushNotifications( +export function setupPushNotifications( + params: PushSetupParams, +): Promise { + const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`; + const existing = inFlightSetups.get(key); + if (existing) return existing; + const run = setupPushNotificationsInner(params).finally(() => { + inFlightSetups.delete(key); + }); + inFlightSetups.set(key, run); + return run; +} + +async function setupPushNotificationsInner( params: PushSetupParams, ): Promise { const native = getNative();