Skip to content

Fix data races on Room's lazy member initialization - #1088

Open
tarsyang wants to merge 2 commits into
livekit:mainfrom
tarsyang:fix/outgoing-stream-manager-init-race
Open

Fix data races on Room's lazy member initialization#1088
tarsyang wants to merge 2 commits into
livekit:mainfrom
tarsyang:fix/outgoing-stream-manager-init-race

Conversation

@tarsyang

@tarsyang tarsyang commented Aug 15, 2026

Copy link
Copy Markdown

Problem

Room.outgoingStreamManager is an unsynchronized lazy 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), via LocalParticipant.streamText, when sending RPC requests over data streams,
  • RpcServerManager (actor), via LocalParticipant.streamText, when writing RPC responses to inbound RPCs,
  • application code calling LocalParticipant.sendText / sendFile / streamText / streamBytes.

LocalParticipant is 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 in Room.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 OutgoingStreamManager instance either leaks while both instances keep working (benign), or is deallocated after a caller already captured it. Destination.manager holds the manager weakly, so a stream that already published its header can fail its first write with StreamError.terminated, leaving a half-open stream on the remote side.

Fix

Eagerly initialize every lazy member of Room in init, while self is 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.

  • metricsManager doesn't capture self, so it becomes a plain let.
  • localParticipant, subscriberDataChannel, publisherDataChannel, outgoingStreamManager, and preConnectBuffer capture self in their initializers, which a stored property can't do; they stay lazy syntactically and are forced with _ = at the top of init, before self escapes (signal client delegate, app-state listener, metrics registration task).

Notes

  • Data streams v2 #1075 replaces the stream managers entirely; this is a minimal fix for the current main / release line until that lands.
  • Same-shape sites elsewhere, not changed here to keep this minimal (may be relevant to the broader Room DI work): DeviceManager.discoverySession (iOS/tvOS: two utility-queue blocks dispatched from init both first-touch it concurrently), AudioManager.capturePostProcessingDelegateAdapter / renderPreProcessingDelegateAdapter (public delegate setters and renderer add/remove APIs callable from arbitrary threads), CameraCapturer.capturer / adapter (public captureSession and multitasking accessors vs. the capture start path), and a narrow window on E2EEManager.delegateAdapter (setup(room:) iterating publications on the connect task vs. room-delegate-queue callbacks; setup adds the delegate before iterating). The lazy members of SignalClient, Transport, and TranscriptionStreamReceiver are actor-isolated and fine.

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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@pblazej

pblazej commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

@tarsyang I agree with the diagnosis, it's a part of DI class of problems with Room. While I'm working on a more complete solution, can we just completely remove lazy from the equation for all the members (or force initialize them 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.
@tarsyang tarsyang changed the title Fix data race on Room.outgoingStreamManager lazy initialization Fix data races on Room's lazy member initialization Aug 17, 2026
@tarsyang

tarsyang commented Aug 17, 2026

Copy link
Copy Markdown
Author

@pblazej Done in 3a7471c, went with eager init for all of them. metricsManager doesn't capture self, so it's a plain let now. The other five capture self in their initializers, so a stored let isn't possible without restructuring; they stay lazy syntactically and get forced with _ = at the top of init, before self escapes to the signal client delegate / app-state listener / metrics task. After init every access is a plain read, so the non-atomic lazy path never runs under concurrency. The StateSync guard from the first commit is gone.

Also swept the rest of the SDK for the same shape and put the inventory in the description. Short version: DeviceManager.discoverySession, the two AudioManager processing-delegate adapters, CameraCapturer.capturer/adapter, and a narrow window on E2EEManager.delegateAdapter are still unsynchronized first-touches, while the SignalClient / Transport / TranscriptionStreamReceiver ones are actor-isolated and fine. Left those out to keep this minimal; happy to follow up on any of them.

Verified with RoomTests (incl. the resourcesCleanUp leak checks) against a local server, plus SwiftLint/SwiftFormat.

@pblazej pblazej left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works as a minimal fix 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants