Skip to content

Commit d3b17e3

Browse files
committed
fix(legacy): run *Async file/ViewModel lookups on the main thread
The legacy backends implemented the *Async lookup APIs as Promise.async { sync impl }, which moves Rive runtime access onto Nitro's background pool (Swift concurrency executor / Dispatchers. Default) — concurrent with the attached views rendering the same RiveFile on the main thread, with no synchronization in the legacy runtime. That is the race class TSan confirmed for issue #297, and useViewModelInstance({ async: true }) routes all instance setup through these paths. iOS already main-hopped instance creation (Promise.onMain in HybridViewModel); this applies the same policy to the file/ViewModel lookups and to Android, where creation also ran on the pool. Android uses a shared, never-cancelled main scope: a Promise launched on a cancelled scope would never settle, so a lookup racing dispose() must still resolve (the bodies null-guard the disposed file). On the legacy backend "async" thus means "doesn't block the JS thread", never "parallel with rendering". Not covered here (same race class, pre-existing via useRiveList/useRiveProperty): the legacy list-property operations and iOS legacy property setters still run on the pool — follow-up.
1 parent 7db22bb commit d3b17e3

4 files changed

Lines changed: 41 additions & 16 deletions

File tree

android/src/legacy/java/com/margelo/nitro/rive/HybridRiveFile.kt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,13 @@ class HybridRiveFile : HybridRiveFileSpec() {
8686
}
8787
}
8888

