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 6585c38ee..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 @@ -270,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()