Skip to content

Commit 21f1a4c

Browse files
authored
fix: [SDK-4754] guard IndexedDB reads + writes from iOS Safari PWA wedge (#1472)
1 parent b7bb0cf commit 21f1a4c

5 files changed

Lines changed: 70 additions & 50 deletions

File tree

preview/pageA.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@
4545
});
4646
const elapsed = Math.round(performance.now() - t0);
4747
console.log(`!!!! [SDK-4336 PAGE A] OneSignal initialized (${elapsed}ms)`);
48+
49+
try {
50+
await OneSignal.login('sdk4336-user');
51+
OneSignal.User.addTag('sdk4336_load', String(Date.now()));
52+
console.log('!!!! [SDK-4336 PAGE A] login + addTag enqueued');
53+
} catch (e) {
54+
console.log('!!!! [SDK-4336 PAGE A] login/addTag error', e);
55+
}
4856
});
4957

5058
async function requestNotificationPermission() {

src/shared/database/client.test.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { APP_ID, EXTERNAL_ID, ONESIGNAL_ID } from '__test__/constants';
22
import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';
33

44
import { SubscriptionType } from '../subscriptions/constants';
5-
import { closeDb, db, getDb, isOptionsWriteWedged } from './client';
5+
import { closeDb, db, getDb, isReadwriteWedged } from './client';
66
import { DATABASE_NAME } from './constants';
77
import type * as idbLite from './idb-lite';
88
import { wrapRequest } from './idb-lite';
@@ -323,7 +323,7 @@ describe('migrations', () => {
323323
});
324324
});
325325