89+
// The *Async lookups run on the main thread (via [riveMainScope], not Nitro's
90+
// Dispatchers.Default pool): they walk the same RiveFile the attached views
91+
// render on the main thread, and the legacy runtime has no internal
92+
// synchronization — off-main access is the race class of issue #297.
93+
// "Async" here means "doesn't block the JS thread".
8994
override fun getViewModelNamesAsync(): Promise<Array<String>> {
90-
return Promise.async {
95+
return Promise.async(riveMainScope) {
9196
val file = riveFile ?: return@async emptyArray()
9297
val count = file.viewModelCount
9398
val names = mutableListOf<String>()
@@ -103,19 +108,19 @@ class HybridRiveFile : HybridRiveFileSpec() {
103108
}
104109

105110
override fun viewModelByNameAsync(name: String, validate: Boolean?): Promise<HybridViewModelSpec?> {
106-
return Promise.async { viewModelByName(name) }
111+
return Promise.async(riveMainScope) { viewModelByName(name) }
107112
}
108113

109114
override fun defaultArtboardViewModelAsync(artboardBy: ArtboardBy?): Promise<HybridViewModelSpec?> {
110-
return Promise.async { defaultArtboardViewModel(artboardBy) }
115+
return Promise.async(riveMainScope) { defaultArtboardViewModel(artboardBy) }
111116
}
112117

113118
override fun getArtboardCountAsync(): Promise<Double> {
114-
return Promise.async { artboardCount }
119+
return Promise.async(riveMainScope) { artboardCount }
115120
}
116121

117122
override fun getArtboardNamesAsync(): Promise<Array<String>> {
118-
return Promise.async { artboardNames }
123+
return Promise.async(riveMainScope) { artboardNames }
119124
}
120125

121126
override fun updateReferencedAssets(referencedAssets: ReferencedAssetsType) {
@@ -140,7 +145,7 @@ class HybridRiveFile : HybridRiveFileSpec() {
140145

141146
override fun getEnums(): Promise<Array<RiveEnumDefinition>> {
142147
val file = riveFile ?: return Promise.resolved(emptyArray())
143-
return Promise.async {
148+
return Promise.async(riveMainScope) {
144149
try {
145150
file.enums
146151
.map { enum ->

android/src/legacy/java/com/margelo/nitro/rive/HybridViewModel.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,23 +57,25 @@ class HybridViewModel(private val viewModel: ViewModel) : HybridViewModelSpec()
5757
return Promise.rejected(UnsupportedOperationException("getPropertiesAsync is not supported on the legacy backend"))
5858
}
5959

60+
// Main-hopped like HybridRiveFile's lookups — the legacy runtime is only
61+
// safe to touch on the main thread (see riveMainScope).
6062
override fun getPropertyCountAsync(): Promise<Double> {
61-
return Promise.async { propertyCount }
63+
return Promise.async(riveMainScope) { propertyCount }
6264
}
6365

6466
override fun getInstanceCountAsync(): Promise<Double> {
65-
return Promise.async { instanceCount }
67+
return Promise.async(riveMainScope) { instanceCount }
6668
}
6769

6870
override fun createInstanceByNameAsync(name: String): Promise<HybridViewModelInstanceSpec?> {
69-
return Promise.async { createInstanceByName(name) }
71+
return Promise.async(riveMainScope) { createInstanceByName(name) }
7072
}
7173

7274
override fun createDefaultInstanceAsync(): Promise<HybridViewModelInstanceSpec?> {
73-
return Promise.async { createDefaultInstance() }
75+
return Promise.async(riveMainScope) { createDefaultInstance() }
7476
}
7577

7678
override fun createBlankInstanceAsync(): Promise<HybridViewModelInstanceSpec?> {
77-
return Promise.async { createInstance() }
79+
return Promise.async(riveMainScope) { createInstance() }
7880
}
7981
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.margelo.nitro.rive
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.SupervisorJob
6+
7+
// Legacy runtime objects are only safe to touch on the main thread: the
8+
// attached views render there and rive-android's legacy API has no internal
9+
// synchronization (off-main access is the race class of issue #297). This
10+
// scope hops *Async calls onto main. It is intentionally never cancelled —
11+
// a Promise launched on a cancelled scope would never settle, so bodies
12+
// guard on disposed state instead.
13+
internal val riveMainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())

ios/legacy/HybridRiveFile.swift

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,13 @@ class HybridRiveFile: HybridRiveFileSpec, RiveViewSource {
9090
return HybridBindableArtboard(bindableArtboard: bindable)
9191
}
9292

93+
// The *Async lookups run on the main thread (not Nitro's pool): they walk
94+
// the same RiveFile the attached views render on the main thread, and the
95+
// legacy runtime has no internal synchronization — off-main access is the
96+
// race class of issue #297. "Async" here means "doesn't block the JS
97+
// thread", matching HybridViewModel's create*InstanceAsync.
9398
func getViewModelNamesAsync() throws -> Promise<[String]> {
94-
return Promise.async {
99+
return Promise.onMain {
95100
guard let file = self.riveFile else { return [] }
96101
let count = file.viewModelCount
97102
var names: [String] = []
@@ -105,19 +110,19 @@ class HybridRiveFile: HybridRiveFileSpec, RiveViewSource {
105110
}
106111

107112
func viewModelByNameAsync(name: String, validate: Bool?) throws -> Promise<(any HybridViewModelSpec)?> {
108-
return Promise.async { try self.viewModelByName(name: name) }
113+
return Promise.onMain { try self.viewModelByName(name: name) }
109114
}
110115

111116
func defaultArtboardViewModelAsync(artboardBy: ArtboardBy?) throws -> Promise<(any HybridViewModelSpec)?> {
112-
return Promise.async { try self.defaultArtboardViewModel(artboardBy: artboardBy) }
117+
return Promise.onMain { try self.defaultArtboardViewModel(artboardBy: artboardBy) }
113118
}
114119

115120
func getArtboardCountAsync() throws -> Promise<Double> {
116-
return Promise.async { self.artboardCount }
121+
return Promise.onMain { self.artboardCount }
117122
}
118123

119124
func getArtboardNamesAsync() throws -> Promise<[String]> {
120-
return Promise.async { self.artboardNames }
125+
return Promise.onMain { self.artboardNames }
121126
}
122127

123128
func updateReferencedAssets(referencedAssets: ReferencedAssetsType) {

0 commit comments

Comments
 (0)