From c7650f05d1496309fe4644019b90d2f4836d916a Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 30 Apr 2026 16:35:56 +0200 Subject: [PATCH] Fix sync issues for dependency container access --- CHANGELOG.md | 1 + .../main/java/com/superwall/sdk/Superwall.kt | 37 +++-- .../sdk/SuperwallConfigureDeadlockTest.kt | 128 ++++++++++++++++++ version.env | 2 +- 4 files changed, 148 insertions(+), 20 deletions(-) create mode 100644 superwall/src/test/java/com/superwall/sdk/SuperwallConfigureDeadlockTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b12a04ed..f13bf307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## Fixes - Fix `device.appVersionPadded` and `device.sdkVersionPadded` emitting non-ASCII digits on devices whose default locale uses a non-Latin numbering system (e.g. `ar-EG`, `fa-IR`, `bn-BD`), which caused audience-rule version comparisons to misbucket affected users. - Ensures timeout applies to HttpUrlConnection for enrichment and subscription API's +- Remove unnecessary sync access causing ANR lock in React Native ## 2.7.12 diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 7ca44896..107ce031 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -381,7 +381,8 @@ class Superwall( /** * Properties stored about the device session, set internally by Superwall * */ - suspend fun deviceAttributes(): Map = dependencyContainer.makeSessionDeviceAttributes() + suspend fun deviceAttributes(): Map = + dependencyContainer.makeSessionDeviceAttributes() /** * Gets the current integration identifiers as a map. @@ -632,14 +633,12 @@ class Superwall( } } + @Volatile private lateinit var _dependencyContainer: DependencyContainer internal val dependencyContainer: DependencyContainer - get() { - synchronized(this) { - return _dependencyContainer - } - } + get() = _dependencyContainer + // / Used to serially execute register calls. internal val serialTaskManager = SerialTaskManager() @@ -1204,7 +1203,7 @@ class Superwall( scope = LogScope.superwallCore, message = "You are trying to observe purchases but the SuperwallOption shouldObservePurchases is " + - "false. Please set it to true to be able to observe purchases.", + "false. Please set it to true to be able to observe purchases.", ) return@launchWithTracking } @@ -1418,11 +1417,11 @@ class Superwall( val url = "https://play.google.com/store/apps/details?id=$packageName" ( - activityProvider?.getCurrentActivity() - ?: paywallView.encapsulatingActivity?.get() - )?.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(url)), - ) + activityProvider?.getCurrentActivity() + ?: paywallView.encapsulatingActivity?.get() + )?.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url)), + ) } } } catch (e: Exception) { @@ -1440,13 +1439,13 @@ class Superwall( val paywallActivity = ( - paywallView - ?.encapsulatingActivity - ?.get() - ?: dependencyContainer - .activityProvider - ?.getCurrentActivity() - ) as SuperwallPaywallActivity? + paywallView + ?.encapsulatingActivity + ?.get() + ?: dependencyContainer + .activityProvider + ?.getCurrentActivity() + ) as SuperwallPaywallActivity? // Cancel any existing fallback notification of the same type before scheduling // the dynamic notification from the paywall paywallActivity?.attemptToScheduleNotifications( diff --git a/superwall/src/test/java/com/superwall/sdk/SuperwallConfigureDeadlockTest.kt b/superwall/src/test/java/com/superwall/sdk/SuperwallConfigureDeadlockTest.kt new file mode 100644 index 00000000..211a8f24 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/SuperwallConfigureDeadlockTest.kt @@ -0,0 +1,128 @@ +package com.superwall.sdk + +import android.content.Context +import com.superwall.sdk.dependencies.DependencyContainer +import com.superwall.sdk.store.Entitlements +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Regression guard for the AB-BA deadlock that caused the production ANR + * tracked in expo-superwall#194 / SW-5092. + * + * Original cycle, present before the fix: + * + * Lock A — the Superwall singleton's intrinsic monitor, taken by both + * setup() and the dependencyContainer getter. + * + * Lock B — the SynchronizedLazyImpl monitor backing the `entitlements` + * property. Its initializer body `{ dependencyContainer.entitlements }` + * re-entered Lock A. + * + * Production trace (before the fix): + * worker-1: holds A inside setup() -> wants B + * worker-2: holds B inside lazy initializer -> wants A + * main: wants A from identify() / setUserAttrs() -> ANR + * + * This test arms the exact interleaving that previously deadlocked: + * 1. Thread X holds the Superwall singleton monitor (Lock A) and then + * reads `entitlements` — the pattern setup() uses when it calls + * setSubscriptionStatus while still inside `synchronized(this@Superwall)`. + * 2. Thread Y reads `entitlements` from outside the singleton monitor — + * the pattern AppSessionManager.detectNewSession -> DeviceHelper takes + * from a worker. This forces Y through the lazy initializer (Lock B). + * + * Under the previous code Thread Y's initializer would block on Lock A + * while Thread X blocked on Lock B, and both threads would stay BLOCKED + * indefinitely. Under the fix, the lazy initializer does not re-enter + * the singleton monitor, so both threads complete promptly. + * + * The guard asserts that both threads finish within a short window. If + * anyone reintroduces a synchronized hop into the `entitlements` / + * `subscriptionStatus` lazy initializers (or anything else they call + * that takes the Superwall singleton monitor), this test will fail by + * timing out. + * + * java.lang.management is unavailable on the Android unit-test runtime, + * so completion is observed via Thread.join with a timeout. + */ +class SuperwallConfigureDeadlockTest { + @Test(timeout = 15_000) + fun entitlements_lazy_initializer_does_not_reenter_singleton_monitor() { + val context = mockk(relaxed = true) + val sw = + Superwall( + context = context, + apiKey = "test", + purchaseController = null, + options = null, + activityProvider = null, + completion = null, + ) + + // Skip setup() but plant a usable _dependencyContainer so the + // entitlements lazy initializer can return without throwing + // UninitializedPropertyAccessException. + val fakeDc = mockk(relaxed = true) + every { fakeDc.entitlements } returns mockk(relaxed = true) + val dcField = Superwall::class.java.getDeclaredField("_dependencyContainer") + dcField.isAccessible = true + dcField.set(sw, fakeDc) + + val xHasLockA = CountDownLatch(1) + val yFinishedLazy = CountDownLatch(1) + + // Thread Y: read `entitlements` from outside the singleton monitor. + // This goes through SynchronizedLazyImpl.getValue (Lock B). For Y to + // complete while X holds Lock A, the lazy initializer must NOT take + // the singleton monitor. + val threadY = + Thread({ + xHasLockA.await() + sw.entitlements + yFinishedLazy.countDown() + }, "deadlock-guard-Y").apply { isDaemon = true } + + // Thread X: hold the singleton monitor (Lock A), then read + // `entitlements`. Mirrors setup() calling setSubscriptionStatus + // while inside `synchronized(this@Superwall)`. Waits until Y has + // finished its lazy access so we know Y did not deadlock. + val threadX = + Thread({ + synchronized(sw) { + xHasLockA.countDown() + yFinishedLazy.await(5, TimeUnit.SECONDS) + sw.entitlements + } + }, "deadlock-guard-X").apply { isDaemon = true } + + threadX.start() + threadY.start() + + threadY.join(5_000) + threadX.join(5_000) + + if (threadY.isAlive || threadX.isAlive) { + val xFrames = threadX.stackTrace.take(8).joinToString("\n") { " at $it" } + val yFrames = threadY.stackTrace.take(8).joinToString("\n") { " at $it" } + val msg = + buildString { + appendLine("AB-BA deadlock regression: the entitlements lazy initializer") + appendLine("appears to re-enter a synchronized scope on the Superwall singleton.") + appendLine("This is the cycle that produced the production ANR in SW-5092.") + appendLine() + appendLine("Thread X (held singleton monitor, then read entitlements) state=${threadX.state}:") + appendLine(xFrames) + appendLine() + appendLine("Thread Y (read entitlements from outside singleton monitor) state=${threadY.state}:") + appendLine(yFrames) + } + // Daemon threads will be cleaned up on JVM exit; we just need them out of the way. + assertTrue(msg, false) + } + } +} diff --git a/version.env b/version.env index c2372f0b..f92112cc 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.7.12 +SUPERWALL_VERSION=2.7.13