Skip to content

Commit c1317cb

Browse files
ErikUggeldahlErikUggeldahl
andcommitted
fix(Android): Race condition between resize and renderer deletion (#11831) 086750cc4a
There is a time-of-check, time-of-use race (TOCTOU) between the renderer being deleted and checking the dimensions. We did a check to early return but it wasn't under frame lock, so the result could change (by deletion) between the check and the retrieval. This splits `resizeArtboard` to have two lock sections (in the case of fit = Layout): one to get the dimensions under frame lock, and another to mutate the artboard under file lock. Co-authored-by: Erik <erik@rive.app>
1 parent 7350069 commit c1317cb

3 files changed

Lines changed: 95 additions & 13 deletions

File tree

.rive_head

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
d9776b5c2e2e1e9bae20c9ccb44858ed3292ea8f
1+
086750cc4a91e37ae3760124805eb9369d3ceff5

kotlin/src/androidTest/kotlin/app/rive/runtime/kotlin/core/RiveArtboardRendererTest.kt

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import app.rive.runtime.kotlin.SharedSurface
77
import app.rive.runtime.kotlin.controllers.RiveFileController
88
import app.rive.runtime.kotlin.renderers.RiveArtboardRenderer
99
import org.junit.Assert.assertFalse
10+
import org.junit.Assert.assertTrue
1011
import org.junit.Test
1112
import org.junit.runner.RunWith
1213
import java.util.concurrent.CountDownLatch
@@ -60,6 +61,9 @@ class RiveArtboardRendererTest {
6061
*/
6162
@Test
6263
fun deleteRendererDuringFrame() {
64+
// Ensure native libraries are loaded for JNI calls.
65+
testUtils.context
66+
6367
// Keep this low, as the test times out during non-critical activeArtboard access.
6468
val timeout = 100L
6569
// Latch to block signal we are passed the `hasCppObject` check in the draw() method
@@ -124,6 +128,9 @@ class RiveArtboardRendererTest {
124128
*/
125129
@Test
126130
fun disposeArtboardDuringFrameAfterEnteringSyncBlock() {
131+
// Ensure native libraries are loaded for JNI calls.
132+
testUtils.context
133+
127134
// Keep this low, as we expect it to timeout.
128135
val timeout = 1000L
129136
// Signals that the artboard has entered draw() and is about to dereference the cppPointer.
@@ -193,6 +200,9 @@ class RiveArtboardRendererTest {
193200
*/
194201
@Test
195202
fun disposeArtboardDuringFrameBeforeEnteringSyncBlock() {
203+
// Ensure native libraries are loaded for JNI calls.
204+
testUtils.context
205+
196206
val timeout = 1000L
197207
// Signals that the artboard has entered draw() and is about to dereference the cppPointer.
198208
val readyForRelease = CountDownLatch(1)
@@ -258,12 +268,16 @@ class RiveArtboardRendererTest {
258268
}
259269

260270
/**
261-
* Tests that the renderer can be safely deleted while resizeArtboard() is executing.
262-
* The fix adds a hasCppObject check at the start of resizeArtboard() to prevent
263-
* accessing disposed C++ objects when accessing width/height properties.
271+
* Tests that the renderer can be safely deleted while resizeArtboard() is executing. The fix
272+
* reads renderer liveness and surface dimensions together under frameLock (in the Fit.LAYOUT
273+
* case where this is required), then applies artboard mutations under the file lock to avoid
274+
* disposal races.
264275
*/
265276
@Test
266277
fun deleteRendererDuringResizeArtboard() {
278+
// Ensure native libraries are loaded for JNI calls.
279+
testUtils.context
280+
267281
val timeout = 1000L
268282
// Latch to signal we've entered resizeArtboard()
269283
val duringResizeLatch = CountDownLatch(1)
@@ -324,4 +338,63 @@ class RiveArtboardRendererTest {
324338
"Got: ${exception?.javaClass?.simpleName}: ${exception?.message}"
325339
}
326340
}
341+
342+
/**
343+
* Regression test for a race where resizeArtboard() reads hasCppObject, then the renderer is
344+
* deleted before width/height are read.
345+
*
346+
* The fit getter is used as a deterministic pause point between those operations.
347+
*/
348+
@Test
349+
fun deleteRendererBetweenCppObjectCheckAndDimensionRead_doesNotCrash() {
350+
// Ensure native libraries are loaded for JNI calls.
351+
testUtils.context
352+
353+
val timeoutMs = 1000L
354+
val readyForDelete = CountDownLatch(1)
355+
val resumeAfterDelete = CountDownLatch(1)
356+
val exceptionRef = AtomicReference<Throwable?>(null)
357+
358+
val controller = object : RiveFileController() {
359+
override var fit: Fit
360+
get() {
361+
readyForDelete.countDown()
362+
resumeAfterDelete.await(timeoutMs, TimeUnit.MILLISECONDS)
363+
return super.fit
364+
}
365+
set(value) {
366+
super.fit = value
367+
}
368+
}
369+
controller.fit = Fit.LAYOUT // Sets requireArtboardResize = true
370+
371+
val renderer = RiveArtboardRenderer(controller = controller)
372+
renderer.make()
373+
374+
val drawThread = Thread {
375+
try {
376+
renderer.draw()
377+
} catch (e: Throwable) {
378+
exceptionRef.set(e)
379+
}
380+
}
381+
drawThread.start()
382+
383+
assertTrue(
384+
"draw() did not reach the fit getter in time; race was not induced.",
385+
readyForDelete.await(timeoutMs, TimeUnit.MILLISECONDS)
386+
)
387+
388+
renderer.delete()
389+
resumeAfterDelete.countDown()
390+
391+
drawThread.join(timeoutMs)
392+
393+
val exception = exceptionRef.get()
394+
assertTrue(
395+
"Expected no exception when deleting renderer during resize race, " +
396+
"but got: ${exception?.javaClass?.simpleName}: ${exception?.message}",
397+
exception == null
398+
)
399+
}
327400
}

kotlin/src/main/java/app/rive/runtime/kotlin/renderers/RiveArtboardRenderer.kt

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,25 +39,34 @@ open class RiveArtboardRenderer(
3939
@WorkerThread
4040
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
4141
open fun resizeArtboard() {
42-
if (!hasCppObject) return
43-
4442
if (fit == Fit.LAYOUT) {
45-
val newWidth = width / scaleFactor
46-
val newHeight = height / scaleFactor
47-
controller.activeArtboard?.apply {
48-
width = newWidth
49-
height = newHeight
43+
// Read surface dimensions under frameLock so delete() cannot null cppPointer between
44+
// hasCppObject checks and width/height dereference.
45+
val (newWidth, newHeight) = synchronized(frameLock) {
46+
if (!hasCppObject || !controller.isActive) return
47+
Pair(width / scaleFactor, height / scaleFactor)
48+
}
49+
50+
// Acquire file lock only after the frameLock section to avoid lock-order inversion and
51+
// serialize artboard mutations with controller/file lifecycle operations.
52+
synchronized(controller.file?.lock ?: this) {
53+
controller.activeArtboard?.apply {
54+
width = newWidth
55+
height = newHeight
56+
}
5057
}
5158
} else {
52-
controller.activeArtboard?.resetArtboardSize()
59+
synchronized(controller.file?.lock ?: this) {
60+
controller.activeArtboard?.resetArtboardSize()
61+
}
5362
}
5463
}
5564

5665
// Be aware of thread safety!
5766
@WorkerThread
5867
override fun draw() {
5968
if (controller.requireArtboardResize.getAndSet(false)) {
60-
synchronized(controller.file?.lock ?: this) { resizeArtboard() }
69+
resizeArtboard()
6170
}
6271

6372
// Deref and draw under frameLock

0 commit comments

Comments
 (0)