Fix data races on Room's lazy member initialization - #1088
Conversation
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.
|
@tarsyang I agree with the diagnosis, it's a part of DI class of problems with By all I mean: metricsManager
localParticipant
subscriberDataChannel
publisherDataChannel
outgoingStreamManager
preConnectBuffer |
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.
|
@pblazej Done in 3a7471c, went with eager init for all of them. Also swept the rest of the SDK for the same shape and put the inventory in the description. Short version: Verified with RoomTests (incl. the |
pblazej
left a comment
There was a problem hiding this comment.
Works as a minimal fix 👍
Problem
Room.outgoingStreamManageris an unsynchronizedlazy var(Sources/LiveKit/Core/Room.swift). Swift does not guarantee once-only initialization for lazy stored properties under concurrent first access.The property is reachable from isolation domains that do not serialize with each other:
RpcClientManager(actor), viaLocalParticipant.streamText, when sending RPC requests over data streams,RpcServerManager(actor), viaLocalParticipant.streamText, when writing RPC responses to inbound RPCs,LocalParticipant.sendText/sendFile/streamText/streamBytes.LocalParticipantis a plain class, so nothing orders these first touches. Thread Sanitizer reports the race in a live session where two concurrent stream sends hit the getter from two GCD worker threads (8-byte read/write race on the same slot, allocation site inRoom.init; full TSan report available on request).The same holds for
Room's other lazy members (localParticipant,subscriberDataChannel,publisherDataChannel,preConnectBuffer), each reachable from multiple isolation domains.Impact
Depending on interleaving, the losing
OutgoingStreamManagerinstance either leaks while both instances keep working (benign), or is deallocated after a caller already captured it.Destination.managerholds the manager weakly, so a stream that already published its header can fail its first write withStreamError.terminated, leaving a half-open stream on the remote side.Fix
Eagerly initialize every lazy member of
Roomininit, whileselfis still exclusively owned by the initializer, so no lazy initializer can ever run concurrently. After init, every access is a plain read of already-initialized storage.metricsManagerdoesn't captureself, so it becomes a plainlet.localParticipant,subscriberDataChannel,publisherDataChannel,outgoingStreamManager, andpreConnectBuffercaptureselfin their initializers, which a stored property can't do; they staylazysyntactically and are forced with_ =at the top ofinit, beforeselfescapes (signal client delegate, app-state listener, metrics registration task).Notes
RoomDI work):DeviceManager.discoverySession(iOS/tvOS: two utility-queue blocks dispatched frominitboth first-touch it concurrently),AudioManager.capturePostProcessingDelegateAdapter/renderPreProcessingDelegateAdapter(public delegate setters and renderer add/remove APIs callable from arbitrary threads),CameraCapturer.capturer/adapter(publiccaptureSessionand multitasking accessors vs. the capture start path), and a narrow window onE2EEManager.delegateAdapter(setup(room:)iterating publications on the connect task vs. room-delegate-queue callbacks;setupadds the delegate before iterating). The lazy members ofSignalClient,Transport, andTranscriptionStreamReceiverare actor-isolated and fine.