From 73a69b63747c6c5c87bdf06f10c3a0ddd0314f55 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 13 May 2026 17:04:32 +0300 Subject: [PATCH] camera: keep devicesInUse aligned across flip + create failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two leak paths surfaced after a flip-then-record-then-pop session left the front camera claim stranded: 1. setDescription swapped instance.device without telling the plugin — devicesInUse still held the original cameraId. After dispose, releaseClaim only removed the *current* id, leaving the original stuck. Next push of the page hit device_busy on the original cam. Fix: setDescription handler now does a contention check, inserts the new id and drops the old (or rolls back on swap failure). 2. create's catch path called releaseClaim(for: instance), but if configureSession threw before instance.device was set, instance.currentCameraId is nil — and the cameraId we inserted on line above leaked. Fix: drop the known cameraId + audio claim explicitly in the catch. --- ios/Classes/Camera/CameraPlugin.swift | 30 ++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/ios/Classes/Camera/CameraPlugin.swift b/ios/Classes/Camera/CameraPlugin.swift index 44d7fd9..6283aa4 100644 --- a/ios/Classes/Camera/CameraPlugin.swift +++ b/ios/Classes/Camera/CameraPlugin.swift @@ -109,10 +109,29 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler { return } withInstance(args, result: result) { instance in + // Contention check against other instances. The instance's + // current cameraId is held by us in `devicesInUse`; only a + // foreign holder should block. (A no-op flip — same id — + // also passes.) + let oldId = instance.currentCameraId + if oldId != cameraId, self.devicesInUse.contains(cameraId) { + result(FlutterError( + code: "device_busy", + message: cameraId, + details: nil + )) + return + } + // Tentatively claim the new id before the async swap so a + // concurrent create can't race us. Roll back on failure. + self.devicesInUse.insert(cameraId) instance.sessionQueueAsync { do { let size = try instance.setDescription(cameraId: cameraId) DispatchQueue.main.async { + if let oldId, oldId != cameraId { + self.devicesInUse.remove(oldId) + } result([ "previewSize": [ "width": size.width, @@ -122,6 +141,9 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler { } } catch let error as NSError { DispatchQueue.main.async { + if oldId != cameraId { + self.devicesInUse.remove(cameraId) + } result(FlutterError( code: "init_failed", message: error.localizedDescription, @@ -326,7 +348,13 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler { } } catch let error as NSError { DispatchQueue.main.async { - self.releaseClaim(for: instance) + // Can't rely on `releaseClaim(for: instance)` here — + // if `configureSession` threw before `instance.device` + // was set, `instance.currentCameraId` is nil and the + // claim we inserted on line above would leak. Drop the + // ids we know we inserted explicitly. + self.devicesInUse.remove(cameraId) + if enableAudio { self.audioInUse = false } self.instances.removeValue(forKey: handle) result(FlutterError( code: "init_failed",