fix: [SDK-4336] add circuit breaker and defer appId commit on wedge#1469
fix: [SDK-4336] add circuit breaker and defer appId commit on wedge#1469sherwinski wants to merge 2 commits into
Conversation
Follow-up hardening on top of 7f5df21, addressing two gaps surfaced during review of #1468: 1. Add a page-scoped circuit breaker (`optionsWriteWedged`) that trips on the first Options-write timeout. Without it, `OneSignal.init()` pays the full 2s timeout on each of ~7 Options writes during a wedged init (~14s total). With it, the first wedge trips the breaker and the rest short-circuit immediately (~2s total). 2. Defer the new-appId commit in `initSaveState` when the Options reset got circuit-broken. The `Ids.appId` write is unguarded (the guard is Options-only), so without this gate it would succeed while the previous app's `isPushEnabled`/`lastPush*`/`lastOptedIn` stayed put, and the `previousAppId !== appId` branch would never re-enter on later loads — leaving cross-app contamination permanent. Skipping the commit lets a future non-wedged load complete the reset. Also fixes a minor leak: the timer is now cleared via `.finally()` when `op` settles before the timeout, so healthy pages doing many Options writes don't accumulate dangling 2s timers. Adds a single `Log._warn` on timeout so support can correlate the breaker tripping with init slowness in console logs. Bumps the size-limit budget by ~80 B (page) / ~120 B (sw) to fit.
There was a problem hiding this comment.
Pull request overview
This PR hardens IndexedDB Options writes against iOS Safari PWA “wedge” behavior by adding a circuit breaker (to avoid repeated timeouts) and by preventing Ids.appId from being committed during an app-id switch when Options writes have wedged, avoiding permanent cross-app state contamination.
Changes:
- Add an
Options-write circuit breaker that trips on the first timeout and short-circuits subsequentOptionswrites. - Defer
Ids.appIdcommit during app-id migration if theOptionsreset was circuit-broken. - Clear the
setTimeouttimer via.finally()to avoid accumulating dangling timers; bump size limits accordingly.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
src/shared/helpers/init.ts |
Bails out of app-id migration before committing the new appId when Options writes are wedged. |
src/shared/database/client.ts |
Implements the Options write circuit breaker and exports isOptionsWriteWedged(). |
package.json |
Adjusts size-limit thresholds to account for small bundle growth. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function guardOptionsWrite<T>( | ||
| storeName: IDBStoreName, | ||
| op: () => Promise<T>, | ||
| ): Promise<T | undefined> { | ||
| if (storeName !== 'Options') return op(); | ||
| if (optionsWriteWedged) return Promise.resolve(undefined); | ||
| let timer: ReturnType<typeof setTimeout> | undefined; | ||
| const timeout = new Promise<undefined>((resolve) => { | ||
| timer = setTimeout(() => { | ||
| optionsWriteWedged = true; | ||
| Log._warn(`db.${storeName} timed out`); | ||
| resolve(undefined); | ||
| }, 2000); | ||
| }); | ||
| return Promise.race([op(), timeout]).finally(() => clearTimeout(timer)); | ||
| } |
| // on stall; the dropped values are session metadata the SW reads with sensible | ||
| // fallbacks. Once any write times out we trip a page-scoped circuit breaker | ||
| // so the remaining ~7 Options writes during init short-circuit instead of | ||
| // each paying the full timeout. Remove if WebKit ever fixes the underlying | ||
| // bug: https://bugs.webkit.org/show_bug.cgi?id=315804 |
| // 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; |
| function guardOptionsWrite<T>( | ||
| storeName: IDBStoreName, | ||
| op: () => Promise<T>, | ||
| ): Promise<T | undefined> { | ||
| if (storeName !== 'Options') return op(); | ||
| if (optionsWriteWedged) return Promise.resolve(undefined); | ||
| let timer: ReturnType<typeof setTimeout> | undefined; | ||
| const timeout = new Promise<undefined>((resolve) => { | ||
| timer = setTimeout(() => { | ||
| optionsWriteWedged = true; | ||
| Log._warn(`db.${storeName} timed out`); | ||
| resolve(undefined); | ||
| }, 2000); | ||
| }); | ||
| return Promise.race([op(), timeout]).finally(() => clearTimeout(timer)); | ||
| } |
| // on stall; the dropped values are session metadata the SW reads with sensible | ||
| // fallbacks. Once any write times out we trip a page-scoped circuit breaker | ||
| // so the remaining ~7 Options writes during init short-circuit instead of | ||
| // each paying the full timeout. Remove if WebKit ever fixes the underlying | ||
| // bug: https://bugs.webkit.org/show_bug.cgi?id=315804 |
| // 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; |
| function guardOptionsWrite<T>( | ||
| storeName: IDBStoreName, | ||
| op: () => Promise<T>, | ||
| ): Promise<T | undefined> { | ||
| if (storeName !== 'Options') return op(); | ||
| if (optionsWriteWedged) return Promise.resolve(undefined); | ||
| let timer: ReturnType<typeof setTimeout> | undefined; | ||
| const timeout = new Promise<undefined>((resolve) => { | ||
| timer = setTimeout(() => { | ||
| optionsWriteWedged = true; | ||
| Log._warn(`db.${storeName} timed out`); | ||
| resolve(undefined); | ||
| }, 2000); | ||
| }); | ||
| return Promise.race([op(), timeout]).finally(() => clearTimeout(timer)); | ||
| } |
| // on stall; the dropped values are session metadata the SW reads with sensible | ||
| // fallbacks. Once any write times out we trip a page-scoped circuit breaker | ||
| // so the remaining ~7 Options writes during init short-circuit instead of | ||
| // each paying the full timeout. Remove if WebKit ever fixes the underlying | ||
| // bug: https://bugs.webkit.org/show_bug.cgi?id=315804 |
| // 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; |
|
just remake your 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; | ||
| await db.put('Ids', { type: 'registrationId', id: null }); | ||
| await db.put('Ids', { type: 'userId', id: null }); | ||
| OneSignal._coreDirector._subscriptionModelStore._clear(ModelChangeTags._Hydrate); |
There was a problem hiding this comment.
🔴 The early-return on isOptionsWriteWedged() at init.ts:360 sits between the four Options resets and the Ids.{registrationId,userId} + _subscriptionModelStore._clear(_Hydrate) calls, so on a wedged app-ID switch the in-memory subscription store (and Ids.userId) keeps the previous app's identity for the rest of the page session. Downstream _sendOnSessionUpdate and outcome requests then serialize previous-app subscription IDs against the new app's config — the exact cross-app contamination this PR exists to prevent, just bounded to the wedged session. Moving _subscriptionModelStore._clear(ModelChangeTags._Hydrate) above line 360 closes the gap; the clear is synchronous in memory and its fire-and-forget db.delete targets the subscriptions store, not the wedged Options store.
Summary
Two follow-up fixes layered on top of 7f5df216, surfaced during review of the (now-closed) #1468:
Ids.appIdis unguarded, so on a wedged page during an app-ID switch theIds.appIdwrite would succeed while the previous app'sOptions.{isPushEnabled,lastPushId,lastPushToken,lastOptedIn}stayed put. ThepreviousAppId !== appIdgate would then keep the reset branch from re-entering on later loads — making the cross-app contamination permanent. With the newisOptionsWriteWedged()accessor,initSaveStatenow bails early on a tripped breaker so a future non-wedged load can finish the reset cleanly.Also fixes a minor leak —
Promise.racenow uses.finally(() => clearTimeout(timer))so healthy pages don't accumulate dangling 2s timers across the dozens of Options writes init issues.Adds a single
Log._warnon timeout so support can correlate the breaker tripping with init slowness in console logs.Test plan
vp test --run— 512/512 passing.vp check— clean (5 unrelated pre-existing warnings).vp run build:prod— passes; bundle deltas covered by a +80 B / +120 B size-limit bump.db.Options timed outwarning fires once.Why a follow-up rather than amending #1468
#1468 had been chasing the same ticket and was effectively superseded by 7f5df21 landing on
main. Closing it as superseded and re-uploading just the additive hardening on top ofmainkeeps this diff minimal and reviewable (3 files, +26/-6).