[Bug] PS2 deadlocks when a Blueprint sends a data-channel response while input is streaming (5.8)

# [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::thread::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.