Fix data race in DependencyContainer.init on plain configure() - #512
Conversation
DependencyContainer.init passed itself as the factory to WebEntitlementRedeemer, whose init immediately spawned a task calling makeIsContainerReady() on a background thread — reading configManager while init was still assigning stored properties. Thread Sanitizer flags this as a data race on every plain configure() call, and the racy guard also made the cold-launch Stripe recovery poll fire only when it happened to lose the race. The redeemer no longer starts any work in its init. The cold-launch poll is now kicked off explicitly by DependencyContainer as the last statement of its init, once every dependency is assigned — which also gives the background task a proper happens-before edge on all of the container's stored properties and makes the poll deterministic. Fixes #504 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ The fix itself looks right — three rough edges around it, none blocking.
Reviewed changes — full diff of b042bb76b (7 files) against develop, plus an audit of every object DependencyContainer.init constructs and of the cold-launch Stripe poll path it now triggers.
- Poll moved out of the redeemer's
init—WebEntitlementRedeemer.initno longer spawns aTask; the cold-launch pending-Stripe-checkout poll is now thenonisolated pollPendingStripeCheckoutOnColdLaunch(), called as the last statement ofDependencyContainer.init. Task creation after every stored-property write gives the background read a happens-before edge, which is the correct shape for #504. - Readiness guard removed from that path only —
factory.makeIsContainerReady()still guardshandleForegroundPolling(WebEntitlementRedeemer.swift:855), so neitherDependencyContainer.makeIsContainerReady()nor itsFactoryProtocolsdeclaration is left dead. - Tests — a new TSan-oriented
DependencyContainerInitTests, plus twoWebEntitlementRedeemerTestscases pinning "init starts no poll" and "explicit kick-off does poll". The first of those two is the assertion that would actually catch a regression, and it does discriminate: with the old init-spawned task it would observepollRedemptionResultCallCount == 1. - Project/scheme regeneration —
project.pbxprojpicks up the new test file; the.xcschemegains a hand-addedparallelizable = "NO"(see inline). - CHANGELOG — appended under the already-staged
## 4.16.4, matchingConstants.swiftand the podspec, so no version bump is owed.
Two things I checked and found clean, for the record: no other type built in DependencyContainer.init starts init-time work that reads factory: self or a container property before init returns, and the retimed poll is behaviourally safe — identityManager.appUserId is loaded synchronously in IdentityManager.init, the actor-atomic hasActiveStripePoll check dedups against the foreground trigger without consuming a remainingForegroundAttempts, and both ReceiptManager.loadPurchasedProducts and internallySetSubscriptionStatus re-read LatestRedeemResponse from disk, so an early successful poll can't be clobbered by later configure-sequence writes.
ℹ️ Nitpicks
- The
project.pbxprojhunks droppingcompatibilityVersion, adding an emptyTargetAttributes = {}and aproductRefGroup, and reordering theCore Datagroup are XcodeGen-version drift from the local toolchain rather than anything meaningful — harmless, since CI regenerates the file anyway, but it does pad the diff. - The PR description's "previously it only ran if the spawned task happened to lose the race" overstates the determinism win: the old guard only short-circuited when the spawned task actually interleaved with
init, which was the unlikely ordering — in the common case cold-launch recovery already fired. The race fix stands on its own without that framing.
Claude Opus | 𝕏
Moves pollPendingStripeCheckoutOnColdLaunch() from the last statement of DependencyContainer.init into Superwall's configure-path convenience init, after self.init(dependencyContainer:) returns. Container completeness is now guaranteed by language rule instead of a "must stay last" comment, and bare DependencyContainer() constructions in tests no longer fire the poll. Also reverts unintended xcodegen scheme drift and trims the TSan repro loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found. All three threads from the previous review are properly addressed.
Reviewed changes — the delta from b042bb76b to ead852094, i.e. the relocated cold-launch kick-off plus the two test/scheme follow-ups, re-verified against the full PR diff.
- Kick-off moved to the configure-only init —
pollPendingStripeCheckoutOnColdLaunch()now fires fromSuperwall'sprivate convenience init(apiKey:purchaseController:options:completion:)(Superwall.swift:479) instead of the tail ofDependencyContainer.init. Container completeness now holds by language rule (self.init(dependencyContainer:)has returned), so the ordering can't be broken by appending a dependency, and bareDependencyContainer()constructions stop firing real polls. This is the structural fix the prior review asked for. - No recovery gap from the narrowing — all four public
configureoverloads converge on that one init (Swift directly atSuperwall.swift:700; both ObjC overloads viaobjcConfigureatSuperwall.swift:855), and every remainingSuperwall(dependencyContainer:)call site is underTests/. The kick-off is load-bearing rather than redundant, sincewillEnterForegroundNotificationdoesn't fire on a true cold launch. - Scheme edit reverted — the hand-added
parallelizable = "NO"is gone, so nothing in the diff depends on state that XcodeGen would regenerate away. - TSan repro trimmed — the loop is 50 → 10 and the doc comment now states plainly that it only signals under
-enableThreadSanitizer YES.testInit_doesNotStartColdLaunchPollremains the case that discriminates in a normal run, and both new redeemer tests do assert exactly (== 0/== 1onpollRedemptionResultCallCount) rather than absorbing whatever they're handed.
Checked and clean, for the record: the relocation introduces no new concurrent read. deviceHelper.vendorId is a let, identityManager.appUserId is queue.sync-guarded and is never written by the identityManager.configure() that runs in the sibling Task, storage I/O is serialized by Cache's ioQueue, and customerInfo/subscriptionStatus are assigned program-order-before the kick-off. The known superwall ?? Superwall.shared unconfigured-instance window is pre-existing and this placement makes it strictly narrower than either earlier one.
ℹ️ The new call site is the only thing keeping cold-launch recovery wired up, and no test can reach it
Moving the kick-off into a private convenience init that only Superwall.configure can reach is the right call for the race, but it trades away the incidental coverage the old placement had: previously every test container exercised the wiring, whereas now deleting Superwall.swift:479 would leave the whole suite green — testPollPendingStripeCheckoutOnColdLaunch_pollsPendingCheckout calls the redeemer method directly and never touches the call site. Worth a conscious decision rather than a fix, since driving Superwall.configure from a test would mutate the Superwall.superwall static and is likely worse than the gap.
Technical details
# Cold-launch kick-off wiring has no test coverage after the move
## Affected sites
- `Sources/SuperwallKit/Superwall.swift:479` — the only production statement that starts cold-launch
Stripe recovery. Reachable exclusively via `Superwall.configure` → the `private convenience init`,
which no test in `Tests/SuperwallKitTests` invokes.
- `Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift:3018` —
`testPollPendingStripeCheckoutOnColdLaunch_pollsPendingCheckout` calls
`redeemer.pollPendingStripeCheckoutOnColdLaunch()` directly, so it pins the method's behavior but
not the fact that anything calls it.
## Required outcome
- A decision (not necessarily a code change) on whether silent removal of the kick-off is an
acceptable failure mode. If it isn't, something needs to fail when `Superwall.swift:479` is deleted.
## Suggested approach (optional)
- Cheapest option that doesn't touch global state: give the redeemer kick-off a spy seam, or assert
the wiring in whatever integration/UI test already drives a real `configure()`.
- Explicitly acceptable outcome: document the trade-off next to the call site and move on. Driving
`Superwall.configure` from a unit test writes the `Superwall.superwall` static and, under the
`SUPERWALL_UNIT_TESTS` launch arg, interacts badly with `Superwall.shared`'s caching fallback —
probably a worse deal than the coverage gap.
## Open questions for the human
- Is the pending-Stripe-checkout recovery path covered anywhere outside the unit suite (UI tests, a
manual QA pass)? If so this is a non-issue.Claude Opus | 𝕏

