Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Runtime/Scripts/Core/Room.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ private void Cleanup()
FfiClient.Instance.RoomEventReceived -= OnEventReceived;
FfiClient.Instance.RpcMethodInvocationReceived -= OnRpcMethodInvocationReceived;
FfiClient.Instance.DisconnectReceived -= OnDisconnectReceived;
FfiClient.Instance.PanicReceived -= OnPanicReceived;

// Participant + track + publication FFI handles are independent entries in the
// Rust handle table — dropping the room handle alone does not cascade to them, so
Expand Down Expand Up @@ -598,6 +599,7 @@ internal void OnConnect(ConnectCallback info)

FfiClient.Instance.RoomEventReceived += OnEventReceived;
FfiClient.Instance.DisconnectReceived += OnDisconnectReceived;
FfiClient.Instance.PanicReceived += OnPanicReceived;
FfiClient.Instance.RpcMethodInvocationReceived += OnRpcMethodInvocationReceived;

// Signal Rust that listeners are installed and it can start forwarding room events.
Expand All @@ -619,6 +621,18 @@ private void OnDisconnectReceived(DisconnectCallback e)
Utils.Debug($"OnDisconnect.... {e}");
}

private void OnPanicReceived(Panic e)
{
// A panic means the FFI layer's background tasks may have died: this
// room could silently stop receiving events (including Disconnected
// itself), so the panic is surfaced through the disconnect path apps
// already handle.
DisconnectReason = DisconnectReason.UnknownReason;
Disconnected?.Invoke(this);
DisconnectedWithReason?.Invoke(this, DisconnectReason);
OnDisconnect();
}

private void OnDisconnect()
{
Cleanup();
Expand Down
10 changes: 10 additions & 0 deletions Runtime/Scripts/Internal/FFI/FFIClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ internal sealed class FfiClient : IFFIClient
private readonly ConcurrentDictionary<ulong, PendingCallbackBase> pendingCallbacks = new();

public event DisconnectReceivedDelegate? DisconnectReceived;
public event PanicReceivedDelegate? PanicReceived;
public event RoomEventReceivedDelegate? RoomEventReceived;
public event TrackEventReceivedDelegate? TrackEventReceived;
public event RpcMethodInvocationReceivedDelegate? RpcMethodInvocationReceived;
Expand Down Expand Up @@ -466,6 +467,15 @@ private static void DispatchEvent(FfiEvent ffiEvent)
Instance.DataTrackStreamEventReceived?.Invoke(ffiEvent.DataTrackStreamEvent!);
break;
case FfiEvent.MessageOneofCase.Panic:
// The FFI layer declares its state unrecoverable after a panic:
// background tasks may have died, so rooms can silently stop
// receiving events and in-flight requests may never complete.
// Pending callbacks are cancelled before PanicReceived so that
// requests issued by user handlers reacting to the panic (e.g. a
// reconnect attempt from a Disconnected handler) are not swept up.
Utils.Error($"FFI panic: {ffiEvent.Panic.Message} — native SDK state is unrecoverable; cancelling pending requests and disconnecting rooms");
Instance.ClearPendingCallbacks();
Instance.PanicReceived?.Invoke(ffiEvent.Panic);
break;
default:
break;
Expand Down
2 changes: 2 additions & 0 deletions Runtime/Scripts/Internal/FFI/FFIEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ namespace LiveKit.Internal.FFI

internal delegate void DisconnectReceivedDelegate(DisconnectCallback e);

internal delegate void PanicReceivedDelegate(Panic e);

internal delegate void GetSessionStatsDelegate(GetStatsCallback e);

internal delegate void SetLocalMetadataReceivedDelegate(SetLocalMetadataCallback e);
Expand Down
101 changes: 101 additions & 0 deletions Tests/EditMode/PanicEventTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading;
using LiveKit.Proto;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

using LiveKit.Internal.FFI;
namespace LiveKit.EditModeTests
{
public class PanicEventTests
{
private sealed class RecordingSyncContext : SynchronizationContext
{
public readonly List<(SendOrPostCallback callback, object state)> Posts = new();
public override void Post(SendOrPostCallback d, object state)
{
Posts.Add((d, state));
}
public void DrainOnce()
{
foreach (var (cb, st) in Posts) cb(st);
Posts.Clear();
}
}

// Seeded in a different high-bit range than SkipDispatchTests so the two
// fixtures cannot collide in FfiClient.Instance's shared pendingCallbacks map.
private static long _asyncIdSeed = 0x7FE0_0000_0000_0000L;
private static ulong NextAsyncId() => (ulong)Interlocked.Increment(ref _asyncIdSeed);

[Test]
public void RouteFfiEvent_Panic_RaisesPanicReceivedOnMainThread_AndCancelsPendingCallbacks()
{
var asyncId = NextAsyncId();
var completed = false;
var canceled = false;

// Stands in for any in-flight async request (e.g. a pending
// ConnectInstruction). After a panic its Rust-side task may be dead,
// so it must be cancelled rather than left to hang forever.
FfiClient.Instance.RegisterPendingCallback<UnpublishTrackCallback>(
asyncId,
static e => e.UnpublishTrack,
cb => { completed = true; },
onCancel: () => { canceled = true; });

string receivedMessage = null;
PanicReceivedDelegate handler = e => receivedMessage = e.Message;
FfiClient.Instance.PanicReceived += handler;

var recording = new RecordingSyncContext();
var originalContext = FfiClient.Instance._context;
FfiClient.Instance._context = recording;
try
{
LogAssert.Expect(LogType.Error, new Regex("FFI panic"));

var ev = new FfiEvent { Panic = new Panic { Message = "test panic from FFI" } };
var dispatcher = new Thread(() => FfiClient.RouteFfiEvent(ev));
dispatcher.Start();
Assert.IsTrue(dispatcher.Join(TimeSpan.FromSeconds(2)),
"Dispatcher thread did not finish within 2s.");

// Panic handling fires user-facing events (room teardown), so it must
// be marshalled to the main thread, not run on the FFI callback thread.
Assert.AreEqual(1, recording.Posts.Count,
"RouteFfiEvent should post the panic to the main-thread sync context.");
Assert.IsNull(receivedMessage,
"PanicReceived ran on the FFI callback thread instead of the main-thread drain.");
Assert.IsFalse(canceled,
"Pending callbacks were cancelled before the main-thread drain.");

recording.DrainOnce();

Assert.AreEqual("test panic from FFI", receivedMessage,
"PanicReceived did not fire with the panic message.");
Assert.IsTrue(canceled,
"Pending callbacks were not cancelled on panic — awaiting instructions would hang forever.");
Assert.IsFalse(completed,
"The pending callback completed instead of being cancelled.");

// The pending entry must be gone: a late callback for it is a no-op.
var lateCallback = new FfiEvent
{
UnpublishTrack = new UnpublishTrackCallback { AsyncId = asyncId }
};
Assert.IsFalse(FfiClient.Instance.TryDispatchPendingCallback(asyncId, lateCallback),
"The cancelled pending entry is still registered.");
}
finally
{
FfiClient.Instance._context = originalContext;
FfiClient.Instance.PanicReceived -= handler;
FfiClient.Instance.CancelPendingCallback(asyncId);
}
}
}
}
11 changes: 11 additions & 0 deletions Tests/EditMode/PanicEventTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 65 additions & 0 deletions Tests/PlayMode/PanicRoomTeardownTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Collections;
using System.Text.RegularExpressions;
using LiveKit.Internal.FFI;
using LiveKit.PlayModeTests.Utils;
using LiveKit.Proto;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

namespace LiveKit.PlayModeTests
{
/// <summary>
/// An FfiEvent.Panic means the FFI layer's state is unrecoverable — its
/// background tasks may have died, leaving rooms that silently stop
/// receiving events (they would not even get a Disconnected event). The
/// SDK must convert that into an observable disconnect on every live
/// room instead of dropping the event.
/// </summary>
public class PanicRoomTeardownTests
{
const float DisconnectTimeoutSeconds = 10f;

[UnityTest, Category("E2E")]
public IEnumerator Panic_DisconnectsConnectedRoom()
{
var options = TestRoomContext.ConnectionOptions.Default;
options.Identity = "panic-teardown";
using var context = new TestRoomContext(options);

yield return context.ConnectRoom(0);
Assert.IsNull(context.ConnectionError, context.ConnectionError);

var room = context.Rooms[0];
Room disconnectedRoom = null;
DisconnectReason? reason = null;
room.Disconnected += r => disconnectedRoom = r;
room.DisconnectedWithReason += (r, dr) => reason = dr;

LogAssert.Expect(LogType.Error, new Regex("FFI panic"));

// Inject a synthetic panic through the same entry point the native
// callback uses; it is posted to the main thread like real events.
FfiClient.RouteFfiEvent(new FfiEvent
{
Panic = new Panic { Message = "synthetic panic (test)" }
});

var disconnected = new Expectation(
predicate: () => disconnectedRoom != null,
timeoutSeconds: DisconnectTimeoutSeconds);
yield return disconnected.Wait();

Assert.IsNull(disconnected.Error,
"Room did not raise Disconnected after an FFI panic event.");
Assert.AreSame(room, disconnectedRoom,
"Disconnected fired for a different room instance.");
Assert.AreEqual(DisconnectReason.UnknownReason, reason,
"DisconnectedWithReason did not carry the expected reason.");
Assert.AreEqual(DisconnectReason.UnknownReason, room.DisconnectReason,
"Room.DisconnectReason was not set by the panic teardown.");
Assert.IsFalse(room.IsConnected,
"Room still reports IsConnected after the panic teardown.");
}
}
}
11 changes: 11 additions & 0 deletions Tests/PlayMode/PanicRoomTeardownTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading