Skip to content

Commit c7650f0

Browse files
committed
Fix sync issues for dependency container access
1 parent 650847b commit c7650f0

4 files changed

Lines changed: 148 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw
77
## Fixes
88
- 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.
99
- Ensures timeout applies to HttpUrlConnection for enrichment and subscription API's
10+
- Remove unnecessary sync access causing ANR lock in React Native
1011

1112
## 2.7.12
1213

superwall/src/main/java/com/superwall/sdk/Superwall.kt

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,8 @@ class Superwall(
381381
/**
382382
* Properties stored about the device session, set internally by Superwall
383383
* */
384-
suspend fun deviceAttributes(): Map<String, Any?> = dependencyContainer.makeSessionDeviceAttributes()
384+
suspend fun deviceAttributes(): Map<String, Any?> =
385+
dependencyContainer.makeSessionDeviceAttributes()
385386

386387
/**
387388
* Gets the current integration identifiers as a map.
@@ -632,14 +633,12 @@ class Superwall(
632633
}
633634
}
634635

636+
@Volatile
635637
private lateinit var _dependencyContainer: DependencyContainer
636638

637639
internal val dependencyContainer: DependencyContainer
638-
get() {
639-
synchronized(this) {
640-
return _dependencyContainer
641-
}
642-
}
640+
get() = _dependencyContainer
641+
643642

644643
// / Used to serially execute register calls.
645644
internal val serialTaskManager = SerialTaskManager()
@@ -1204,7 +1203,7 @@ class Superwall(
12041203
scope = LogScope.superwallCore,
12051204
message =
12061205
"You are trying to observe purchases but the SuperwallOption shouldObservePurchases is " +
1207-
"false. Please set it to true to be able to observe purchases.",
1206+
"false. Please set it to true to be able to observe purchases.",
12081207
)
12091208
return@launchWithTracking
12101209
}
@@ -1418,11 +1417,11 @@ class Superwall(
14181417
val url =
14191418
"https://play.google.com/store/apps/details?id=$packageName"
14201419
(
1421-
activityProvider?.getCurrentActivity()
1422-
?: paywallView.encapsulatingActivity?.get()
1423-
)?.startActivity(
1424-
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
1425-
)
1420+
activityProvider?.getCurrentActivity()
1421+
?: paywallView.encapsulatingActivity?.get()
1422+
)?.startActivity(
1423+
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
1424+
)
14261425
}
14271426
}
14281427
} catch (e: Exception) {
@@ -1440,13 +1439,13 @@ class Superwall(
14401439
val paywallActivity =
14411440

14421441
(
1443-
paywallView
1444-
?.encapsulatingActivity
1445-
?.get()
1446-
?: dependencyContainer
1447-
.activityProvider
1448-
?.getCurrentActivity()
1449-
) as SuperwallPaywallActivity?
1442+
paywallView
1443+
?.encapsulatingActivity
1444+
?.get()
1445+
?: dependencyContainer
1446+
.activityProvider
1447+
?.getCurrentActivity()
1448+
) as SuperwallPaywallActivity?
14501449
// Cancel any existing fallback notification of the same type before scheduling
14511450
// the dynamic notification from the paywall
14521451
paywallActivity?.attemptToScheduleNotifications(
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.superwall.sdk
2+
3+
import android.content.Context
4+
import com.superwall.sdk.dependencies.DependencyContainer
5+
import com.superwall.sdk.store.Entitlements
6+
import io.mockk.every
7+
import io.mockk.mockk
8+
import org.junit.Assert.assertTrue
9+
import org.junit.Test
10+
import java.util.concurrent.CountDownLatch
11+
import java.util.concurrent.TimeUnit
12+
13+
/**
14+
* Regression guard for the AB-BA deadlock that caused the production ANR
15+
* tracked in expo-superwall#194 / SW-5092.
16+
*
17+
* Original cycle, present before the fix:
18+
*
19+
* Lock A — the Superwall singleton's intrinsic monitor, taken by both
20+
* setup() and the dependencyContainer getter.
21+
*
22+
* Lock B — the SynchronizedLazyImpl monitor backing the `entitlements`
23+
* property. Its initializer body `{ dependencyContainer.entitlements }`
24+
* re-entered Lock A.
25+
*
26+
* Production trace (before the fix):
27+
* worker-1: holds A inside setup() -> wants B
28+
* worker-2: holds B inside lazy initializer -> wants A
29+
* main: wants A from identify() / setUserAttrs() -> ANR
30+
*
31+
* This test arms the exact interleaving that previously deadlocked:
32+
* 1. Thread X holds the Superwall singleton monitor (Lock A) and then
33+
* reads `entitlements` — the pattern setup() uses when it calls
34+
* setSubscriptionStatus while still inside `synchronized(this@Superwall)`.
35+
* 2. Thread Y reads `entitlements` from outside the singleton monitor —
36+
* the pattern AppSessionManager.detectNewSession -> DeviceHelper takes
37+
* from a worker. This forces Y through the lazy initializer (Lock B).
38+
*
39+
* Under the previous code Thread Y's initializer would block on Lock A
40+
* while Thread X blocked on Lock B, and both threads would stay BLOCKED
41+
* indefinitely. Under the fix, the lazy initializer does not re-enter
42+
* the singleton monitor, so both threads complete promptly.
43+
*
44+
* The guard asserts that both threads finish within a short window. If
45+
* anyone reintroduces a synchronized hop into the `entitlements` /
46+
* `subscriptionStatus` lazy initializers (or anything else they call
47+
* that takes the Superwall singleton monitor), this test will fail by
48+
* timing out.
49+
*
50+
* java.lang.management is unavailable on the Android unit-test runtime,
51+
* so completion is observed via Thread.join with a timeout.
52+
*/
53+
class SuperwallConfigureDeadlockTest {
54+
@Test(timeout = 15_000)
55+
fun entitlements_lazy_initializer_does_not_reenter_singleton_monitor() {
56+
val context = mockk<Context>(relaxed = true)
57+
val sw =
58+
Superwall(
59+
context = context,
60+
apiKey = "test",
61+
purchaseController = null,
62+
options = null,
63+
activityProvider = null,
64+
completion = null,
65+
)
66+
67+
// Skip setup() but plant a usable _dependencyContainer so the
68+
// entitlements lazy initializer can return without throwing
69+
// UninitializedPropertyAccessException.
70+
val fakeDc = mockk<DependencyContainer>(relaxed = true)
71+
every { fakeDc.entitlements } returns mockk<Entitlements>(relaxed = true)
72+
val dcField = Superwall::class.java.getDeclaredField("_dependencyContainer")
73+
dcField.isAccessible = true
74+
dcField.set(sw, fakeDc)
75+
76+
val xHasLockA = CountDownLatch(1)
77+
val yFinishedLazy = CountDownLatch(1)
78+
79+
// Thread Y: read `entitlements` from outside the singleton monitor.
80+
// This goes through SynchronizedLazyImpl.getValue (Lock B). For Y to
81+
// complete while X holds Lock A, the lazy initializer must NOT take
82+
// the singleton monitor.
83+
val threadY =
84+
Thread({
85+
xHasLockA.await()
86+
sw.entitlements
87+
yFinishedLazy.countDown()
88+
}, "deadlock-guard-Y").apply { isDaemon = true }
89+
90+
// Thread X: hold the singleton monitor (Lock A), then read
91+
// `entitlements`. Mirrors setup() calling setSubscriptionStatus
92+
// while inside `synchronized(this@Superwall)`. Waits until Y has
93+
// finished its lazy access so we know Y did not deadlock.
94+
val threadX =
95+
Thread({
96+
synchronized(sw) {
97+
xHasLockA.countDown()
98+
yFinishedLazy.await(5, TimeUnit.SECONDS)
99+
sw.entitlements
100+
}
101+
}, "deadlock-guard-X").apply { isDaemon = true }
102+
103+
threadX.start()
104+
threadY.start()
105+
106+
threadY.join(5_000)
107+
threadX.join(5_000)
108+
109+
if (threadY.isAlive || threadX.isAlive) {
110+
val xFrames = threadX.stackTrace.take(8).joinToString("\n") { " at $it" }
111+
val yFrames = threadY.stackTrace.take(8).joinToString("\n") { " at $it" }
112+
val msg =
113+
buildString {
114+
appendLine("AB-BA deadlock regression: the entitlements lazy initializer")
115+
appendLine("appears to re-enter a synchronized scope on the Superwall singleton.")
116+
appendLine("This is the cycle that produced the production ANR in SW-5092.")
117+
appendLine()
118+
appendLine("Thread X (held singleton monitor, then read entitlements) state=${threadX.state}:")
119+
appendLine(xFrames)
120+
appendLine()
121+
appendLine("Thread Y (read entitlements from outside singleton monitor) state=${threadY.state}:")
122+
appendLine(yFrames)
123+
}
124+
// Daemon threads will be cleaned up on JVM exit; we just need them out of the way.
125+
assertTrue(msg, false)
126+
}
127+
}
128+
}

version.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
SUPERWALL_VERSION=2.7.12
1+
SUPERWALL_VERSION=2.7.13

0 commit comments

Comments
 (0)