camera: stricter macOS dispose order + plugin diagnostics → ux.Log

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.
This commit is contained in:
agra
2026-05-13 19:35:36 +03:00
parent de1a9fd25e
commit 8bed5435ad
3 changed files with 59 additions and 7 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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<Object?> _rawEvents =
_eventsChannel.receiveBroadcastStream();
late final Stream<Object?> _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<Object?, Object?>();
if (m['event'] != 'diagnostic') return;
if ((m['handle'] as num?)?.toInt() != -1) return;
_log.i('plugin: ${m['message'] ?? ''}');
});
return stream;
}();
@override
Future<List<UxCameraDescription>> availableCameras() async {