Skip to content

Commit 8f720da

Browse files
authored
fix race condition that causes plugin update with incorrect type (#196)
* add initializedPlugins to system state * fix broken tests * add unit tests
1 parent 895c3ab commit 8f720da

File tree

8 files changed

+104
-43
lines changed

8 files changed

+104
-43
lines changed

android/src/test/java/com/segment/analytics/kotlin/android/StorageTests.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import com.segment.analytics.kotlin.core.UserInfo
1414
import com.segment.analytics.kotlin.core.emptyJsonObject
1515
import kotlinx.coroutines.test.UnconfinedTestDispatcher
1616
import kotlinx.coroutines.test.runTest
17-
import kotlinx.serialization.decodeFromString
1817
import kotlinx.serialization.encodeToString
1918
import kotlinx.serialization.json.Json
2019
import kotlinx.serialization.json.JsonObject
@@ -59,7 +58,7 @@ class StorageTests {
5958
configuration = Configuration("123"),
6059
settings = Settings(),
6160
running = false,
62-
initialSettingsDispatched = false,
61+
initializedPlugins = setOf(),
6362
enabled = true
6463
)
6564
)
@@ -129,7 +128,7 @@ class StorageTests {
129128
edgeFunction = emptyJsonObject
130129
),
131130
running = false,
132-
initialSettingsDispatched = false,
131+
initializedPlugins = setOf(),
133132
enabled = true
134133
)
135134
}
@@ -154,7 +153,7 @@ class StorageTests {
154153
fun `system reset action removes system`() = runTest {
155154
val action = object : Action<System> {
156155
override fun reduce(state: System): System {
157-
return System(state.configuration, null, state.running, state.initialSettingsDispatched, state.enabled)
156+
return System(state.configuration, null, state.running, state.initializedPlugins, state.enabled)
158157
}
159158
}
160159
store.dispatch(action, System::class)

core/src/main/java/com/segment/analytics/kotlin/core/Settings.kt

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import kotlinx.coroutines.launch
1111
import kotlinx.coroutines.withContext
1212
import kotlinx.serialization.DeserializationStrategy
1313
import kotlinx.serialization.Serializable
14-
import kotlinx.serialization.decodeFromString
1514
import kotlinx.serialization.json.Json
1615
import kotlinx.serialization.json.JsonObject
1716
import kotlinx.serialization.serializer
@@ -42,12 +41,22 @@ data class Settings(
4241
}
4342
}
4443

45-
internal fun Analytics.update(settings: Settings, type: Plugin.UpdateType) {
44+
internal suspend fun Analytics.update(settings: Settings) {
45+
val systemState = store.currentState(System::class) ?: return
46+
val set = mutableSetOf<Int>()
4647
timeline.applyClosure { plugin ->
4748
// tell all top level plugins to update.
4849
// For destination plugins they auto-handle propagation to sub-plugins
50+
val type: Plugin.UpdateType =
51+
if (systemState.initializedPlugins.contains(plugin.hashCode())) {
52+
Plugin.UpdateType.Refresh
53+
} else {
54+
set.add(plugin.hashCode())
55+
Plugin.UpdateType.Initial
56+
}
4957
plugin.update(settings, type)
5058
}
59+
store.dispatch(System.AddInitializedPlugins(set), System::class)
5160
}
5261

5362
/**
@@ -74,14 +83,7 @@ suspend fun Analytics.checkSettings() {
7483
val writeKey = configuration.writeKey
7584
val cdnHost = configuration.cdnHost
7685

77-
// check current system state to determine whether it's initial or refresh
78-
val systemState = store.currentState(System::class) ?: return
79-
val updateType = if (systemState.initialSettingsDispatched) {
80-
Plugin.UpdateType.Refresh
81-
} else {
82-
Plugin.UpdateType.Initial
83-
}
84-
86+
store.currentState(System::class) ?: return
8587
store.dispatch(System.ToggleRunningAction(running = false), System::class)
8688

8789
withContext(networkIODispatcher) {
@@ -92,8 +94,7 @@ suspend fun Analytics.checkSettings() {
9294
settingsObj?.let {
9395
log("Dispatching update settings on ${Thread.currentThread().name}")
9496
store.dispatch(System.UpdateSettingsAction(settingsObj), System::class)
95-
update(settingsObj, updateType)
96-
store.dispatch(System.ToggleSettingsDispatch(dispatched = true), System::class)
97+
update(settingsObj)
9798
}
9899

99100
// we're good to go back to a running state.

core/src/main/java/com/segment/analytics/kotlin/core/State.kt

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
package com.segment.analytics.kotlin.core
22

33
import com.segment.analytics.kotlin.core.utilities.putAll
4-
import kotlinx.serialization.decodeFromString
54
import kotlinx.serialization.json.Json
65
import kotlinx.serialization.json.JsonObject
76
import kotlinx.serialization.json.buildJsonObject
87
import kotlinx.serialization.json.put
98
import sovran.kotlin.Action
109
import sovran.kotlin.State
11-
import java.util.UUID
10+
import java.util.*
1211

1312
/**
1413
* Stores state related to the analytics system
@@ -20,7 +19,7 @@ data class System(
2019
var configuration: Configuration = Configuration(""),
2120
var settings: Settings?,
2221
var running: Boolean,
23-
var initialSettingsDispatched: Boolean,
22+
var initializedPlugins: Set<Int>,
2423
var enabled: Boolean
2524
) : State {
2625

@@ -38,7 +37,7 @@ data class System(
3837
configuration = configuration,
3938
settings = settings,
4039
running = false,
41-
initialSettingsDispatched = false,
40+
initializedPlugins = setOf(),
4241
enabled = true
4342
)
4443
}
@@ -50,7 +49,7 @@ data class System(
5049
state.configuration,
5150
settings,
5251
state.running,
53-
state.initialSettingsDispatched,
52+
state.initializedPlugins,
5453
state.enabled
5554
)
5655
}
@@ -62,7 +61,7 @@ data class System(
6261
state.configuration,
6362
state.settings,
6463
running,
65-
state.initialSettingsDispatched,
64+
state.initializedPlugins,
6665
state.enabled
6766
)
6867
}
@@ -81,21 +80,22 @@ data class System(
8180
state.configuration,
8281
newSettings,
8382
state.running,
84-
state.initialSettingsDispatched,
83+
state.initializedPlugins,
8584
state.enabled
8685
)
8786
}
8887
}
8988

90-
class ToggleSettingsDispatch(
91-
var dispatched: Boolean,
89+
class AddInitializedPlugins(
90+
var dispatched: Set<Int>,
9291
) : Action<System> {
9392
override fun reduce(state: System): System {
93+
val initializedPlugins = state.initializedPlugins + dispatched
9494
return System(
9595
state.configuration,
9696
state.settings,
9797
state.running,
98-
dispatched,
98+
initializedPlugins,
9999
state.enabled
100100
)
101101
}
@@ -107,7 +107,7 @@ data class System(
107107
state.configuration,
108108
state.settings,
109109
state.running,
110-
state.initialSettingsDispatched,
110+
state.initializedPlugins,
111111
enabled
112112
)
113113
}

core/src/main/java/com/segment/analytics/kotlin/core/platform/Timeline.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import com.segment.analytics.kotlin.core.System
66
import com.segment.analytics.kotlin.core.platform.plugins.logger.segmentLog
77
import com.segment.analytics.kotlin.core.reportInternalError
88
import kotlinx.coroutines.launch
9-
import java.util.concurrent.CopyOnWriteArrayList
109
import kotlin.reflect.KClass
1110

1211
// Platform abstraction for managing all plugins and their execution
@@ -74,12 +73,12 @@ internal class Timeline {
7473
val systemState = store.currentState(System::class)
7574
val systemSettings = systemState?.settings
7675
systemSettings?.let {
77-
// if we have settings then update plugin with it
78-
plugin.update(it, Plugin.UpdateType.Initial)
79-
80-
if (!systemState.initialSettingsDispatched) {
76+
if (systemState.initializedPlugins.isNotEmpty()) {
77+
// if we have settings then update plugin with it
78+
// otherwise it will be updated when settings becomes available
79+
plugin.update(it, Plugin.UpdateType.Initial)
8180
store.dispatch(
82-
System.ToggleSettingsDispatch(dispatched = true),
81+
System.AddInitializedPlugins(setOf(plugin.hashCode())),
8382
System::class
8483
)
8584
}

core/src/test/kotlin/com/segment/analytics/kotlin/core/AnalyticsTests.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,7 @@ class AnalyticsTests {
809809
segmentDestination.execute(any())
810810
}
811811
verify { segmentDestination.onEnableToggled(capture(state)) }
812-
assertEquals(false, state[1].enabled)
812+
assertEquals(false, state.last().enabled)
813813

814814
analytics.enabled = true
815815
analytics.track("test")
@@ -818,7 +818,7 @@ class AnalyticsTests {
818818
segmentDestination.execute(any())
819819
}
820820
verify { segmentDestination.onEnableToggled(capture(state)) }
821-
assertEquals(true, state[2].enabled)
821+
assertEquals(true, state.last().enabled)
822822
}
823823

824824
@Test

core/src/test/kotlin/com/segment/analytics/kotlin/core/SettingsTests.kt

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,69 @@ class SettingsTests {
8585
}
8686
}
8787

88+
@Test
89+
fun `plugin added before settings is available updates plugin correctly`() = runTest {
90+
// forces settings to fail
91+
mockHTTPClient("")
92+
93+
analytics = testAnalytics(Configuration(
94+
writeKey = "123",
95+
application = "Test",
96+
autoAddSegmentDestination = false
97+
), testScope, testDispatcher)
98+
val mockPlugin = spyk<StubPlugin>()
99+
100+
// no settings available, should not be called
101+
analytics.add(mockPlugin)
102+
verify (exactly = 0){
103+
mockPlugin.update(any(), any())
104+
}
105+
106+
// load settings
107+
mockHTTPClient()
108+
analytics.checkSettings()
109+
verify (exactly = 1) {
110+
mockPlugin.update(any(), Plugin.UpdateType.Initial)
111+
}
112+
val system = analytics.store.currentState(System::class)
113+
assertTrue(system!!.initializedPlugins.contains(mockPlugin.hashCode()))
114+
115+
// load settings again
116+
mockHTTPClient()
117+
analytics.checkSettings()
118+
verify (exactly = 1) {
119+
mockPlugin.update(any(), Plugin.UpdateType.Refresh)
120+
}
121+
}
122+
123+
@Test
124+
fun `plugin added after settings is available updates plugin correctly`() = runTest {
125+
// load settings
126+
mockHTTPClient()
127+
analytics = testAnalytics(Configuration(
128+
writeKey = "123",
129+
application = "Test",
130+
autoAddSegmentDestination = false
131+
), testScope, testDispatcher)
132+
val mockPlugin = spyk<StubPlugin>()
133+
134+
analytics.add(mockPlugin)
135+
136+
// settings is already available, update with Initial
137+
verify (exactly = 1) {
138+
mockPlugin.update(any(), Plugin.UpdateType.Initial)
139+
}
140+
val system = analytics.store.currentState(System::class)
141+
assertTrue(system!!.initializedPlugins.contains(mockPlugin.hashCode()))
142+
143+
// load settings again
144+
mockHTTPClient()
145+
analytics.checkSettings()
146+
verify (exactly = 1) {
147+
mockPlugin.update(any(), Plugin.UpdateType.Refresh)
148+
}
149+
}
150+
88151
@Test
89152
fun `isDestinationEnabled returns true when present`() {
90153
val settings = Settings(
@@ -146,7 +209,7 @@ class SettingsTests {
146209

147210
@Disabled
148211
@Test
149-
fun `can manually enable destinations`() {
212+
fun `can manually enable destinations`() = runTest {
150213
val settings = Settings(
151214
integrations = buildJsonObject {
152215
put("Foo", buildJsonObject {
@@ -168,7 +231,7 @@ class SettingsTests {
168231
}
169232

170233
analytics.add(barDestination)
171-
analytics.update(settings, Plugin.UpdateType.Initial)
234+
analytics.update(settings)
172235

173236
analytics.track("track", buildJsonObject { put("direct", true) })
174237
assertEquals(0, eventCounter.get())

core/src/test/kotlin/com/segment/analytics/kotlin/core/compat/JavaAnalyticsTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ internal class JavaAnalyticsTest {
608608
segmentDestination.execute(any())
609609
}
610610
verify { segmentDestination.onEnableToggled(capture(state)) }
611-
assertEquals(false, state[1].enabled)
611+
assertEquals(false, state.last().enabled)
612612

613613
analytics.enabled = true
614614
analytics.track("test")
@@ -617,7 +617,7 @@ internal class JavaAnalyticsTest {
617617
segmentDestination.execute(any())
618618
}
619619
verify { segmentDestination.onEnableToggled(capture(state)) }
620-
assertEquals(true, state[2].enabled)
620+
assertEquals(true, state.last().enabled)
621621
}
622622

623623
private fun BaseEvent.populate() = apply {

core/src/test/kotlin/com/segment/analytics/kotlin/core/utilities/StorageImplTest.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import com.segment.analytics.kotlin.core.utils.spyStore
1212
import kotlinx.coroutines.test.TestScope
1313
import kotlinx.coroutines.test.UnconfinedTestDispatcher
1414
import kotlinx.coroutines.test.runTest
15-
import kotlinx.serialization.decodeFromString
1615
import kotlinx.serialization.encodeToString
1716
import kotlinx.serialization.json.Json
1817
import kotlinx.serialization.json.JsonObject
@@ -56,7 +55,7 @@ internal class StorageImplTest {
5655
configuration = Configuration("123"),
5756
settings = Settings(),
5857
running = false,
59-
initialSettingsDispatched = false,
58+
initializedPlugins = setOf(),
6059
enabled = true
6160
)
6261
)
@@ -126,7 +125,7 @@ internal class StorageImplTest {
126125
middlewareSettings = emptyJsonObject
127126
),
128127
running = false,
129-
initialSettingsDispatched = false,
128+
initializedPlugins = setOf(),
130129
enabled = true
131130
)
132131
}
@@ -152,7 +151,7 @@ internal class StorageImplTest {
152151
fun `system reset action removes system`() = runTest {
153152
val action = object : Action<System> {
154153
override fun reduce(state: System): System {
155-
return System(state.configuration, null, state.running, state.initialSettingsDispatched, state.enabled)
154+
return System(state.configuration, null, state.running, state.initializedPlugins, state.enabled)
156155
}
157156
}
158157
store.dispatch(action, System::class)

0 commit comments

Comments
 (0)