Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions preview/pageA.html
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
31 changes: 21 additions & 10 deletions src/shared/database/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -323,7 +323,7 @@ describe('migrations', () => {
});
});

describe('Options write timeout', () => {
describe('db timeout', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
});
Expand All @@ -332,30 +332,41 @@ 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 every op, reads included', 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<typeof realPut>[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 short-circuit to a no-op.
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();

// 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([]);
});
});

Expand Down
69 changes: 35 additions & 34 deletions src/shared/database/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,38 +93,34 @@ 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, 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. 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<T>(
storeName: IDBStoreName,
label: string,
op: () => Promise<T>,
): Promise<T | undefined> {
if (storeName !== 'Options') return op();
if (optionsWriteWedged) return Promise.resolve(undefined);
// timeout scopes only to the request, not DB open/upgrade.
function guard<T>(label: string, op: () => Promise<T>, fallback: T): Promise<T> {
if (dbWedged) return Promise.resolve(fallback);
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<undefined>((resolve) => {
const timeout = new Promise<T>((resolve) => {
timer = setTimeout(() => {
optionsWriteWedged = true;
dbWedged = true;
Log._warn(`db.${label} timed out`);
resolve(undefined);
}, OPTIONS_WRITE_TIMEOUT_MS);
resolve(fallback);
}, DB_TIMEOUT_MS);
});
return Promise.race([op(), timeout]).finally(() => clearTimeout(timer));
}
Expand All @@ -134,25 +130,30 @@ export const db = {
storeName: K,
key: IndexedDBSchema[K]['key'],
): Promise<IndexedDBSchema[K]['value'] | undefined> {
return (await dbPromise).get(storeName, key);
const _db = await dbPromise;
return guard(`get(${storeName})`, () => _db.get(storeName, key), undefined);
},
async getAll<K extends IDBStoreName>(storeName: K): Promise<IndexedDBSchema[K]['value'][]> {
return (await dbPromise).getAll(storeName);
const _db = await dbPromise;
return guard<IndexedDBSchema[K]['value'][]>(
`getAll(${storeName})`,
() => _db.getAll(storeName),
[],
);
},
async put<K extends IDBStoreName>(storeName: K, value: IndexedDBSchema[K]['value']) {
const _db = await dbPromise;
return guardOptionsWrite(storeName, `put(${storeName})`, () => _db.put(storeName, value));
return guard(`put(${storeName})`, () => _db.put(storeName, value), undefined);
},
async delete<K extends IDBStoreName>(storeName: K, key: IndexedDBSchema[K]['key']) {
const _db = await dbPromise;
return guardOptionsWrite(storeName, `delete(${storeName}/${key})`, () =>
_db.delete(storeName, key),
);
return guard(`delete(${storeName}/${key})`, () => _db.delete(storeName, key), undefined);
},
};

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

export const getObjectStoreNames = async () => {
Expand Down
6 changes: 3 additions & 3 deletions src/shared/helpers/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ vi.mock('../database/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../database/client')>();
return {
...actual,
isOptionsWriteWedged: vi.fn(() => false),
isReadwriteWedged: vi.fn(() => false),
};
});

Expand Down Expand Up @@ -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();

Expand Down
6 changes: 3 additions & 3 deletions src/shared/helpers/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Loading