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

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