Summary
When LocalParticipant._publish fails at the frame-watcher step (local audio track produces no PCM frame within 5s → LiveKitError(.timedOut), code 101), the track has already been added server-side via AddTrack and negotiated, but add(publication:) is never reached. The catch block only calls track.stop(), and LocalAudioTrack.stopCapture() merely cleans up the frame watcher. Result: an orphan track — the SFU keeps a live publication and the transceiver stays attached, while the client has no LocalTrackPublication for it.
Where (v2.11.0, unchanged on main as of 2.16.x)
Sources/LiveKit/Participant/LocalParticipant.swift
- ~L712–719: after
AddTrack + negotiate, try await track.startWaitingForFrames()
- ~L736:
add(publication:) (never reached on timeout)
- ~L749–755:
catch → try await track.stop() only
Sources/LiveKit/Track/Local/LocalAudioTrack.swift ~L125: AsyncCompleter<Void>(label: "Frame watcher", defaultTimeout: 5); ~L101–103: stopCapture() → cleanUpFrameWatcher() only
Impact
- Mute silently no-ops:
setMicrophone(enabled: false) / set(source: .microphone, enabled: false) finds no publication → nothing happens, while the server-side track (and transceiver) remains. UI can show "muted" while the track is still published.
isMicrophoneStreaming reports false although the SFU lists an active audio track for the participant.
- The app receives
timedOut(101) and has no handle to the orphan track to unpublish it. Server logs show mediaTrack published at publish time and track_unpublished only when the participant leaves.
- From the app's point of view the failure is indistinguishable from "nothing was published", so retrying
setMicrophone(enabled: true) publishes a second track.
Observed timeline (iOS 26.x, iPhone, SDK 2.11.0, protocol 16)
- t+0.000 app calls
setMicrophone(enabled: true)
- t+0.205 SFU logs
mediaTrack published (AddTrack round-trip)
- t+5.35 client gets
timedOut(101) — matches AddTrack RTT + 5s frame-watcher timeout (the 10s AddTrack completer is not what fires)
- SFU keeps the track until
participant closing ~19s later
Audio session was already activated via CallKit (provider(_:didActivate:)) ~8s before publish, so this is not the session-activation race.
Expected
Either of:
- On frame-watcher timeout, still call
add(publication:) (or return the publication in the thrown error) so the app can mute/unpublish it; or
- In the
catch, fully roll back: send RemoveTrack for the already-added SID, stop/detach the underlying mediaTrack, and remove the transceiver — so the server state matches the client state.
Related: #843 (video side, dimensionsCompleter) — that one times out before AddTrack, so it does not leave an orphan; the audio path does.
Happy to open a PR for the rollback variant if that's the preferred direction.
Summary
When
LocalParticipant._publishfails at the frame-watcher step (local audio track produces no PCM frame within 5s →LiveKitError(.timedOut), code 101), the track has already been added server-side viaAddTrackand negotiated, butadd(publication:)is never reached. The catch block only callstrack.stop(), andLocalAudioTrack.stopCapture()merely cleans up the frame watcher. Result: an orphan track — the SFU keeps a live publication and the transceiver stays attached, while the client has noLocalTrackPublicationfor it.Where (v2.11.0, unchanged on
mainas of 2.16.x)Sources/LiveKit/Participant/LocalParticipant.swiftAddTrack+ negotiate,try await track.startWaitingForFrames()add(publication:)(never reached on timeout)catch→try await track.stop()onlySources/LiveKit/Track/Local/LocalAudioTrack.swift~L125:AsyncCompleter<Void>(label: "Frame watcher", defaultTimeout: 5); ~L101–103:stopCapture()→cleanUpFrameWatcher()onlyImpact
setMicrophone(enabled: false)/set(source: .microphone, enabled: false)finds no publication → nothing happens, while the server-side track (and transceiver) remains. UI can show "muted" while the track is still published.isMicrophoneStreamingreportsfalsealthough the SFU lists an active audio track for the participant.timedOut(101)and has no handle to the orphan track to unpublish it. Server logs showmediaTrack publishedat publish time andtrack_unpublishedonly when the participant leaves.setMicrophone(enabled: true)publishes a second track.Observed timeline (iOS 26.x, iPhone, SDK 2.11.0, protocol 16)
setMicrophone(enabled: true)mediaTrack published(AddTrack round-trip)timedOut(101)— matches AddTrack RTT + 5s frame-watcher timeout (the 10sAddTrackcompleter is not what fires)participant closing~19s laterAudio session was already activated via CallKit (
provider(_:didActivate:)) ~8s before publish, so this is not the session-activation race.Expected
Either of:
add(publication:)(or return the publication in the thrown error) so the app can mute/unpublish it; orcatch, fully roll back: sendRemoveTrackfor the already-added SID, stop/detach the underlyingmediaTrack, and remove the transceiver — so the server state matches the client state.Related: #843 (video side,
dimensionsCompleter) — that one times out beforeAddTrack, so it does not leave an orphan; the audio path does.Happy to open a PR for the rollback variant if that's the preferred direction.