Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ebab556
fix(core): surface transport failures as .network errors
pblazej May 6, 2026
37218e5
fix(core): thread disconnect cause to remaining completer waiters
pblazej May 6, 2026
04f3866
fix(core): close activeParticipantCompleters leak across cleanUp
pblazej May 6, 2026
cdcdaff
test(core): silence weak-var false-positive in WeakRoomRefs
pblazej May 6, 2026
52d9b92
test(audio): drop deprecated Stopwatch/split usage
pblazej May 6, 2026
29aeda4
feat(errors): narrow AsyncCompleter and Room.sid() to throws(LiveKitE…
pblazej May 6, 2026
33745c1
chore(lint): add public_typed_throws SwiftLint rule
pblazej May 6, 2026
40fb2e2
feat(errors): wave 1 — boundary cleanups for typed throws
pblazej May 6, 2026
4fd60ef
feat(errors): wave 2 — SerialRunnerActor narrows to throws(LiveKitError)
pblazej May 6, 2026
c5e5e44
chore(lint): use per-line public_typed_throws suppressions
pblazej May 6, 2026
cb73ff0
feat(errors): wave 3 — narrow internal require* helpers to typed throws
pblazej May 6, 2026
281d5ea
feat(errors): wave 4 — narrow Track internals to typed throws
pblazej May 6, 2026
c65536e
feat(errors): wave 5 — capturers (typed throws where possible, suppre…
pblazej May 6, 2026
2948a1c
feat(errors): wave 6 — narrow TrackPublication public API to typed th…
pblazej May 6, 2026
cd2e222
feat(errors): wave 7 — narrow LocalParticipant public API + supportin…
pblazej May 6, 2026
cab85a5
feat(errors): wave 8 — Room.prepareConnection typed; connect kept unt…
pblazej May 6, 2026
4cb825c
chore(lint): tighten public_typed_throws to error + fix suppression form
pblazej May 6, 2026
7079ff3
feat(errors): narrow audio public APIs to throws(LiveKitError)
pblazej May 6, 2026
fb69ace
feat(errors): typed Room.connect via @objc bridge + LiveKitError(from:)
pblazej May 6, 2026
623946c
feat(errors): apply Obj-C bridge pattern to capturer @objc methods
pblazej May 6, 2026
d893613
feat(errors): add .dataStream LiveKitErrorType + narrow stream APIs
pblazej May 6, 2026
d03c7be
chore(lint): exempt _objc_* bridge shims from public_typed_throws
pblazej May 6, 2026
7f7f59c
refactor(connect): inline _connect into connect
pblazej May 6, 2026
afa0aa1
refactor(errors): low-risk internal narrows + WebSocket cleanup
pblazej May 6, 2026
4af365a
refactor(errors): tier 1 + tier 2 internal narrows
pblazej May 6, 2026
8754b1a
test: tighten #expect(throws:) to LiveKitError where API is now typed
pblazej May 6, 2026
f24ef85
docs(agents): document typed throws + Obj-C bridge pattern
pblazej May 6, 2026
750abb2
docs(agents): trim Typed-throws / Obj-C tradeoffs section
pblazej May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/typed-disconnect-error
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Report transport-level disconnects as LiveKitError(.network) instead of LiveKitError(.cancelled) so consumers can distinguish network failures from user-initiated cancellation"
11 changes: 11 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,14 @@ custom_rules:
regex: "@objc(?![(\\[])\\s+(?:(?:public|open|final|internal|package)\\s+)*class\\b"
message: "Use @objcMembers instead of @objc for classes to implicitly expose members to Objective-C."
severity: warning
public_typed_throws:
name: "Public typed throws"
# `func\s+(?!_objc_)\w+` skips Obj-C-only bridge shims, which by
# convention are named `_objc_*` and are hidden from Swift via
# `@available(swift, obsoleted: 1.0)`. Typed throws is a Swift-side
# concern, so those shims are exempt.
regex: "^\\s*(@\\w+(\\([^)]*\\))?\\s+)*(?:(?:static|class|final|override|convenience|nonisolated)\\s+)*(open|public)\\s+(?:(?:static|class|final|override|convenience|nonisolated)\\s+)*func\\s+(?!_objc_)\\w+(?:<[^>]*>)?\\s*\\((?:[^()]|\\([^()]*\\))*\\)\\s*(async\\s+)?\\bthrows\\b(?!\\s*\\()"
message: "Public throwing methods should declare a typed throws clause (e.g. `throws(LiveKitError)`). Suppress with // swiftlint:disable:next public_typed_throws if propagation is unbounded; include a one-line comment explaining why."
severity: error
included:
- "Sources/LiveKit/.*\\.swift"
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,16 @@ private static let playAndRecordOptions: AVAudioSession.CategoryOptions = [.mixW
- For non-recoverable errors, propagate with `throws` using `LiveKitError` with proper type/code
- Anticipate invalid states at compile time using algebraic data types, typestates, etc.
- Unsafe APIs like subscript `[0]` should be wrapped and leverage optional `?`
- Public throwing methods declare typed throws: `throws(LiveKitError)`. Enforced by the `public_typed_throws` SwiftLint rule
- At I/O edges (Foundation/AVAudio/WebRTC/protobuf), wrap with `LiveKitError(from: error)` — passes through existing `LiveKitError`, classifies `CancellationError`/`URLError`/`StreamError`, wraps everything else as `.unknown` with `internalError` set
- Inside `throws(LiveKitError)` contexts, use `try checkCancellation()` (typed helper in `Errors.swift`) instead of `try Task.checkCancellation()`

#### Typed-throws / Obj-C tradeoffs

- `@objc` forbids typed throws. Pair `@nonobjc public func foo() throws(LiveKitError)` with an `@objc(originalSelector)` shim named `_objc_foo`, hidden from Swift via `@available(swift, obsoleted: 1.0)` (the rule's regex exempts `_objc_*`)
- `@objc` protocol conformers (e.g. `LocalTrackProtocol`) stay untyped — suppress with a one-line reason
- `Task<_, Failure: Error>` and `withCheckedThrowingContinuation` have no typed-failure initializers; convert at the `await` site with `LiveKitError(from:)`
- Stored typed-throws closures need macOS 15+; until the floor moves, store untyped and convert at the call site

### Coding Style

Expand Down
20 changes: 10 additions & 10 deletions Sources/LiveKit/Audio/AudioSessionEngineObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck
/// of the WebRTC engine lifecycle.
///
/// - Throws: ``LiveKitError`` if the audio session fails to configure or activate.
public func acquire(requirement: SessionRequirement) throws -> SessionRequirementHandle {
public func acquire(requirement: SessionRequirement) throws(LiveKitError) -> SessionRequirementHandle {
let id = UUID()
try set(requirement: requirement, for: id)
return SessionRequirementHandle(releaseImpl: { [weak self] in
Expand All @@ -111,7 +111,7 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck
})
}

private func set(requirement: SessionRequirement, for id: UUID) throws {
private func set(requirement: SessionRequirement, for id: UUID) throws(LiveKitError) {
try updateRequirements {
if requirement == .none {
$0.removeValue(forKey: id)
Expand All @@ -121,21 +121,21 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck
}
}

fileprivate func removeRequirement(for id: UUID) throws {
fileprivate func removeRequirement(for id: UUID) throws(LiveKitError) {
try updateRequirements {
$0.removeValue(forKey: id)
}
}

private func updateRequirements(_ block: (inout [UUID: SessionRequirement]) -> Void) throws {
try _state.mutate {
let oldState = $0
block(&$0.sessionRequirements)
guard $0.sessionRequirements != oldState.sessionRequirements else { return }
private func updateRequirements(_ block: (inout [UUID: SessionRequirement]) -> Void) throws(LiveKitError) {
try _state.mutate { state throws(LiveKitError) in
let oldState = state
block(&state.sessionRequirements)
guard state.sessionRequirements != oldState.sessionRequirements else { return }
do {
try configureIfNeeded(oldState: oldState, newState: $0)
try configureIfNeeded(oldState: oldState, newState: state)
} catch {
$0 = oldState
state = oldState
throw LiveKitError(.audioSession, message: "Failed to configure audio session")
}
}
Expand Down
30 changes: 19 additions & 11 deletions Sources/LiveKit/Audio/Manager/AudioManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ public struct SessionRequirement: OptionSet, Sendable {
/// If not released explicitly, the requirement is released automatically on deinit.
public final class SessionRequirementHandle: @unchecked Sendable {
private struct State {
// Stored as untyped throws because typed-throws function types in
// stored properties need macOS 15+ runtime support; the wrapping
// public API still exposes throws(LiveKitError).
var releaseImpl: (@Sendable () throws -> Void)?
}

Expand All @@ -83,17 +86,22 @@ public final class SessionRequirementHandle: @unchecked Sendable {
/// Releases the associated audio session requirement.
///
/// Releasing the same handle multiple times is a no-op.
public func release() throws {
public func release() throws(LiveKitError) {
try releaseIfNeeded()
}

private func releaseIfNeeded() throws {
private func releaseIfNeeded() throws(LiveKitError) {
let releaseImpl = _state.mutate { state -> (@Sendable () throws -> Void)? in
let releaseImpl = state.releaseImpl
state.releaseImpl = nil
return releaseImpl
}
try releaseImpl?()
do {
try releaseImpl?()
} catch {
// Constructed via acquire() whose closure throws only LiveKitError.
throw LiveKitError(from: error)
}
}
}

Expand Down Expand Up @@ -326,7 +334,7 @@ public class AudioManager: Loggable {
/// Defaults to `true`.
public var isVoiceProcessingEnabled: Bool { RTC.audioDeviceModule.isVoiceProcessingEnabled }

public func setVoiceProcessingEnabled(_ enabled: Bool) throws {
public func setVoiceProcessingEnabled(_ enabled: Bool) throws(LiveKitError) {
let result = RTC.audioDeviceModule.setVoiceProcessingEnabled(enabled)
try checkAdmResult(code: result)
}
Expand Down Expand Up @@ -363,7 +371,7 @@ public class AudioManager: Loggable {
/// In this mode, you can provide audio buffers by calling `AudioManager.shared.mixer.capture(appAudio:)` continuously.
/// Remote audio will not play out automatically. Get remote mixed audio buffers with `AudioManager.shared.add(localAudioRenderer:)` or individual tracks with ``RemoteAudioTrack/add(audioRenderer:)``.
/// - Note: While enabled, the SDK will not configure `AVAudioSession`. Configure it yourself if your app does its own audio I/O.
public func setManualRenderingMode(_ enabled: Bool) throws {
public func setManualRenderingMode(_ enabled: Bool) throws(LiveKitError) {
let result = RTC.audioDeviceModule.setManualRenderingMode(enabled)
try checkAdmResult(code: result)
}
Expand All @@ -387,14 +395,14 @@ public class AudioManager: Loggable {
/// - Note: Microphone permission is required. iOS may prompt if not already granted.
/// - Note: This persists across ``Room`` lifecycles and connections until disabled.
/// - Throws: An error if the underlying audio device module fails to apply the setting.
public func setRecordingAlwaysPreparedMode(_ enabled: Bool) async throws {
public func setRecordingAlwaysPreparedMode(_ enabled: Bool) async throws(LiveKitError) {
let result = RTC.audioDeviceModule.setRecordingAlwaysPreparedMode(enabled)
try checkAdmResult(code: result)
}

/// Starts mic input to the SDK even without any ``Room`` or a connection.
/// Audio buffers will flow into ``LocalAudioTrack/add(audioRenderer:)`` and ``capturePostProcessingDelegate``.
public func startLocalRecording() throws {
public func startLocalRecording() throws(LiveKitError) {
// Always unmute APM if muted by last session.
RTC.audioProcessingModule.isMuted = false // TODO: Possibly not required anymore with new libs
// Start recording on the ADM.
Expand All @@ -403,7 +411,7 @@ public class AudioManager: Loggable {
}

/// Stops mic input after it was started with ``startLocalRecording()``
public func stopLocalRecording() throws {
public func stopLocalRecording() throws(LiveKitError) {
let result = RTC.audioDeviceModule.stopRecording()
try checkAdmResult(code: result)
}
Expand All @@ -423,7 +431,7 @@ public class AudioManager: Loggable {
/// This is useful when you need to set up connections without touching the audio
/// device yet (e.g., CallKit flows), or to guarantee the engine remains off
/// regardless of subscription/publication requests.
public func setEngineAvailability(_ availability: AudioEngineAvailability) throws {
public func setEngineAvailability(_ availability: AudioEngineAvailability) throws(LiveKitError) {
let result = RTC.audioDeviceModule.setEngineAvailability(availability.toRTCType())
try checkAdmResult(code: result)
}
Expand All @@ -450,7 +458,7 @@ public class AudioManager: Loggable {
/// Acquires an audio session requirement for external ownership.
///
/// On platforms without `AVAudioSession`, this returns a no-op handle.
public func acquireSessionRequirement(_ requirement: SessionRequirement) throws -> SessionRequirementHandle {
public func acquireSessionRequirement(_ requirement: SessionRequirement) throws(LiveKitError) -> SessionRequirementHandle {
#if os(iOS) || os(visionOS) || os(tvOS)
try audioSession.acquire(requirement: requirement)
#else
Expand Down Expand Up @@ -546,7 +554,7 @@ let kAudioEngineErrorAudioSessionCategoryRecordingRequired = -4102
let kAudioEngineErrorInsufficientDevicePermission = -4101

extension AudioManager {
func checkAdmResult(code: Int) throws {
func checkAdmResult(code: Int) throws(LiveKitError) {
if code == kAudioEngineErrorFailedToConfigureAudioSession {
throw LiveKitError(.audioSession, message: "Failed to configure audio session")
} else if code == kAudioEngineErrorInsufficientDevicePermission {
Expand Down
49 changes: 28 additions & 21 deletions Sources/LiveKit/Audio/PlayerNodePool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,33 +63,40 @@ class AVAudioPlayerNodePool: @unchecked Sendable, Loggable {
}

@discardableResult
func play(_ buffer: AVAudioPCMBuffer, loop: Bool = false) throws -> SoundPlayback {
let acquired = try executionQueue.sync { () throws -> AcquiredNode in
guard let index = items.firstIndex(where: { $0.state == .idle }) else {
throw LiveKitError(.audioEngine, message: "No available player nodes")
}

items[index].state = .inUse
items[index].generation &+= 1
func play(_ buffer: AVAudioPCMBuffer, loop: Bool = false) throws(LiveKitError) -> SoundPlayback {
let acquired: AcquiredNode
do {
acquired = try executionQueue.sync { () throws -> AcquiredNode in
guard let index = items.firstIndex(where: { $0.state == .idle }) else {
throw LiveKitError(.audioEngine, message: "No available player nodes")
}

let node = items[index].node
let generation = items[index].generation
node.volume = 1.0
node.pan = 0.0
items[index].state = .inUse
items[index].generation &+= 1

if loop {
node.scheduleBuffer(buffer, at: nil, options: .loops)
} else {
node.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
self?.executionQueue.async { [weak self] in
self?.releaseCompletedSlot(index: index, generation: generation)
let node = items[index].node
let generation = items[index].generation
node.volume = 1.0
node.pan = 0.0

if loop {
node.scheduleBuffer(buffer, at: nil, options: .loops)
} else {
node.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
self?.executionQueue.async { [weak self] in
self?.releaseCompletedSlot(index: index, generation: generation)
}
}
}
}

node.play()
node.play()

return AcquiredNode(index: index, node: node, generation: generation)
return AcquiredNode(index: index, node: node, generation: generation)
}
} catch let error as LiveKitError {
throw error
} catch {
throw LiveKitError(.audioEngine, internalError: error)
}

return NodePlayback(node: acquired.node) { [weak self] in
Expand Down
4 changes: 2 additions & 2 deletions Sources/LiveKit/Audio/SoundPlayer+Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public struct SoundHandle: Hashable, Sendable {
let id: UUID

/// Plays this prepared sound with the provided options.
public func play(options: SoundPlaybackOptions = SoundPlaybackOptions()) async throws {
public func play(options: SoundPlaybackOptions = SoundPlaybackOptions()) async throws(LiveKitError) {
try await SoundPlayer.shared.play(self, options: options)
}

Expand Down Expand Up @@ -149,7 +149,7 @@ class PreparedSound {
cleanUp()
}

func localBuffer(for playerNodeFormat: AVAudioFormat) throws -> AVAudioPCMBuffer {
func localBuffer(for playerNodeFormat: AVAudioFormat) throws(LiveKitError) -> AVAudioPCMBuffer {
if let cachedLocalBuffer, let cachedLocalBufferFormat, cachedLocalBufferFormat == playerNodeFormat {
return cachedLocalBuffer
}
Expand Down
29 changes: 20 additions & 9 deletions Sources/LiveKit/Audio/SoundPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public final class SoundPlayer: Loggable {
/// - Note: Repeated playback of the same short clip should generally reuse a prepared sound
/// instead of decoding from disk each time.
@discardableResult
public func prepare(fileURL: URL, named name: String? = nil) async throws -> SoundHandle {
public func prepare(fileURL: URL, named name: String? = nil) async throws(LiveKitError) -> SoundHandle {
let readBuffer = try await Self.decodeBuffer(from: fileURL)
let sessionRequirementHandle = try AudioManager.shared.acquireSessionRequirement(.playbackOnly)
let soundId = UUID()
Expand Down Expand Up @@ -135,7 +135,7 @@ extension SoundPlayer {
return format
}

func makePlayerNodeFormat(for outputFormat: AVAudioFormat) throws -> AVAudioFormat {
func makePlayerNodeFormat(for outputFormat: AVAudioFormat) throws(LiveKitError) -> AVAudioFormat {
guard let format = AVAudioFormat(commonFormat: .pcmFormatFloat32,
sampleRate: outputFormat.sampleRate,
channels: outputFormat.channelCount,
Expand Down Expand Up @@ -168,21 +168,25 @@ extension SoundPlayer {
invalidateLocalState()
}

func reconnectEngine(outputFormat: AVAudioFormat, playerNodeFormat: AVAudioFormat) throws {
func reconnectEngine(outputFormat: AVAudioFormat, playerNodeFormat: AVAudioFormat) throws(LiveKitError) {
playerNodePool.stop()
engine.stop()
engine.disconnect(playerNodePool)
playerNodePool.setMaximumFramesToRender(engine.outputNode.auAudioUnit.maximumFramesToRender)
engine.connect(playerNodePool, to: engine.mainMixerNode,
format: outputFormat, playerNodeFormat: playerNodeFormat)
try engine.start()
do {
try engine.start()
} catch {
throw LiveKitError(.audioEngine, internalError: error)
}
localEngineState.connectedOutputFormat = outputFormat
localEngineState.playerNodeFormat = playerNodeFormat
localEngineState.needsReconnect = false
}

@discardableResult
func startEngineIfNeeded() throws -> AVAudioFormat {
func startEngineIfNeeded() throws(LiveKitError) -> AVAudioFormat {
guard let outputFormat else {
throw LiveKitError(.soundPlayer, message: "Invalid output format")
}
Expand Down Expand Up @@ -245,7 +249,7 @@ extension SoundPlayer {
await soundState.stop(destination: destination)
}

func play(_ sound: SoundHandle, options: SoundPlaybackOptions = SoundPlaybackOptions()) async throws {
func play(_ sound: SoundHandle, options: SoundPlaybackOptions = SoundPlaybackOptions()) async throws(LiveKitError) {
guard let soundState = sounds[sound.id] else {
throw LiveKitError(.soundPlayer, message: "Sound not prepared")
}
Expand All @@ -272,12 +276,12 @@ extension SoundPlayer {
}
}

static func decodeBuffer(from fileURL: URL) async throws -> AVAudioPCMBuffer {
static func decodeBuffer(from fileURL: URL) async throws(LiveKitError) -> AVAudioPCMBuffer {
guard fileURL.isFileURL else {
throw LiveKitError(.invalidParameter, message: "Only file URLs are supported")
}

return try await Task.detached(priority: .userInitiated) {
let task = Task.detached(priority: .userInitiated) { () throws -> AVAudioPCMBuffer in
let audioFile = try AVAudioFile(forReading: fileURL)
guard let readBuffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat,
frameCapacity: AVAudioFrameCount(audioFile.length))
Expand All @@ -286,6 +290,13 @@ extension SoundPlayer {
}
try audioFile.read(into: readBuffer, frameCount: AVAudioFrameCount(audioFile.length))
return readBuffer
}.value
}
do {
return try await task.value
} catch let error as LiveKitError {
throw error
} catch {
throw LiveKitError(.soundPlayer, internalError: error)
}
}
}
2 changes: 2 additions & 0 deletions Sources/LiveKit/Broadcast/IPC/BroadcastAudioCodec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ struct BroadcastAudioCodec {
}

extension AudioStreamBasicDescription: Codable {
// Encodable.encode requires untyped throws.
// swiftlint:disable:next public_typed_throws
public func encode(to encoder: any Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(mSampleRate)
Expand Down
Loading