Skip to content

Fix data race in DependencyContainer.init on plain configure() - #512

Merged
yusuftor merged 2 commits into
developfrom
fix/dependency-container-init-race
Aug 25, 2026
Merged

Fix data race in DependencyContainer.init on plain configure()#512
yusuftor merged 2 commits into
developfrom
fix/dependency-container-init-race

Conversation

@yusuftor

@yusuftor yusuftor commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes #504

The race

DependencyContainer.init passed self as the factory to WebEntitlementRedeemer, whose init immediately spawned a Task calling factory.makeIsContainerReady() on a background thread. That read configManager while the main thread was still assigning the container's stored properties — self escaped 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 same DependencyContainer.init write vs makeIsContainerReady() 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 new pollPendingStripeCheckoutOnColdLaunch(), called from Superwall's configure-path convenience init after self.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 of DependencyContainer.init so the ordering is structural rather than comment-enforced, and bare DependencyContainer() 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

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs. (No public API changes.)
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

Greptile Summary

This PR removes the background Stripe-recovery task from WebEntitlementRedeemer.init and explicitly starts it after DependencyContainer finishes wiring its dependencies.

  • Moves cold-launch pending-checkout polling to the end of dependency-container initialization.
  • Adds regression coverage for container initialization and explicit polling behavior.
  • Disables test parallelization for the shared test scheme and updates the changelog.

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

Filename Overview
Sources/SuperwallKit/Dependencies/DependencyContainer.swift Starts cold-launch checkout recovery only after all container dependencies have been assigned; no actionable defect was found.
Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift Removes initializer side effects and adds an explicit nonisolated kickoff whose actor-isolated polling path retains in-flight coordination.
Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift Adds a Thread Sanitizer-oriented regression test that repeatedly constructs the dependency container.
Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift Verifies that redeemer initialization is side-effect free and explicit cold-launch kickoff polls persisted checkout state.
SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme Disables parallel execution for the test target, consistent with deterministic race-regression coverage.

Sequence Diagram

sequenceDiagram
  participant App as Host App
  participant Container as DependencyContainer
  participant Redeemer as WebEntitlementRedeemer
  participant Poll as Background Task
  participant Backend as Subscriptions API
  App->>Container: initialize dependencies
  Container->>Redeemer: initialize without starting work
  Container->>Container: finish dependency assignments
  Container->>Redeemer: pollPendingStripeCheckoutOnColdLaunch()
  Redeemer->>Poll: spawn task
  Poll->>Redeemer: check persisted pending checkout
  Redeemer->>Backend: poll redemption result
Loading

Reviews (1): Last reviewed commit: "fix(configure): stop the container escap..." | Re-trigger Greptile

Context used:

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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 initWebEntitlementRedeemer.init no longer spawns a Task; the cold-launch pending-Stripe-checkout poll is now the nonisolated pollPendingStripeCheckoutOnColdLaunch(), called as the last statement of DependencyContainer.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 onlyfactory.makeIsContainerReady() still guards handleForegroundPolling (WebEntitlementRedeemer.swift:855), so neither DependencyContainer.makeIsContainerReady() nor its FactoryProtocols declaration is left dead.
  • Tests — a new TSan-oriented DependencyContainerInitTests, plus two WebEntitlementRedeemerTests cases 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 observe pollRedemptionResultCallCount == 1.
  • Project/scheme regenerationproject.pbxproj picks up the new test file; the .xcscheme gains a hand-added parallelizable = "NO" (see inline).
  • CHANGELOG — appended under the already-staged ## 4.16.4, matching Constants.swift and 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.pbxproj hunks dropping compatibilityVersion, adding an empty TargetAttributes = {} and a productRefGroup, and reordering the Core Data group 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme Outdated
Comment thread Sources/SuperwallKit/Dependencies/DependencyContainer.swift Outdated
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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 initpollPendingStripeCheckoutOnColdLaunch() now fires from Superwall's private convenience init(apiKey:purchaseController:options:completion:) (Superwall.swift:479) instead of the tail of DependencyContainer.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 bare DependencyContainer() constructions stop firing real polls. This is the structural fix the prior review asked for.
  • No recovery gap from the narrowing — all four public configure overloads converge on that one init (Swift directly at Superwall.swift:700; both ObjC overloads via objcConfigure at Superwall.swift:855), and every remaining Superwall(dependencyContainer:) call site is under Tests/. The kick-off is load-bearing rather than redundant, since willEnterForegroundNotification doesn'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_doesNotStartColdLaunchPoll remains the case that discriminates in a normal run, and both new redeemer tests do assert exactly (== 0 / == 1 on pollRedemptionResultCallCount) 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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@yusuftor
yusuftor merged commit 15647e1 into develop Aug 25, 2026
4 checks passed
@yusuftor
yusuftor deleted the fix/dependency-container-init-race branch August 25, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Data race in DependencyContainer.init on plain configure() — self escapes to WebEntitlementRedeemer before init completes

1 participant