Fixes #504
The race
DependencyContainer.initpassedselfas the factory toWebEntitlementRedeemer, whose init immediately spawned aTaskcallingfactory.makeIsContainerReady()on a background thread. That readconfigManagerwhile the main thread was still assigning the container's stored properties —selfescaped before init completed, exactly as reported. The readiness guard intended to protect against this was the racy access.Reproduced deterministically with a new test (
DependencyContainerInitTests) run under Thread Sanitizer: the sameDependencyContainer.initwrite vsmakeIsContainerReady()read report as the issue, on the first container construction.The fix
The redeemer no longer starts any work in its
init. The cold-launch pending-Stripe-checkout poll is now kicked off explicitly via a newpollPendingStripeCheckoutOnColdLaunch(), called fromSuperwall's configure-path convenience init afterself.init(dependencyContainer:)returns — so the container is provably complete by language rule when the background work starts, giving it a proper happens-before edge on the container's state. The readiness guard is no longer needed on this path. (Per review feedback, this moved from the last statement ofDependencyContainer.initso the ordering is structural rather than comment-enforced, and bareDependencyContainer()constructions in tests no longer fire the poll.)Side benefit: cold-launch recovery no longer depends on task-scheduling timing — previously, if the spawned task ran early enough to interleave with
init(the racy case), the guard silently skipped recovery for that launch.Verification
DependencyContainer.init/makeIsContainerReadyreports.initstarts no poll (seeded pending state stays untouched), and the cold-launch kick-off polls a seeded pending checkout.WebEntitlementRedeemerTestssuite under TSan still surfaces pre-existing, unrelated reports where test bodies reassign container properties while detachedtracktasks are in flight (same family as [BUG] Data race in SuperwallKit.Superwall.internallyRegister (reported by thread sanitizer) #364/[BUG] Swift access race when run with Thread sanitizer #157) — untouched by this PR.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code
Greptile Summary
This PR removes the background Stripe-recovery task from
WebEntitlementRedeemer.initand explicitly starts it afterDependencyContainerfinishes wiring its dependencies.Confidence Score: 5/5
The PR appears safe to merge, with no concrete changed-code failure identified.
The cold-launch poll now starts only after dependency wiring completes, and the state it reads is initialized, immutable or synchronized, and protected against duplicate Stripe polling.
Important Files Changed
Sequence Diagram
Reviews (1): Last reviewed commit: "fix(configure): stop the container escap..." | Re-trigger Greptile
Context used: