From 8bed5435ad8828ba495dbf254753143226dedb14 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 13 May 2026 19:35:36 +0300 Subject: [PATCH] =?UTF-8?q?camera:=20stricter=20macOS=20dispose=20order=20?= =?UTF-8?q?+=20plugin=20diagnostics=20=E2=86=92=20ux.Log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that target the macOS "camera not found" leak after a few open/close cycles. macOS's `AVCaptureDevice.DiscoverySession` excludes devices that are still claimed by another session — even our own zombie session that hasn't fully released its grip on the hardware. So if dispose leaves the session in a partially torn-down state, the next `availableCameras` returns empty. CameraInstance.dispose now: - Cancels the recorder (was already there) so the audio data output's retain on the recorder drops. - Stops the session. - **Nils sample-buffer delegates** on the video + audio data outputs before removing them. `setSampleBufferDelegate` holds a strong reference to the delegate; the macOS reference to our `SampleFanout` was transitively keeping the session alive. - Removes inputs + outputs inside a single `session.configure { … }` block (begin/commitConfiguration) so AVFoundation sees the teardown as one atomic transition rather than a sequence of partial states. Apple's docs are explicit on this; we weren't following. - Clears the strong references to the instance vars. Plugin diagnostics: - When availableCameras returns empty, native now emits an event `{handle: -1, event: "diagnostic", message: …}` carrying the current `devicesInUse`, `audioInUse` and `instances` keys. Per-handle diagnostics already flow through a controller's `_onEvent`; plugin-level ones (handle == -1) had no path to the log_server jsonl. - `MethodChannelUxCameraBackend` now subscribes to its raw event stream once and pipes any handle=-1 diagnostic through `ux.Log.tag('camera').i('plugin: …')`. The subscription kicks in when the broadcast stream is first accessed (still lazy — matches the prior behavior). If the macOS "camera not found" reproduces, the jsonl will show which side leaked: a non-empty `devicesInUse` says our claim tracking is stale; an empty one says AVFoundation itself is holding the hardware. --- darwin/Camera/CameraInstance.swift | 31 +++++++++++++++++++++++++----- darwin/Camera/CameraPlugin.swift | 15 +++++++++++++++ lib/src/camera/camera_channel.dart | 20 +++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/darwin/Camera/CameraInstance.swift b/darwin/Camera/CameraInstance.swift index a54ba5b..faf492b 100644 --- a/darwin/Camera/CameraInstance.swift +++ b/darwin/Camera/CameraInstance.swift @@ -169,6 +169,21 @@ final class CameraInstance { /// recording (drops queued audio, `cancelWriting`, deletes the /// partial file — telegram-ios's `cancelRecording` path). Must run /// on sessionQueue. + /// + /// Order matters for releasing the camera back to the OS: + /// 1. Cancel the recorder so its retain on the audio data output + /// drops. + /// 2. Stop the session so the hardware stops streaming. + /// 3. Detach sample-buffer delegates — `setSampleBufferDelegate` + /// holds a strong reference to the delegate (our `SampleFanout`), + /// which transitively keeps the session alive on macOS. + /// 4. Remove inputs + outputs inside a single + /// `beginConfiguration` / `commitConfiguration` so AVFoundation + /// sees one atomic teardown rather than a sequence of partial + /// states. macOS's `AVCaptureDevice.DiscoverySession` won't + /// return a device that's still claimed by a half-torn-down + /// session, which is what was causing "camera not found" on + /// re-open after a few use cycles. func dispose() { if disposed { return } disposed = true @@ -180,11 +195,17 @@ final class CameraInstance { } session.stop() - if let input = deviceInput { session.av.removeInput(input) } - if let input = audioDeviceInput { session.av.removeInput(input) } - if let output = videoDataOutput { session.av.removeOutput(output) } - if let output = audioDataOutput { session.av.removeOutput(output) } - session.av.removeOutput(photoOutput.avOutput) + + videoDataOutput?.setSampleBufferDelegate(nil, queue: nil) + audioDataOutput?.setSampleBufferDelegate(nil, queue: nil) + + session.configure { + if let input = deviceInput { session.av.removeInput(input) } + if let input = audioDeviceInput { session.av.removeInput(input) } + if let output = videoDataOutput { session.av.removeOutput(output) } + if let output = audioDataOutput { session.av.removeOutput(output) } + session.av.removeOutput(photoOutput.avOutput) + } videoDataOutput = nil deviceInput = nil diff --git a/darwin/Camera/CameraPlugin.swift b/darwin/Camera/CameraPlugin.swift index 172cc49..17313af 100644 --- a/darwin/Camera/CameraPlugin.swift +++ b/darwin/Camera/CameraPlugin.swift @@ -291,6 +291,21 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler { private func availableCameras(result: @escaping FlutterResult) { let cameras = CaptureDevice.discover().map { $0.toWire() } + if cameras.isEmpty { + // Empty discovery has two common causes that look the same + // to Dart: real permission denial vs a zombie AVCaptureSession + // somewhere holding the device. Emit a diagnostic with the + // current plugin claim state so the log_server jsonl shows + // which side the leak is on if this reproduces. + eventSink?([ + "handle": -1, + "event": "diagnostic", + "message": "availableCameras: empty" + + " devicesInUse=\(Array(devicesInUse).sorted())" + + " audioInUse=\(audioInUse)" + + " instances=\(Array(instances.keys).sorted())", + ]) + } result(cameras) } diff --git a/lib/src/camera/camera_channel.dart b/lib/src/camera/camera_channel.dart index f23e7c4..b3274fd 100644 --- a/lib/src/camera/camera_channel.dart +++ b/lib/src/camera/camera_channel.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/services.dart'; import '../file.dart' show UxFile; +import '../log.dart' show Log; import 'camera.dart' show UxCameraDescription, @@ -12,6 +13,8 @@ import 'camera.dart' UxResolutionPreset; import 'camera_backend.dart'; +final _log = Log.tag('camera'); + /// Production [UxCameraBackend]. Hand-rolled MethodChannel + /// EventChannel — matches the rest of `package:ux`, no pigeon. class MethodChannelUxCameraBackend implements UxCameraBackend { @@ -23,8 +26,21 @@ class MethodChannelUxCameraBackend implements UxCameraBackend { /// Single broadcast stream over the native event channel. We /// demultiplex per-handle inside [events] so a Stream subscription /// is cheap (no per-instance EventChannel hop). - late final Stream _rawEvents = - _eventsChannel.receiveBroadcastStream(); + late final Stream _rawEvents = () { + final stream = _eventsChannel.receiveBroadcastStream(); + // Plugin-level diagnostics (handle == -1) flow over the same + // channel but aren't claimed by any controller's per-handle + // filter. Subscribe once globally so they reach `ux.Log` (and + // therefore the log_server jsonl) even before any controller + // is alive. + stream.listen((e) { + final m = (e as Map).cast(); + if (m['event'] != 'diagnostic') return; + if ((m['handle'] as num?)?.toInt() != -1) return; + _log.i('plugin: ${m['message'] ?? ''}'); + }); + return stream; + }(); @override Future> availableCameras() async {