Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ import { ComputeToys } from "./ComputeToys";
import { Reanimated } from "./Reanimated";
import { AsyncStarvation } from "./Diagnostics/AsyncStarvation";
import { DeviceLostHang } from "./Diagnostics/DeviceLostHang";
import { DiagnosticsList } from "./Diagnostics/DiagnosticsList";
import { WorkletRequestAdapter } from "./Diagnostics/WorkletRequestAdapter";
import { ContextEdgeCases } from "./Diagnostics/ContextEdgeCases";
import { ViewFormatsUseAfterFree } from "./Diagnostics/ViewFormatsUseAfterFree";
import { RenderAfterUnmount } from "./Diagnostics/RenderAfterUnmount";
import { BackgroundDetach } from "./Diagnostics/BackgroundDetach";
import { SurfaceChurn } from "./Diagnostics/SurfaceChurn";
import { StorageBufferVertices } from "./StorageBufferVertices";
import { SharedTextureMemory } from "./SharedTextureMemory";
import { ImportExternalTexture } from "./ImportExternalTexture";
Expand Down Expand Up @@ -96,12 +102,28 @@ function App() {
</Stack.Screen>
<Stack.Screen name="GradientTiles" component={GradientTiles} />
<Stack.Screen name="Reanimated" component={Reanimated} />
<Stack.Screen
name="Diagnostics"
component={DiagnosticsList}
options={{ title: "Tests" }}
/>
<Stack.Screen name="AsyncStarvation" component={AsyncStarvation} />
<Stack.Screen name="DeviceLostHang" component={DeviceLostHang} />
<Stack.Screen
name="WorkletRequestAdapter"
component={WorkletRequestAdapter}
/>
<Stack.Screen name="ContextEdgeCases" component={ContextEdgeCases} />
<Stack.Screen
name="ViewFormatsUseAfterFree"
component={ViewFormatsUseAfterFree}
/>
<Stack.Screen
name="RenderAfterUnmount"
component={RenderAfterUnmount}
/>
<Stack.Screen name="BackgroundDetach" component={BackgroundDetach} />
<Stack.Screen name="SurfaceChurn" component={SurfaceChurn} />
<Stack.Screen
name="StorageBufferVertices"
component={StorageBufferVertices}
Expand Down
111 changes: 111 additions & 0 deletions apps/example/src/Diagnostics/BackgroundDetach.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React, { useEffect, useRef, useState } from "react";
import { AppState, ScrollView, Switch, Text, View } from "react-native";
import type { CanvasRef } from "react-native-webgpu";
import { Canvas } from "react-native-webgpu";

import {
diagnosticStyles,
drawClearFrame,
initGPU,
useDiagnosticLog,
} from "./surfaceLifecycle";

// Backgrounding the app destroys the native surface while the JS context
// stays alive. This screen renders continuously; background the app, wait a
// second, and come back. Primarily an Android repro (iOS keeps the
// CAMetalLayer alive in the background).
//
// On a broken build the two Android view flavors fail differently:
// - transparent (TextureView): onSurfaceTextureDestroyed removes the surface
// registry entry entirely. The resumed view registers a fresh entry the JS
// context has never seen, so the canvas stays black forever (and the
// context renders into the orphaned surface on a destroyed window).
// - opaque (SurfaceView): the surface detaches to offscreen and reattaches on
// resume, but the reattach blit presents a frame the context never
// acquired (watch for a present-without-acquire validation error in the
// log) and permanently widens the configured usage with CopyDst.
//
// Flipping the switch while rendering tears down the current native view the
// same way backgrounding does, so it reproduces the TextureView teardown
// without leaving the app.
export const BackgroundDetach = () => {
const ref = useRef<CanvasRef>(null);
const { log, append } = useDiagnosticLog();
const [transparent, setTransparent] = useState(true);

useEffect(() => {
const sub = AppState.addEventListener("change", (state) => {
append(`AppState: ${state}`);
});
return () => sub.remove();
}, [append]);

useEffect(() => {
let running = true;
let frame = 0;
(async () => {
const { device, format } = await initGPU(append);
const ctx = ref.current!.getContext("webgpu")!;
ctx.configure({ device, format, alphaMode: "premultiplied" });
append(
"rendering, background the app and come back (or flip the switch)",
);
const loop = () => {
if (!running) {
return;
}
try {
drawClearFrame(device, ctx, frame++);
if (frame % 120 === 0) {
append(`frame ${frame} ok`);
}
} catch (e) {
append(`frame ${frame}: ${e}`);
}
requestAnimationFrame(loop);
};
loop();
})();
return () => {
running = false;
};
}, [append]);

return (
<View style={diagnosticStyles.container}>
<View style={diagnosticStyles.controls}>
<Text style={diagnosticStyles.description}>
Background the app and return: the animated gradient must resume. On a
broken build the transparent canvas stays black forever (Android), and
the opaque one logs a validation error on resume.
</Text>
<View style={styles.row}>
<Text style={diagnosticStyles.description}>
transparent (TextureView on Android)
</Text>
<Switch value={transparent} onValueChange={setTransparent} />
</View>
</View>
<Canvas
ref={ref}
style={diagnosticStyles.canvas}
transparent={transparent}
/>
<ScrollView style={diagnosticStyles.log}>
{log.map((line, i) => (
<Text key={i} style={diagnosticStyles.logLine}>
{line}
</Text>
))}
</ScrollView>
</View>
);
};

const styles = {
row: {
flexDirection: "row" as const,
alignItems: "center" as const,
justifyContent: "space-between" as const,
},
};
113 changes: 113 additions & 0 deletions apps/example/src/Diagnostics/ContextEdgeCases.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { useRef } from "react";
import { Button, ScrollView, Text, View } from "react-native";
import type { CanvasRef } from "react-native-webgpu";
import { Canvas } from "react-native-webgpu";

import {
diagnosticStyles,
drawClearFrame,
initGPU,
useDiagnosticLog,
} from "./surfaceLifecycle";

// Repros for GPUCanvasContext entry points the WebGPU spec expects to fail
// gracefully but that crash or silently misbehave in the native
// implementation:
//
// 1. getCurrentTexture() before configure(): SurfaceInfo has no device yet.
// The native size-changed path reconfigures with a null wgpu::Device
// (CreateTexture on the offscreen path, Surface::Configure on-screen) and
// crashes the app instead of throwing a catchable JS error.
//
// 2. configure() on a 0x0 canvas: Dawn refuses zero-sized textures, so
// getCurrentTexture() wraps a null texture and createView() dereferences
// it. A canvas can legitimately be measured at 0x0 for a frame (collapsed
// layout, display: none equivalents), so this is reachable from ordinary
// app code.
//
// 3. unconfigure(): the native method is an empty stub. After unconfigure()
// the context keeps handing out textures as if still configured, where the
// spec says the canvas should behave as if it was never configured.
//
// Each button is an independent repro; on a broken build the first two
// terminate the app, so relaunch between attempts.
export const ContextEdgeCases = () => {
const ref = useRef<CanvasRef>(null);
const { log, append } = useDiagnosticLog();

const getCurrentTextureUnconfigured = () => {
try {
const ctx = ref.current!.getContext("webgpu")!;
append("getCurrentTexture() before configure()...");
const texture = ctx.getCurrentTexture();
append(
`returned a ${texture.width}x${texture.height} texture (spec: should throw)`,
);
} catch (e) {
append(`threw (correct per spec): ${e}`);
}
};

const zeroSizedCanvas = async () => {
try {
const { device, format } = await initGPU(append);
const ctx = ref.current!.getContext("webgpu")!;
const canvas = ctx.canvas as HTMLCanvasElement;
canvas.width = 0;
canvas.height = 0;
append("configure() with canvas.width = canvas.height = 0...");
ctx.configure({ device, format, alphaMode: "opaque" });
const texture = ctx.getCurrentTexture();
append(`getCurrentTexture() -> ${texture.width}x${texture.height}`);
texture.createView();
append("createView() survived");
} catch (e) {
append(`threw: ${e}`);
}
};

const unconfigureStub = async () => {
try {
const { device, format } = await initGPU(append);
const ctx = ref.current!.getContext("webgpu")!;
ctx.configure({ device, format, alphaMode: "opaque" });
drawClearFrame(device, ctx, 0);
append("rendered one frame, calling unconfigure()...");
ctx.unconfigure();
const texture = ctx.getCurrentTexture();
append(
`getCurrentTexture() after unconfigure() -> ${texture.width}x${texture.height} texture (spec: context should be unconfigured)`,
);
} catch (e) {
append(`threw (correct per spec): ${e}`);
}
};

return (
<View style={diagnosticStyles.container}>
<View style={diagnosticStyles.controls}>
<Text style={diagnosticStyles.description}>
Each button exercises a context entry point that must fail gracefully
per the WebGPU spec. On a broken build the first two crash the app.
</Text>
<Button
title="getCurrentTexture() before configure()"
onPress={getCurrentTextureUnconfigured}
/>
<Button title="configure() a 0x0 canvas" onPress={zeroSizedCanvas} />
<Button
title="unconfigure() then getCurrentTexture()"
onPress={unconfigureStub}
/>
</View>
<Canvas ref={ref} style={diagnosticStyles.canvas} />
<ScrollView style={diagnosticStyles.log}>
{log.map((line, i) => (
<Text key={i} style={diagnosticStyles.logLine}>
{line}
</Text>
))}
</ScrollView>
</View>
);
};
75 changes: 75 additions & 0 deletions apps/example/src/Diagnostics/DiagnosticsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as React from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { useNavigation } from "@react-navigation/native";
import { RectButton } from "react-native-gesture-handler";
import type { StackNavigationProp } from "@react-navigation/stack";

import type { Routes } from "../Route";

// Sub navigation for the diagnostic screens: each entry reproduces a bug or
// stresses an invariant of the native implementation.
const tests = [
{
screen: "AsyncStarvation",
title: "⚠️ Async Runner Starvation",
},
{
screen: "DeviceLostHang",
title: "⚠️ Device Lost Hang",
},
{
screen: "WorkletRequestAdapter",
title: "⚠️ Worklet requestAdapter",
},
{
screen: "ContextEdgeCases",
title: "⚠️ Context Edge Cases",
},
{
screen: "ViewFormatsUseAfterFree",
title: "⚠️ viewFormats Use-After-Free",
},
{
screen: "RenderAfterUnmount",
title: "⚠️ Render After Unmount",
},
{
screen: "BackgroundDetach",
title: "⚠️ Background Detach",
},
{
screen: "SurfaceChurn",
title: "⚠️ Surface Churn",
},
] as const;

export const DiagnosticsList = () => {
const { navigate } =
useNavigation<StackNavigationProp<Routes, "Diagnostics">>();
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{tests.map((test) => (
<RectButton key={test.screen} onPress={() => navigate(test.screen)}>
<View style={styles.thumbnail}>
<Text style={styles.title}>{test.title}</Text>
</View>
</RectButton>
))}
</ScrollView>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
marginBottom: 32,
},
thumbnail: {
backgroundColor: "white",
padding: 32,
borderBottomWidth: StyleSheet.hairlineWidth,
},
title: {},
});
Loading
Loading