Skip to content

Commit e4f7f7a

Browse files
authored
chore(🐙): add test cases (#428)
1 parent 24dc52d commit e4f7f7a

10 files changed

Lines changed: 746 additions & 10 deletions

apps/example/src/App.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ import { ComputeToys } from "./ComputeToys";
3535
import { Reanimated } from "./Reanimated";
3636
import { AsyncStarvation } from "./Diagnostics/AsyncStarvation";
3737
import { DeviceLostHang } from "./Diagnostics/DeviceLostHang";
38+
import { DiagnosticsList } from "./Diagnostics/DiagnosticsList";
3839
import { WorkletRequestAdapter } from "./Diagnostics/WorkletRequestAdapter";
40+
import { ContextEdgeCases } from "./Diagnostics/ContextEdgeCases";
41+
import { ViewFormatsUseAfterFree } from "./Diagnostics/ViewFormatsUseAfterFree";
42+
import { RenderAfterUnmount } from "./Diagnostics/RenderAfterUnmount";
43+
import { BackgroundDetach } from "./Diagnostics/BackgroundDetach";
44+
import { SurfaceChurn } from "./Diagnostics/SurfaceChurn";
3945
import { StorageBufferVertices } from "./StorageBufferVertices";
4046
import { SharedTextureMemory } from "./SharedTextureMemory";
4147
import { ImportExternalTexture } from "./ImportExternalTexture";
@@ -96,12 +102,28 @@ function App() {
96102
</Stack.Screen>
97103
<Stack.Screen name="GradientTiles" component={GradientTiles} />
98104
<Stack.Screen name="Reanimated" component={Reanimated} />
105+
<Stack.Screen
106+
name="Diagnostics"
107+
component={DiagnosticsList}
108+
options={{ title: "Tests" }}
109+
/>
99110
<Stack.Screen name="AsyncStarvation" component={AsyncStarvation} />
100111
<Stack.Screen name="DeviceLostHang" component={DeviceLostHang} />
101112
<Stack.Screen
102113
name="WorkletRequestAdapter"
103114
component={WorkletRequestAdapter}
104115
/>
116+
<Stack.Screen name="ContextEdgeCases" component={ContextEdgeCases} />
117+
<Stack.Screen
118+
name="ViewFormatsUseAfterFree"
119+
component={ViewFormatsUseAfterFree}
120+
/>
121+
<Stack.Screen
122+
name="RenderAfterUnmount"
123+
component={RenderAfterUnmount}
124+
/>
125+
<Stack.Screen name="BackgroundDetach" component={BackgroundDetach} />
126+
<Stack.Screen name="SurfaceChurn" component={SurfaceChurn} />
105127
<Stack.Screen
106128
name="StorageBufferVertices"
107129
component={StorageBufferVertices}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { AppState, ScrollView, Switch, Text, View } from "react-native";
3+
import type { CanvasRef } from "react-native-webgpu";
4+
import { Canvas } from "react-native-webgpu";
5+
6+
import {
7+
diagnosticStyles,
8+
drawClearFrame,
9+
initGPU,
10+
useDiagnosticLog,
11+
} from "./surfaceLifecycle";
12+
13+
// Backgrounding the app destroys the native surface while the JS context
14+
// stays alive. This screen renders continuously; background the app, wait a
15+
// second, and come back. Primarily an Android repro (iOS keeps the
16+
// CAMetalLayer alive in the background).
17+
//
18+
// On a broken build the two Android view flavors fail differently:
19+
// - transparent (TextureView): onSurfaceTextureDestroyed removes the surface
20+
// registry entry entirely. The resumed view registers a fresh entry the JS
21+
// context has never seen, so the canvas stays black forever (and the
22+
// context renders into the orphaned surface on a destroyed window).
23+
// - opaque (SurfaceView): the surface detaches to offscreen and reattaches on
24+
// resume, but the reattach blit presents a frame the context never
25+
// acquired (watch for a present-without-acquire validation error in the
26+
// log) and permanently widens the configured usage with CopyDst.
27+
//
28+
// Flipping the switch while rendering tears down the current native view the
29+
// same way backgrounding does, so it reproduces the TextureView teardown
30+
// without leaving the app.
31+
export const BackgroundDetach = () => {
32+
const ref = useRef<CanvasRef>(null);
33+
const { log, append } = useDiagnosticLog();
34+
const [transparent, setTransparent] = useState(true);
35+
36+
useEffect(() => {
37+
const sub = AppState.addEventListener("change", (state) => {
38+
append(`AppState: ${state}`);
39+
});
40+
return () => sub.remove();
41+
}, [append]);
42+
43+
useEffect(() => {
44+
let running = true;
45+
let frame = 0;
46+
(async () => {
47+
const { device, format } = await initGPU(append);
48+
const ctx = ref.current!.getContext("webgpu")!;
49+
ctx.configure({ device, format, alphaMode: "premultiplied" });
50+
append(
51+
"rendering, background the app and come back (or flip the switch)",
52+
);
53+
const loop = () => {
54+
if (!running) {
55+
return;
56+
}
57+
try {
58+
drawClearFrame(device, ctx, frame++);
59+
if (frame % 120 === 0) {
60+
append(`frame ${frame} ok`);
61+
}
62+
} catch (e) {
63+
append(`frame ${frame}: ${e}`);
64+
}
65+
requestAnimationFrame(loop);
66+
};
67+
loop();
68+
})();
69+
return () => {
70+
running = false;
71+
};
72+
}, [append]);
73+
74+
return (
75+
<View style={diagnosticStyles.container}>
76+
<View style={diagnosticStyles.controls}>
77+
<Text style={diagnosticStyles.description}>
78+
Background the app and return: the animated gradient must resume. On a
79+
broken build the transparent canvas stays black forever (Android), and
80+
the opaque one logs a validation error on resume.
81+
</Text>
82+
<View style={styles.row}>
83+
<Text style={diagnosticStyles.description}>
84+
transparent (TextureView on Android)
85+
</Text>
86+
<Switch value={transparent} onValueChange={setTransparent} />
87+
</View>
88+
</View>
89+
<Canvas
90+
ref={ref}
91+
style={diagnosticStyles.canvas}
92+
transparent={transparent}
93+
/>
94+
<ScrollView style={diagnosticStyles.log}>
95+
{log.map((line, i) => (
96+
<Text key={i} style={diagnosticStyles.logLine}>
97+
{line}
98+
</Text>
99+
))}
100+
</ScrollView>
101+
</View>
102+
);
103+
};
104+
105+
const styles = {
106+
row: {
107+
flexDirection: "row" as const,
108+
alignItems: "center" as const,
109+
justifyContent: "space-between" as const,
110+
},
111+
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React, { useRef } from "react";
2+
import { Button, ScrollView, Text, View } from "react-native";
3+
import type { CanvasRef } from "react-native-webgpu";
4+
import { Canvas } from "react-native-webgpu";
5+
6+
import {
7+
diagnosticStyles,
8+
drawClearFrame,
9+
initGPU,
10+
useDiagnosticLog,
11+
} from "./surfaceLifecycle";
12+
13+
// Repros for GPUCanvasContext entry points the WebGPU spec expects to fail
14+
// gracefully but that crash or silently misbehave in the native
15+
// implementation:
16+
//
17+
// 1. getCurrentTexture() before configure(): SurfaceInfo has no device yet.
18+
// The native size-changed path reconfigures with a null wgpu::Device
19+
// (CreateTexture on the offscreen path, Surface::Configure on-screen) and
20+
// crashes the app instead of throwing a catchable JS error.
21+
//
22+
// 2. configure() on a 0x0 canvas: Dawn refuses zero-sized textures, so
23+
// getCurrentTexture() wraps a null texture and createView() dereferences
24+
// it. A canvas can legitimately be measured at 0x0 for a frame (collapsed
25+
// layout, display: none equivalents), so this is reachable from ordinary
26+
// app code.
27+
//
28+
// 3. unconfigure(): the native method is an empty stub. After unconfigure()
29+
// the context keeps handing out textures as if still configured, where the
30+
// spec says the canvas should behave as if it was never configured.
31+
//
32+
// Each button is an independent repro; on a broken build the first two
33+
// terminate the app, so relaunch between attempts.
34+
export const ContextEdgeCases = () => {
35+
const ref = useRef<CanvasRef>(null);
36+
const { log, append } = useDiagnosticLog();
37+
38+
const getCurrentTextureUnconfigured = () => {
39+
try {
40+
const ctx = ref.current!.getContext("webgpu")!;
41+
append("getCurrentTexture() before configure()...");
42+
const texture = ctx.getCurrentTexture();
43+
append(
44+
`returned a ${texture.width}x${texture.height} texture (spec: should throw)`,
45+
);
46+
} catch (e) {
47+
append(`threw (correct per spec): ${e}`);
48+
}
49+
};
50+
51+
const zeroSizedCanvas = async () => {
52+
try {
53+
const { device, format } = await initGPU(append);
54+
const ctx = ref.current!.getContext("webgpu")!;
55+
const canvas = ctx.canvas as HTMLCanvasElement;
56+
canvas.width = 0;
57+
canvas.height = 0;
58+
append("configure() with canvas.width = canvas.height = 0...");
59+
ctx.configure({ device, format, alphaMode: "opaque" });
60+
const texture = ctx.getCurrentTexture();
61+
append(`getCurrentTexture() -> ${texture.width}x${texture.height}`);
62+
texture.createView();
63+
append("createView() survived");
64+
} catch (e) {
65+
append(`threw: ${e}`);
66+
}
67+
};
68+
69+
const unconfigureStub = async () => {
70+
try {
71+
const { device, format } = await initGPU(append);
72+
const ctx = ref.current!.getContext("webgpu")!;
73+
ctx.configure({ device, format, alphaMode: "opaque" });
74+
drawClearFrame(device, ctx, 0);
75+
append("rendered one frame, calling unconfigure()...");
76+
ctx.unconfigure();
77+
const texture = ctx.getCurrentTexture();
78+
append(
79+
`getCurrentTexture() after unconfigure() -> ${texture.width}x${texture.height} texture (spec: context should be unconfigured)`,
80+
);
81+
} catch (e) {
82+
append(`threw (correct per spec): ${e}`);
83+
}
84+
};
85+
86+
return (
87+
<View style={diagnosticStyles.container}>
88+
<View style={diagnosticStyles.controls}>
89+
<Text style={diagnosticStyles.description}>
90+
Each button exercises a context entry point that must fail gracefully
91+
per the WebGPU spec. On a broken build the first two crash the app.
92+
</Text>
93+
<Button
94+
title="getCurrentTexture() before configure()"
95+
onPress={getCurrentTextureUnconfigured}
96+
/>
97+
<Button title="configure() a 0x0 canvas" onPress={zeroSizedCanvas} />
98+
<Button
99+
title="unconfigure() then getCurrentTexture()"
100+
onPress={unconfigureStub}
101+
/>
102+
</View>
103+
<Canvas ref={ref} style={diagnosticStyles.canvas} />
104+
<ScrollView style={diagnosticStyles.log}>
105+
{log.map((line, i) => (
106+
<Text key={i} style={diagnosticStyles.logLine}>
107+
{line}
108+
</Text>
109+
))}
110+
</ScrollView>
111+
</View>
112+
);
113+
};
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as React from "react";
2+
import { ScrollView, StyleSheet, Text, View } from "react-native";
3+
import { useNavigation } from "@react-navigation/native";
4+
import { RectButton } from "react-native-gesture-handler";
5+
import type { StackNavigationProp } from "@react-navigation/stack";
6+
7+
import type { Routes } from "../Route";
8+
9+
// Sub navigation for the diagnostic screens: each entry reproduces a bug or
10+
// stresses an invariant of the native implementation.
11+
const tests = [
12+
{
13+
screen: "AsyncStarvation",
14+
title: "⚠️ Async Runner Starvation",
15+
},
16+
{
17+
screen: "DeviceLostHang",
18+
title: "⚠️ Device Lost Hang",
19+
},
20+
{
21+
screen: "WorkletRequestAdapter",
22+
title: "⚠️ Worklet requestAdapter",
23+
},
24+
{
25+
screen: "ContextEdgeCases",
26+
title: "⚠️ Context Edge Cases",
27+
},
28+
{
29+
screen: "ViewFormatsUseAfterFree",
30+
title: "⚠️ viewFormats Use-After-Free",
31+
},
32+
{
33+
screen: "RenderAfterUnmount",
34+
title: "⚠️ Render After Unmount",
35+
},
36+
{
37+
screen: "BackgroundDetach",
38+
title: "⚠️ Background Detach",
39+
},
40+
{
41+
screen: "SurfaceChurn",
42+
title: "⚠️ Surface Churn",
43+
},
44+
] as const;
45+
46+
export const DiagnosticsList = () => {
47+
const { navigate } =
48+
useNavigation<StackNavigationProp<Routes, "Diagnostics">>();
49+
return (
50+
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
51+
{tests.map((test) => (
52+
<RectButton key={test.screen} onPress={() => navigate(test.screen)}>
53+
<View style={styles.thumbnail}>
54+
<Text style={styles.title}>{test.title}</Text>
55+
</View>
56+
</RectButton>
57+
))}
58+
</ScrollView>
59+
);
60+
};
61+
62+
const styles = StyleSheet.create({
63+
container: {
64+
flex: 1,
65+
},
66+
content: {
67+
marginBottom: 32,
68+
},
69+
thumbnail: {
70+
backgroundColor: "white",
71+
padding: 32,
72+
borderBottomWidth: StyleSheet.hairlineWidth,
73+
},
74+
title: {},
75+
});

0 commit comments

Comments
 (0)