What happens
Render a canvas from the Reanimated UI runtime, then touch the same GPUDevice from the React JS runtime, and the app dies in 4 to 12 seconds. No exotic API needed. queue.writeBuffer, queue.submit, device.createBuffer, buffer.mapAsync and context.present each do it.
Five signatures so far:
- iOS, Metal assert in
-[_MTLCommandEncoder dealloc]
- iOS, Metal assert
encodeSignalEvent:value: during Submit
- iOS, Metal assert in
-[_MTLCommandBuffer commit], reached from Instance::ProcessEvents
- iOS,
AddressSanitizer: heap-use-after-free in Dawn's RingBufferAllocator
- Android,
SIGSEGV on the JS thread inside BufferBase::MapAtCreation
I chased this on iOS first and assumed it was a Metal quirk. It is not. Android/Vulkan crashes too, and so does a Release build with no sanitizer linked, so the Metal asserts are not just validation noise.
Repro
Branch: https://github.com/AlirezaHadjar/react-native-webgpu/tree/repro/cross-runtime-device
It sits on main at 244ad53 and only touches apps/example.
It adds a screen under Diagnostics -> "Cross-Runtime Device Stress". The UI runtime renders a canvas through runOnUI while the React JS runtime hammers the same device from a setTimeout pump. Each mode picks which API family both sides hit.
yarn build:ios or yarn build:android in apps/example
- Home, scroll to the bottom, tap Tests, then Cross-Runtime Device Stress
- It starts in
write mode and dies in about 10 seconds. Tap submit, map or present for the other signatures.
On iOS you can pick the mode at launch:
xcrun simctl launch <udid> <bundle-id> -RNWebGPUCrossRuntimeMode map
Watch the ui: and js: counters under the title. If either stops climbing, that runtime died and nothing is racing any more, so "it survived" means nothing.
What I measured
iPhone 17 Pro simulator, iOS 26.5, Debug with ASan, 30 seconds per mode:
| mode |
result |
crash |
| write |
crash 12s |
-[_MTLCommandEncoder dealloc] <- EnsureBlit <- QueueBase::WriteBuffer |
| submit |
crash 6s |
ASan + encodeSignalEvent <- Submit |
| create |
survived |
|
| map |
crash 4s |
-[_MTLCommandBuffer commit] <- SubmitPendingCommandBuffer <- DeviceBase::Tick <- Instance::ProcessEvents |
| present |
crash 4s |
ASan + encodeSignalEvent <- Submit |
| churn |
survived |
|
| destroy |
survived |
|
| all |
crash 6s |
ASan + Submit |
Release build for the simulator, no sanitizer in the image list: write crashes in about 10 seconds with the same -[_MTLCommandEncoder dealloc] stack.
Android emulator, API 36, arm64, Debug: submit crashes with SIGSEGV on the React JS thread inside createBuffer.
Stacks
iOS, both runtimes sitting in rnwgpu::GPUQueue::writeBuffer at the same time. Which thread aborts changes run to run:
abort -> __assert_rtn -> MTLReportFailure
-[_MTLCommandEncoder dealloc]
-[MTLSerializerBlitCommandEncoder dealloc]
dawn::native::metal::CommandRecordingContext::EnsureBlit()
dawn::native::metal::Device::CopyFromStagingToBuffer(...)
dawn::native::BufferBase::UploadData(...)
dawn::native::QueueBase::WriteBuffer(...)
wgpu::Queue::WriteBuffer(...)
rnwgpu::GPUQueue::writeBuffer(...)
iOS, the uploader's ring buffer torn up by one thread while another walks it:
AddressSanitizer: heap-use-after-free on address 0x0001047986c0
#1 dawn::SerialQueue<..., RingBufferAllocator::Request>::Enqueue(...)
#2 dawn::native::RingBufferAllocator::Allocate(...)
#3 dawn::native::DynamicUploader::Reserve(...)
-> BufferBase::UploadData -> QueueBase::WriteBuffer -> rnwgpu::GPUQueue::writeBuffer
freed by thread T7 here: same Enqueue frame
previously allocated by thread T7 here: same Enqueue frame
T7 created by -[RCTJSThreadManager startJSThread] (RCTJSThreadManager.mm:69)
iOS map mode. The JS thread aborts inside the event pump while the main thread encodes:
thread com.facebook.react.runtime.JavaScript (triggered)
-[_MTLCommandBuffer commit] <- metal::Queue::SubmitPendingCommandBuffer
<- metal::Device::TickImpl <- DeviceBase::Tick <- InstanceBase::ProcessEvents
<- rnwgpu::async::RuntimeContext::tick()
main thread
-[MTLSerializerCommandEncoder endEncoding]
Android submit mode, tombstone on the React JS thread:
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x76954777a8
tid: 10325, name: mqt_v_js
#01 libwebgpu_dawn.so dawn::native::BufferBase::MapAtCreation()+408
#02 libwebgpu_dawn.so dawn::native::DeviceBase::CreateBuffer(...)+368
#03 libwebgpu_dawn.so dawn::native::BufferBase::MapAtCreationInternal()+196
#05 libwebgpu_dawn.so dawn::native::DeviceBase::APICreateBuffer(...)+864
#07 libreact-native-webgpu.so rnwgpu::GPUDevice::createBuffer(...)+260
Environment
- react-native-webgpu
main at 244ad53
- react-native 0.81.4, react-native-reanimated 4.3.1
- iPhone 17 Pro simulator, iOS 26.5, Debug with ASan and Release without
- Android emulator, API 36, arm64, Debug
What happens
Render a canvas from the Reanimated UI runtime, then touch the same
GPUDevicefrom the React JS runtime, and the app dies in 4 to 12 seconds. No exotic API needed.queue.writeBuffer,queue.submit,device.createBuffer,buffer.mapAsyncandcontext.presenteach do it.Five signatures so far:
-[_MTLCommandEncoder dealloc]encodeSignalEvent:value:duringSubmit-[_MTLCommandBuffer commit], reached fromInstance::ProcessEventsAddressSanitizer: heap-use-after-freein Dawn'sRingBufferAllocatorSIGSEGVon the JS thread insideBufferBase::MapAtCreationI chased this on iOS first and assumed it was a Metal quirk. It is not. Android/Vulkan crashes too, and so does a Release build with no sanitizer linked, so the Metal asserts are not just validation noise.
Repro
Branch: https://github.com/AlirezaHadjar/react-native-webgpu/tree/repro/cross-runtime-device
It sits on
mainat 244ad53 and only touchesapps/example.It adds a screen under Diagnostics -> "Cross-Runtime Device Stress". The UI runtime renders a canvas through
runOnUIwhile the React JS runtime hammers the same device from asetTimeoutpump. Each mode picks which API family both sides hit.yarn build:iosoryarn build:androidinapps/examplewritemode and dies in about 10 seconds. Tapsubmit,maporpresentfor the other signatures.On iOS you can pick the mode at launch:
Watch the
ui:andjs:counters under the title. If either stops climbing, that runtime died and nothing is racing any more, so "it survived" means nothing.What I measured
iPhone 17 Pro simulator, iOS 26.5, Debug with ASan, 30 seconds per mode:
-[_MTLCommandEncoder dealloc]<-EnsureBlit<-QueueBase::WriteBufferencodeSignalEvent<-Submit-[_MTLCommandBuffer commit]<-SubmitPendingCommandBuffer<-DeviceBase::Tick<-Instance::ProcessEventsencodeSignalEvent<-SubmitSubmitRelease build for the simulator, no sanitizer in the image list:
writecrashes in about 10 seconds with the same-[_MTLCommandEncoder dealloc]stack.Android emulator, API 36, arm64, Debug:
submitcrashes withSIGSEGVon the React JS thread insidecreateBuffer.Stacks
iOS, both runtimes sitting in
rnwgpu::GPUQueue::writeBufferat the same time. Which thread aborts changes run to run:iOS, the uploader's ring buffer torn up by one thread while another walks it:
iOS
mapmode. The JS thread aborts inside the event pump while the main thread encodes:Android
submitmode, tombstone on the React JS thread:Environment
mainat 244ad53