# [Bug] Pixel Streaming 2 deadlocks when a Blueprint sends a data-channel response while input is streaming (UE 5.8)
## Summary
`FEpicRtcStreamer::SendAllPlayersMessage()` sends data-channel messages **while holding the participants
map lock**. Sending marshals onto the WebRTC signalling thread and can block. The signalling thread needs
the *same* lock when it handles an inbound data-track message (`OnDataTrackMessage` → `FindPlayerFromTrack`
→ `TThreadSafeMap::ApplyUntil`). The result is a classic AB-BA deadlock between the game thread and the
signalling thread: the whole game freezes with 0% CPU and 0% GPU, and never recovers.
This is reachable from the *documented* Blueprint workflow: `OnInputEvent` → `SendPixelStreaming2Response`.
Any project that echoes UI/mouse interactions back to the browser will eventually hit it.
## Environment
- Unreal Engine 5.8.3 (`++UE5+Release-5.8`, CL 58210709), installed (Launcher) build
- Windows 11 25H2, RTX 5070, driver 617.42
- Packaged Shipping and Development builds both affected
- Pixel Streaming 2 plugin, H.264 and AV1 both affected
## Symptoms
- Streaming runs fine for a while, then **freezes permanently** — the engine stops ticking, CPU and GPU
usage drop to 0%, but the process does not crash.
- Most easily reproduced by **dragging in the scene** (continuous mouse input) while the project replies
to input events.
- Only one stream is required; it is not related to stream count or encoder.
- The network/data channel appears “connected” from the browser side, but no frames are produced.
## Root cause
### 1. The send happens under the map lock
`Engine/Plugins/Media/PixelStreaming2/Source/PixelStreaming2RTC/Private/EpicRtcStreamer.cpp`
```cpp
void FEpicRtcStreamer::SendAllPlayersMessage(FString MessageType, const FString& Descriptor)
{
Participants->Apply(\[&MessageType, &Descriptor\](FString PlayerId, TSharedPtr<FPlayerContext> Participant) {
if (Participant->DataTrack && !IsSFU(PlayerId))
{
Participant->DataTrack->SendMessage(MessageType, Descriptor); // <-- send inside the lock
}
});
}
```
`TThreadSafeMap::Apply()` holds `Mutex` for the whole traversal
(`Plugins/Media/PixelStreaming2/Source/PixelStreaming2/Internal/ThreadSafeMap.h:224`):
```cpp
void Apply(T&& Visitor)
{
FScopeLock Lock(&Mutex);
for (typename Super::ElementSetType::TIterator It(Super::Pairs); It; ++It)
{
Visitor(It->Key, It->Value);
}
}
// Note: Do not call a map method inside a visitor or you will deadlock
```
`DataTrack->SendMessage()` ends up in `webrtc::SctpDataChannel::Send()`, which marshals the call onto the
signalling thread (`MethodCall<…>::Marshal`) and **waits** for it to run.
### 2. The signalling thread needs the same lock
```cpp
void FEpicRtcStreamer::OnDataTrackMessage(EpicRtcDataTrackInterface* DataTrack, const EpicRtcDataFrame& DataFrame)
{
...
else if (!FindPlayerFromTrack(DataTrack, PlayerId)) // -> Participants->ApplyUntil() -> FScopeLock Mutex
```
### 3. AB-BA
- **Game thread**: holds `Mutex` in `Apply()` → blocks in `SctpDataChannel::Send` waiting for the
signalling thread.
- **Signalling thread**: inside `OnDataTrackMessage` → `FindPlayerFromTrack` → blocks on `Mutex`.
Neither can make progress.
## Evidence (full-memory dump, shipping build, symbols from the PDB)
Game thread (`. 0 Id: … “CrBrowserMain”` — BLUI/CEF renamed the game thread):
```
ntdll!NtWaitForSingleObject
KERNELBASE!WaitForSingleObjectEx
Template58!rtc::Event::WinSystemEvent::Wait
Template58!webrtc::MethodCall<webrtc::DataChannelInterface,bool,webrtc::DataBuffer const &>::Marshal
Template58!webrtc::SctpDataChannel::Send
Template58!EpicRtc::DataTrackInternal::PushFrame
Template58!UE::PixelStreaming2::FEpicRtcDataTrack::SendMessage
Template58!UE::PixelStreaming2::FEpicRtcStreamer::SendAllPlayersMessage
…
Template58!UPixelStreaming2Input::execSendPixelStreaming2Response ← Blueprint node
Template58!UObject::ProcessEvent
Template58!AActor::Tick
```
Signalling thread (`“signalling_thread_pixel_streaming_conference_instance”`):
```
ntdll!RtlEnterCriticalSection ← waiting for the SAME mutex
ntdll!RtlSleepConditionVariableCS
Template58!UE::PixelStreaming2::TThreadSafeMap<FString,TSharedPtr>::ApplyUntil<
UE::PixelStreaming2::FEpicRtcStreamer::FindPlayerFromTrack<EpicRtcDataTrackInterface\*>>
Template58!UE::PixelStreaming2::FEpicRtcStreamer::FindPlayerFromTrack
Template58!UE::PixelStreaming2::FEpicRtcStreamer::OnDataTrackMessage
Template58!UE::PixelStreaming2::FEpicRtcDataTrackObserver::OnDataTrackMessage
Template58!EpicRtc::DataTrackInternal::OnMessage
Template58!webrtc::SctpDataChannel::OnDataReceived
Template58!rtc:
:Dispatch
```
## Reproduction outline
1. Packaged (or Development) build, Pixel Streaming 2 enabled, `-PixelStreamingURL=ws://:/`
`-RenderOffscreen`.
2. Blueprint: a `PixelStreaming2Input` component → bind `OnInputEvent` → call
`SendPixelStreaming2Response`.
3. Connect a browser and drag/interact continuously.
4. After a short while the engine freezes (0% CPU/GPU). A dump shows the two stacks above.
## Suggested fix
Take a snapshot of the data tracks under the lock, then send once the lock has been released — the same
pattern `SendPlayerMessage` already uses correctly:
```cpp
void FEpicRtcStreamer::SendAllPlayersMessage(FString MessageType, const FString& Descriptor)
{
TArray<TSharedPtr<FEpicRtcDataTrack>> DataTracks;
Participants->Apply(\[&DataTracks\](FString PlayerId, TSharedPtr<FPlayerContext> Participant) {
if (Participant->DataTrack && !IsSFU(PlayerId))
{
DataTracks.Add(Participant->DataTrack); // collect only
}
});
for (const TSharedPtr<FEpicRtcDataTrack>& DataTrack : DataTracks)
{
DataTrack->SendMessage(MessageType, Descriptor); // lock released
}
}
```
The same hazard exists in two more places and should be fixed identically:
- `FEpicRtcStreamer::SendFileData()` — already carries a TODO that describes exactly this problem:
*"because we lock when we visit the data channels it might be a bad idea. At some point it would be
good to take a snapshot of the keys in the map when we start, then one by one get the channel and send
the data"*.
- `FFreezeFrame::FreezeFrameCapture()` (`FreezeFrame.cpp`) — sends the JPEG to every player from inside
`Players->Apply`.
Contrast with `FEpicRtcStreamer::SendPlayerMessage()`, which is correct — it copies the `DataTrack`
shared pointer out of the container and sends **after** the lock is released. All engine-internal traffic
(latency reports, stats) uses that safe path, which is why this only shows up in app-driven sends.
## Note on 5.6 vs 5.8
We upgraded the same project from 5.6 to 5.8. The C++ path above is unchanged between the two versions
(`SendAllPlayersMessage`, `TThreadSafeMap::Apply`, `FindPlayerFromTrack`, `OnDataTrackMessage` are
identical), but the project did **not** freeze on 5.6. The difference appears to be inside the EpicRtc /
libwebrtc build shipped with 5.8 (the EpicRtc API was reshaped between versions:
`EpicRtcDataFrameInput` + `EpicRtcDataFrameInterface` + `PopFrame` → `EpicRtcDataFrame`). On 5.8 the game
thread demonstrably blocks inside `SctpDataChannel::Send`; on 5.6 it evidently returned without waiting on
the signalling thread, so the pre-existing lock misuse stayed latent.
## Workaround for other projects
Until this is fixed, defer the response by one frame so it is not sent from inside the input dispatch:
```
OnInputEvent → SetTimerForNextTick → SendPixelStreaming2Response
```
This does not remove the bug but drastically shrinks the window in which the signalling thread can be
waiting for the participants lock at the moment of the send.
## Verified
Patching `SendAllPlayersMessage` (and `SendFileData` / `FreezeFrameCapture`) as shown above and rebuilding,
the freeze no longer reproduces in our project under continuous scene dragging with a connected browser.