fix: stop a stuck push setup from holding up every tab and login

This commit is contained in:
Linus Rath
2026-11-04 03:13:58 +01:00
parent bc8653de8b
commit 6dbda4424e
2 changed files with 72 additions and 4 deletions
+54
View File
@@ -903,6 +903,60 @@ describe('a subscription id the server gave to another device', () => {
});
});
describe('a setup that gets stuck', () => {
afterEach(() => {
vi.useRealTimers();
delete (navigator as unknown as { locks?: unknown }).locks;
});
it('fails in the end and lets the next one through', async () => {
vi.useFakeTimers();
let tail: Promise<unknown> = Promise.resolve();
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request: (_n: string, run: () => Promise<unknown>) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } },
});
const { registration } = installPushBrowser();
(registration.pushManager.getSubscription as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
(registration.pushManager.subscribe as ReturnType<typeof vi.fn>).mockReturnValueOnce(new Promise(() => {}));
const client = makeClient([]);
installFetch({});
const stuck = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed');
await vi.advanceTimersByTimeAsync(1_000);
const second = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed');
await vi.advanceTimersByTimeAsync(200_000);
expect(await stuck).toBe('failed');
expect(await second).toBe('done');
});
it('turns off for the account it was asked about, even if the client disconnected while waiting', async () => {
let tail: Promise<unknown> = Promise.resolve();
let release = () => {};
tail = new Promise<void>((resolve) => { release = resolve; });
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request: (_n: string, run: () => Promise<unknown>) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } },
});
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
let accountId = ACCOUNT_ID;
(client as unknown as { getAccountId: () => string }).getAccountId = () => accountId;
const calls = installFetch({});
const off = disableWebPush({ client, relayBaseUrl: RELAY }).catch(() => undefined);
accountId = '';
release();
await off;
expect(localStorage.getItem(SUB_KEY)).toBeNull();
expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' });
});
});
describe('resyncWebPush when the relay changed (#903)', () => {
afterEach(() => setNoThirdPartyTraffic(false));
+18 -4
View File
@@ -775,8 +775,21 @@ async function refreshSubscriptionExpires(
// verifying. Tabs take turns, in every browser that has Web Locks. Whoever comes
// second finds the first one's work and only refreshes it.
function oneTabAtATime<T>(run: () => Promise<T>): Promise<T> {
const limited = () => withinPushSetupLimit(run);
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
return locks ? locks.request('bulwark-web-push', run) as Promise<T> : run();
return locks ? locks.request('bulwark-web-push', limited) as Promise<T> : limited();
}
// A worker that never gets ready or a subscribe() that never answers would hold up
// every tab and every login, for good. Verification alone can take 80 s.
const PUSH_SETUP_LIMIT_MS = 120_000;
function withinPushSetupLimit<T>(run: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const limit = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new WebPushStepError('verification-timeout', 'Push setup took too long')), PUSH_SETUP_LIMIT_MS);
});
return Promise.race([run(), limit]).finally(() => clearTimeout(timer));
}
export async function enableWebPush(
@@ -1018,12 +1031,13 @@ async function dropRelayRecord(relayBaseUrl: string, deviceClientId: string): Pr
// a later page load won't bring push back. But a server or relay that couldn't be
// told keeps pinging this browser for days, so the caller hears about it at the end.
export function disableWebPush(params: DisableWebPushParams): Promise<void> {
return oneTabAtATime(() => disableWebPushForThisTab(params));
// Read now: a client signing out loses it while this waits its turn.
const accountId = params.client.getAccountId();
return oneTabAtATime(() => disableWebPushForThisTab(params, accountId));
}
async function disableWebPushForThisTab(params: DisableWebPushParams): Promise<void> {
async function disableWebPushForThisTab(params: DisableWebPushParams, accountId: string): Promise<void> {
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
const accountId = params.client.getAccountId();
const subIdKey = subscriptionIdKey(accountId);
const devIdKey = deviceClientIdKey(accountId);