From c4cb11afb75d9412723e62a0fe26b3b92c3083e8 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:43:48 +0200 Subject: [PATCH] Add repro test: no ParticipantConnected for participants already in room at connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote participant that is already in the room when the local participant connects (e.g. an agent dispatched concurrently with the user's connect) is delivered in the connect snapshot instead of as a ParticipantConnected delta. No layer (Rust SDK, FFI, Unity) ever raises ParticipantConnected for snapshot participants, so apps driving their "participant joined" logic purely from that event never learn the participant exists — although it is present in Room.RemoteParticipants. The test connects an "agent" first, wires ParticipantConnected before the subscriber's Connect(), and uses a third late-joining participant as an in-order control: once the control's event has fired, any event for the agent would already have been dispatched, so the failing case is fast and deterministic. Currently fails (by design) with: ParticipantConnected never fired for 'snapshot-agent' ... It IS present in RemoteParticipants, so only the event is missing. Received: [snapshot-late-joiner] Co-Authored-By: Claude Fable 5 --- .../SnapshotParticipantEventsTests.cs | 107 ++++++++++++++++++ .../SnapshotParticipantEventsTests.cs.meta | 11 ++ 2 files changed, 118 insertions(+) create mode 100644 Tests/PlayMode/SnapshotParticipantEventsTests.cs create mode 100644 Tests/PlayMode/SnapshotParticipantEventsTests.cs.meta diff --git a/Tests/PlayMode/SnapshotParticipantEventsTests.cs b/Tests/PlayMode/SnapshotParticipantEventsTests.cs new file mode 100644 index 00000000..504b4a25 --- /dev/null +++ b/Tests/PlayMode/SnapshotParticipantEventsTests.cs @@ -0,0 +1,107 @@ +using System.Collections; +using System.Collections.Generic; +using LiveKit.PlayModeTests.Utils; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace LiveKit.PlayModeTests +{ + /// + /// Reproduces the "agent dispatched at connect time" report: a remote + /// participant that is already in the room when the local participant + /// connects arrives in the connect snapshot instead of as a + /// ParticipantConnected delta — and the SDK never raises + /// ParticipantConnected for it, even for handlers wired before Connect(). + /// Apps that drive their "remote participant joined → subscribe/render" + /// logic purely from that event never learn the participant exists, + /// although it is present in Room.RemoteParticipants. + /// + /// Whether a concurrently connecting participant (typically an agent) + /// lands in the snapshot or in the event stream is a server-side race, so + /// in the field this manifests intermittently. The test makes the losing + /// side deterministic by fully connecting the "agent" first. + /// + public class SnapshotParticipantEventsTests + { + const float ControlEventTimeoutSeconds = 15f; + + static (TestRoomContext.ConnectionOptions agent, + TestRoomContext.ConnectionOptions subscriber, + TestRoomContext.ConnectionOptions lateJoiner) ThreePeers() + { + var agent = TestRoomContext.ConnectionOptions.Default; + agent.Identity = "snapshot-agent"; + var subscriber = TestRoomContext.ConnectionOptions.Default; + subscriber.Identity = "snapshot-subscriber"; + var lateJoiner = TestRoomContext.ConnectionOptions.Default; + lateJoiner.Identity = "snapshot-late-joiner"; + return (agent, subscriber, lateJoiner); + } + + [UnityTest, Category("E2E")] + public IEnumerator Connect_RaisesParticipantConnected_ForParticipantAlreadyInRoom() + { + var (agentOptions, subscriberOptions, lateJoinerOptions) = ThreePeers(); + using var context = new TestRoomContext(new[] { agentOptions, subscriberOptions, lateJoinerOptions }); + + // 1. The "agent" is fully connected before the subscriber starts + // connecting, guaranteeing it is part of the subscriber's + // connect snapshot rather than a ParticipantConnected delta. + yield return context.ConnectRoom(0); + Assert.IsNull(context.ConnectionError, context.ConnectionError); + + var agentIdentity = context.Rooms[0].LocalParticipant.Identity; + var lateJoinerIdentity = lateJoinerOptions.Identity; + var subscriberRoom = context.Rooms[1]; + + // 2. Wire ParticipantConnected BEFORE Connect() — the earliest + // possible subscription an app can make. + var connectedIdentities = new List(); + subscriberRoom.ParticipantConnected += participant => + { + lock (connectedIdentities) connectedIdentities.Add(participant.Identity); + }; + + // 3. Subscriber joins; the agent is already in the room. + yield return context.ConnectRoom(1); + Assert.IsNull(context.ConnectionError, context.ConnectionError); + + // 4. Control: a participant joining AFTER the subscriber must fire + // ParticipantConnected via the regular delta path. Room events + // are delivered in order, so once the control event has fired, + // any event for the agent would already have been dispatched. + // This keeps the failing case fast and proves the handler + // wiring works. + yield return context.ConnectRoom(2); + Assert.IsNull(context.ConnectionError, context.ConnectionError); + + var controlEvent = new Expectation( + predicate: () => + { + lock (connectedIdentities) return connectedIdentities.Contains(lateJoinerIdentity); + }, + timeoutSeconds: ControlEventTimeoutSeconds); + yield return controlEvent.Wait(); + Assert.IsNull(controlEvent.Error, + $"Control failed: ParticipantConnected never fired for the late joiner " + + $"'{lateJoinerIdentity}' — event delivery is broken beyond the snapshot case. " + + $"Received: [{string.Join(", ", connectedIdentities)}]"); + + // 5. The snapshot data itself must have arrived: the agent is + // visible in RemoteParticipants. This isolates the defect to + // event emission, not data delivery. + Assert.IsTrue(subscriberRoom.RemoteParticipants.ContainsKey(agentIdentity), + $"Agent '{agentIdentity}' missing from RemoteParticipants — snapshot itself was lost"); + + // 6. The repro assertion: ParticipantConnected must also fire for + // the participant that was already in the room at connect time. + bool agentConnectedFired; + lock (connectedIdentities) agentConnectedFired = connectedIdentities.Contains(agentIdentity); + Assert.IsTrue(agentConnectedFired, + $"ParticipantConnected never fired for '{agentIdentity}', which was already in the " + + $"room when the subscriber connected (snapshot participant). It IS present in " + + $"RemoteParticipants, so only the event is missing. " + + $"Received: [{string.Join(", ", connectedIdentities)}]"); + } + } +} diff --git a/Tests/PlayMode/SnapshotParticipantEventsTests.cs.meta b/Tests/PlayMode/SnapshotParticipantEventsTests.cs.meta new file mode 100644 index 00000000..c305650a --- /dev/null +++ b/Tests/PlayMode/SnapshotParticipantEventsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb62102e6f034de1b527e7d150663c57 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: