From e69fe1f4f820c995ad1233f556f53f14c0047c1f Mon Sep 17 00:00:00 2001 From: tarsyang Date: Sun, 16 Aug 2026 07:18:18 +0800 Subject: [PATCH 1/2] Fix data race on Room.outgoingStreamManager lazy initialization Swift lazy stored properties are not initialized atomically. This property is reached concurrently from isolation domains that do not serialize with each other: RpcClientManager and RpcServerManager (actors) via LocalParticipant.streamText, and application calls into LocalParticipant data stream methods. Thread Sanitizer reports the race under a concurrent RPC and stream send. Guard initialization with the StateSync pattern Room already uses for _e2eeManager and _regionManager. The guarded block only allocates the actor and captures weak-self closures; encryptionProvider reads e2eeManager at send time, outside the lock. --- .changes/fix-outgoing-stream-manager-race | 1 + Sources/LiveKit/Core/Room.swift | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 .changes/fix-outgoing-stream-manager-race diff --git a/.changes/fix-outgoing-stream-manager-race b/.changes/fix-outgoing-stream-manager-race new file mode 100644 index 000000000..90a5ee061 --- /dev/null +++ b/.changes/fix-outgoing-stream-manager-race @@ -0,0 +1 @@ +patch type="fixed" "Fix a data race on Room.outgoingStreamManager: unsynchronized lazy initialization could run twice under concurrent first access, leaking or deallocating one instance mid-use" diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 6585c38ee..69e89d363 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -123,10 +123,24 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { lazy var publisherDataChannel = DataChannelPair(delegate: self) let incomingStreamManager = IncomingStreamManager() - lazy var outgoingStreamManager = OutgoingStreamManager { [weak self] packet in - try await self?.send(dataPacket: packet) - } encryptionProvider: { [weak self] in - self?.e2eeManager?.dataChannelEncryptionType ?? .none + private let _outgoingStreamManager = StateSync(nil) + + // Lazy properties are not initialized atomically, and this manager is + // reachable from isolation domains that do not serialize with each + // other (RPC client/server managers and app calls into + // LocalParticipant data stream methods), so initialization is guarded + // by the same StateSync pattern used for _e2eeManager. + var outgoingStreamManager: OutgoingStreamManager { + _outgoingStreamManager.mutate { state in + if let existing = state { return existing } + let manager = OutgoingStreamManager { [weak self] packet in + try await self?.send(dataPacket: packet) + } encryptionProvider: { [weak self] in + self?.e2eeManager?.dataChannelEncryptionType ?? .none + } + state = manager + return manager + } } // MARK: - PreConnect From 3a7471cd6af14c9fabd9aa4bfc296619bbfa5745 Mon Sep 17 00:00:00 2001 From: tarsyang Date: Mon, 17 Aug 2026 15:53:05 +0800 Subject: [PATCH 2/2] Initialize all lazy Room members eagerly in init Per review: instead of guarding one member with StateSync, force-initialize every lazy member inside init, while self is still exclusively owned, so no lazy initializer can ever run concurrently. metricsManager does not capture self and becomes a plain let; the remaining five (localParticipant, both data channels, outgoingStreamManager, preConnectBuffer) capture self in their initializers, so they stay lazy syntactically and are forced with _ = before self escapes to the signal client delegate, app-state listener, and metrics registration task. --- .changes/fix-outgoing-stream-manager-race | 1 - .changes/fix-room-lazy-init-race | 1 + Sources/LiveKit/Core/Room.swift | 37 +++++++++++------------ 3 files changed, 19 insertions(+), 20 deletions(-) delete mode 100644 .changes/fix-outgoing-stream-manager-race create mode 100644 .changes/fix-room-lazy-init-race diff --git a/.changes/fix-outgoing-stream-manager-race b/.changes/fix-outgoing-stream-manager-race deleted file mode 100644 index 90a5ee061..000000000 --- a/.changes/fix-outgoing-stream-manager-race +++ /dev/null @@ -1 +0,0 @@ -patch type="fixed" "Fix a data race on Room.outgoingStreamManager: unsynchronized lazy initialization could run twice under concurrent first access, leaking or deallocating one instance mid-use" diff --git a/.changes/fix-room-lazy-init-race b/.changes/fix-room-lazy-init-race new file mode 100644 index 000000000..5cd18585d --- /dev/null +++ b/.changes/fix-room-lazy-init-race @@ -0,0 +1 @@ +patch type="fixed" "Fix data races on Room's lazily initialized members (localParticipant, data channels, outgoingStreamManager, preConnectBuffer, metricsManager): concurrent first access could run a lazy initializer twice; they are now initialized eagerly in Room's initializer" diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 69e89d363..f29317581 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -32,7 +32,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { // MARK: - Metrics - lazy var metricsManager = MetricsManager() + let metricsManager = MetricsManager() // MARK: - Public @@ -123,24 +123,10 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { lazy var publisherDataChannel = DataChannelPair(delegate: self) let incomingStreamManager = IncomingStreamManager() - private let _outgoingStreamManager = StateSync(nil) - - // Lazy properties are not initialized atomically, and this manager is - // reachable from isolation domains that do not serialize with each - // other (RPC client/server managers and app calls into - // LocalParticipant data stream methods), so initialization is guarded - // by the same StateSync pattern used for _e2eeManager. - var outgoingStreamManager: OutgoingStreamManager { - _outgoingStreamManager.mutate { state in - if let existing = state { return existing } - let manager = OutgoingStreamManager { [weak self] packet in - try await self?.send(dataPacket: packet) - } encryptionProvider: { [weak self] in - self?.e2eeManager?.dataChannelEncryptionType ?? .none - } - state = manager - return manager - } + lazy var outgoingStreamManager = OutgoingStreamManager { [weak self] packet in + try await self?.send(dataPacket: packet) + } encryptionProvider: { [weak self] in + self?.e2eeManager?.dataChannelEncryptionType ?? .none } // MARK: - PreConnect @@ -284,6 +270,19 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { // log sdk & os versions log("sdk: \(LiveKitSDK.version), ffi: \(LiveKitSDK.ffiVersion), os: \(String(describing: Utils.os()))(\(Utils.osVersionString())), modelId: \(String(describing: Utils.modelIdentifier() ?? "unknown"))") + // Force initialization of lazy members while `self` is still exclusively + // owned by this initializer. Lazy initialization is not atomic, and these + // members are reachable from isolation domains that do not serialize with + // each other, so a first touch after init could otherwise run an + // initializer twice. After this block every access is a plain read of + // already-initialized storage. The members stay `lazy` only because their + // initializers capture `self`, which a stored property cannot do. + _ = localParticipant + _ = subscriberDataChannel + _ = publisherDataChannel + _ = outgoingStreamManager + _ = preConnectBuffer + signalClient._delegate.set(delegate: self) log()