Skip to content

Fix capturer state desync when startCapture fails after the base state transition - #1095

Open
tarsyang wants to merge 1 commit into
livekit:mainfrom
tarsyang:fix/capturer-start-state-rollback
Open

Fix capturer state desync when startCapture fails after the base state transition#1095
tarsyang wants to merge 1 commit into
livekit:mainfrom
tarsyang:fix/capturer-start-state-rollback

Conversation

@tarsyang

Copy link
Copy Markdown

Problem

VideoCapturer.startCapture() increments the start/stop counter and
notifies .started before subclasses perform the actual device-level
start work. When that work throws, the counter is left incremented:

  • The capturer permanently reports .started while nothing is
    capturing.
  • Every subsequent startCapture() call returns false early
    ("already started") instead of retrying, and Track.start() does not
    inspect the return value, so the track keeps reporting
    started/unmuted with a dead capturer.

On iOS this is reachable on every foreground return: the background
suspend/resume path goes through resume() → unmute → Track.start() → startCapture() and Room.appWillEnterForeground only logs the resume
error, so one transient CameraCapturer failure (no capture device,
unresolvable format, or an AVFoundation start error during the app
transition) leaves the camera dead for the rest of the session while
track.isMuted == false, with no error surfaced to the app. The only
recovery is unpublishing and republishing the track.

Fix

Balance the counter on failure with try? await stopCapture() before
rethrowing (or before reporting failure): the capturer returns to
.stopped, and running the subclass stop body tears down any partially
configured device state. Applied to every start path that can fail
after the base call:

  • CameraCapturer, ARCameraCapturer, MacOSScreenCapturer,
    InAppScreenCapturer: the start body moves into a private
    performStartCapture() and the override wraps it in do/catch.
  • BroadcastScreenCapturer: the receiver-creation failure path
    returned false while leaving the counter incremented; it now
    balances the counter first (whether that path should throw instead of
    returning false is left unchanged). Balancing also resets the
    pre-filled dimensions completer, so publishing with a misconfigured
    broadcast bundle now fails at the publish-time dimensions wait
    instead of publishing a track that never produces frames.

No public API changes.

Note

MacOSScreenCapturer.stopCapture() has the mirrored issue on the stop
side: throwing after the counter already decremented leaves a running
SCStream on a capturer that reports .stopped. Left out to keep this
PR focused on the start path; happy to follow up.

The structural alternative (base class transitions state only after the
subclass start work succeeds, template-method style) would also cover
third-party capturer subclasses, but it changes the public subclassing
contract, so this PR keeps the non-breaking per-subclass shape.

Testing

  • New VideoCapturerStartFailureTests: a capturer failing after the
    base transition returns to .stopped, and a second attempt reaches
    the subclass again instead of silently returning false.
  • Verified both tests fail against the unbalanced behavior (state stays
    .started; the second call returns false).
  • Built for iOS Simulator and macOS.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread Sources/LiveKit/Track/Capturers/CameraCapturer.swift Outdated
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).
@tarsyang
tarsyang force-pushed the fix/capturer-start-state-rollback branch from 59cdc5c to 9054973 Compare August 20, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant