From d27ebffbdec1d28e786e2e225f21b303e418501c Mon Sep 17 00:00:00 2001 From: William Candillon Date: Fri, 21 Aug 2026 09:54:58 +0200 Subject: [PATCH 1/2] :wrench: --- apps/example/src/ThreeJS/List.tsx | 4 + apps/example/src/ThreeJS/Memory.tsx | 162 ++++++++++++++++++++++++++++ apps/example/src/ThreeJS/Routes.ts | 1 + apps/example/src/ThreeJS/index.tsx | 8 ++ 4 files changed, 175 insertions(+) create mode 100644 apps/example/src/ThreeJS/Memory.tsx diff --git a/apps/example/src/ThreeJS/List.tsx b/apps/example/src/ThreeJS/List.tsx index 98025fa8a..9164a5f7b 100644 --- a/apps/example/src/ThreeJS/List.tsx +++ b/apps/example/src/ThreeJS/List.tsx @@ -39,6 +39,10 @@ export const examples = [ screen: "ToneMapping", title: "🎭 Tone Mapping", }, + { + screen: "Memory", + title: "🧠 Memory", + }, ] as const; const styles = StyleSheet.create({ diff --git a/apps/example/src/ThreeJS/Memory.tsx b/apps/example/src/ThreeJS/Memory.tsx new file mode 100644 index 000000000..d2f5e85fe --- /dev/null +++ b/apps/example/src/ThreeJS/Memory.tsx @@ -0,0 +1,162 @@ +import * as THREE from "three"; +import type { CanvasRef } from "react-native-webgpu"; +import { Canvas } from "react-native-webgpu"; +import { StyleSheet, Text, View } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { RectButton } from "react-native-gesture-handler"; + +import { makeWebGPURenderer } from "./components/makeWebGPURenderer"; + +declare const HermesInternal: + | { getInstrumentedStats?: () => Record } + | undefined; + +const getExternalMB = () => { + const stats = + typeof HermesInternal !== "undefined" + ? HermesInternal?.getInstrumentedStats?.() + : undefined; + return (stats?.js_externalBytes ?? 0) / (1024 * 1024); +}; + +const forceGC = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { gc } = global as any; + if (typeof gc === "function") { + gc(); + return; + } + // Fallback: allocate short-lived garbage so Hermes runs a collection. + let junk: number[][] = []; + for (let i = 0; i < 50; i++) { + junk.push(new Array(100000).fill(i)); + } + junk = []; +}; + +// Big enough for a leak to be obvious: a 2048x2048 texture (16MB) on top of +// the renderer's own render targets. +const TEXTURE_SIZE = 2048; + +// Regression demo for https://github.com/wcandillon/react-native-webgpu/issues/445 +// The cleanup below intentionally only stops the animation loop, exactly like +// the issue repro: no renderer.dispose(), no device.destroy(). Three.js +// registers `device.lost.then(...)` which captures the whole renderer; the GC +// must still be able to reclaim the scene once this component unmounts. +const Scene = () => { + const ref = useRef(null); + useEffect(() => { + const context = ref.current!.getContext("webgpu")!; + const { width, height } = context.canvas; + + const camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 10); + camera.position.z = 1; + + const scene = new THREE.Scene(); + + const data = new Uint8Array(TEXTURE_SIZE * TEXTURE_SIZE * 4); + for (let i = 0; i < data.length; i += 4) { + data[i] = i % 255; + data[i + 1] = (i / 4) % 255; + data[i + 2] = 255 - (i % 255); + data[i + 3] = 255; + } + const texture = new THREE.DataTexture( + data, + TEXTURE_SIZE, + TEXTURE_SIZE, + THREE.RGBAFormat, + ); + texture.needsUpdate = true; + + const geometry = new THREE.BoxGeometry(0.4, 0.4, 0.4); + const material = new THREE.MeshBasicMaterial({ map: texture }); + const mesh = new THREE.Mesh(geometry, material); + scene.add(mesh); + + const renderer = makeWebGPURenderer(context); + renderer.init(); + + function animate(time: number) { + mesh.rotation.x = time / 2000; + mesh.rotation.y = time / 1000; + renderer.render(scene, camera); + context.present(); + } + renderer.setAnimationLoop(animate); + return () => { + renderer.setAnimationLoop(null); + }; + }, [ref]); + + return ; +}; + +export const Memory = () => { + const [mounted, setMounted] = useState(true); + const [cycles, setCycles] = useState(0); + const [externalMB, setExternalMB] = useState(getExternalMB()); + useEffect(() => { + const interval = setInterval(() => { + forceGC(); + setExternalMB(getExternalMB()); + }, 1000); + return () => clearInterval(interval); + }, []); + return ( + + + + js_externalBytes: {externalMB.toFixed(1)} MB + + + Mount/unmount the scene repeatedly. The value should return close to + its baseline a few seconds after each unmount (issue #445). + + { + if (mounted) { + setCycles((c) => c + 1); + } + setMounted((m) => !m); + }} + > + + + {mounted ? "Unmount scene" : `Mount scene (${cycles} cycles)`} + + + + + {mounted && } + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + panel: { + padding: 16, + gap: 8, + }, + stat: { + fontVariant: ["tabular-nums"], + fontWeight: "bold", + }, + caption: { + color: "#666", + }, + button: { + backgroundColor: "white", + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + buttonLabel: { + textAlign: "center", + }, + scene: { + flex: 1, + }, +}); diff --git a/apps/example/src/ThreeJS/Routes.ts b/apps/example/src/ThreeJS/Routes.ts index c093cc5ec..1b9e2cf1c 100644 --- a/apps/example/src/ThreeJS/Routes.ts +++ b/apps/example/src/ThreeJS/Routes.ts @@ -8,4 +8,5 @@ export type Routes = { PostProcessing: undefined; Retargeting: undefined; ToneMapping: undefined; + Memory: undefined; }; diff --git a/apps/example/src/ThreeJS/index.tsx b/apps/example/src/ThreeJS/index.tsx index 7813e23be..4ddf38ab7 100644 --- a/apps/example/src/ThreeJS/index.tsx +++ b/apps/example/src/ThreeJS/index.tsx @@ -12,6 +12,7 @@ import { Fiber } from "./Fiber"; import { PostProcessing } from "./PostProcessing"; import { Retargeting } from "./Retargeting"; import { ToneMapping } from "./ToneMapping"; +import { Memory } from "./Memory"; const Stack = createStackNavigator(); export const ThreeJS = () => { @@ -88,6 +89,13 @@ export const ThreeJS = () => { title: "🎭 Tone Mapping", }} /> + ); }; From 1767cc7f303de3b108fbf560f5164880d6507f42 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Fri, 21 Aug 2026 13:31:39 +0200 Subject: [PATCH 2/2] :wrench: --- apps/example/ios/Podfile.lock | 136 ++++----- apps/example/src/ThreeJS/Backdrop.tsx | 7 +- apps/example/src/ThreeJS/Cube.tsx | 7 +- apps/example/src/ThreeJS/Helmet.tsx | 7 +- apps/example/src/ThreeJS/InstancedMesh.tsx | 7 +- apps/example/src/ThreeJS/Memory.tsx | 287 ++++++++++++++++-- apps/example/src/ThreeJS/PostProcessing.tsx | 7 +- apps/example/src/ThreeJS/Retargeting.tsx | 7 +- apps/example/src/ThreeJS/ToneMapping.tsx | 7 +- .../src/ThreeJS/components/FiberCanvas.tsx | 10 +- .../ThreeJS/components/makeWebGPURenderer.ts | 27 ++ packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp | 198 ++++++++---- packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 60 +++- .../webgpu/cpp/rnwgpu/async/RuntimeContext.h | 43 +-- packages/webgpu/src/__tests__/Device.spec.ts | 55 ++++ 15 files changed, 677 insertions(+), 188 deletions(-) diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 3008b3bf3..75d6c7933 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -3037,85 +3037,85 @@ SPEC CHECKSUMS: fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 35c763d57c9832d0eef764316ca1c4d043581394 - NitroImage: 75df80efc3bebd26e5fbe66a546ca83122773919 - NitroModules: d2c5b374ac227a6a27e2ef95c21c99bc63ea44af - RCT-Folly: 121436bcc4611f6bde5c09bf35f0a7a82cef1969 + NitroImage: 4ffcf183d975de179ae1662b7c3b4b3b37747c7e + NitroModules: 9ec4a2e0b9af22ba1f1f550e1dd9be94143afd18 + RCT-Folly: b29feb752b08042c62badaef7d453f3bb5e6ae23 RCTDeprecation: c0ed3249a97243002615517dff789bf4666cf585 RCTRequired: 58719f5124f9267b5f9649c08bf23d9aea845b23 RCTTypeSafety: 4aefa8328ab1f86da273f08517f1f6b343f6c2cc React: 2073376f47c71b7e9a0af7535986a77522ce1049 React-callinvoker: 751b6f2c83347a0486391c3f266f291f0f53b27e - React-Core: 7195661f0b48e7ea46c3360ccb575288a20c932c - React-CoreModules: 14f0054ab46000dd3b816d6528af3bd600d82073 - React-cxxreact: 7f602425c63096c398dac13cd7a300efd7c281ae + React-Core: dff5d29973349b11dd6631c9498456d75f846d5e + React-CoreModules: c0ae04452e4c5d30e06f8e94692a49107657f537 + React-cxxreact: 376fd672c95dfb64ad5cc246e6a1e9edb78dec4c React-debug: 7b56a0a7da432353287d2eedac727903e35278f5 - React-defaultsnativemodule: 695d8a0b40f735edb3c4031e0f049e567fdac47a - React-domnativemodule: 6d66c1f61f277d008d98cae650ce2c025b89d3b9 - React-Fabric: 997d4115d688f483cb409a1290171bff3c93dab4 - React-FabricComponents: 8167e5e363ca3a3fe394d8afee355e4072bea1db - React-FabricImage: f8f9f2c97657116702acc670e3f4357bc842bed3 - React-featureflags: dfb4d0d527d55dd968231370f6832b9197ee653d - React-featureflagsnativemodule: c63cfd8fe95cd98f12ebb37daa919c4544810a45 - React-graphics: fd795f1c2a1133a08dde31725b20949edd545dca - React-hermes: 0a167bbb02c242664745e82154578c64e90a88e5 - React-idlecallbacksnativemodule: 1798c6aa33ddc7c2e9fa3c3d67729728639889e9 - React-ImageManager: c498ee6945dffacc82bfa175aa3264212f27c70b - React-jserrorhandler: 216951fea62fc26c600f4c96f0dc4fd53d1e7a9b - React-jsi: 9c27d27d3007b73c702ad3fd5a6166557c741020 - React-jsiexecutor: 2b24f4ed4026344a27f717bf947a434cbbeeff7a - React-jsinspector: 02394b059c48805780f7d977366317a24168d00e - React-jsinspectorcdp: f4b6d5c5c9db05ef44d082716714f90cfeed96bb - React-jsinspectornetwork: e7c77d01b5f0664e24c0bec1aea27d5e3d7fb746 - React-jsinspectortracing: aaa96a4e53abb88dc6d47da3b5744c710652fef9 - React-jsitooling: 226e5f4147c7b6f1ae1954a8406ffa713f3da828 - React-jsitracing: 8a2fbeaa9c53c3f0b23904ccffefc890eae48d71 - React-logger: 1767babce2d28c3251039ce05556714a2c8c6ded - React-Mapbuffer: 33f678ee25b6c0ee2b01b1ecec08e3e02424cefe - React-microtasksnativemodule: 44b44a4d3cd6ffb85d928abf741acdc26722de2e - react-native-safe-area-context: 54d812805f3c4e08a4580ad086cbde1d8780c2e4 - react-native-skia: 6354d59a2751ef30f1025deb1bf355dd3856963e - react-native-webgpu: 8fed9d0ea5e5b10e0392d4c549cdf0cea86f3c26 - React-NativeModulesApple: b5d18bc109c45c9a1c6b71664991b5cc3adc4e48 + React-defaultsnativemodule: 393b81aaa6211408f50a6ef00a277847256dd881 + React-domnativemodule: 5fb5829baa7a7a0f217019cbad1eb226d94f7062 + React-Fabric: a17c4ae35503673b57b91c2d1388429e7cbee452 + React-FabricComponents: a76572ddeba78ebe4ec58615291e9db4a55cd46a + React-FabricImage: d806eb2695d7ef355ec28d1a21f5a14ac26b1cae + React-featureflags: 1690ec3c453920b6308e23a4e24eb9c3632f9c75 + React-featureflagsnativemodule: 7b7e8483fc671c5a33aefd699b7c7a3c0bdfdfec + React-graphics: ea146ee799dc816524a3a0922fc7be0b5a52dcc1 + React-hermes: fcbdc45ecf38259fe3b12642bd0757c52270a107 + React-idlecallbacksnativemodule: a353f9162eaa7ad787e68aba9f52a1cfa8154098 + React-ImageManager: ec5cf55ce9cc81719eb5f1f51d23d04db851c86c + React-jserrorhandler: 594c593f3d60f527be081e2cace7710c2bd9f524 + React-jsi: 59ec3190dd364cca86a58869e7755477d2468948 + React-jsiexecutor: b87d78a2e8dd7a6f56e9cdac038da45de98c944f + React-jsinspector: b9204adf1af622c98e78af96ec1bca615c2ce2bd + React-jsinspectorcdp: 4a356fa69e412d35d3a38c44d4a6cc555c5931e8 + React-jsinspectornetwork: 7820056773178f321cbf18689e1ffcd38276a878 + React-jsinspectortracing: b341c5ef6e031a33e0bd462d67fd397e8e9cd612 + React-jsitooling: 401655e05cb966b0081225c5201d90734a567cb9 + React-jsitracing: 67eff6dea0cb58a1e7bd8b49243012d88c0f511e + React-logger: a3cb5b29c32b8e447b5a96919340e89334062b48 + React-Mapbuffer: 9d2434a42701d6144ca18f0ca1c4507808ca7696 + React-microtasksnativemodule: 75b6604b667d297292345302cc5bfb6b6aeccc1b + react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 + react-native-skia: fc73e9bdc46ebb420a98c9c2be29fee80f565e79 + react-native-webgpu: fcba69b760e8971acc7ebb04041a9a04694c049f + React-NativeModulesApple: 879fbdc5dcff7136abceb7880fe8a2022a1bd7c3 React-oscompat: 93b5535ea7f7dff46aaee4f78309a70979bdde9d - React-perflogger: a03d913e3205b00aee4128082abe42fd45ce0c98 - React-performancetimeline: 9b5986cc15afafb9bf246d7dd55bdd138df94451 + React-perflogger: 5536d2df3d18fe0920263466f7b46a56351c0510 + React-performancetimeline: 9041c53efa07f537164dcfe7670a36642352f4c2 React-RCTActionSheet: 42195ae666e6d79b4af2346770f765b7c29435b9 - React-RCTAnimation: 5c10527683128c56ff2c09297fb080f7c35bd293 - React-RCTAppDelegate: c616bd5b0d12f0b21dfacee9cd2d512c6df013aa - React-RCTBlob: 6e3757bdd7dce6fd9788c0dd675fd6b6c432db9d - React-RCTFabric: e8f3b9da97477710bf0904a62eb5b5209c964694 - React-RCTFBReactNativeSpec: c042f8d60d44ad9e2c722da89323c0bdab7a37af - React-RCTImage: a3482fe1ae562d1bab08b42d4670a7c9a21813cd - React-RCTLinking: d82b9adb141aef9d2b38d446b837ae7017ab60aa - React-RCTNetwork: fa9350dd99354c5695964f589bd4790bdd4f6a85 - React-RCTRuntime: be99a38cd23388c08921d8969c82a1997a11ec90 - React-RCTSettings: b7f4a03f44dba1d3a4dc6770843547b203ca9129 - React-RCTText: 91dc597a5f6b27fd1048bb287c41ea05eeca9333 - React-RCTVibration: 27b09ddf74bddfa30a58d20e48f885ea6ed6c9d9 + React-RCTAnimation: fa103ccc3503b1ed8dedca7e62e7823937748843 + React-RCTAppDelegate: 665d4baf19424cef08276e9ac0d8771eec4519f9 + React-RCTBlob: 0fa9530c255644db095f2c4fd8d89738d9d9ecc0 + React-RCTFabric: 1fcd8af6e25f92532f56b4ba092e58662c14d156 + React-RCTFBReactNativeSpec: db171247585774f9f0a30f75109cc51568686213 + React-RCTImage: ba824e61ce2e920a239a65d130b83c3a1d426dff + React-RCTLinking: d2dc199c37e71e6f505d9eca3e5c33be930014d4 + React-RCTNetwork: 87137d4b9bd77e5068f854dd5c1f30d4b072faf6 + React-RCTRuntime: 137fafaa808a8b7e76a510e8be45f9f827899daa + React-RCTSettings: 71f5c7fd7b5f4e725a4e2114a4b4373d0e46048f + React-RCTText: b94d4699b49285bee22b8ebf768924d607eccee3 + React-RCTVibration: 6e3993c4f6c36a3899059f9a9ead560ddaf5a7d7 React-rendererconsistency: b4785e5ed837dc7c242bbc5fdd464b33ef5bfae7 - React-renderercss: cef3f26df2ddec558ce3c0790fc574b4fb62ce67 - React-rendererdebug: e68433ae67738caeb672a6c8cc993e9276b298a9 - React-RuntimeApple: dc1d4709bf847bc695dbe6e8aaf3e22ef25aef02 - React-RuntimeCore: ca3473c8b6578693fa3bad4d44240098d49d6723 - React-runtimeexecutor: 0db3ca0b09cd72489cef3a3729349b3c2cf13320 - React-RuntimeHermes: f92cabaf97ef2546a74360eddfc1c74a34cb9ff8 - React-runtimescheduler: 06aea75069e0d556a75d258bfc89eb0ebd5d557e - React-timing: 1a90df9a04d8e7fd165ff7fa0918b9595c776373 - React-utils: 92115441fb55ce01ded4abfb5e9336a74cd93e9c - ReactAppDependencyProvider: b20fba6c3d091a393925890009999472c8f94d95 - ReactCodegen: cf03d376a26d393f818d511240b026fc8c95313c - ReactCommon: 00df7b9f859c9d02181844255bb89a8bca544374 - ReactNativeHost: b63ce830e7c5b4e3adcf556b2ef41665da189da0 - ReactTestApp-DevSupport: c7bff1aee7663f2fb1eefcf60c41573f02916c41 + React-renderercss: e6fb0ba387b389c595ffa86b8b628716d31f58dc + React-rendererdebug: 60a03de5c7ea59bf2d39791eb43c4c0f5d8b24e3 + React-RuntimeApple: 3df6788cd9b938bb8cb28298d80b5fbd98a4d852 + React-RuntimeCore: fad8adb4172c414c00ff6980250caf35601a0f5d + React-runtimeexecutor: d2db7e72d97751855ea0bf5273d2ac84e5ea390c + React-RuntimeHermes: 04faa4cf9a285136a6d73738787fe36020170613 + React-runtimescheduler: f6a1c9555e7131b4a8b64cce01489ad0405f6e8d + React-timing: 1e6a8acb66e2b7ac9d418956617fd1fdb19322fd + React-utils: 52bbb03f130319ef82e4c3bc7a85eaacdb1fec87 + ReactAppDependencyProvider: 433ddfb4536948630aadd5bd925aff8a632d2fe3 + ReactCodegen: 64dbbed4e9e0264d799578ea78492479a66fba4a + ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6 + ReactNativeHost: f5e054387e917216a2a021a3f7fdc4f9f158e7e4 + ReactTestApp-DevSupport: 9b7bbba5e8fed998e763809171d9906a1375f9d3 ReactTestApp-Resources: 1bd9ff10e4c24f2ad87101a32023721ae923bccf - RNGestureHandler: 92ad734ef0da16d69d0a325b7e1e9ae80bdce1f9 - RNReanimated: f02fb52cfc20b2255887d44633ddf5e9bcff73b1 - RNWorklets: 99f43a35141998a990ccff4ecc685cf2ef8fbe8a + RNGestureHandler: e37bdb684df1ac17c7e1d8f71a3311b2793c186b + RNReanimated: 9d012d4031abc9df896f8a82f9928eb2b9eae417 + RNWorklets: 0da2552f9ff5d17506918a692304110cfebb9f0a SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - VisionCamera: fa37d864bf901b5a0bdef4cd155ab1c62f3147f9 - VisionCameraWorklets: a11eb167fa85d14615b9ccb7892f6509c81c3e72 - Yoga: 922d794dce2af9c437f864bf4093abfa7a131adb + VisionCamera: 8c913d0cb2c868f779035fb69a1e0ab69e10f1c3 + VisionCameraWorklets: cc88e3e6d7304e2c00a95cae4f728aec5a6758c1 + Yoga: a3ed390a19db0459bd6839823a6ac6d9c6db198d PODFILE CHECKSUM: 22a8651333bf096f67ca333598bd33455d994c1f -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/apps/example/src/ThreeJS/Backdrop.tsx b/apps/example/src/ThreeJS/Backdrop.tsx index 12189ce60..9a2bbcf0f 100644 --- a/apps/example/src/ThreeJS/Backdrop.tsx +++ b/apps/example/src/ThreeJS/Backdrop.tsx @@ -24,7 +24,10 @@ import { } from "three/tsl"; import { useGLTF } from "./assets/AssetManager"; -import { makeWebGPURenderer } from "./components/makeWebGPURenderer"; +import { + makeWebGPURenderer, + disposeWebGPURenderer, +} from "./components/makeWebGPURenderer"; export const Backdrop = () => { const gltf = useGLTF(require("./assets/michelle/model.gltf")); @@ -153,7 +156,7 @@ export const Backdrop = () => { context.present(); } return () => { - renderer.setAnimationLoop(null); + disposeWebGPURenderer(renderer); }; }, [gltf, ref]); return ( diff --git a/apps/example/src/ThreeJS/Cube.tsx b/apps/example/src/ThreeJS/Cube.tsx index 1d87e80f3..329bc1f30 100644 --- a/apps/example/src/ThreeJS/Cube.tsx +++ b/apps/example/src/ThreeJS/Cube.tsx @@ -4,7 +4,10 @@ import { Canvas } from "react-native-webgpu"; import { View } from "react-native"; import { useEffect, useRef } from "react"; -import { makeWebGPURenderer } from "./components/makeWebGPURenderer"; +import { + makeWebGPURenderer, + disposeWebGPURenderer, +} from "./components/makeWebGPURenderer"; export const Cube = () => { const ref = useRef(null); @@ -35,7 +38,7 @@ export const Cube = () => { } renderer.setAnimationLoop(animate); return () => { - renderer.setAnimationLoop(null); + disposeWebGPURenderer(renderer); }; }, [ref]); diff --git a/apps/example/src/ThreeJS/Helmet.tsx b/apps/example/src/ThreeJS/Helmet.tsx index 0dbb8dd91..f5484e7de 100644 --- a/apps/example/src/ThreeJS/Helmet.tsx +++ b/apps/example/src/ThreeJS/Helmet.tsx @@ -5,7 +5,10 @@ import { StyleSheet, Text, View } from "react-native"; import { useEffect, useRef } from "react"; import { useGLTF, useRGBE } from "./assets/AssetManager"; -import { makeWebGPURenderer } from "./components/makeWebGPURenderer"; +import { + makeWebGPURenderer, + disposeWebGPURenderer, +} from "./components/makeWebGPURenderer"; export const Helmet = () => { const texture = useRGBE(require("./assets/helmet/royal_esplanade_1k.hdr")); @@ -53,7 +56,7 @@ export const Helmet = () => { } return () => { - renderer.setAnimationLoop(null); + disposeWebGPURenderer(renderer); }; }, [texture, gltf, ref]); diff --git a/apps/example/src/ThreeJS/InstancedMesh.tsx b/apps/example/src/ThreeJS/InstancedMesh.tsx index 42f489f0d..4088dd4d6 100644 --- a/apps/example/src/ThreeJS/InstancedMesh.tsx +++ b/apps/example/src/ThreeJS/InstancedMesh.tsx @@ -5,7 +5,10 @@ import { View } from "react-native"; import { useEffect, useRef } from "react"; import { time, oscSine, mix, range, normalWorld } from "three/tsl"; -import { makeWebGPURenderer } from "./components/makeWebGPURenderer"; +import { + makeWebGPURenderer, + disposeWebGPURenderer, +} from "./components/makeWebGPURenderer"; import { useGeometry } from "./assets/AssetManager"; export const InstancedMesh = () => { @@ -90,7 +93,7 @@ export const InstancedMesh = () => { context.present(); } return () => { - renderer.setAnimationLoop(null); + disposeWebGPURenderer(renderer); }; }, [geometry, ref]); diff --git a/apps/example/src/ThreeJS/Memory.tsx b/apps/example/src/ThreeJS/Memory.tsx index d2f5e85fe..16c6a8ee2 100644 --- a/apps/example/src/ThreeJS/Memory.tsx +++ b/apps/example/src/ThreeJS/Memory.tsx @@ -1,11 +1,14 @@ import * as THREE from "three"; -import type { CanvasRef } from "react-native-webgpu"; +import type { CanvasRef, RNCanvasContext } from "react-native-webgpu"; import { Canvas } from "react-native-webgpu"; import { StyleSheet, Text, View } from "react-native"; import { useEffect, useRef, useState } from "react"; import { RectButton } from "react-native-gesture-handler"; -import { makeWebGPURenderer } from "./components/makeWebGPURenderer"; +import { + makeWebGPURenderer, + disposeWebGPURenderer, +} from "./components/makeWebGPURenderer"; declare const HermesInternal: | { getInstrumentedStats?: () => Record } @@ -34,15 +37,204 @@ const forceGC = () => { junk = []; }; +// Schedules a deferred full GC + measurement. Isolated in its own function on +// purpose: Hermes closures share their enclosing environment, so a timeout +// created inside the test function would keep the test's locals (device, +// buffer) alive until it fires and pollute the measurement. +const measureLater = (label: string, before: number, allocated: number) => { + setTimeout(() => { + forceGC(); + const after = getExternalMB(); + console.log( + `[Memory #445] ${label}: baseline=${before.toFixed(1)} MB, ` + + `allocated=${allocated.toFixed(1)} MB, after gc()=${after.toFixed(1)} MB, ` + + `reclaimed=${(allocated - after).toFixed(1)} MB`, + ); + }, 500); +}; + +// Isolates the device.lost root from everything three.js does: allocates a +// 128MB buffer on a fresh device, optionally attaches the lost.then callback +// that used to root the graph (issue #445), drops every reference, then +// measures whether gc() reclaims the external bytes. +const runPureLostTest = async (withLostThen: boolean) => { + const before = getExternalMB(); + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + console.log("[Memory #445] no adapter"); + return; + } + { + const device = await adapter.requestDevice(); + const buffer = device.createBuffer({ + size: 128 * 1024 * 1024, + usage: GPUBufferUsage.VERTEX, + }); + if (withLostThen) { + device.lost.then((info) => + console.log("[Memory #445] lost fired", info.reason, buffer.size), + ); + console.log( + "[Memory #445] native fix present:", + Object.getOwnPropertyNames(device).includes("__rnwgpuLostPromise"), + ); + } + } + const allocated = getExternalMB(); + measureLater( + withLostThen ? "pure test WITH lost.then" : "pure test without lost.then", + before, + allocated, + ); +}; + // Big enough for a leak to be obvious: a 2048x2048 texture (16MB) on top of // the renderer's own render targets. const TEXTURE_SIZE = 2048; +// A raw WebGPU scene with the exact same lifecycle as the three.js scene +// below (fresh adapter + device per mount, big resources, a render loop, a +// `device.lost.then(...)` reaction capturing the whole scene, and NO +// device.destroy() on unmount), but zero three.js. With the #445 fix the GC +// must reclaim everything after unmount; contrasting it with the three.js +// scene isolates the remaining three.js-internal retention. +const pureSceneWGSL = /* wgsl */ ` + @group(0) @binding(0) var samp: sampler; + @group(0) @binding(1) var tex: texture_2d; + + struct VOut { + @builtin(position) pos: vec4f, + @location(0) uv: vec2f, + }; + + @vertex fn vs(@builtin(vertex_index) i: u32) -> VOut { + var pos = array(vec2f(-1, -3), vec2f(3, 1), vec2f(-1, 1)); + var out: VOut; + out.pos = vec4f(pos[i], 0, 1); + out.uv = pos[i] * vec2f(0.5, -0.5) + 0.5; + return out; + } + + @fragment fn fs(in: VOut) -> @location(0) vec4f { + return textureSample(tex, samp, in.uv); + } +`; + +const startPureScene = async (context: RNCanvasContext) => { + const adapter = await navigator.gpu.requestAdapter(); + const device = await adapter!.requestDevice(); + const format = navigator.gpu.getPreferredCanvasFormat(); + context.configure({ device, format, alphaMode: "premultiplied" }); + + // Same footprint as the three.js scene: a 16MB texture, plus a large + // vertex buffer so the leak (if any) is unmissable. + const bigBuffer = device.createBuffer({ + size: 64 * 1024 * 1024, + usage: GPUBufferUsage.VERTEX, + }); + const data = new Uint8Array(TEXTURE_SIZE * TEXTURE_SIZE * 4); + for (let i = 0; i < data.length; i += 4) { + data[i] = i % 255; + data[i + 1] = (i / 4) % 255; + data[i + 2] = 255 - (i % 255); + data[i + 3] = 255; + } + const texture = device.createTexture({ + size: [TEXTURE_SIZE, TEXTURE_SIZE], + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + device.queue.writeTexture( + { texture }, + data, + { bytesPerRow: TEXTURE_SIZE * 4 }, + { width: TEXTURE_SIZE, height: TEXTURE_SIZE }, + ); + const sampler = device.createSampler({ magFilter: "linear" }); + const module = device.createShaderModule({ code: pureSceneWGSL }); + const pipeline = device.createRenderPipeline({ + layout: "auto", + vertex: { module, entryPoint: "vs" }, + fragment: { module, entryPoint: "fs", targets: [{ format }] }, + primitive: { topology: "triangle-list" }, + }); + const bindGroup = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: sampler }, + { binding: 1, resource: texture.createView() }, + ], + }); + + // Mimic three's WebGPUBackend.init(): a lost reaction whose closure + // captures the whole scene. + device.lost.then((info) => { + if (info.reason !== "destroyed") { + console.log( + "[Memory #445] pure scene device lost", + info.reason, + bigBuffer.size, + bindGroup.label, + ); + } + }); + + let rafId = 0; + const render = () => { + const encoder = device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: context.getCurrentTexture().createView(), + clearValue: [0, 0, 0, 1], + loadOp: "clear", + storeOp: "store", + }, + ], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + device.queue.submit([encoder.finish()]); + context.present(); + rafId = requestAnimationFrame(render); + }; + rafId = requestAnimationFrame(render); + + // Cleanup only stops the loop, mirroring the three.js scene: no + // device.destroy(), the GC has to reclaim everything. + return () => cancelAnimationFrame(rafId); +}; + +const PureScene = () => { + const ref = useRef(null); + useEffect(() => { + let cleanup: (() => void) | null = null; + let unmounted = false; + startPureScene(ref.current!.getContext("webgpu")!).then((c) => { + if (unmounted) { + c(); + } else { + cleanup = c; + } + }); + return () => { + unmounted = true; + cleanup?.(); + }; + }, [ref]); + + return ; +}; + // Regression demo for https://github.com/wcandillon/react-native-webgpu/issues/445 -// The cleanup below intentionally only stops the animation loop, exactly like -// the issue repro: no renderer.dispose(), no device.destroy(). Three.js -// registers `device.lost.then(...)` which captures the whole renderer; the GC -// must still be able to reclaim the scene once this component unmounts. +// The cleanup calls renderer.dispose() (required by three: it stops the +// renderer's internal requestAnimationFrame loop, which otherwise keeps the +// whole renderer alive; setAnimationLoop(null) alone does NOT stop it) but +// deliberately does NOT destroy the device. Three registers +// `device.lost.then(...)` which captures the whole renderer; the GC must be +// able to reclaim the scene, the device, and all GPU wrappers regardless. const Scene = () => { const ref = useRef(null); useEffect(() => { @@ -85,16 +277,17 @@ const Scene = () => { } renderer.setAnimationLoop(animate); return () => { - renderer.setAnimationLoop(null); + disposeWebGPURenderer(renderer); }; }, [ref]); return ; }; +type SceneKind = "three" | "pure" | null; + export const Memory = () => { - const [mounted, setMounted] = useState(true); - const [cycles, setCycles] = useState(0); + const [scene, setScene] = useState(null); const [externalMB, setExternalMB] = useState(getExternalMB()); useEffect(() => { const interval = setInterval(() => { @@ -103,32 +296,82 @@ export const Memory = () => { }, 1000); return () => clearInterval(interval); }, []); + // Post-unmount probe: give React a tick to detach the subtree, then force + // a full GC and log whether the scene was reclaimed. Deferred on purpose: + // a synchronous gc() at unmount time runs while the effect cleanup + // closures still reference the scene. + const toggle = (kind: Exclude) => { + if (scene === kind) { + const before = getExternalMB(); + setTimeout(() => { + forceGC(); + const after = getExternalMB(); + console.log( + `[Memory #445] ${kind} scene unmount: ${before.toFixed(1)} MB -> ` + + `after gc(): ${after.toFixed(1)} MB ` + + `(reclaimed ${(before - after).toFixed(1)} MB)`, + ); + setExternalMB(after); + }, 500); + setScene(null); + } else { + setScene(kind); + } + }; return ( - js_externalBytes: {externalMB.toFixed(1)} MB + js_externalBytes: {externalMB.toFixed(1)} MB (gc():{" "} + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {typeof (global as any).gc === "function" + ? "available" + : "MISSING, using churn fallback"} + ) - Mount/unmount the scene repeatedly. The value should return close to - its baseline a few seconds after each unmount (issue #445). + Mount/unmount a scene repeatedly. The value should return close to its + baseline a few seconds after each unmount (issue #445). The pure + WebGPU scene has the same lifecycle without three.js, isolating + three's internal retention from the library. - { - if (mounted) { - setCycles((c) => c + 1); - } - setMounted((m) => !m); - }} - > + toggle("pure")}> + + + {scene === "pure" + ? "Unmount pure WebGPU scene" + : "Mount pure WebGPU scene (no three.js)"} + + + + toggle("three")}> + + + {scene === "three" + ? "Unmount three.js scene" + : "Mount three.js scene"} + + + + runPureLostTest(true)}> - {mounted ? "Unmount scene" : `Mount scene (${cycles} cycles)`} + Pure test: 128MB buffer WITH device.lost.then + runPureLostTest(false)}> + + + Pure test: 128MB buffer without lost.then + + + + + + {scene === "three" && } + {scene === "pure" && } - {mounted && } ); }; diff --git a/apps/example/src/ThreeJS/PostProcessing.tsx b/apps/example/src/ThreeJS/PostProcessing.tsx index 1698563c0..c038dd6b6 100644 --- a/apps/example/src/ThreeJS/PostProcessing.tsx +++ b/apps/example/src/ThreeJS/PostProcessing.tsx @@ -7,7 +7,10 @@ import { color, pass } from "three/tsl"; import { bloom } from "three/addons/tsl/display/BloomNode"; import { useGLTF } from "./assets/AssetManager"; -import { makeWebGPURenderer } from "./components/makeWebGPURenderer"; +import { + makeWebGPURenderer, + disposeWebGPURenderer, +} from "./components/makeWebGPURenderer"; export const PostProcessing = () => { const gltf = useGLTF(require("./assets/PrimaryIonDrive.glb")); @@ -75,7 +78,7 @@ export const PostProcessing = () => { context.present(); } return () => { - renderer.setAnimationLoop(null); + disposeWebGPURenderer(renderer); }; }, [gltf, ref]); return ( diff --git a/apps/example/src/ThreeJS/Retargeting.tsx b/apps/example/src/ThreeJS/Retargeting.tsx index 4add77625..1ea4881bd 100644 --- a/apps/example/src/ThreeJS/Retargeting.tsx +++ b/apps/example/src/ThreeJS/Retargeting.tsx @@ -53,7 +53,10 @@ import { import * as SkeletonUtils from "three/addons/utils/SkeletonUtils"; import { useGLTF } from "./assets/AssetManager"; -import { makeWebGPURenderer } from "./components/makeWebGPURenderer"; +import { + makeWebGPURenderer, + disposeWebGPURenderer, +} from "./components/makeWebGPURenderer"; // forked from https://www.shadertoy.com/view/7ly3D1 const lightSpeed = Fn(([suvImmutable]: [THREE.Node<"vec2">]) => { @@ -306,7 +309,7 @@ export const Retargeting = () => { }); return () => { - renderer.setAnimationLoop(null); + disposeWebGPURenderer(renderer); }; }, [sourceGltf, targetGltf]); diff --git a/apps/example/src/ThreeJS/ToneMapping.tsx b/apps/example/src/ThreeJS/ToneMapping.tsx index 7ec8dd8aa..ee407e1e0 100644 --- a/apps/example/src/ThreeJS/ToneMapping.tsx +++ b/apps/example/src/ThreeJS/ToneMapping.tsx @@ -11,7 +11,10 @@ import { import { useEffect, useRef, useState } from "react"; import { useGLTF, useRGBE } from "./assets/AssetManager"; -import { makeWebGPURenderer } from "./components/makeWebGPURenderer"; +import { + makeWebGPURenderer, + disposeWebGPURenderer, +} from "./components/makeWebGPURenderer"; const toneMappingOptions = { None: THREE.NoToneMapping, @@ -87,7 +90,7 @@ export const ToneMapping = () => { } return () => { - renderer.setAnimationLoop(null); + disposeWebGPURenderer(renderer); rendererRef.current = null; }; }, [texture, gltf, ref]); diff --git a/apps/example/src/ThreeJS/components/FiberCanvas.tsx b/apps/example/src/ThreeJS/components/FiberCanvas.tsx index 11af3fb0d..44fc55c1f 100644 --- a/apps/example/src/ThreeJS/components/FiberCanvas.tsx +++ b/apps/example/src/ThreeJS/components/FiberCanvas.tsx @@ -12,7 +12,10 @@ import { PixelRatio } from "react-native"; import type { CanvasRef } from "react-native-webgpu"; import { Canvas } from "react-native-webgpu"; -import { makeWebGPURenderer } from "./makeWebGPURenderer"; +import { + makeWebGPURenderer, + disposeWebGPURenderer, +} from "./makeWebGPURenderer"; //global.THREE = global.THREE || THREE; @@ -73,6 +76,11 @@ export const FiberCanvas = ({ if (canvas != null) { unmountComponentAtNode(canvas!); } + // react-three-fiber's unmount only performs WebGL-era cleanup + // (renderLists.dispose, forceContextLoss), both of which are no-ops on + // a WebGPURenderer, so it never stops three's internal rAF loop. Tear + // the renderer down ourselves (issue #445). + disposeWebGPURenderer(renderer); }; }); diff --git a/apps/example/src/ThreeJS/components/makeWebGPURenderer.ts b/apps/example/src/ThreeJS/components/makeWebGPURenderer.ts index 207f13945..7014c204c 100644 --- a/apps/example/src/ThreeJS/components/makeWebGPURenderer.ts +++ b/apps/example/src/ThreeJS/components/makeWebGPURenderer.ts @@ -9,3 +9,30 @@ export const makeWebGPURenderer = ( canvas: context.canvas, context, }); + +// Tears a renderer down so the GC can reclaim it and its GPU resources +// (https://github.com/wcandillon/react-native-webgpu/issues/445): +// - renderer.dispose() stops three's internal requestAnimationFrame loop. +// setAnimationLoop(null) alone leaves that loop running, and its callback +// roots the entire renderer graph forever. +// - three's RenderObjects.dispose() drops its chainMaps without disposing the +// individual RenderObjects, so their 'dispose'/'release' listeners survive +// on the module-level shared QuadMesh geometry singleton and root the +// disposed renderer's backend. Clearing the stale listeners is safe while +// the app has at most one live renderer (upstream fix pending). +export const disposeWebGPURenderer = (renderer: THREE.WebGPURenderer) => { + renderer.setAnimationLoop(null); + renderer.dispose(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const quad = new (THREE as any).QuadMesh(); + const targets = [ + quad.geometry, + quad.geometry.index, + ...Object.values(quad.geometry.attributes), + ]; + for (const target of targets) { + if (target && target._listeners) { + target._listeners = {}; + } + } +}; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp index 96c5153cb..13ce67eb5 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -19,9 +19,35 @@ namespace rnwgpu { +namespace { + +// Hidden own properties used by device.lost. The promise cache lives on the +// device wrapper (the spec requires `lost` to return a stable promise) and +// the resolve function lives on the promise object itself, so both are traced +// by the GC as part of the device's JS object graph instead of being rooted +// from C++ (issue #445). +constexpr const char *kLostPromiseProp = "__rnwgpuLostPromise"; +constexpr const char *kLostResolveProp = "__rnwgpuLostResolve"; + +void defineHiddenProperty(jsi::Runtime &runtime, const jsi::Object &target, + const char *name, const jsi::Value &value) { + auto objectCtor = runtime.global().getPropertyAsObject(runtime, "Object"); + auto defineProperty = + objectCtor.getPropertyAsFunction(runtime, "defineProperty"); + jsi::Object descriptor(runtime); + descriptor.setProperty(runtime, "value", value); + descriptor.setProperty(runtime, "enumerable", false); + descriptor.setProperty(runtime, "writable", false); + descriptor.setProperty(runtime, "configurable", false); + defineProperty.call(runtime, target, + jsi::String::createFromUtf8(runtime, name), descriptor); +} + +} // namespace + void GPUDevice::notifyDeviceLost(wgpu::DeviceLostReason reason, std::string message) { - std::optional resolveToCall; + std::vector toResolve; std::shared_ptr info; { std::lock_guard lock(_lostMutex); @@ -32,22 +58,49 @@ void GPUDevice::notifyDeviceLost(wgpu::DeviceLostReason reason, _lostSettled = true; _lostInfo = std::make_shared(reason, std::move(message)); info = _lostInfo; + toResolve = std::move(_lostPromises); + _lostPromises.clear(); + } - if (_lostResolve.has_value()) { - resolveToCall = std::move(*_lostResolve); - _lostResolve.reset(); - } - - _lostHandle.reset(); + if (toResolve.empty()) { + return; } - // Settle outside the lock: resolve() only enqueues onto the JS thread. - if (resolveToCall.has_value()) { - (*resolveToCall)([info](jsi::Runtime &runtime) mutable { - return JSIConverter>::toJSI(runtime, - info); - }); + // getLost() only registers promises when the device's context has a + // CallInvoker (main JS runtime), so a non-empty list implies an invoker. + auto invoker = _async ? _async->callInvoker() : nullptr; + if (!invoker) { + return; } + + // Settle on the owning runtime's JS thread. The promises are held weakly: + // if the device graph was collected in the meantime there is nothing to do, + // since nobody could observe the resolution anyway. The shared_ptr is only + // there because jsi::WeakObject is move-only and std::function requires a + // copyable closure; it also ensures the WeakObjects are destroyed on the JS + // thread. + auto pending = + std::make_shared>(std::move(toResolve)); + invoker->invokeAsync([pending, info]() { + for (auto &entry : *pending) { + auto &runtime = *entry.runtime; + auto locked = entry.promise.lock(runtime); + if (!locked.isObject()) { + continue; + } + auto promiseObj = locked.getObject(runtime); + auto resolveProp = promiseObj.getProperty(runtime, kLostResolveProp); + if (!resolveProp.isObject() || + !resolveProp.getObject(runtime).isFunction(runtime)) { + continue; + } + auto resolveFn = resolveProp.getObject(runtime).getFunction(runtime); + resolveFn.call(runtime, + JSIConverter>::toJSI( + runtime, info)); + } + pending->clear(); + }); } void GPUDevice::forceLossForTesting() { @@ -523,47 +576,84 @@ std::unordered_set GPUDevice::getFeatures() { return result; } -async::AsyncTaskHandle GPUDevice::getLost() { - // Held across the whole body: the postTask callback below runs synchronously - // on this (JS) thread and touches the same _lost* fields, so it must not - // re-lock. notifyDeviceLost() takes the same lock from its (possibly worker) - // thread. - std::lock_guard lock(_lostMutex); - if (_lostHandle.has_value()) { - return *_lostHandle; - } - - if (_lostSettled && _lostInfo) { - return _async->postTask( - [info = _lostInfo]( - const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction & /*reject*/) { - resolve([info](jsi::Runtime &runtime) mutable { - return JSIConverter>::toJSI( - runtime, info); - }); - }, - /*keepPumping=*/false); - } - - auto handle = _async->postTask( - [this](const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction & /*reject*/) { - if (_lostSettled && _lostInfo) { - resolve([info = _lostInfo](jsi::Runtime &runtime) mutable { - return JSIConverter>::toJSI( - runtime, info); - }); - return; - } - - // Resolved later from notifyDeviceLost(). - _lostResolve = resolve; - }, - /*keepPumping=*/false); - - _lostHandle = handle; - return handle; +jsi::Value GPUDevice::getLost(jsi::Runtime &runtime, + const jsi::Object &wrapper) { + // The promise is cached on the wrapper, not natively: a strong native + // reference would be a GC root, keeping the promise's .then reactions (and + // anything they capture, e.g. a whole three.js renderer) alive forever + // (issue #445). + auto cached = wrapper.getProperty(runtime, kLostPromiseProp); + if (cached.isObject()) { + return cached; + } + + auto promiseCtor = runtime.global().getPropertyAsObject(runtime, "Promise"); + + std::shared_ptr settledInfo; + { + std::lock_guard lock(_lostMutex); + if (_lostSettled) { + settledInfo = _lostInfo; + } + } + + jsi::Value promiseValue; + if (settledInfo) { + auto info = JSIConverter>::toJSI( + runtime, settledInfo); + promiseValue = promiseCtor.getPropertyAsFunction(runtime, "resolve") + .callWithThis(runtime, promiseCtor, info); + } else { + // new Promise(executor): the executor runs synchronously inside the + // constructor call, capturing the resolve function. + auto capturedResolve = std::make_shared(); + auto executor = jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forUtf8(runtime, "lostExecutor"), 2, + [capturedResolve](jsi::Runtime &rt, const jsi::Value & /*thisVal*/, + const jsi::Value *args, size_t count) -> jsi::Value { + if (count > 0) { + *capturedResolve = jsi::Value(rt, args[0]); + } + return jsi::Value::undefined(); + }); + auto promiseObj = promiseCtor.asFunction(runtime) + .callAsConstructor(runtime, executor) + .getObject(runtime); + + // Stash the resolve function on the promise itself so the GC traces it as + // part of the promise graph. + defineHiddenProperty(runtime, promiseObj, kLostResolveProp, + *capturedResolve); + + // Register a WEAK reference so notifyDeviceLost() can settle the promise + // if it is still alive. Only wired for the device's own runtime when a + // CallInvoker exists (main JS runtime): spontaneous events are delivered + // through that invoker. A promise created on another runtime stays + // pending, matching the previous best-effort behavior. + bool settledMeanwhile = false; + if (_async && _async->callInvoker() && &runtime == &_async->runtime()) { + std::lock_guard lock(_lostMutex); + if (_lostSettled) { + // The device was lost between the check above and now. + settledMeanwhile = true; + settledInfo = _lostInfo; + } else { + _lostPromises.push_back( + PendingLostPromise{&runtime, jsi::WeakObject(runtime, promiseObj)}); + } + } + if (settledMeanwhile) { + auto resolveFn = + promiseObj.getPropertyAsFunction(runtime, kLostResolveProp); + resolveFn.call(runtime, + JSIConverter>::toJSI( + runtime, settledInfo)); + } + promiseValue = jsi::Value(runtime, promiseObj); + } + + defineHiddenProperty(runtime, wrapper, kLostPromiseProp, promiseValue); + return promiseValue; } void GPUDevice::addEventListener(std::string type, jsi::Function callback) { auto funcPtr = std::make_shared(std::move(callback)); diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index 58b1dd62c..1f55f135f 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -45,11 +45,11 @@ #include "GPURenderPipelineDescriptor.h" #include "GPUSampler.h" #include "GPUSamplerDescriptor.h" +#include "GPUShaderModule.h" +#include "GPUShaderModuleDescriptor.h" #include "GPUSharedFenceDescriptor.h" #include "GPUSharedTextureMemory.h" #include "GPUSharedTextureMemoryDescriptor.h" -#include "GPUShaderModule.h" -#include "GPUShaderModuleDescriptor.h" #include "GPUSupportedLimits.h" #include "GPUTexture.h" #include "GPUTextureDescriptor.h" @@ -121,8 +121,8 @@ class GPUDevice : public NativeObject { std::shared_ptr descriptor); std::shared_ptr importSharedTextureMemory( std::shared_ptr descriptor); - std::shared_ptr importSharedFence( - std::shared_ptr descriptor); + std::shared_ptr + importSharedFence(std::shared_ptr descriptor); std::shared_ptr createBindGroupLayout( std::shared_ptr descriptor); std::shared_ptr @@ -153,7 +153,7 @@ class GPUDevice : public NativeObject { std::unordered_set getFeatures(); std::shared_ptr getLimits(); std::shared_ptr getQueue(); - async::AsyncTaskHandle getLost(); + jsi::Value getLost(jsi::Runtime &runtime, const jsi::Object &wrapper); void notifyDeviceLost(wgpu::DeviceLostReason reason, std::string message); void notifyUncapturedError(wgpu::ErrorType type, std::string message); void forceLossForTesting(); @@ -211,7 +211,30 @@ class GPUDevice : public NativeObject { installGetter(runtime, prototype, "features", &GPUDevice::getFeatures); installGetter(runtime, prototype, "limits", &GPUDevice::getLimits); installGetter(runtime, prototype, "queue", &GPUDevice::getQueue); - installGetter(runtime, prototype, "lost", &GPUDevice::getLost); + // `lost` is installed manually: the getter needs the wrapper object + // itself, because the promise (and its resolve function) are cached as + // hidden properties on the JS side so the GC traces them as part of the + // device graph. Holding them strongly from C++ would root the promise's + // .then reactions forever (issue #445). + { + auto lostGetter = jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forUtf8(runtime, "get_lost"), 0, + [](jsi::Runtime &rt, const jsi::Value &thisVal, + const jsi::Value * /*args*/, size_t /*count*/) -> jsi::Value { + auto native = GPUDevice::fromValue(rt, thisVal); + return native->getLost(rt, thisVal.getObject(rt)); + }); + auto objectCtor = runtime.global().getPropertyAsObject(runtime, "Object"); + auto defineProperty = + objectCtor.getPropertyAsFunction(runtime, "defineProperty"); + jsi::Object descriptor(runtime); + descriptor.setProperty(runtime, "get", lostGetter); + descriptor.setProperty(runtime, "enumerable", true); + descriptor.setProperty(runtime, "configurable", true); + defineProperty.call(runtime, prototype, + jsi::String::createFromUtf8(runtime, "lost"), + descriptor); + } installGetterSetter(runtime, prototype, "label", &GPUDevice::getLabel, &GPUDevice::setLabel); installMethod(runtime, prototype, "forceLossForTesting", @@ -262,15 +285,28 @@ class GPUDevice : public NativeObject { wgpu::Device _instance; std::shared_ptr _async; std::string _label; - // Guards the device-lost state below. In the ProcessEvents model both - // notifyDeviceLost() (fired by Dawn during ProcessEvents) and getLost() run on - // the owning runtime's own thread, but device destruction can also trigger - // notifyDeviceLost() synchronously, so the mutex keeps these fields safe. + // Guards the device-lost state below. getLost() runs on a JS thread, but + // Dawn's AllowSpontaneous device-lost callback (and device destruction) can + // fire notifyDeviceLost() from other threads, so the mutex keeps these + // fields safe. std::mutex _lostMutex; - std::optional _lostHandle; std::shared_ptr _lostInfo; bool _lostSettled = false; - std::optional _lostResolve; + // Pending `lost` promises, held WEAKLY. A strong native reference would be + // a GC root: the promise's .then reactions (three.js captures its whole + // renderer there) could never be collected, pinning every GPU wrapper of + // the scene and its reported external memory forever (issue #445). The + // resolve function lives as a hidden property on the promise object itself, + // so if the promise is still alive when the device is lost we can settle + // it; if it was collected, nobody could have observed the resolution. + // Entries are only added for the device's own runtime when a CallInvoker is + // available (main JS runtime), matching the best-effort contract for + // spontaneous events. + struct PendingLostPromise { + jsi::Runtime *runtime; + jsi::WeakObject promise; + }; + std::vector _lostPromises; // Event listeners storage - keyed by event type // Each entry contains a vector of shared_ptr to functions diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h index cb7fd8da2..6787a60db 100644 --- a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h @@ -36,14 +36,18 @@ namespace rnwgpu::async { * The pump only runs while at least one "pumping" task is outstanding, so it * costs nothing when idle and stops cleanly. * - * Spontaneous events (keepPumping = false): events that may fire at any time, - * independent of any request/response op (today only GPUDevice::getLost, whose - * Dawn callback is registered AllowSpontaneous). These are NOT driven by the - * pump. Instead their settle is marshalled onto the owning runtime's JS thread - * via that runtime's CallInvoker, which is wired only for the MAIN JS runtime - * (callInvoker()). A device created on a worklet runtime has no invoker, so its - * device.lost is best-effort and may never fire. See the README "Threading - * model" section. + * Spontaneous events: events that may fire at any time, independent of any + * request/response op (today only device.lost, whose Dawn callback is + * registered AllowSpontaneous). These are NOT driven by the pump and do NOT go + * through postTask: a task that may never settle must not hold the JS promise + * strongly from C++, or the promise (and everything reachable from its .then + * reactions) becomes a permanent GC root (issue #445). Instead GPUDevice keeps + * a jsi::WeakObject to the promise and settles it on the owning runtime's JS + * thread via that runtime's CallInvoker, which is wired only for the MAIN JS + * runtime (callInvoker()). A device created on a worklet runtime has no + * invoker, so its device.lost is best-effort and may never fire. See the + * README "Threading model" section. The keepPumping=false path in postTask is + * currently unused; it remains for tasks that settle without Dawn's pump. * * Shared-instance safety (mailbox): multiple runtimes may share one * wgpu::Instance. ProcessEvents() drains the whole instance queue and fires @@ -78,9 +82,10 @@ class RuntimeContext : public std::enable_shared_from_this { static std::shared_ptr getOrCreate(jsi::Runtime &runtime, wgpu::Instance instance); - // Register the main JS runtime and its CallInvoker. The RuntimeContext created - // for this runtime gets the invoker (callInvoker() returns it); every other - // runtime's context returns null. Called once from RNWebGPUManager on install. + // Register the main JS runtime and its CallInvoker. The RuntimeContext + // created for this runtime gets the invoker (callInvoker() returns it); every + // other runtime's context returns null. Called once from RNWebGPUManager on + // install. static void registerMainRuntime(jsi::Runtime *runtime, std::shared_ptr invoker); @@ -95,17 +100,21 @@ class RuntimeContext : public std::enable_shared_from_this { // The wgpu::Instance bound to this runtime. wgpu::Instance instance() const { return _instance; } + // The runtime this context belongs to. Safe for pointer-identity checks from + // any thread; only touch JSI through it on the runtime's own thread. + jsi::Runtime &runtime() const { return _runtime; } + AsyncTaskHandle postTask(const TaskCallback &callback, bool keepPumping = true); - // Deposit a settle-action to run on THIS context's runtime thread. Thread-safe - // (callable from any thread, e.g. another runtime that pumped ProcessEvents). - // The job must not touch JSI until it runs (it runs during drainMailbox on the - // owning thread). + // Deposit a settle-action to run on THIS context's runtime thread. + // Thread-safe (callable from any thread, e.g. another runtime that pumped + // ProcessEvents). The job must not touch JSI until it runs (it runs during + // drainMailbox on the owning thread). void postSettle(std::function job); - // Invoked by a drained settle-action when its task settles. Runs on the owning - // runtime's thread. + // Invoked by a drained settle-action when its task settles. Runs on the + // owning runtime's thread. void onTaskSettled(bool keepPumping); private: diff --git a/packages/webgpu/src/__tests__/Device.spec.ts b/packages/webgpu/src/__tests__/Device.spec.ts index d02dd57b7..431e06ea1 100644 --- a/packages/webgpu/src/__tests__/Device.spec.ts +++ b/packages/webgpu/src/__tests__/Device.spec.ts @@ -132,6 +132,61 @@ describe("Device", () => { expect(isDeviceLost).toBeFalsy(); }); + // Regression test for #445: a pending device.lost promise must not be a GC + // root. Before the fix, native code held the promise strongly, so a + // lost.then() reaction (three.js registers one capturing its whole + // renderer) pinned the device and every resource wrapper forever, and their + // external memory pressure accumulated until Hermes OOMed. + it("releases external memory of a dropped device with a pending lost.then (#445)", async () => { + const result = await client.eval(({ gpu }) => { + const getExternal = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hermes = (globalThis as any).HermesInternal; + return hermes?.getInstrumentedStats?.()?.js_externalBytes ?? 0; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { gc } = globalThis as any; + if (typeof gc !== "function" || getExternal() === 0) { + // Not running on Hermes with GC instrumentation (e.g. web reference + // run): nothing to measure. + return Promise.resolve(null); + } + // Kept in a separate function so its locals (device, buffer) are not + // captured by the enclosing environment when we measure afterwards. + const allocate = () => + gpu.requestAdapter().then((adapter) => + adapter!.requestDevice().then((device) => { + const buffer = device.createBuffer({ + size: 64 * 1024 * 1024, + usage: GPUBufferUsage.VERTEX, + }); + device.lost.then(() => buffer); + }), + ); + gc(); + const baseline = getExternal(); + return allocate().then( + () => + new Promise((resolve) => { + const allocated = getExternal(); + setTimeout(() => { + gc(); + resolve({ baseline, allocated, after: getExternal() }); + }, 100); + }), + ); + }); + if (result !== null) { + const { baseline, allocated, after } = result as { + baseline: number; + allocated: number; + after: number; + }; + expect(allocated - baseline).toBeGreaterThanOrEqual(64 * 1024 * 1024); + expect(after - baseline).toBeLessThan(8 * 1024 * 1024); + } + }); + it("resolves an awaited device.lost when device.destroy is called", async () => { const result = await client.eval(({ gpu }) => gpu.requestAdapter().then((adapter) =>