From 90549738864f91eabc53e1cf4257daccb4629062 Mon Sep 17 00:00:00 2001 From: tarsyang Date: Thu, 20 Aug 2026 22:54:37 +0800 Subject: [PATCH] Fix capturer state desync when startCapture fails in a subclass VideoCapturer.startCapture() increments the start/stop counter before subclasses perform the actual device-level start work. When that work throws (e.g. CameraCapturer: no capture device, unresolvable format, or an AVFoundation start error), the counter was left incremented: the capturer permanently reported .started while nothing was capturing, and every subsequent startCapture() call returned false early ("already started") instead of retrying. On iOS this turns one transient failure during a background-return resume into a camera that stays dead for the rest of the session while the track still reports unmuted. Balances the counter on failure with try? await stopCapture(), which also runs the subclass stop body and tears down any partially configured device state. Applied to every start path that can fail after the base call: CameraCapturer, ARCameraCapturer, MacOSScreenCapturer, InAppScreenCapturer (throwing paths), and BroadcastScreenCapturer (receiver creation failure path that returns false). --- .changes/capturer-start-failure-balance | 1 + .../Broadcast/BroadcastScreenCapturer.swift | 9 ++- .../Track/Capturers/ARCameraCapturer.swift | 10 +++ .../Track/Capturers/CameraCapturer.swift | 12 +++- .../Track/Capturers/InAppCapturer.swift | 10 +++ .../Track/Capturers/MacOSScreenCapturer.swift | 10 +++ .../VideoCapturerStartFailureTests.swift | 69 +++++++++++++++++++ 7 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 .changes/capturer-start-failure-balance create mode 100644 Tests/LiveKitCoreTests/VideoCapturerStartFailureTests.swift diff --git a/.changes/capturer-start-failure-balance b/.changes/capturer-start-failure-balance new file mode 100644 index 000000000..090986665 --- /dev/null +++ b/.changes/capturer-start-failure-balance @@ -0,0 +1 @@ +patch type="fixed" "Fix capturers permanently reporting started after a failed startCapture: the start/stop counter is now balanced on failure so a subsequent start attempt can retry" diff --git a/Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift b/Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift index f3d2c3c09..1aa1ef71d 100644 --- a/Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift +++ b/Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift @@ -45,7 +45,14 @@ class BroadcastScreenCapturer: BufferCapturer, @unchecked Sendable { .toEncodeSafeDimensions() set(dimensions: targetDimensions) - return createReceiver() + + guard createReceiver() else { + // Balance the counter so the capturer does not report `.started` + // while no capture is running. + _ = try? await stopCapture() + return false + } + return true } private func createReceiver() -> Bool { diff --git a/Sources/LiveKit/Track/Capturers/ARCameraCapturer.swift b/Sources/LiveKit/Track/Capturers/ARCameraCapturer.swift index 2de870e8f..ee267dc65 100644 --- a/Sources/LiveKit/Track/Capturers/ARCameraCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/ARCameraCapturer.swift @@ -37,6 +37,16 @@ public class ARCameraCapturer: VideoCapturer, @unchecked Sendable { } override public func startCapture() async throws -> Bool { + do { + return try await performStartCapture() + } catch { + // Balance the counter so a subsequent startCapture() can retry. + try? await stopCapture() + throw error + } + } + + private func performStartCapture() async throws -> Bool { let didStart = try await super.startCapture() // Already started guard didStart else { return false } diff --git a/Sources/LiveKit/Track/Capturers/CameraCapturer.swift b/Sources/LiveKit/Track/Capturers/CameraCapturer.swift index 118e8f259..d1101979f 100644 --- a/Sources/LiveKit/Track/Capturers/CameraCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/CameraCapturer.swift @@ -151,8 +151,18 @@ public class CameraCapturer: VideoCapturer, @unchecked Sendable { return try await restartCapture() } - // swiftlint:disable:next cyclomatic_complexity function_body_length override public func startCapture() async throws -> Bool { + do { + return try await performStartCapture() + } catch { + // Balance the counter so a subsequent startCapture() can retry. + try? await stopCapture() + throw error + } + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func performStartCapture() async throws -> Bool { let didStart = try await super.startCapture() // Already started diff --git a/Sources/LiveKit/Track/Capturers/InAppCapturer.swift b/Sources/LiveKit/Track/Capturers/InAppCapturer.swift index 8e0aad6fa..6a903518d 100644 --- a/Sources/LiveKit/Track/Capturers/InAppCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/InAppCapturer.swift @@ -33,6 +33,16 @@ public class InAppScreenCapturer: VideoCapturer, @unchecked Sendable { } override public func startCapture() async throws -> Bool { + do { + return try await performStartCapture() + } catch { + // Balance the counter so a subsequent startCapture() can retry. + try? await stopCapture() + throw error + } + } + + private func performStartCapture() async throws -> Bool { let didStart = try await super.startCapture() // Already started diff --git a/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift b/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift index 685f3b94e..1916bc0e0 100644 --- a/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift @@ -58,6 +58,16 @@ public class MacOSScreenCapturer: VideoCapturer, @unchecked Sendable { } override public func startCapture() async throws -> Bool { + do { + return try await performStartCapture() + } catch { + // Balance the counter so a subsequent startCapture() can retry. + try? await stopCapture() + throw error + } + } + + private func performStartCapture() async throws -> Bool { let didStart = try await super.startCapture() // Already started diff --git a/Tests/LiveKitCoreTests/VideoCapturerStartFailureTests.swift b/Tests/LiveKitCoreTests/VideoCapturerStartFailureTests.swift new file mode 100644 index 000000000..5616551c0 --- /dev/null +++ b/Tests/LiveKitCoreTests/VideoCapturerStartFailureTests.swift @@ -0,0 +1,69 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import LiveKitWebRTC +import Testing + +/// Emulates a capturer whose device-level start work fails after +/// `super.startCapture()` has already transitioned the state to `.started`, +/// using the same balancing pattern as the built-in capturers. +private final class StartFailingCapturer: VideoCapturer, @unchecked Sendable { + override func startCapture() async throws -> Bool { + do { + let didStart = try await super.startCapture() + guard didStart else { return false } + throw LiveKitError(.invalidState, message: "Simulated device failure") + } catch { + try? await stopCapture() + throw error + } + } +} + +private final class NullCapturerDelegate: NSObject, LKRTCVideoCapturerDelegate { + func capturer(_: LKRTCVideoCapturer, didCapture _: LKRTCVideoFrame) {} +} + +@Suite(.tags(.media)) +struct VideoCapturerStartFailureTests { + @Test func startFailureLeavesCapturerStopped() async throws { + let capturer = StartFailingCapturer(delegate: NullCapturerDelegate()) + #expect(capturer.captureState == .stopped) + + await #expect(throws: LiveKitError.self) { + try await capturer.startCapture() + } + // Without balancing the counter stays at 1: the capturer reports + // `.started` while nothing is capturing. + #expect(capturer.captureState == .stopped) + } + + @Test func startCanRetryAfterFailure() async throws { + let capturer = StartFailingCapturer(delegate: NullCapturerDelegate()) + + await #expect(throws: LiveKitError.self) { + try await capturer.startCapture() + } + // Without balancing this second call would return `false` early + // ("already started") instead of reaching the subclass again. + await #expect(throws: LiveKitError.self) { + try await capturer.startCapture() + } + #expect(capturer.captureState == .stopped) + } +}