From 3f71e399eae2a11593c507d34d10ae8e2b297599 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Fri, 5 Jun 2026 13:08:26 -0700 Subject: [PATCH 1/4] fix: [SDK-4754] extend IndexedDB wedge guard to all readwrite ops The 160605 fix only guarded the Options store, but the iOS Safari PWA WebKit readwrite wedge is DB-wide: once init clears the Options write, it re-hangs at the next unguarded write (operation queue / model-store persistence). Generalize the timeout + page-scoped circuit breaker to every readwrite op (put/delete/clear) across all stores. --- src/shared/database/client.test.ts | 29 ++++++++++------ src/shared/database/client.ts | 53 +++++++++++++----------------- src/shared/helpers/init.test.ts | 6 ++-- src/shared/helpers/init.ts | 6 ++-- 4 files changed, 48 insertions(+), 46 deletions(-) diff --git a/src/shared/database/client.test.ts b/src/shared/database/client.test.ts index 1238eb17e..76472dfee 100644 --- a/src/shared/database/client.test.ts +++ b/src/shared/database/client.test.ts @@ -2,7 +2,7 @@ import { APP_ID, EXTERNAL_ID, ONESIGNAL_ID } from '__test__/constants'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test'; import { SubscriptionType } from '../subscriptions/constants'; -import { closeDb, db, getDb, isOptionsWriteWedged } from './client'; +import { closeDb, db, getDb, isReadwriteWedged } from './client'; import { DATABASE_NAME } from './constants'; import type * as idbLite from './idb-lite'; import { wrapRequest } from './idb-lite'; @@ -323,7 +323,7 @@ describe('migrations', () => { }); }); -describe('Options write timeout', () => { +describe('readwrite timeout', () => { beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); }); @@ -332,30 +332,39 @@ describe('Options write timeout', () => { vi.useRealTimers(); }); - test('clears the timeout when an Options put resolves before it fires', async () => { + test('clears the timeout when a write resolves before it fires', async () => { await getDb(); await db.put('Options', { key: 'userConsent', value: true }); expect(vi.getTimerCount()).toBe(0); }); - test('trips circuit breaker on Options put timeout, short-circuits subsequent writes', async () => { - expect(isOptionsWriteWedged()).toBe(false); + test('trips circuit breaker on a wedged write and short-circuits writes to every store', async () => { + expect(isReadwriteWedged()).toBe(false); const _db = await getDb(); const realPut = _db.put.bind(_db); vi.spyOn(_db, 'put').mockImplementation(((s: string, v: unknown) => { - if (s === 'Options') return new Promise(() => {}); + if (s === 'operations') return new Promise(() => {}); return realPut(s as Parameters[0], v as never); }) as typeof _db.put); - const first = db.put('Options', { key: 'isPushEnabled', value: true }); + const first = db.put('operations', { + modelId: '1', + modelName: 'operations', + name: 'create-subscription', + }); await vi.advanceTimersByTimeAsync(2001); expect(await first).toBeUndefined(); - expect(isOptionsWriteWedged()).toBe(true); + expect(isReadwriteWedged()).toBe(true); + // Once wedged, writes to any store (not just the one that timed out) + // short-circuit to a no-op instead of hanging. expect(await db.put('Options', { key: 'lastPushId', value: 'x' })).toBeUndefined(); - await db.put('Ids', { type: 'appId', id: 'A' }); - expect((await db.get('Ids', 'appId'))?.id).toBe('A'); + expect(await db.put('Ids', { type: 'appId', id: 'A' })).toBeUndefined(); + expect(await db.delete('Options', 'lastPushId')).toBeUndefined(); + + // Reads are unaffected; the skipped writes never persisted. + expect(await db.get('Ids', 'appId')).toBeUndefined(); }); }); diff --git a/src/shared/database/client.ts b/src/shared/database/client.ts index 62368e428..ccfdd0e71 100644 --- a/src/shared/database/client.ts +++ b/src/shared/database/client.ts @@ -93,38 +93,32 @@ export const getDb = (version = VERSION) => { return dbPromise; }; -// On iOS Safari PWA after a push subscription, `readwrite` requests on the -// `Options` object store can stall indefinitely (no success/error/abort). -// Other stores and reads are unaffected, and reopening the DB doesn't help. -// Without this guard, `OneSignal.init()` hangs until WebKit's watchdog -// eventually aborts the transaction (~30 minutes). Workaround: cap Options -// writes with a short timeout, then trip a page-scoped circuit breaker so -// subsequent writes short-circuit. The values that fail to persist are -// session metadata the SW reads with sensible fallbacks. Remove if WebKit -// ever fixes the underlying bug: https://bugs.webkit.org/show_bug.cgi?id=315804 -const OPTIONS_WRITE_TIMEOUT_MS = 1500; -let optionsWriteWedged = false; - -export const isOptionsWriteWedged = () => optionsWriteWedged; +// On iOS Safari PWA after a push subscription, `readwrite` requests can stall +// indefinitely (no success/error/abort) across the whole database, not just a +// single store; reads are unaffected and reopening the DB doesn't help. Without +// this guard, `OneSignal.init()` and the operation queue hang until WebKit's +// watchdog eventually aborts the transaction (~30 minutes). Workaround: cap each +// readwrite op with a short timeout, then trip a page-scoped circuit breaker so +// subsequent writes short-circuit. Dropped values are either session metadata +// the SW re-derives or queued operations whose server-side effects are +// idempotent and retried on the next load. Remove if WebKit ever fixes the +// underlying bug: https://bugs.webkit.org/show_bug.cgi?id=315804 +const READWRITE_TIMEOUT_MS = 1500; +let readwriteWedged = false; + +export const isReadwriteWedged = () => readwriteWedged; // `op` is invoked synchronously (callers await `dbPromise` first), so the -// timeout scopes only to the readwrite request, not DB open/upgrade. Once a -// write times out we trip a page-scoped circuit breaker so the rest of init's -// Options writes short-circuit instead of each paying the full timeout. -function guardOptionsWrite( - storeName: IDBStoreName, - label: string, - op: () => Promise, -): Promise { - if (storeName !== 'Options') return op(); - if (optionsWriteWedged) return Promise.resolve(undefined); +// timeout scopes only to the readwrite request, not DB open/upgrade. +function guardReadwrite(label: string, op: () => Promise): Promise { + if (readwriteWedged) return Promise.resolve(undefined); let timer: ReturnType; const timeout = new Promise((resolve) => { timer = setTimeout(() => { - optionsWriteWedged = true; + readwriteWedged = true; Log._warn(`db.${label} timed out`); resolve(undefined); - }, OPTIONS_WRITE_TIMEOUT_MS); + }, READWRITE_TIMEOUT_MS); }); return Promise.race([op(), timeout]).finally(() => clearTimeout(timer)); } @@ -141,18 +135,17 @@ export const db = { }, async put(storeName: K, value: IndexedDBSchema[K]['value']) { const _db = await dbPromise; - return guardOptionsWrite(storeName, `put(${storeName})`, () => _db.put(storeName, value)); + return guardReadwrite(`put(${storeName})`, () => _db.put(storeName, value)); }, async delete(storeName: K, key: IndexedDBSchema[K]['key']) { const _db = await dbPromise; - return guardOptionsWrite(storeName, `delete(${storeName}/${key})`, () => - _db.delete(storeName, key), - ); + return guardReadwrite(`delete(${storeName}/${key})`, () => _db.delete(storeName, key)); }, }; export const clearStore = async (storeName: K) => { - return (await dbPromise).clear(storeName); + const _db = await dbPromise; + return guardReadwrite(`clear(${storeName})`, () => _db.clear(storeName)); }; export const getObjectStoreNames = async () => { diff --git a/src/shared/helpers/init.test.ts b/src/shared/helpers/init.test.ts index f8d004026..bf752c768 100644 --- a/src/shared/helpers/init.test.ts +++ b/src/shared/helpers/init.test.ts @@ -15,7 +15,7 @@ vi.mock('../database/client', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - isOptionsWriteWedged: vi.fn(() => false), + isReadwriteWedged: vi.fn(() => false), }; }); @@ -201,11 +201,11 @@ describe('initSaveState: App ID migration', () => { expect(storedAppId?.id).toBe(NEW_APP_ID); }); - test('defers App ID commit when Options write breaker is tripped', async () => { + test('defers App ID commit when readwrite breaker is tripped', async () => { await seedStaleState(); await db.put('Ids', { type: 'userId', id: 'old-user-id' }); - vi.mocked(clientModule.isOptionsWriteWedged).mockReturnValueOnce(true); + vi.mocked(clientModule.isReadwriteWedged).mockReturnValueOnce(true); await InitHelper.initSaveState(); diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts index 1fd9e54ff..92adafba1 100755 --- a/src/shared/helpers/init.ts +++ b/src/shared/helpers/init.ts @@ -3,7 +3,7 @@ import { ModelChangeTags } from 'src/core/types/models'; import Bell from '../../page/bell/Bell'; import type { AppConfig } from '../config/types'; import type { ContextInterface } from '../context/types'; -import { db, getIdsValue, isOptionsWriteWedged } from '../database/client'; +import { db, getIdsValue, isReadwriteWedged } from '../database/client'; import { getSubscription, setSubscription } from '../database/subscription'; import type { OptionKey } from '../database/types'; import Log from '../libraries/Log'; @@ -352,12 +352,12 @@ export async function initSaveState(overridingPageTitle?: string) { await db.put('Options', { key: 'lastPushId', value: null }); await db.put('Options', { key: 'lastPushToken', value: null }); await db.put('Options', { key: 'lastOptedIn', value: null }); - // Bail out if the Options reset got circuit-broken. Committing the new + // Bail out if the reset writes got circuit-broken. Committing the new // appId now would strand the previous app's metadata under it, and the // `previousAppId !== appId` gate above would keep us out of this branch // on later loads — leaving the stale values permanent. Skipping the // appId commit instead lets a future non-wedged load complete the reset. - if (isOptionsWriteWedged()) return; + if (isReadwriteWedged()) return; await db.put('Ids', { type: 'registrationId', id: null }); await db.put('Ids', { type: 'userId', id: null }); OneSignal._coreDirector._subscriptionModelStore._clear(ModelChangeTags._Hydrate); From d38032645ccd311258fd9af7f4b8483f78b90757 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Mon, 8 Jun 2026 18:25:04 -0700 Subject: [PATCH 2/4] fix: [SDK-4754] short-circuit IndexedDB reads when wedged The 1.5s timeout makes the readwrite promise resolve, but the underlying WebKit IndexedDB transaction stays open and serializes every later op on that object store behind it -- including reads. Guarding only writes let init() crawl past the wedged Options write and then hang on the first post-wedge read of the same store (getSubscription's Options reads during storeInitialValues), reproducing the ~30 min stall. Route get/getAll through the same timeout + page-scoped circuit breaker as put/delete/clear: once wedged, reads short-circuit to a fallback (get -> undefined, getAll -> []) and a read that itself stalls also trips the breaker. Dropped reads fall back to the in-memory model state hydrated before the wedge. --- src/shared/database/client.ts | 60 ++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/shared/database/client.ts b/src/shared/database/client.ts index ccfdd0e71..a621babde 100644 --- a/src/shared/database/client.ts +++ b/src/shared/database/client.ts @@ -93,32 +93,34 @@ export const getDb = (version = VERSION) => { return dbPromise; }; -// On iOS Safari PWA after a push subscription, `readwrite` requests can stall -// indefinitely (no success/error/abort) across the whole database, not just a -// single store; reads are unaffected and reopening the DB doesn't help. Without -// this guard, `OneSignal.init()` and the operation queue hang until WebKit's -// watchdog eventually aborts the transaction (~30 minutes). Workaround: cap each -// readwrite op with a short timeout, then trip a page-scoped circuit breaker so -// subsequent writes short-circuit. Dropped values are either session metadata -// the SW re-derives or queued operations whose server-side effects are -// idempotent and retried on the next load. Remove if WebKit ever fixes the -// underlying bug: https://bugs.webkit.org/show_bug.cgi?id=315804 -const READWRITE_TIMEOUT_MS = 1500; -let readwriteWedged = false; - -export const isReadwriteWedged = () => readwriteWedged; +// On iOS Safari PWA after a push subscription, a `readwrite` request can stall +// indefinitely (no success/error/abort). Our timeout makes the JS promise +// resolve, but the underlying IndexedDB transaction stays open and blocks every +// later operation queued behind it on that object store -- including reads. So +// guarding writes alone isn't enough: once a write wedges, the next read of the +// same store (e.g. Options) hangs too, stalling `OneSignal.init()` until +// WebKit's watchdog aborts the txn (~30 minutes). Workaround: cap every op with +// a short timeout, trip a page-scoped circuit breaker on the first stall, then +// short-circuit all subsequent ops (reads included). Dropped writes are session +// metadata the SW re-derives or idempotent queued operations retried next load; +// dropped reads fall back to the in-memory model state hydrated before the +// wedge. Remove if WebKit ever fixes it: https://bugs.webkit.org/show_bug.cgi?id=315804 +const DB_TIMEOUT_MS = 1500; +let dbWedged = false; + +export const isReadwriteWedged = () => dbWedged; // `op` is invoked synchronously (callers await `dbPromise` first), so the -// timeout scopes only to the readwrite request, not DB open/upgrade. -function guardReadwrite(label: string, op: () => Promise): Promise { - if (readwriteWedged) return Promise.resolve(undefined); +// timeout scopes only to the request, not DB open/upgrade. +function guard(label: string, op: () => Promise, fallback: T): Promise { + if (dbWedged) return Promise.resolve(fallback); let timer: ReturnType; - const timeout = new Promise((resolve) => { + const timeout = new Promise((resolve) => { timer = setTimeout(() => { - readwriteWedged = true; + dbWedged = true; Log._warn(`db.${label} timed out`); - resolve(undefined); - }, READWRITE_TIMEOUT_MS); + resolve(fallback); + }, DB_TIMEOUT_MS); }); return Promise.race([op(), timeout]).finally(() => clearTimeout(timer)); } @@ -128,24 +130,30 @@ export const db = { storeName: K, key: IndexedDBSchema[K]['key'], ): Promise { - return (await dbPromise).get(storeName, key); + const _db = await dbPromise; + return guard(`get(${storeName})`, () => _db.get(storeName, key), undefined); }, async getAll(storeName: K): Promise { - return (await dbPromise).getAll(storeName); + const _db = await dbPromise; + return guard( + `getAll(${storeName})`, + () => _db.getAll(storeName), + [], + ); }, async put(storeName: K, value: IndexedDBSchema[K]['value']) { const _db = await dbPromise; - return guardReadwrite(`put(${storeName})`, () => _db.put(storeName, value)); + return guard(`put(${storeName})`, () => _db.put(storeName, value), undefined); }, async delete(storeName: K, key: IndexedDBSchema[K]['key']) { const _db = await dbPromise; - return guardReadwrite(`delete(${storeName}/${key})`, () => _db.delete(storeName, key)); + return guard(`delete(${storeName}/${key})`, () => _db.delete(storeName, key), undefined); }, }; export const clearStore = async (storeName: K) => { const _db = await dbPromise; - return guardReadwrite(`clear(${storeName})`, () => _db.clear(storeName)); + return guard(`clear(${storeName})`, () => _db.clear(storeName), undefined); }; export const getObjectStoreNames = async () => { From 0d811c7536378205c15889660a1bc531b5ef0c00 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Mon, 8 Jun 2026 18:27:27 -0700 Subject: [PATCH 3/4] test: [SDK-4754] assert reads short-circuit on a wedged db Extend the breaker test to verify that once a wedged write trips the circuit breaker, reads short-circuit too (get -> undefined, getAll -> []), locking in the guard that keeps init() from hanging on the first post-wedge read of the wedged store. --- src/shared/database/client.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/shared/database/client.test.ts b/src/shared/database/client.test.ts index 76472dfee..553f9769d 100644 --- a/src/shared/database/client.test.ts +++ b/src/shared/database/client.test.ts @@ -323,7 +323,7 @@ describe('migrations', () => { }); }); -describe('readwrite timeout', () => { +describe('db timeout', () => { beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); }); @@ -338,7 +338,7 @@ describe('readwrite timeout', () => { expect(vi.getTimerCount()).toBe(0); }); - test('trips circuit breaker on a wedged write and short-circuits writes to every store', async () => { + test('trips circuit breaker on a wedged write and short-circuits every op, reads included', async () => { expect(isReadwriteWedged()).toBe(false); const _db = await getDb(); @@ -357,14 +357,16 @@ describe('readwrite timeout', () => { expect(await first).toBeUndefined(); expect(isReadwriteWedged()).toBe(true); - // Once wedged, writes to any store (not just the one that timed out) - // short-circuit to a no-op instead of hanging. + // Once wedged, writes to any store short-circuit to a no-op. expect(await db.put('Options', { key: 'lastPushId', value: 'x' })).toBeUndefined(); expect(await db.put('Ids', { type: 'appId', id: 'A' })).toBeUndefined(); expect(await db.delete('Options', 'lastPushId')).toBeUndefined(); - // Reads are unaffected; the skipped writes never persisted. + // A wedged write leaves the IndexedDB txn open and blocks same-store reads, + // so reads must short-circuit too: get -> undefined, getAll -> []. Without + // this, init() hangs on the first post-wedge read of the wedged store. expect(await db.get('Ids', 'appId')).toBeUndefined(); + expect(await db.getAll('Options')).toEqual([]); }); }); From daaf408157046b072825aa6e77bfab647c42ce60 Mon Sep 17 00:00:00 2001 From: sherwinski Date: Mon, 8 Jun 2026 18:27:35 -0700 Subject: [PATCH 4/4] chore(preview): drive operation-queue traffic in iOS PWA repro After init, Page A now calls login + addTag so the operations store keeps getting writes on every load. This mirrors the customer's app behavior and reliably reproduces the readwrite wedge (and the subsequent same-store read hang) that the guard fix addresses. --- preview/pageA.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/preview/pageA.html b/preview/pageA.html index 179ccb547..740e84ffc 100644 --- a/preview/pageA.html +++ b/preview/pageA.html @@ -45,6 +45,14 @@ }); const elapsed = Math.round(performance.now() - t0); console.log(`!!!! [SDK-4336 PAGE A] OneSignal initialized (${elapsed}ms)`); + + try { + await OneSignal.login('sdk4336-user'); + OneSignal.User.addTag('sdk4336_load', String(Date.now())); + console.log('!!!! [SDK-4336 PAGE A] login + addTag enqueued'); + } catch (e) { + console.log('!!!! [SDK-4336 PAGE A] login/addTag error', e); + } }); async function requestNotificationPermission() {