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