From 65509e87e536978297da7cbeea767c42969873c7 Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Thu, 14 May 2026 01:30:23 +0200 Subject: [PATCH 01/10] fix: prevent native teardown on application exit for Rust runtime threads - Skip `SegmentServerDispose` during `Application.quitting` to avoid main thread stalls caused by Rust runtime thread joins during HTTP timeouts. --- .../RustSegmentAnalyticsService.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs index 93a73fe2ab..b524fce4f1 100644 --- a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs +++ b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs @@ -52,6 +52,17 @@ private enum Operation // temportal sentry budget fix. TODO remove once the core issue solved private static bool ONCE_PATTERN_ALREADY_CAUGHT = false; + // Tracks Application.quitting so Dispose can skip the native teardown on EXIT. + // The native dispose calls into a Rust tokio Runtime drop that joins worker + // threads; if HTTP requests to api.segment.io are in-flight (very common given + // batched analytics), the main thread can stall for the full HTTP timeout. + // The OS reclaims those threads on process exit, so we can safely skip. + private static volatile bool isApplicationQuitting; + + [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)] + private static void SubscribeApplicationQuitting() => + UnityEngine.Application.quitting += () => isApplicationQuitting = true; + public RustSegmentAnalyticsService(string writerKey, string? anonId) { using Mutex.Guard instanceGuard = CURRENT.Lock(); // IGNORE_LINE_WEBGL_THREAD_SAFETY_FLAG @@ -108,6 +119,15 @@ public void Dispose() cancellationTokenSource.Dispose(); instanceGuard.Value = null; + + // Skip the native dispose when the process is exiting: SegmentServerDispose + // drops the Rust tokio Runtime, which joins worker threads and can stall the + // main thread for the full HTTP timeout if requests to api.segment.io are + // in-flight. The OS reclaims those threads on process exit. In-session + // dispose (account switch, re-init, etc.) still runs the full teardown. + if (isApplicationQuitting) + return; + bool result = NativeMethods.SegmentServerDispose(); if (result == false) From 16458850b406df0f3b2b2eaed8982dc5c7a0b055 Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Thu, 14 May 2026 12:15:36 +0200 Subject: [PATCH 02/10] revert: undo Rust Segment dispose skip on Application.Quit The previous diagnosis pointed at SegmentServerDispose() as the cause of the exit freeze, but the WinDbg attach with GameAssembly.pdb symbols revealed the real culprit is livekit_ffi tokio threads attached to the IL2CPP runtime (confirmed empirically by Ashley disabling VoiceChatPlugin). The Segment skip therefore brings no value and is being reverted to keep the experimentation branch focused on the actual cause. Refs #8764 --- .../RustSegmentAnalyticsService.cs | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs index b524fce4f1..93a73fe2ab 100644 --- a/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs +++ b/Explorer/Assets/Plugins/RustSegment/SegmentServerWrap/RustSegmentAnalyticsService.cs @@ -52,17 +52,6 @@ private enum Operation // temportal sentry budget fix. TODO remove once the core issue solved private static bool ONCE_PATTERN_ALREADY_CAUGHT = false; - // Tracks Application.quitting so Dispose can skip the native teardown on EXIT. - // The native dispose calls into a Rust tokio Runtime drop that joins worker - // threads; if HTTP requests to api.segment.io are in-flight (very common given - // batched analytics), the main thread can stall for the full HTTP timeout. - // The OS reclaims those threads on process exit, so we can safely skip. - private static volatile bool isApplicationQuitting; - - [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)] - private static void SubscribeApplicationQuitting() => - UnityEngine.Application.quitting += () => isApplicationQuitting = true; - public RustSegmentAnalyticsService(string writerKey, string? anonId) { using Mutex.Guard instanceGuard = CURRENT.Lock(); // IGNORE_LINE_WEBGL_THREAD_SAFETY_FLAG @@ -119,15 +108,6 @@ public void Dispose() cancellationTokenSource.Dispose(); instanceGuard.Value = null; - - // Skip the native dispose when the process is exiting: SegmentServerDispose - // drops the Rust tokio Runtime, which joins worker threads and can stall the - // main thread for the full HTTP timeout if requests to api.segment.io are - // in-flight. The OS reclaims those threads on process exit. In-session - // dispose (account switch, re-init, etc.) still runs the full teardown. - if (isApplicationQuitting) - return; - bool result = NativeMethods.SegmentServerDispose(); if (result == false) From 51f6d1cc1c90c56012c56dc614900df7eb59be84 Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Thu, 14 May 2026 12:16:00 +0200 Subject: [PATCH 03/10] chore: add VoiceChatPlugin init-stop bisection flags Adds two command-line toggles to narrow down which VoiceChatPlugin component leaves livekit_ffi tokio threads attached to the IL2CPP runtime, blocking process shutdown (Ashley's voice-chat-disabled test proved the freeze lives inside this plugin). --exit-test-voice-init-stop=N (N in 1..8) stops InitializeAsync after stage N --exit-test-skip-nearby-voice-systems skips the NEARBY ECS systems in InjectToWorld Stages map to sequential component instantiations in InitializeAsync: 1 VoiceChatMicrophoneHandler + VoiceChatMicrophoneStateManager 2 MicrophoneTrackPublisher (first to touch VoiceChatRoom) 3 RemoteTrackListener (second to touch VoiceChatRoom) 4 VoiceChatRoomManager 5 VoiceChatNametagsHandler 6 MicrophoneAudioToggleHandler 7 VoiceChatPanelPresenter 8 VoiceChatDebugContainer Flag-gated. No behavior change without the flags. Investigation tooling, to be removed once the offending component is identified. Refs #8764 --- .../Global/AppArgs/AppArgsFlags.cs | 8 +++ .../PluginSystem/Global/VoiceChatPlugin.cs | 62 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs index 1ba0bde988..69ea791736 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs @@ -5,6 +5,14 @@ public static class AppArgsFlags public const string DEBUG = "debug"; public const string DCL_EDITOR = "hub"; + // EXIT-DELAY INVESTIGATION (#8764): bisection flags for VoiceChatPlugin init. + // Used with the value 1..8 to stop InitializeAsync at successive stages and + // identify which component leaves livekit_ffi tokio threads attached to the + // IL2CPP runtime, preventing process shutdown. + // Remove once the offending component is identified and the dispose path is fixed. + public const string EXIT_TEST_VOICE_INIT_STOP = "exit-test-voice-init-stop"; + public const string EXIT_TEST_SKIP_NEARBY_VOICE_SYSTEMS = "exit-test-skip-nearby-voice-systems"; + public const string SKIP_VERSION_CHECK = "skip-version-check"; public const string SIMULATE_VERSION = "simulateVersion"; public const string FORCE_MINIMUM_SPECS_SCREEN = "forceMinimumSpecsScreen"; diff --git a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs index b2abd0e8fa..cf15444faf 100644 --- a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs @@ -21,6 +21,7 @@ using LiveKit.Rooms.Streaming; using LiveKit.Rooms.Streaming.Audio; using LiveKit.Rooms; +using Global.AppArgs; using System; using System.Collections.Generic; using System.Threading; @@ -145,8 +146,45 @@ public void Dispose() RustAudioClient.DeInit(); } + // EXIT-DELAY BISECTION (#8764): helpers to read --exit-test-* flags directly + // from the process command line. Kept local to avoid plumbing IAppArgs through + // the plugin's DI graph for an investigation toggle. + private static int GetExitTestVoiceInitStopStage() + { + string[] args = Environment.GetCommandLineArgs(); + string prefix = "--" + AppArgsFlags.EXIT_TEST_VOICE_INIT_STOP + "="; + for (var i = 0; i < args.Length; i++) + { + if (args[i].StartsWith(prefix, StringComparison.Ordinal) + && int.TryParse(args[i].AsSpan(prefix.Length), out int n)) + return n; + } + return 0; // 0 = no stop, full init + } + + private static bool HasExitTestSkipNearbyVoiceSystems() + { + string[] args = Environment.GetCommandLineArgs(); + string dashed = "--" + AppArgsFlags.EXIT_TEST_SKIP_NEARBY_VOICE_SYSTEMS; + for (var i = 0; i < args.Length; i++) + if (args[i] == dashed) + return true; + return false; + } + public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in GlobalPluginArguments arguments) { + // EXIT-DELAY BISECTION (#8764): skip the nearby voice ECS systems independently + // of the InitializeAsync stop, and defensively guard against null when init was + // halted before the NEARBY block ran. + if (HasExitTestSkipNearbyVoiceSystems()) + { + ReportHub.LogWarning(ReportCategory.VOICE_CHAT, "EXIT TEST: skipping NEARBY voice systems in InjectToWorld"); + return; + } + if (nearbyAudioStreamRegistry == null) + return; + if (FeaturesRegistry.Instance.IsEnabled(FeatureId.NEARBY_VOICE_CHAT)) { var listenerState = new NearbyListenerState(); @@ -174,23 +212,40 @@ public async UniTask InitializeAsync(Settings settings, CancellationToken ct) VoiceChatPluginSettings pluginSettings = voiceChatPluginSettingsAsset.Value; voiceChatConfiguration = pluginSettings.VoiceChatConfiguration; + // EXIT-DELAY BISECTION (#8764): + // Read --exit-test-voice-init-stop=N and stop initialization after stage N to + // identify which voice chat component keeps livekit_ffi tokio threads attached + // to the IL2CPP runtime, preventing process shutdown. + int stopAfter = GetExitTestVoiceInitStopStage(); + if (stopAfter > 0) + ReportHub.LogWarning(ReportCategory.VOICE_CHAT, $"EXIT TEST: VoiceChat init will stop after stage {stopAfter}"); + + // Stage 1: local-only handlers (no Room access) voiceChatHandler = new VoiceChatMicrophoneHandler(voiceChatConfiguration, voiceChatOrchestrator); pluginScope.Add(voiceChatHandler); microphoneStateManager = new VoiceChatMicrophoneStateManager(voiceChatHandler, voiceChatOrchestrator); pluginScope.Add(microphoneStateManager); + if (stopAfter == 1) return; + // Stage 2: MicrophoneTrackPublisher — first component that touches voice chat Room microphonePublisher = new MicrophoneTrackPublisher(roomHub.VoiceChatRoom().Room(), voiceChatConfiguration, VoiceChatType.COMMUNITY); + if (stopAfter == 2) return; + // Stage 3: RemoteTrackListener — second component that touches voice chat Room var callPlaybackSourcesHub = new PlaybackSourcesHub("Call", voiceChatConfiguration.ChatAudioMixerGroup.EnsureNotNull()); remoteListener = new RemoteTrackListener( roomHub.VoiceChatRoom().Room(), voiceChatConfiguration, callPlaybackSourcesHub); + if (stopAfter == 3) return; + // Stage 4: VoiceChatRoomManager (orchestrates publisher/listener) roomManager = new VoiceChatRoomManager(microphonePublisher, remoteListener, roomHub, roomHub.VoiceChatRoom().Room(), voiceChatOrchestrator, voiceChatConfiguration, microphoneStateManager, voiceChatHandler); pluginScope.Add(roomManager); + if (stopAfter == 4) return; + // Stage 5: VoiceChatNametagsHandler (subscribes to Room events) nametagsHandler = new VoiceChatNametagsHandler( roomHub.VoiceChatRoom().Room(), voiceChatOrchestrator, @@ -198,18 +253,25 @@ public async UniTask InitializeAsync(Settings settings, CancellationToken ct) world, playerEntity); pluginScope.Add(nametagsHandler); + if (stopAfter == 5) return; + // Stage 6: MicrophoneAudioToggleHandler (audio cues, no Room access) VoiceChatParticipantEntryView playerEntry = pluginSettings.PlayerEntryView; AudioClipConfig muteMicrophoneAudio = pluginSettings.MuteMicrophoneAudio; AudioClipConfig unmuteMicrophoneAudio = pluginSettings.UnmuteMicrophoneAudio; microphoneAudioToggleHandler = new MicrophoneAudioToggleHandler(voiceChatHandler, muteMicrophoneAudio, unmuteMicrophoneAudio); pluginScope.Add(microphoneAudioToggleHandler); + if (stopAfter == 6) return; + // Stage 7: VoiceChatPanelPresenter (UI) voiceChatPanelPresenter = new VoiceChatPanelPresenter(voiceChatPanelView, profileDataProvider, communityDataProvider, imageControllerProvider, voiceChatOrchestrator, voiceChatHandler, roomManager, roomHub, playerEntry, chatSharedAreaEventBus); pluginScope.Add(voiceChatPanelPresenter); + if (stopAfter == 7) return; + // Stage 8: VoiceChatDebugContainer voiceChatDebugContainer = new VoiceChatDebugContainer(debugContainer, microphonePublisher, roomHub.VoiceChatRoom().Room(), callPlaybackSourcesHub); pluginScope.Add(voiceChatDebugContainer); + if (stopAfter == 8) return; if (FeaturesRegistry.Instance.IsEnabled(FeatureId.NEARBY_VOICE_CHAT)) { From 5bc3622a7a4611b4d7b1a604560a119cceb123ef Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Thu, 14 May 2026 15:36:08 +0200 Subject: [PATCH 04/10] chore: log EXIT TEST bisection markers under ALWAYS category The VOICE_CHAT report category is filtered out of Player.log builds, which hides the bisection flag confirmation. Move both EXIT TEST log lines to ReportCategory.ALWAYS so they're always visible and we can verify the flag is being read. Refs #8764 --- Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs index cf15444faf..447844972c 100644 --- a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs @@ -179,7 +179,7 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder builder, // halted before the NEARBY block ran. if (HasExitTestSkipNearbyVoiceSystems()) { - ReportHub.LogWarning(ReportCategory.VOICE_CHAT, "EXIT TEST: skipping NEARBY voice systems in InjectToWorld"); + ReportHub.LogWarning(ReportCategory.ALWAYS, "EXIT TEST: skipping NEARBY voice systems in InjectToWorld"); return; } if (nearbyAudioStreamRegistry == null) @@ -218,7 +218,7 @@ public async UniTask InitializeAsync(Settings settings, CancellationToken ct) // to the IL2CPP runtime, preventing process shutdown. int stopAfter = GetExitTestVoiceInitStopStage(); if (stopAfter > 0) - ReportHub.LogWarning(ReportCategory.VOICE_CHAT, $"EXIT TEST: VoiceChat init will stop after stage {stopAfter}"); + ReportHub.LogWarning(ReportCategory.ALWAYS, $"EXIT TEST: VoiceChat init will stop after stage {stopAfter}"); // Stage 1: local-only handlers (no Room access) voiceChatHandler = new VoiceChatMicrophoneHandler(voiceChatConfiguration, voiceChatOrchestrator); From d3c09b3488385fe04c14e4043f13f342904e5275 Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Thu, 14 May 2026 15:56:07 +0200 Subject: [PATCH 05/10] chore: bisection for NEARBY voice ECS systems in InjectToWorld Bisection round 1 (--exit-test-voice-init-stop=N) ruled out every component instantiated in InitializeAsync and the NEARBY block of InitializeAsync itself. The freeze is triggered only when the 7 ECS systems in InjectToWorld's NEARBY block run. Adds --exit-test-nearby-inject-stop=N (N in 1..7) to stop after each of the 7 NearbyXxxSystem.InjectToWorld(...) calls, so we can pin down which one (or which subset) leaves livekit_ffi tokio threads attached to the IL2CPP runtime. Refs #8764 --- .../Global/AppArgs/AppArgsFlags.cs | 1 + .../PluginSystem/Global/VoiceChatPlugin.cs | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs index 69ea791736..6d9bf6ee80 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs @@ -12,6 +12,7 @@ public static class AppArgsFlags // Remove once the offending component is identified and the dispose path is fixed. public const string EXIT_TEST_VOICE_INIT_STOP = "exit-test-voice-init-stop"; public const string EXIT_TEST_SKIP_NEARBY_VOICE_SYSTEMS = "exit-test-skip-nearby-voice-systems"; + public const string EXIT_TEST_NEARBY_INJECT_STOP = "exit-test-nearby-inject-stop"; public const string SKIP_VERSION_CHECK = "skip-version-check"; public const string SIMULATE_VERSION = "simulateVersion"; diff --git a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs index 447844972c..85a1119064 100644 --- a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs @@ -172,6 +172,19 @@ private static bool HasExitTestSkipNearbyVoiceSystems() return false; } + private static int GetExitTestNearbyInjectStopStage() + { + string[] args = Environment.GetCommandLineArgs(); + string prefix = "--" + AppArgsFlags.EXIT_TEST_NEARBY_INJECT_STOP + "="; + for (var i = 0; i < args.Length; i++) + { + if (args[i].StartsWith(prefix, StringComparison.Ordinal) + && int.TryParse(args[i].AsSpan(prefix.Length), out int n)) + return n; + } + return 0; // 0 = no stop, all systems injected + } + public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in GlobalPluginArguments arguments) { // EXIT-DELAY BISECTION (#8764): skip the nearby voice ECS systems independently @@ -187,15 +200,41 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder builder, if (FeaturesRegistry.Instance.IsEnabled(FeatureId.NEARBY_VOICE_CHAT)) { + // EXIT-DELAY BISECTION (#8764): + // Read --exit-test-nearby-inject-stop=N and stop after registering N ECS systems + // to identify which NEARBY voice system is leaving livekit_ffi tokio threads + // attached to the IL2CPP runtime. + int stopAfter = GetExitTestNearbyInjectStopStage(); + if (stopAfter > 0) + ReportHub.LogWarning(ReportCategory.ALWAYS, $"EXIT TEST: NEARBY inject will stop after stage {stopAfter}"); + var listenerState = new NearbyListenerState(); + // Stage 1: NearbyLivekitBridgeSystem (primary suspect, bridges to livekit_ffi) NearbyLivekitBridgeSystem.InjectToWorld(ref builder, nearbyAudioStreamRegistry!); + if (stopAfter == 1) return; + + // Stage 2: NearbyAudibleRangeSystem NearbyAudibleRangeSystem.InjectToWorld(ref builder, voiceChatConfiguration, listenerState); + if (stopAfter == 2) return; + + // Stage 3: NearbyAudioBindingSystem NearbyAudioBindingSystem.InjectToWorld(ref builder, nearbyAudioStreamRegistry!, nearbyAudioBindings!, userBlockingCache, nearbyStateModel!, nearbyAudioSourceFactory!); + if (stopAfter == 3) return; + + // Stage 4: NearbyAudioPositionSystem NearbyAudioPositionSystem.InjectToWorld(ref builder, nearbyMuteService!, listenerState); + if (stopAfter == 4) return; + + // Stage 5: NearbyAudioCleanupSystem NearbyAudioCleanupSystem.InjectToWorld(ref builder, nearbyAudioStreamRegistry!, nearbyAudioBindings!, userBlockingCache, nearbyStateModel!, nearbyAudioSourceFactory!); + if (stopAfter == 5) return; + + // Stage 6: NearbyVoiceChatNametagSystem NearbyVoiceChatNametagSystem.InjectToWorld(ref builder, playerEntity, nearbyAudioStreamRegistry!, nearbyStateModel!, nearbyMuteService!); + if (stopAfter == 6) return; + // Stage 7: NearbyVoiceChatDebugSystem NearbyVoiceChatDebugSystem.InjectToWorld(ref builder, voiceChatConfiguration, debugContainer, roomHub.IslandRoom(), nearbyStateModel!, nearbyAudioStreamRegistry!, entityParticipantTable); } } From 63f5a8871ca88443cd1f463c141087c9b889c33a Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Thu, 14 May 2026 19:24:58 +0200 Subject: [PATCH 06/10] fix: tear down live LivekitAudioSource instances on DisposeRoot NearbyAudioSourceFactory.DisposeRoot() destroyed the pool's parent GameObject without first running Stop+Free on the LIVE (and legacy) LivekitAudioSource instances. Unity.Destroy() on those GameObjects fires LivekitAudioSource.OnDestroy() which only disposes the WavWriter - it does not stop the underlying AudioSource nor null the Weak reference. The AudioSource therefore stays in Play state long enough for one or more OnAudioFilterRead callbacks to cross into livekit_ffi via AudioStream.ReadAudio. Those FFI calls keep the consuming threads attached to the IL2CPP managed runtime; the threads never detach, and il2cpp::vm::Runtime::Shutdown deadlocks waiting on them - the multi-second to minute-long EXIT freeze tracked in #8764. This change iterates liveInstances and legacyInstances and explicitly invokes Stop()+Free() on each before pool.Dispose() and the parent container destruction. Mirrors the in-pool teardown path that already runs via ResetForPool (onRelease). Refs #8764 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/NearbyAudioSourceFactory.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioSourceFactory.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioSourceFactory.cs index f39eda647b..550866cc6b 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioSourceFactory.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioSourceFactory.cs @@ -78,6 +78,30 @@ public void Dispose(LivekitAudioSource? source) public void DisposeRoot() { + // Explicit teardown of LIVE and LEGACY instances before destroying the root. + // pool.Dispose() only runs the onRelease callback (Stop+Free) on instances + // currently in the pool; live and legacy instances would otherwise be + // destroyed via Unity.Destroy() on the parent container, which fires + // OnDestroy() (only DisposeWavWriter) but bypasses LivekitAudioSource.Stop() + // and LivekitAudioSource.Free(). The AudioSource then stays in Play state + // long enough for one or more OnAudioFilterRead callbacks to cross into + // livekit_ffi (AudioStream.ReadAudio), keeping the FFI subscriptions alive + // and attaching the consuming threads to the IL2CPP managed runtime. + // Those attached threads never detach, deadlocking il2cpp::vm::Runtime::Shutdown + // and producing the multi-second to minute-long EXIT freeze tracked in #8764. + foreach (LivekitAudioSource live in liveInstances) + { + if (live == null) continue; + live.Stop(); + live.Free(); + } + foreach (LivekitAudioSource legacy in legacyInstances) + { + if (legacy == null) continue; + legacy.Stop(); + legacy.Free(); + } + pool.Dispose(); UnityObjectUtils.SafeDestroyGameObject(pool.ParentContainer); liveInstances.Clear(); From d79ba24e649bee068d48d996764a4fb34e59bfd2 Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Thu, 14 May 2026 22:55:56 +0200 Subject: [PATCH 07/10] chore: add two bisection flags for VoiceChatPlugin shutdown freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the WinDbg re-attach showing the DisposeRoot fix did not move the deadlock pattern (same 16 tokio-runtime-worker threads still attached to the IL2CPP runtime), adds two complementary toggles to isolate the real attachment-keeping path in a single build. --exit-test-skip-audio-source-create In NearbyAudioBindingSystem, skip the sourceFactory.Create() call (and resulting NearbyAudioSourceComponent). The system stays registered and iterates avatars normally — only the LivekitAudioSource creation/Play() that triggers OnAudioFilterRead → AudioStream.ReadAudio is bypassed. Pass-fail: if EXIT is fast with this flag, the active AudioSource binding is what keeps tokio workers attached. If still slow, the mere registration of the NEARBY systems is enough (independent of audio data flow). --exit-test-disconnect-rooms-on-quit In VoiceChatPlugin.Dispose, call DisconnectAsync on IslandRoom and VoiceChatRoom (capped to 3s total). Disconnecting the rooms shuts down their LiveKit tokio runtime, which terminates the worker threads and lets them detach from IL2CPP, freeing Runtime::Shutdown. Pass-fail: if EXIT is fast with this flag alone (no other flags), the absence of room disconnect on quit is the cause. This would point to the proper fix being a disconnect call somewhere in the production shutdown path. Refs #8764 --- .../Global/AppArgs/AppArgsFlags.cs | 2 ++ .../PluginSystem/Global/VoiceChatPlugin.cs | 34 +++++++++++++++++++ .../Systems/NearbyAudioBindingSystem.cs | 21 ++++++++++++ 3 files changed, 57 insertions(+) diff --git a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs index 6d9bf6ee80..20954ecc57 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs @@ -13,6 +13,8 @@ public static class AppArgsFlags public const string EXIT_TEST_VOICE_INIT_STOP = "exit-test-voice-init-stop"; public const string EXIT_TEST_SKIP_NEARBY_VOICE_SYSTEMS = "exit-test-skip-nearby-voice-systems"; public const string EXIT_TEST_NEARBY_INJECT_STOP = "exit-test-nearby-inject-stop"; + public const string EXIT_TEST_SKIP_AUDIO_SOURCE_CREATE = "exit-test-skip-audio-source-create"; + public const string EXIT_TEST_DISCONNECT_ROOMS_ON_QUIT = "exit-test-disconnect-rooms-on-quit"; public const string SKIP_VERSION_CHECK = "skip-version-check"; public const string SIMULATE_VERSION = "simulateVersion"; diff --git a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs index 85a1119064..9b73c6f965 100644 --- a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs @@ -137,6 +137,30 @@ public VoiceChatPlugin( public void Dispose() { + // EXIT-DELAY BISECTION (#8764): when --exit-test-disconnect-rooms-on-quit is set, + // disconnect the LiveKit rooms explicitly before tearing down the plugin. Rationale: + // livekit_ffi tokio workers get attached to the IL2CPP managed runtime via the first + // managed callback they fire (track subscribed, participant update, etc.) and never + // detach. The only way to make them detach is to make the threads themselves exit, + // which happens when the tokio runtime owning them shuts down — which in turn happens + // when the LiveKit Room is disconnected. We block up to 3s for completion so we cap + // any potential hang in the disconnect path itself. + if (HasExitTestDisconnectRoomsOnQuit()) + { + ReportHub.LogWarning(ReportCategory.ALWAYS, "EXIT TEST: disconnecting LiveKit rooms on quit"); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + try + { + roomHub.IslandRoom().DisconnectAsync(cts.Token).AsTask().Wait(cts.Token); + } + catch (Exception ex) { ReportHub.LogWarning(ReportCategory.ALWAYS, $"EXIT TEST: IslandRoom disconnect failed: {ex.Message}"); } + try + { + roomHub.VoiceChatRoom().Room().DisconnectAsync(cts.Token).AsTask().Wait(cts.Token); + } + catch (Exception ex) { ReportHub.LogWarning(ReportCategory.ALWAYS, $"EXIT TEST: VoiceChatRoom disconnect failed: {ex.Message}"); } + } + nearbyTipCts.SafeCancelAndDispose(); pluginScope.Dispose(); @@ -185,6 +209,16 @@ private static int GetExitTestNearbyInjectStopStage() return 0; // 0 = no stop, all systems injected } + private static bool HasExitTestDisconnectRoomsOnQuit() + { + string[] args = Environment.GetCommandLineArgs(); + string dashed = "--" + AppArgsFlags.EXIT_TEST_DISCONNECT_ROOMS_ON_QUIT; + for (var i = 0; i < args.Length; i++) + if (args[i] == dashed) + return true; + return false; + } + public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in GlobalPluginArguments arguments) { // EXIT-DELAY BISECTION (#8764): skip the nearby voice ECS systems independently diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs index 129362df12..c051a0f60a 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs @@ -7,9 +7,11 @@ using DCL.VoiceChat.Nearby.Audio; using ECS.Abstract; using ECS.LifeCycle.Components; +using Global.AppArgs; using LiveKit.Rooms.Streaming; using LiveKit.Rooms.Streaming.Audio; using RichTypes; +using System; using System.Collections.Generic; namespace DCL.VoiceChat.Nearby.Systems @@ -48,6 +50,16 @@ protected override void OnDispose() sourceFactory.DisposeRoot(); } + private static bool HasExitTestSkipAudioSourceCreate() + { + string[] args = Environment.GetCommandLineArgs(); + string dashed = "--" + AppArgsFlags.EXIT_TEST_SKIP_AUDIO_SOURCE_CREATE; + for (var i = 0; i < args.Length; i++) + if (args[i] == dashed) + return true; + return false; + } + protected override void Update(float t) { // Listening gate: skip the entire avatar query when nearby chat is SUPPRESSED or DISABLED. @@ -81,6 +93,15 @@ private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profil // Track was unsubscribed between collection (snapshot read) and resolve (GetActiveStream); skip to avoid a one-frame ghost source. if (!stream.Resource.Has) continue; + // EXIT-DELAY BISECTION (#8764): when --exit-test-skip-audio-source-create is set, + // skip the LivekitAudioSource creation/Play() that triggers OnAudioFilterRead → FFI. + // The system stays registered and iterates avatars normally — only the Create call + // and the resulting NearbyAudioSourceComponent are bypassed. Used to isolate whether + // the active AudioSource binding is what keeps livekit_ffi tokio workers attached + // to the IL2CPP runtime, or whether the registration of the system alone is enough. + if (HasExitTestSkipAudioSourceCreate()) + continue; + LivekitAudioSource source = sourceFactory.Create(key, stream); World.Create(new NearbyAudioSourceComponent(key, avatarEntity, source)); From eb7593c03fc86c25efee97cf7b22f81e59e99b16 Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Fri, 15 May 2026 00:18:09 +0200 Subject: [PATCH 08/10] test: rework --exit-test-disconnect-rooms-on-quit via Application.wantsToQuit Previous attempt used .AsTask().Wait() in Dispose() which deadlocked: blocking the main thread prevented PlayerLoop ticks, so the UniTask continuations inside DisconnectAsync.AwaitWithSuccess could not resume, and the disconnect was cancelled at the 3s timeout without ever completing. New approach: hook Application.wantsToQuit so the room disconnects run while the PlayerLoop is still pumping. The handler returns false to cancel the quit, fires DisconnectRoomsThenQuitAsync().Forget(), and the async task re-issues Application.Quit() once both rooms are disconnected (or the 10s timeout fires). The second wantsToQuit invocation observes disconnectsCompleted=true and returns true so Unity proceeds with shutdown. Investigation of #8764. --- .../PluginSystem/Global/VoiceChatPlugin.cs | 87 ++++++++++++++----- 1 file changed, 65 insertions(+), 22 deletions(-) diff --git a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs index 9b73c6f965..ae43f1bcd4 100644 --- a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs @@ -86,6 +86,13 @@ public class VoiceChatPlugin : IDCLGlobalPlugin private CancellationTokenSource? nearbyTipCts; private VoiceChatConfiguration voiceChatConfiguration; + // EXIT-DELAY BISECTION (#8764): state for the wantsToQuit interception flow. + // disconnectsCompleted starts false; on the first wantsToQuit we cancel the quit, + // run the async room disconnects, set the flag, and re-issue Application.Quit(). + // The second wantsToQuit sees the flag and returns true so Unity proceeds with shutdown. + private bool disconnectsCompleted; + private bool disconnectsInFlight; + public VoiceChatPlugin( IRoomHub roomHub, VoiceChatPanelView voiceChatPanelView, @@ -133,33 +140,22 @@ public VoiceChatPlugin( this.volumeBus = volumeBus; voiceChatOrchestrator = voiceChatContainer.VoiceChatOrchestrator; + + // EXIT-DELAY BISECTION (#8764): hook Application.wantsToQuit so we can run the + // LiveKit room disconnects while the PlayerLoop is still pumping. Returning false + // cancels the quit, we drive the disconnects async, then re-issue Application.Quit() + // when complete. The second wantsToQuit invocation sees disconnectsCompleted=true + // and lets Unity proceed. This avoids the sync-over-async deadlock that .AsTask().Wait() + // hit in the previous attempt: blocking the main thread prevented UniTask continuations + // from resuming, so DisconnectInstruction.AwaitWithSuccess never completed. + if (HasExitTestDisconnectRoomsOnQuit()) + Application.wantsToQuit += OnWantsToQuitInterceptForRoomDisconnect; } public void Dispose() { - // EXIT-DELAY BISECTION (#8764): when --exit-test-disconnect-rooms-on-quit is set, - // disconnect the LiveKit rooms explicitly before tearing down the plugin. Rationale: - // livekit_ffi tokio workers get attached to the IL2CPP managed runtime via the first - // managed callback they fire (track subscribed, participant update, etc.) and never - // detach. The only way to make them detach is to make the threads themselves exit, - // which happens when the tokio runtime owning them shuts down — which in turn happens - // when the LiveKit Room is disconnected. We block up to 3s for completion so we cap - // any potential hang in the disconnect path itself. if (HasExitTestDisconnectRoomsOnQuit()) - { - ReportHub.LogWarning(ReportCategory.ALWAYS, "EXIT TEST: disconnecting LiveKit rooms on quit"); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); - try - { - roomHub.IslandRoom().DisconnectAsync(cts.Token).AsTask().Wait(cts.Token); - } - catch (Exception ex) { ReportHub.LogWarning(ReportCategory.ALWAYS, $"EXIT TEST: IslandRoom disconnect failed: {ex.Message}"); } - try - { - roomHub.VoiceChatRoom().Room().DisconnectAsync(cts.Token).AsTask().Wait(cts.Token); - } - catch (Exception ex) { ReportHub.LogWarning(ReportCategory.ALWAYS, $"EXIT TEST: VoiceChatRoom disconnect failed: {ex.Message}"); } - } + Application.wantsToQuit -= OnWantsToQuitInterceptForRoomDisconnect; nearbyTipCts.SafeCancelAndDispose(); pluginScope.Dispose(); @@ -170,6 +166,53 @@ public void Dispose() RustAudioClient.DeInit(); } + // EXIT-DELAY BISECTION (#8764): intercept the quit, run room disconnects asynchronously + // while the PlayerLoop is still active, then re-trigger Application.Quit() once complete. + private bool OnWantsToQuitInterceptForRoomDisconnect() + { + if (disconnectsCompleted) + { + ReportHub.LogWarning(ReportCategory.ALWAYS, "EXIT TEST: wantsToQuit second pass — disconnects complete, allowing quit"); + return true; + } + + if (disconnectsInFlight) + { + // Should not happen because Unity only invokes wantsToQuit once per Quit() call, + // but guard against re-entry just in case. + ReportHub.LogWarning(ReportCategory.ALWAYS, "EXIT TEST: wantsToQuit re-entered while disconnects in flight — blocking quit"); + return false; + } + + disconnectsInFlight = true; + ReportHub.LogWarning(ReportCategory.ALWAYS, "EXIT TEST: wantsToQuit — deferring quit to disconnect LiveKit rooms"); + DisconnectRoomsThenQuitAsync().Forget(); + return false; + } + + private async UniTaskVoid DisconnectRoomsThenQuitAsync() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + try + { + await UniTask.WhenAll( + roomHub.IslandRoom().DisconnectAsync(cts.Token), + roomHub.VoiceChatRoom().Room().DisconnectAsync(cts.Token)); + ReportHub.LogWarning(ReportCategory.ALWAYS, "EXIT TEST: LiveKit room disconnects complete"); + } + catch (OperationCanceledException) + { + ReportHub.LogWarning(ReportCategory.ALWAYS, "EXIT TEST: LiveKit room disconnects timed out after 10s"); + } + catch (Exception ex) + { + ReportHub.LogException(ex, ReportCategory.ALWAYS); + } + + disconnectsCompleted = true; + Application.Quit(); + } + // EXIT-DELAY BISECTION (#8764): helpers to read --exit-test-* flags directly // from the process command line. Kept local to avoid plumbing IAppArgs through // the plugin's DI graph for an investigation toggle. From 1ba57b9af9ce65f8691a6aff8c5e6035b862c94f Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Fri, 15 May 2026 11:41:16 +0200 Subject: [PATCH 09/10] test: add --exit-test-post-disconnect-delay-ms knob Allows inserting a delay between DisconnectAsync completion and Application.Quit() to test the hypothesis that the FFI tokio runtime needs a wind-down window after the room disconnect to fully detach its worker threads from IL2CPP. Default 0 keeps existing behavior. Use 1000/2000/3000 to bisect the value that makes exit consistently fast across runs. Refs #8764. --- .../Global/AppArgs/AppArgsFlags.cs | 1 + .../PluginSystem/Global/VoiceChatPlugin.cs | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs index 20954ecc57..7576ebcb3f 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs @@ -15,6 +15,7 @@ public static class AppArgsFlags public const string EXIT_TEST_NEARBY_INJECT_STOP = "exit-test-nearby-inject-stop"; public const string EXIT_TEST_SKIP_AUDIO_SOURCE_CREATE = "exit-test-skip-audio-source-create"; public const string EXIT_TEST_DISCONNECT_ROOMS_ON_QUIT = "exit-test-disconnect-rooms-on-quit"; + public const string EXIT_TEST_POST_DISCONNECT_DELAY_MS = "exit-test-post-disconnect-delay-ms"; public const string SKIP_VERSION_CHECK = "skip-version-check"; public const string SIMULATE_VERSION = "simulateVersion"; diff --git a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs index ae43f1bcd4..e3066b3bda 100644 --- a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs @@ -209,6 +209,15 @@ await UniTask.WhenAll( ReportHub.LogException(ex, ReportCategory.ALWAYS); } + int postDelayMs = GetExitTestPostDisconnectDelayMs(); + if (postDelayMs > 0) + { + ReportHub.LogWarning(ReportCategory.ALWAYS, $"EXIT TEST: post-disconnect delay {postDelayMs}ms before Application.Quit()"); + try { await UniTask.Delay(postDelayMs, ignoreTimeScale: true); } + catch (Exception ex) { ReportHub.LogException(ex, ReportCategory.ALWAYS); } + ReportHub.LogWarning(ReportCategory.ALWAYS, "EXIT TEST: post-disconnect delay elapsed"); + } + disconnectsCompleted = true; Application.Quit(); } @@ -262,6 +271,22 @@ private static bool HasExitTestDisconnectRoomsOnQuit() return false; } + // EXIT-DELAY BISECTION (#8764): post-disconnect delay in ms before Application.Quit(). + // Hypothesis: after DisconnectAsync returns, the FFI tokio runtime needs a brief window + // to wind down its worker threads. Returning 0 keeps the previous behavior. + private static int GetExitTestPostDisconnectDelayMs() + { + string[] args = Environment.GetCommandLineArgs(); + string prefix = "--" + AppArgsFlags.EXIT_TEST_POST_DISCONNECT_DELAY_MS + "="; + for (var i = 0; i < args.Length; i++) + { + if (args[i].StartsWith(prefix, StringComparison.Ordinal) + && int.TryParse(args[i].AsSpan(prefix.Length), out int n)) + return n; + } + return 0; + } + public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in GlobalPluginArguments arguments) { // EXIT-DELAY BISECTION (#8764): skip the nearby voice ECS systems independently From 595d01d0ffd1c71b73da157d66cd63d2a45aac1a Mon Sep 17 00:00:00 2001 From: Santi Andrade Date: Fri, 15 May 2026 12:56:29 +0200 Subject: [PATCH 10/10] test: add --exit-test-skip-get-active-stream flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing SKIP_AUDIO_SOURCE_CREATE flag was skipping LivekitAudioSource.Create() but NOT the registry.GetActiveStream(key) call that precedes it. GetActiveStream constructs an AudioStreamInternal that synchronously hits FFI and subscribes to FfiClient.AudioStreamEventReceived — callbacks on that event arrive from livekit_ffi tokio worker threads, which is how those threads become attached to the IL2CPP managed runtime. This new flag moves the bypass earlier so the GetActiveStream call itself is skipped. If exit becomes consistently fast with this flag (and slow without it), the AudioStream subscription is the attachment trigger and the real fix is to ensure every AudioStream gets disposed on quit, on top of room DisconnectAsync. Refs #8764. --- .../Global/AppArgs/AppArgsFlags.cs | 1 + .../Systems/NearbyAudioBindingSystem.cs | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs index 7576ebcb3f..96758eb322 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs @@ -14,6 +14,7 @@ public static class AppArgsFlags public const string EXIT_TEST_SKIP_NEARBY_VOICE_SYSTEMS = "exit-test-skip-nearby-voice-systems"; public const string EXIT_TEST_NEARBY_INJECT_STOP = "exit-test-nearby-inject-stop"; public const string EXIT_TEST_SKIP_AUDIO_SOURCE_CREATE = "exit-test-skip-audio-source-create"; + public const string EXIT_TEST_SKIP_GET_ACTIVE_STREAM = "exit-test-skip-get-active-stream"; public const string EXIT_TEST_DISCONNECT_ROOMS_ON_QUIT = "exit-test-disconnect-rooms-on-quit"; public const string EXIT_TEST_POST_DISCONNECT_DELAY_MS = "exit-test-post-disconnect-delay-ms"; diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs index c051a0f60a..760507afc8 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs @@ -60,6 +60,21 @@ private static bool HasExitTestSkipAudioSourceCreate() return false; } + // EXIT-DELAY BISECTION (#8764): skip the registry.GetActiveStream(key) call. The earlier + // SKIP_AUDIO_SOURCE_CREATE flag only skipped LivekitAudioSource.Create()/.Play(); it did + // NOT skip GetActiveStream, which lazily constructs an AudioStream and performs a + // synchronous FFI request. If this flag makes the exit consistently fast, GetActiveStream + // is what attaches the tokio workers to the IL2CPP runtime. + private static bool HasExitTestSkipGetActiveStream() + { + string[] args = Environment.GetCommandLineArgs(); + string dashed = "--" + AppArgsFlags.EXIT_TEST_SKIP_GET_ACTIVE_STREAM; + for (var i = 0; i < args.Length; i++) + if (args[i] == dashed) + return true; + return false; + } + protected override void Update(float t) { // Listening gate: skip the entire avatar query when nearby chat is SUPPRESSED or DISABLED. @@ -88,6 +103,13 @@ private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profil if (!bindings.Contains(key)) { + // EXIT-DELAY BISECTION (#8764): when --exit-test-skip-get-active-stream is set, + // bypass the registry.GetActiveStream(key) call entirely. That call lazily + // constructs a LiveKit AudioStream and performs a synchronous FFI request; + // we suspect it is what attaches tokio workers to the IL2CPP runtime. + if (HasExitTestSkipGetActiveStream()) + continue; + Weak stream = registry.GetActiveStream(key); // Track was unsubscribed between collection (snapshot read) and resolve (GetActiveStream); skip to avoid a one-frame ghost source.