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