From d345e2aff2f8f6a5d91157a1a4dc43c77f50bd03 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:29:29 +0200 Subject: [PATCH] Fix MissingReferenceException when stopping scene with active mic On play-mode exit, Unity tears down the DontDestroyOnLoad LiveKitSDK GameObject (backing the MonoBehaviourContext singleton) before MeetManager.OnDestroy() runs. MicrophoneSource.Stop() then calls RunCoroutine, which did _instance.StartCoroutine(...) on the destroyed instance and threw a MissingReferenceException. Guard RunCoroutine: when _instance has been destroyed (Unity's overloaded == reports it as null), drain the coroutine synchronously via a new DrainCoroutine helper and invoke onComplete, instead of touching the dead MonoBehaviour. This keeps cleanup side effects working and protects every caller, not just MicrophoneSource. Add an EditMode regression test that deterministically reproduces the failure mode by pointing _instance at a DestroyImmediate-d object and asserting RunCoroutine drains without throwing. Co-Authored-By: Claude Fable 5 --- .../Scripts/Internal/MonoBehaviourContext.cs | 29 +++++++++ Tests/EditMode/MonoBehaviourContextTests.cs | 62 +++++++++++++++++++ .../MonoBehaviourContextTests.cs.meta | 2 + 3 files changed, 93 insertions(+) create mode 100644 Tests/EditMode/MonoBehaviourContextTests.cs create mode 100644 Tests/EditMode/MonoBehaviourContextTests.cs.meta 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