diff --git a/Runtime/Scripts/Internal/MonoBehaviourContext.cs b/Runtime/Scripts/Internal/MonoBehaviourContext.cs index 5e30b187..233f6890 100644 --- a/Runtime/Scripts/Internal/MonoBehaviourContext.cs +++ b/Runtime/Scripts/Internal/MonoBehaviourContext.cs @@ -34,11 +34,40 @@ private static void Init() /// Runs a coroutine from a non-MonoBehaviour context, invoking the callback when the /// coroutine completes. /// + /// + /// If the singleton has already been destroyed (e.g. during play mode exit or + /// application shutdown) the coroutine cannot be started on a MonoBehaviour, so it is + /// drained synchronously instead. This keeps cleanup side effects working without + /// throwing a . + /// internal static void RunCoroutine(IEnumerator coroutine, Action onComplete = null) { + // Unity's overloaded == treats a destroyed object as null. + if (_instance == null) + { + DrainCoroutine(coroutine); + onComplete?.Invoke(); + return; + } + _instance.StartCoroutine(WrapCoroutine(coroutine, onComplete)); } + /// + /// Synchronously runs a coroutine to completion, ignoring time-based yield instructions + /// and recursing into nested enumerators. Used as a fallback when no MonoBehaviour is + /// available to host the coroutine. + /// + private static void DrainCoroutine(IEnumerator coroutine) + { + if (coroutine == null) return; + while (coroutine.MoveNext()) + { + if (coroutine.Current is IEnumerator nested) + DrainCoroutine(nested); + } + } + private static IEnumerator WrapCoroutine(IEnumerator coroutine, Action onComplete = null) { yield return coroutine; diff --git a/Tests/EditMode/MonoBehaviourContextTests.cs b/Tests/EditMode/MonoBehaviourContextTests.cs new file mode 100644 index 00000000..dc22d000 --- /dev/null +++ b/Tests/EditMode/MonoBehaviourContextTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; + +namespace LiveKit.EditModeTests +{ + public class MonoBehaviourContextTests + { + // Regression test for the MissingReferenceException thrown when the scene is stopped: + // Unity tears down the DontDestroyOnLoad LiveKitSDK GameObject (backing the + // MonoBehaviourContext singleton) before MeetManager.OnDestroy() runs, then + // MicrophoneSource.Stop() calls RunCoroutine on the destroyed instance. + // + // The real play-mode-exit teardown ordering can't be controlled from a test, but the + // throwing call (_instance.StartCoroutine) fires synchronously the moment its host is + // destroyed. So we reproduce the precondition deterministically: point _instance at a + // destroyed object via DestroyImmediate, then call RunCoroutine. Against the old code + // this throws MissingReferenceException; against the fix it drains the coroutine + // synchronously and invokes onComplete. + [Test] + public void RunCoroutine_AfterInstanceDestroyed_DrainsSynchronously_DoesNotThrow() + { + var instanceField = typeof(MonoBehaviourContext) + .GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(instanceField, "Expected private static MonoBehaviourContext._instance field"); + + // Save the global singleton so destroying our stand-in can't disturb other tests. + var original = instanceField.GetValue(null); + try + { + var go = new GameObject("temp-monobehaviourcontext"); + var ctx = go.AddComponent(); + instanceField.SetValue(null, ctx); + + // Synchronous destroy: _instance now reports == null via Unity's overloaded ==. + UnityEngine.Object.DestroyImmediate(go); + + bool ran = false; + bool completed = false; + + Assert.DoesNotThrow( + () => MonoBehaviourContext.RunCoroutine(Body(() => ran = true), () => completed = true), + "RunCoroutine must not throw when the singleton has already been destroyed"); + + Assert.IsTrue(ran, "coroutine body should have been drained synchronously"); + Assert.IsTrue(completed, "onComplete should fire after the drained coroutine"); + } + finally + { + instanceField.SetValue(null, original); + } + } + + private static IEnumerator Body(Action onStep) + { + onStep(); + yield return null; + } + } +} diff --git a/Tests/EditMode/MonoBehaviourContextTests.cs.meta b/Tests/EditMode/MonoBehaviourContextTests.cs.meta new file mode 100644 index 00000000..cdf2fd48 --- /dev/null +++ b/Tests/EditMode/MonoBehaviourContextTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e666f058c2d5643cf820691de34bc582 \ No newline at end of file