Skip to content

Commit 04d72e3

Browse files
authored
fix(💣): fix potential race condition in the renderer (#3588)
1 parent 00a06ca commit 04d72e3

File tree

5 files changed

+197
-5
lines changed

5 files changed

+197
-5
lines changed

‎apps/example/src/Examples/API/List.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ export const examples = [
134134
screen: "StressTest4",
135135
title: "🔥 Stress Test 4",
136136
},
137+
{
138+
screen: "PictureViewCrashTest",
139+
title: "💥 PictureView Race Condition",
140+
},
137141
{
138142
screen: "FirstFrame",
139143
title: "🎬 First Frame",
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import React, { useState, useRef, useEffect } from "react";
2+
import { Button, ScrollView, StyleSheet, Text, View } from "react-native";
3+
import { Canvas, Fill } from "@shopify/react-native-skia";
4+
import {
5+
useSharedValue,
6+
withRepeat,
7+
withTiming,
8+
useDerivedValue,
9+
} from "react-native-reanimated";
10+
11+
/**
12+
* This stress test attempts to reproduce a race condition crash in RNSkPictureView.
13+
*
14+
* The crash occurs when:
15+
* 1. A Canvas with animations is rapidly mounted/unmounted
16+
* 2. The render thread may still be drawing while the view is being torn down
17+
* 3. _picture could become invalid between the null check and drawPicture() call
18+
*
19+
* The crash manifests as:
20+
* null pointer dereference: SIGSEGV
21+
* at SkRecord::Record::visit (SkRecordDraw)
22+
*
23+
* This test rapidly mounts/unmounts animated Canvas components to maximize
24+
* the chance of hitting this race condition.
25+
*/
26+
27+
const AnimatedCanvas = () => {
28+
const progress = useSharedValue(0);
29+
30+
useEffect(() => {
31+
progress.value = withRepeat(withTiming(1, { duration: 16 }), -1, true);
32+
}, [progress]);
33+
34+
const color = useDerivedValue(() => {
35+
const r = Math.floor(progress.value * 255);
36+
const g = Math.floor((1 - progress.value) * 255);
37+
const b = Math.floor(Math.abs(0.5 - progress.value) * 2 * 255);
38+
return `rgb(${r}, ${g}, ${b})`;
39+
});
40+
41+
return (
42+
<Canvas style={styles.smallCanvas}>
43+
<Fill color={color} />
44+
</Canvas>
45+
);
46+
};
47+
48+
export const PictureViewCrashTest = () => {
49+
const [isRunning, setIsRunning] = useState(false);
50+
const [iterations, setIterations] = useState(0);
51+
const [canvasCount, setCanvasCount] = useState(0);
52+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
53+
const keyRef = useRef(0);
54+
55+
useEffect(() => {
56+
return () => {
57+
if (intervalRef.current) {
58+
clearInterval(intervalRef.current);
59+
}
60+
};
61+
}, []);
62+
63+
const startTest = () => {
64+
if (isRunning) return;
65+
setIsRunning(true);
66+
setIterations(0);
67+
keyRef.current = 0;
68+
69+
// Rapidly mount/unmount animated Canvas components
70+
// This creates a race between:
71+
// - The animation mapper setting pictures on the UI thread
72+
// - The view unregistering and potentially clearing state
73+
// - The render thread drawing the picture
74+
intervalRef.current = setInterval(() => {
75+
keyRef.current += 1;
76+
// Toggle between 0 and 5 canvases to force mount/unmount cycles
77+
setCanvasCount((prev) => (prev > 0 ? 0 : 5));
78+
setIterations((prev) => prev + 1);
79+
}, 50); // 50ms gives enough time for animations to start before unmounting
80+
};
81+
82+
const stopTest = () => {
83+
if (intervalRef.current) {
84+
clearInterval(intervalRef.current);
85+
intervalRef.current = null;
86+
}
87+
setIsRunning(false);
88+
setCanvasCount(0);
89+
};
90+
91+
return (
92+
<ScrollView style={styles.container}>
93+
<View style={styles.infoContainer}>
94+
<Text style={styles.title}>PictureView Race Condition Test</Text>
95+
<Text style={styles.description}>
96+
This test rapidly mounts and unmounts animated Canvas components to
97+
reproduce the race condition crash where the render thread tries to
98+
draw a picture that has been cleared during unmount.
99+
</Text>
100+
<Text style={styles.iterations}>
101+
Mount/Unmount cycles: {iterations}
102+
</Text>
103+
<Text style={styles.canvasCount}>Active canvases: {canvasCount}</Text>
104+
{isRunning && (
105+
<Text style={styles.warning}>
106+
Running... If the app crashes, the race condition was triggered.
107+
</Text>
108+
)}
109+
</View>
110+
111+
<View style={styles.canvasContainer}>
112+
{Array.from({ length: canvasCount }).map((_, index) => (
113+
<AnimatedCanvas key={`${keyRef.current}-${index}`} />
114+
))}
115+
</View>
116+
117+
<View style={styles.buttonContainer}>
118+
<Button
119+
onPress={isRunning ? stopTest : startTest}
120+
title={isRunning ? "Stop Test" : "Start Mount/Unmount Test"}
121+
color={isRunning ? "#dc3545" : "#28a745"}
122+
/>
123+
</View>
124+
</ScrollView>
125+
);
126+
};
127+
128+
const styles = StyleSheet.create({
129+
container: {
130+
flex: 1,
131+
},
132+
infoContainer: {
133+
padding: 16,
134+
},
135+
title: {
136+
fontSize: 18,
137+
fontWeight: "bold",
138+
marginBottom: 8,
139+
},
140+
description: {
141+
fontSize: 14,
142+
color: "#666",
143+
marginBottom: 8,
144+
},
145+
iterations: {
146+
fontSize: 16,
147+
fontWeight: "600",
148+
marginBottom: 4,
149+
},
150+
canvasCount: {
151+
fontSize: 16,
152+
fontWeight: "600",
153+
marginBottom: 4,
154+
},
155+
warning: {
156+
fontSize: 14,
157+
color: "#dc3545",
158+
fontWeight: "600",
159+
},
160+
canvasContainer: {
161+
flexDirection: "row",
162+
flexWrap: "wrap",
163+
justifyContent: "center",
164+
padding: 8,
165+
minHeight: 200,
166+
},
167+
smallCanvas: {
168+
width: 80,
169+
height: 80,
170+
margin: 4,
171+
borderColor: "red",
172+
borderWidth: 1,
173+
},
174+
buttonContainer: {
175+
padding: 16,
176+
},
177+
});

‎apps/example/src/Examples/API/Routes.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type Routes = {
3232
StressTest2: undefined;
3333
StressTest3: undefined;
3434
StressTest4: undefined;
35+
PictureViewCrashTest: undefined;
3536
FirstFrame: undefined;
3637
FirstFrameEmpty: undefined;
3738
};

‎apps/example/src/Examples/API/index.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { StressTest } from "./StressTest";
3535
import { StressTest2 } from "./StressTest2";
3636
import { StressTest3 } from "./StressTest3";
3737
import { StressTest4 } from "./StressTest4";
38+
import { PictureViewCrashTest } from "./PictureViewCrashTest";
3839
import { FirstFrame, FirstFrameEmpty } from "./FirstFrame";
3940
import { ZIndexExample } from "./ZIndex";
4041

@@ -275,6 +276,13 @@ export const API = () => {
275276
title: "🔥 Stress Test 4",
276277
}}
277278
/>
279+
<Stack.Screen
280+
name="PictureViewCrashTest"
281+
component={PictureViewCrashTest}
282+
options={{
283+
title: "💥 PictureView Race Condition",
284+
}}
285+
/>
278286
<Stack.Screen
279287
name="FirstFrame"
280288
component={FirstFrame}

‎packages/skia/cpp/rnskia/RNSkPictureView.h‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,16 @@ class RNSkPictureRenderer
6060

6161
private:
6262
bool performDraw(std::shared_ptr<RNSkCanvasProvider> canvasProvider) {
63-
return canvasProvider->renderToCanvas([=, this](SkCanvas *canvas) {
64-
// Make sure to scale correctly
65-
auto pd = _platformContext->getPixelDensity();
63+
// Capture picture pointer to ensure thread safety - _picture can be
64+
// modified from the JS thread while we're drawing on the render thread
65+
sk_sp<SkPicture> picture = _picture;
66+
auto pd = _platformContext->getPixelDensity();
67+
return canvasProvider->renderToCanvas([=](SkCanvas *canvas) {
6668
canvas->clear(SK_ColorTRANSPARENT);
6769
canvas->save();
6870
canvas->scale(pd, pd);
69-
if (_picture != nullptr) {
70-
canvas->drawPicture(_picture);
71+
if (picture != nullptr) {
72+
canvas->drawPicture(picture);
7173
}
7274
canvas->restore();
7375
});

0 commit comments

Comments
 (0)