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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user