326-
describe('Options write timeout', () => {
326+
describe('db timeout', () => {
327327
beforeEach(() => {
328328
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
329329
});
@@ -332,30 +332,41 @@ describe('Options write timeout', () => {
332332
vi.useRealTimers();
333333
});
334334

335-
test('clears the timeout when an Options put resolves before it fires', async () => {
335+
test('clears the timeout when a write resolves before it fires', async () => {
336336
await getDb();
337337
await db.put('Options', { key: 'userConsent', value: true });
338338
expect(vi.getTimerCount()).toBe(0);
339339
});
340340

341-
test('trips circuit breaker on Options put timeout, short-circuits subsequent writes', async () => {
342-
expect(isOptionsWriteWedged()).toBe(false);
341+
test('trips circuit breaker on a wedged write and short-circuits every op, reads included', async () => {
342+
expect(isReadwriteWedged()).toBe(false);
343343

344344
const _db = await getDb();
345345
const realPut = _db.put.bind(_db);
346346
vi.spyOn(_db, 'put').mockImplementation(((s: string, v: unknown) => {
347-
if (s === 'Options') return new Promise(() => {});
347+
if (s === 'operations') return new Promise(() => {});
348348
return realPut(s as Parameters<typeof realPut>[0], v as never);
349349
}) as typeof _db.put);
350350

351-
const first = db.put('Options', { key: 'isPushEnabled', value: true });
351+
const first = db.put('operations', {
352+
modelId: '1',
353+
modelName: 'operations',
354+
name: 'create-subscription',
355+
});
352356
await vi.advanceTimersByTimeAsync(2001);
353357
expect(await first).toBeUndefined();
354-
expect(isOptionsWriteWedged()).toBe(true);
358+
expect(isReadwriteWedged()).toBe(true);
355359

360+
// Once wedged, writes to any store short-circuit to a no-op.
356361
expect(await db.put('Options', { key: 'lastPushId', value: 'x' })).toBeUndefined();
357-
await db.put('Ids', { type: 'appId', id: 'A' });
358-
expect((await db.get('Ids', 'appId'))?.id).toBe('A');
362+
expect(await db.put('Ids', { type: 'appId', id: 'A' })).toBeUndefined();
363+
expect(await db.delete('Options', 'lastPushId')).toBeUndefined();
364+
365+
// A wedged write leaves the IndexedDB txn open and blocks same-store reads,
366+
// so reads must short-circuit too: get -> undefined, getAll -> []. Without
367+
// this, init() hangs on the first post-wedge read of the wedged store.
368+
expect(await db.get('Ids', 'appId')).toBeUndefined();
369+
expect(await db.getAll('Options')).toEqual([]);
359370
});
360371
});
361372

src/shared/database/client.ts

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -93,38 +93,34 @@ export const getDb = (version = VERSION) => {
9393
return dbPromise;
9494
};
9595

96-
// On iOS Safari PWA after a push subscription, `readwrite` requests on the
97-
// `Options` object store can stall indefinitely (no success/error/abort).
98-
// Other stores and reads are unaffected, and reopening the DB doesn't help.
99-
// Without this guard, `OneSignal.init()` hangs until WebKit's watchdog
100-
// eventually aborts the transaction (~30 minutes). Workaround: cap Options
101-
// writes with a short timeout, then trip a page-scoped circuit breaker so
102-
// subsequent writes short-circuit. The values that fail to persist are
103-
// session metadata the SW reads with sensible fallbacks. Remove if WebKit
104-
// ever fixes the underlying bug: https://bugs.webkit.org/show_bug.cgi?id=315804
105-
const OPTIONS_WRITE_TIMEOUT_MS = 1500;
106-
let optionsWriteWedged = false;
107-
108-
export const isOptionsWriteWedged = () => optionsWriteWedged;
96+
// On iOS Safari PWA after a push subscription, a `readwrite` request can stall
97+
// indefinitely (no success/error/abort). Our timeout makes the JS promise
98+
// resolve, but the underlying IndexedDB transaction stays open and blocks every
99+
// later operation queued behind it on that object store -- including reads. So
100+
// guarding writes alone isn't enough: once a write wedges, the next read of the
101+
// same store (e.g. Options) hangs too, stalling `OneSignal.init()` until
102+
// WebKit's watchdog aborts the txn (~30 minutes). Workaround: cap every op with
103+
// a short timeout, trip a page-scoped circuit breaker on the first stall, then
104+
// short-circuit all subsequent ops (reads included). Dropped writes are session
105+
// metadata the SW re-derives or idempotent queued operations retried next load;
106+
// dropped reads fall back to the in-memory model state hydrated before the
107+
// wedge. Remove if WebKit ever fixes it: https://bugs.webkit.org/show_bug.cgi?id=315804
108+
const DB_TIMEOUT_MS = 1500;
109+
let dbWedged = false;
110+
111+
export const isReadwriteWedged = () => dbWedged;
109112

110113
// `op` is invoked synchronously (callers await `dbPromise` first), so the
111-
// timeout scopes only to the readwrite request, not DB open/upgrade. Once a
112-
// write times out we trip a page-scoped circuit breaker so the rest of init's
113-
// Options writes short-circuit instead of each paying the full timeout.
114-
function guardOptionsWrite<T>(
115-
storeName: IDBStoreName,
116-
label: string,
117-
op: () => Promise<T>,
118-
): Promise<T | undefined> {
119-
if (storeName !== 'Options') return op();
120-
if (optionsWriteWedged) return Promise.resolve(undefined);
114+
// timeout scopes only to the request, not DB open/upgrade.
115+
function guard<T>(label: string, op: () => Promise<T>, fallback: T): Promise<T> {
116+
if (dbWedged) return Promise.resolve(fallback);
121117
let timer: ReturnType<typeof setTimeout>;
122-
const timeout = new Promise<undefined>((resolve) => {
118+
const timeout = new Promise<T>((resolve) => {
123119
timer = setTimeout(() => {
124-
optionsWriteWedged = true;
120+
dbWedged = true;
125121
Log._warn(`db.${label} timed out`);
126-
resolve(undefined);
127-
}, OPTIONS_WRITE_TIMEOUT_MS);
122+
resolve(fallback);
123+
}, DB_TIMEOUT_MS);
128124
});
129125
return Promise.race([op(), timeout]).finally(() => clearTimeout(timer));
130126
}
@@ -134,25 +130,30 @@ export const db = {
134130
storeName: K,
135131
key: IndexedDBSchema[K]['key'],
136132
): Promise<IndexedDBSchema[K]['value'] | undefined> {
137-
return (await dbPromise).get(storeName, key);
133+
const _db = await dbPromise;
134+
return guard(`get(${storeName})`, () => _db.get(storeName, key), undefined);
138135
},
139136
async getAll<K extends IDBStoreName>(storeName: K): Promise<IndexedDBSchema[K]['value'][]> {
140-
return (await dbPromise).getAll(storeName);
137+
const _db = await dbPromise;
138+
return guard<IndexedDBSchema[K]['value'][]>(
139+
`getAll(${storeName})`,
140+
() => _db.getAll(storeName),
141+
[],
142+
);
141143
},
142144
async put<K extends IDBStoreName>(storeName: K, value: IndexedDBSchema[K]['value']) {
143145
const _db = await dbPromise;
144-
return guardOptionsWrite(storeName, `put(${storeName})`, () => _db.put(storeName, value));
146+
return guard(`put(${storeName})`, () => _db.put(storeName, value), undefined);
145147
},
146148
async delete<K extends IDBStoreName>(storeName: K, key: IndexedDBSchema[K]['key']) {
147149
const _db = await dbPromise;
148-
return guardOptionsWrite(storeName, `delete(${storeName}/${key})`, () =>
149-
_db.delete(storeName, key),
150-
);
150+
return guard(`delete(${storeName}/${key})`, () => _db.delete(storeName, key), undefined);
151151
},
152152
};
153153

154154
export const clearStore = async <K extends IDBStoreName>(storeName: K) => {
155-
return (await dbPromise).clear(storeName);
155+
const _db = await dbPromise;
156+
return guard(`clear(${storeName})`, () => _db.clear(storeName), undefined);
156157
};
157158

158159
export const getObjectStoreNames = async () => {

src/shared/helpers/init.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ vi.mock('../database/client', async (importOriginal) => {
1515
const actual = await importOriginal<typeof import('../database/client')>();
1616
return {
1717
...actual,
18-
isOptionsWriteWedged: vi.fn(() => false),
18+
isReadwriteWedged: vi.fn(() => false),
1919
};
2020
});
2121

@@ -201,11 +201,11 @@ describe('initSaveState: App ID migration', () => {
201201
expect(storedAppId?.id).toBe(NEW_APP_ID);
202202
});
203203

204-
test('defers App ID commit when Options write breaker is tripped', async () => {
204+
test('defers App ID commit when readwrite breaker is tripped', async () => {
205205
await seedStaleState();
206206
await db.put('Ids', { type: 'userId', id: 'old-user-id' });
207207

208-
vi.mocked(clientModule.isOptionsWriteWedged).mockReturnValueOnce(true);
208+
vi.mocked(clientModule.isReadwriteWedged).mockReturnValueOnce(true);
209209

210210
await InitHelper.initSaveState();
211211

src/shared/helpers/init.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ModelChangeTags } from 'src/core/types/models';
33
import Bell from '../../page/bell/Bell';
44
import type { AppConfig } from '../config/types';
55
import type { ContextInterface } from '../context/types';
6-
import { db, getIdsValue, isOptionsWriteWedged } from '../database/client';
6+
import { db, getIdsValue, isReadwriteWedged } from '../database/client';
77
import { getSubscription, setSubscription } from '../database/subscription';
88
import type { OptionKey } from '../database/types';
99
import Log from '../libraries/Log';
@@ -352,12 +352,12 @@ export async function initSaveState(overridingPageTitle?: string) {
352352
await db.put('Options', { key: 'lastPushId', value: null });
353353
await db.put('Options', { key: 'lastPushToken', value: null });
354354
await db.put('Options', { key: 'lastOptedIn', value: null });
355-
// Bail out if the Options reset got circuit-broken. Committing the new
355+
// Bail out if the reset writes got circuit-broken. Committing the new
356356
// appId now would strand the previous app's metadata under it, and the
357357
// `previousAppId !== appId` gate above would keep us out of this branch
358358
// on later loads — leaving the stale values permanent. Skipping the
359359
// appId commit instead lets a future non-wedged load complete the reset.
360-
if (isOptionsWriteWedged()) return;
360+
if (isReadwriteWedged()) return;
361361
await db.put('Ids', { type: 'registrationId', id: null });
362362
await db.put('Ids', { type: 'userId', id: null });
363363
OneSignal._coreDirector._subscriptionModelStore._clear(ModelChangeTags._Hydrate);

0 commit comments

Comments
 (0)