From 403d1c529594f52a3bf04d547b934a9fb8420ee0 Mon Sep 17 00:00:00 2001 From: jslok Date: Tue, 4 Aug 2026 18:06:37 -0700 Subject: [PATCH] fix(resizer): guard dispose() against an in-flight resize() resize() dereferenced the pipeline with no synchronization while dispose() could concurrently destroy it from the JS thread - a native use-after-free instead of the catchable "already been disposed" error. Serialize resize(), dispose() and the memorySize getter behind a lifecycle lock on both platforms. Co-Authored-By: Claude Fable 5 --- .../android/src/main/cpp/HybridResizer.cpp | 8 ++++++++ .../android/src/main/cpp/HybridResizer.hpp | 6 ++++++ .../ios/HybridResizer.swift | 11 +++++++++++ 3 files changed, 25 insertions(+) diff --git a/packages/react-native-vision-camera-resizer/android/src/main/cpp/HybridResizer.cpp b/packages/react-native-vision-camera-resizer/android/src/main/cpp/HybridResizer.cpp index dec11ffd57..26cd99b2da 100644 --- a/packages/react-native-vision-camera-resizer/android/src/main/cpp/HybridResizer.cpp +++ b/packages/react-native-vision-camera-resizer/android/src/main/cpp/HybridResizer.cpp @@ -38,6 +38,8 @@ namespace { HybridResizer::HybridResizer(const ResizerOptions& options) : HybridObject(TAG), _pipeline(std::make_unique(options)) {} std::shared_ptr HybridResizer::resize(const std::shared_ptr& frame) { + std::lock_guard lock(_lifecycleMutex); + if (_pipeline == nullptr) [[unlikely]] { throw std::runtime_error("This Resizer has already been disposed!"); } @@ -59,6 +61,8 @@ std::shared_ptr HybridResizer::resize(const std::shared_ptr< } void HybridResizer::dispose() { + std::lock_guard lock(_lifecycleMutex); + if (_pipeline == nullptr) { return; } @@ -71,6 +75,10 @@ void HybridResizer::dispose() { } size_t HybridResizer::getExternalMemorySize() noexcept { + // Also guarded: Nitro calls this from toObject() on arbitrary threads, and it is never called + // from inside resize()/dispose(), so it cannot self-deadlock. + std::lock_guard lock(_lifecycleMutex); + return _pipeline != nullptr ? _pipeline->getOutputBufferAllocationSize() : 0; } diff --git a/packages/react-native-vision-camera-resizer/android/src/main/cpp/HybridResizer.hpp b/packages/react-native-vision-camera-resizer/android/src/main/cpp/HybridResizer.hpp index 8d323af6a7..56c9813abc 100644 --- a/packages/react-native-vision-camera-resizer/android/src/main/cpp/HybridResizer.hpp +++ b/packages/react-native-vision-camera-resizer/android/src/main/cpp/HybridResizer.hpp @@ -11,6 +11,7 @@ #include "vulkan/VulkanResizerPipeline.hpp" #include +#include namespace margelo::nitro::camera::resizer { @@ -28,6 +29,11 @@ class HybridResizer final : public HybridResizerSpec { size_t getExternalMemorySize() noexcept override; private: + // Serializes resize() against dispose(): dispose() can run on the JS thread while resize() is + // still running on the frame-processor thread, and resetting _pipeline mid-run() tears down the + // VkDevice underneath it. A dispose landing mid-frame blocks until that resize returns; every + // later call throws the catchable "already been disposed" error. + std::mutex _lifecycleMutex; std::unique_ptr _pipeline; }; diff --git a/packages/react-native-vision-camera-resizer/ios/HybridResizer.swift b/packages/react-native-vision-camera-resizer/ios/HybridResizer.swift index b05ee8c7f3..4942cde35b 100644 --- a/packages/react-native-vision-camera-resizer/ios/HybridResizer.swift +++ b/packages/react-native-vision-camera-resizer/ios/HybridResizer.swift @@ -12,6 +12,11 @@ import VisionCamera /// High-level iOS resizer that turns camera frames into GPU-backed JS-visible output frames. final class HybridResizer: HybridResizerSpec { + /// Serializes `resize()` against `dispose()`: `pipeline` is read on the frame-processor thread + /// while `dispose()` clears it on the JS thread, and a Swift class-reference load/store is not + /// atomic - `pipeline = nil` racing the `guard let` load is a data race on the possibly-last + /// reference. + private let lifecycleLock = NSLock() private var pipeline: MetalResizerPipeline? init(options: ResizerOptions) throws { @@ -20,14 +25,20 @@ final class HybridResizer: HybridResizerSpec { } var memorySize: Int { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } return pipeline?.outputByteCount ?? 0 } func dispose() { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } pipeline = nil } func resize(frame: any HybridFrameSpec) throws -> any HybridGPUFrameSpec { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } guard let pipeline else { throw RuntimeError.error(withMessage: "This Resizer has already been disposed!") }