// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Core.Helper { using System; using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using UnityEngine; using Utils; using WallstopStudios.UnityHelpers.Core.Extension; #if UNITY_EDITOR using UnityEditor; #endif /// /// Thread-safe dispatcher that enqueues work to run on Unity's main thread. /// /// /// Works in both edit mode and play mode. Use for marshalling callbacks from tasks/threads to main thread. /// [ExecuteAlways] public sealed class UnityMainThreadDispatcher : RuntimeSingleton { private readonly ConcurrentQueue _actions = new(); private const int DefaultQueueLimit = 4096; private int _pendingActionCount; private int _lastOverflowFrame = -1; #if UNITY_EDITOR private const HideFlags EditorDispatcherHideFlags = HideFlags.None; #endif [SerializeField] [Tooltip( "Maximum number of queued actions before new submissions are dropped. Set to 0 for unlimited." )] private int maxPendingActions = DefaultQueueLimit; protected override bool Preserve => false; protected override bool LogErrorOnDestruction => false; internal static bool AutoCreationEnabled { get; private set; } = false; /// /// Gets the number of actions currently waiting to be executed on the main thread. /// public int PendingActionCount => Volatile.Read(ref _pendingActionCount); /// /// Gets or sets the maximum number of queued actions allowed before new submissions are dropped. /// A value of 0 disables the limit. /// public int PendingActionLimit { get => maxPendingActions; set => maxPendingActions = Mathf.Max(0, value); } internal static bool TryGetInstance(out UnityMainThreadDispatcher dispatcher) { dispatcher = _instance; return dispatcher != null; } internal static bool TryDispatchToMainThread(Action action) { if (action == null) { return false; } UnityMainThreadDispatcher dispatcher = _instance; if (dispatcher == null) { return false; } dispatcher.RunOnMainThread(action); return true; } #if UNITY_EDITOR private readonly EditorApplication.CallbackFunction _update; private bool _attachedEditorUpdate; #endif public UnityMainThreadDispatcher() { #if UNITY_EDITOR _update = Update; #endif } /// /// Gets the singleton dispatcher, creating it automatically when auto-creation is enabled. /// When auto-creation is disabled (for example inside tests) this simply returns the existing instance, which may be null. /// /// /// /// UnityMainThreadDispatcher dispatcher = UnityMainThreadDispatcher.Instance; /// if (dispatcher != null) /// { /// dispatcher.RunOnMainThread(() => Debug.Log("Marshalled to main thread")); /// } /// /// public static new UnityMainThreadDispatcher Instance { get { if (!AutoCreationEnabled) { return _instance; } return RuntimeSingleton.Instance; } } internal static void SetAutoCreationEnabled(bool enabled) { AutoCreationEnabled = enabled; } internal static bool DestroyExistingDispatcher(bool immediate) { bool destroyed = false; if (TryGetInstance(out UnityMainThreadDispatcher dispatcher) && dispatcher != null) { destroyed |= DestroyDispatcherObject(dispatcher, immediate); } UnityMainThreadDispatcher[] allDispatchers = Resources.FindObjectsOfTypeAll(); if (allDispatchers is { Length: > 0 }) { foreach (UnityMainThreadDispatcher localDispatcher in allDispatchers) { destroyed |= DestroyDispatcherObject(localDispatcher, immediate); } } return destroyed; } private static bool DestroyDispatcherObject( UnityMainThreadDispatcher dispatcher, bool immediate ) { if (dispatcher == null) { return false; } GameObject dispatcherObject = dispatcher.gameObject; if (dispatcherObject == null) { return false; } if (_instance == dispatcher) { _instance = null; } if (immediate || !Application.isPlaying) { // Tests rely on immediate destruction to stay deterministic. Production code // should prefer deferred destruction by passing immediate: false. DestroyImmediate(dispatcherObject); return true; } Destroy(dispatcherObject); return true; } /// /// Disposable helper that temporarily overrides and (optionally) destroys dispatcher instances on enter/exit. /// Use this to keep tests and integration setups deterministic without hand-written try/finally blocks. /// /// /// The scope records the previous value, switches to the desired state, and restores the original value on dispose. /// It also exposes knobs for destroying existing dispatcher GameObjects immediately (ideal for EditMode tests) or on dispose. /// /// /// /// using UnityMainThreadDispatcher.AutoCreationScope scope = /// UnityMainThreadDispatcher.AutoCreationScope.Disabled( /// destroyExistingInstanceOnEnter: true, /// destroyInstancesOnDispose: true, /// destroyImmediate: true); /// /// // Inside the scope auto-creation is off, so tests can create/destroy the dispatcher manually. /// UnityMainThreadDispatcher.SetAutoCreationEnabled(true); /// UnityMainThreadDispatcher dispatcher = UnityMainThreadDispatcher.Instance; /// /// public sealed class AutoCreationScope : IDisposable { private readonly bool _previousState; private readonly bool _destroyOnDispose; private readonly bool _destroyImmediate; private bool _disposed; private AutoCreationScope( bool desiredAutoCreationState, bool destroyExistingInstance, bool destroyInstancesOnDispose, bool destroyImmediate ) { _previousState = AutoCreationEnabled; _destroyOnDispose = destroyInstancesOnDispose; _destroyImmediate = destroyImmediate; SetAutoCreationEnabled(desiredAutoCreationState); if (destroyExistingInstance) { DestroyExistingDispatcher(destroyImmediate); } } /// /// Creates a scope that disables auto-creation and (by default) destroys dispatcher instances both when entering and leaving the scope. /// /// Set to false if the caller wants to keep the current dispatcher alive while auto-creation is disabled. /// When true, any dispatcher created while the scope was active is destroyed as soon as the scope is disposed. /// /// Uses when true (ideal for EditMode tests) and otherwise. /// /// A disposable scope that restores the previous value on dispose. /// /// /// using UnityMainThreadDispatcher.AutoCreationScope scope = /// UnityMainThreadDispatcher.AutoCreationScope.Disabled(destroyImmediate: Application.isEditor); /// // Perform work that must not auto-create the dispatcher. /// /// public static AutoCreationScope Disabled( bool destroyExistingInstanceOnEnter = true, bool destroyInstancesOnDispose = true, bool destroyImmediate = true ) { return new AutoCreationScope( desiredAutoCreationState: false, destroyExistingInstance: destroyExistingInstanceOnEnter, destroyInstancesOnDispose: destroyInstancesOnDispose, destroyImmediate: destroyImmediate ); } /// /// Creates a scope that forces auto-creation on even if callers disabled it previously. /// This is useful for integration tests that temporarily require the dispatcher before restoring the prior state. /// /// Destroy the dispatcher before enabling auto-creation (rare). /// Destroy any instances created during the scope once it ends. /// Choose between and for cleanup. /// A scope that restores to its previous value when disposed. /// /// /// using UnityMainThreadDispatcher.AutoCreationScope scope = /// UnityMainThreadDispatcher.AutoCreationScope.Enabled(); /// // Dispatcher is guaranteed to auto-create when accessed here. /// /// public static AutoCreationScope Enabled( bool destroyExistingInstanceOnEnter = false, bool destroyInstancesOnDispose = false, bool destroyImmediate = true ) { return new AutoCreationScope( desiredAutoCreationState: true, destroyExistingInstance: destroyExistingInstanceOnEnter, destroyInstancesOnDispose: destroyInstancesOnDispose, destroyImmediate: destroyImmediate ); } /// /// Restores the previously captured value and optionally destroys dispatcher instances created inside the scope. /// public void Dispose() { if (_disposed) { return; } _disposed = true; SetAutoCreationEnabled(_previousState); if (_destroyOnDispose) { DestroyExistingDispatcher(_destroyImmediate); } } } /// /// Creates a dispatcher test scope that follows the recommended pattern: disable auto-creation, destroy lingering instances immediately, re-enable auto-creation for the test body, and clean everything up on dispose. /// /// When true, uses for cleanup. Set to false in play mode so Unity can process destruction safely. /// An that automatically restores the previous auto-creation state when disposed. /// /// /// private UnityMainThreadDispatcher.AutoCreationScope _scope; /// /// [SetUp] /// public void SetUp() /// { /// _scope = UnityMainThreadDispatcher.CreateTestScope(destroyImmediate: true); /// } /// /// [TearDown] /// public void TearDown() /// { /// _scope?.Dispose(); /// _scope = null; /// } /// /// public static AutoCreationScope CreateTestScope(bool destroyImmediate = true) { AutoCreationScope scope = AutoCreationScope.Disabled( destroyExistingInstanceOnEnter: true, destroyInstancesOnDispose: true, destroyImmediate: destroyImmediate ); SetAutoCreationEnabled(true); return scope; } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSplashScreen)] private static void EnsureDispatcherBootstrap() { if (!AutoCreationEnabled) { return; } EnsureDispatcherExists("runtime bootstrap"); } #if UNITY_EDITOR [InitializeOnLoadMethod] private static void EnsureDispatcherBootstrapInEditor() { if (!AutoCreationEnabled) { return; } if (Application.isPlaying) { return; } EnsureDispatcherExists("editor bootstrap"); } #endif private static void EnsureDispatcherExists(string reason) { if (!AutoCreationEnabled) { return; } if (HasInstance) { return; } UnityMainThreadGuard.EnsureMainThread(reason); UnityMainThreadDispatcher dispatcher = Instance; if (!Application.isPlaying && dispatcher != null) { #if UNITY_EDITOR dispatcher.ApplyEditorHideFlags(); #endif } } /// /// Enqueues an action to be executed on the main thread during the next Update. /// /// /// /// Task.Run(async () => /// { /// string data = await FetchAsync(); /// UnityMainThreadDispatcher.Instance.RunOnMainThread(() => Apply(data)); /// }); /// /// public void RunOnMainThread(Action action) { _ = Enqueue(action, logOverflow: true); } /// /// Attempts to enqueue an action to execute on the main thread without logging overflow warnings. /// Returns true when successfully queued; otherwise false. /// /// Use this when overflow is expected and callers want to silently drop work (for example telemetry callbacks). /// /// /// UnityMainThreadDispatcher dispatcher = UnityMainThreadDispatcher.Instance; /// string status = BuildStatus(); /// bool queued = dispatcher.TryRunOnMainThread(() => UpdateUi(status)); /// if (!queued) /// { /// Debug.LogWarning("UI update dropped because dispatcher queue is full."); /// } /// /// public bool TryRunOnMainThread(Action action) { return Enqueue(action, logOverflow: false); } private bool Enqueue(Action action, bool logOverflow) { if (action == null) { throw new ArgumentNullException(nameof(action)); } if (!TryEnqueueInternal(action)) { if (logOverflow) { LogOverflow(); } return false; } return true; } private bool TryEnqueueInternal(Action action) { int newCount = Interlocked.Increment(ref _pendingActionCount); if (maxPendingActions > 0 && newCount > maxPendingActions) { Interlocked.Decrement(ref _pendingActionCount); return false; } _actions.Enqueue(action); return true; } private void LogOverflow() { string message = BuildOverflowMessage(); if (!Application.isPlaying) { FormattableString formatted = FormattableStringFactory.Create("{0}", message); Debug.LogWarning(formatted); return; } int currentFrame = Time.frameCount; if (currentFrame == _lastOverflowFrame) { return; } _lastOverflowFrame = currentFrame; FormattableString throttled = FormattableStringFactory.Create("{0}", message); Debug.LogWarning(throttled); } private string BuildOverflowMessage() { int limit = maxPendingActions; if (limit <= 0) { limit = 0; } int pending = PendingActionCount; return $"UnityMainThreadDispatcher queue overflow (limit {limit}). Dropping action. Pending count: {pending}."; } private void OnEnable() { #if UNITY_EDITOR if (!_attachedEditorUpdate && !Application.isPlaying) { EditorApplication.update += _update; _attachedEditorUpdate = true; ApplyEditorHideFlags(); } #endif } private void OnDisable() { #if UNITY_EDITOR if (_attachedEditorUpdate) { EditorApplication.update -= _update; _attachedEditorUpdate = false; } #endif } protected override void OnDestroy() { #if UNITY_EDITOR if (_attachedEditorUpdate) { EditorApplication.update -= _update; _attachedEditorUpdate = false; } ApplyEditorHideFlags(); #endif base.OnDestroy(); } private void Update() { while (_actions.TryDequeue(out Action action)) { try { action(); } catch (Exception e) { Debug.LogError($"UnityMainThreadDispatcher action threw an exception: {e}"); } finally { Interlocked.Decrement(ref _pendingActionCount); } } } /// /// Posts an action to run on the main thread and returns a that completes after execution. /// /// /// /// UnityMainThreadDispatcher dispatcher = UnityMainThreadDispatcher.Instance; /// PlayerController player = GetPlayer(); /// Animator playerAnimator = player.Animator; /// await dispatcher.RunAsync(() => /// { /// player.Health = 0; /// playerAnimator.Play("Die"); /// }); /// /// public Task RunAsync(Action action) { if (action == null) { throw new ArgumentNullException(nameof(action)); } TaskCompletionSource taskCompletionSource = new( TaskCreationOptions.RunContinuationsAsynchronously ); bool queued = Enqueue( () => { try { action(); taskCompletionSource.TrySetResult(true); } catch (Exception e) { taskCompletionSource.TrySetException(e); } }, logOverflow: true ); if (!queued) { taskCompletionSource.TrySetException( new InvalidOperationException(BuildOverflowMessage()) ); } return taskCompletionSource.Task; } /// /// Posts an asynchronous delegate that receives a and completes when the returned Task does. /// /// /// /// UnityMainThreadDispatcher dispatcher = UnityMainThreadDispatcher.Instance; /// CanvasGroup canvasGroup = GetLoadingOverlay(); /// using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); /// await dispatcher.RunAsync(async token => /// { /// await FadeCanvasGroupAsync(canvasGroup, 0f, token); /// }, timeout.Token); /// /// public Task RunAsync( Func action, CancellationToken cancellationToken = default ) { if (action == null) { throw new ArgumentNullException(nameof(action)); } TaskCompletionSource taskCompletionSource = new( TaskCreationOptions.RunContinuationsAsynchronously ); CancellationTokenRegistration registration = default; if (cancellationToken.CanBeCanceled) { if (cancellationToken.IsCancellationRequested) { taskCompletionSource.TrySetCanceled(cancellationToken); return taskCompletionSource.Task; } registration = cancellationToken.Register(() => { taskCompletionSource.TrySetCanceled(cancellationToken); }); } bool queued = Enqueue( () => { if (taskCompletionSource.Task.IsCompleted) { registration.Dispose(); return; } Task runTask; try { runTask = action(cancellationToken); } catch (Exception e) { registration.Dispose(); taskCompletionSource.TrySetException(e); return; } if (runTask == null) { registration.Dispose(); taskCompletionSource.TrySetException( new InvalidOperationException( "UnityMainThreadDispatcher.RunAsync expected the delegate to return a Task." ) ); return; } if (runTask.IsCompleted) { registration.Dispose(); CompleteFromTask(runTask, taskCompletionSource, cancellationToken); return; } runTask.ContinueWith( completedTask => { registration.Dispose(); CompleteFromTask( completedTask, taskCompletionSource, cancellationToken ); }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default ); }, logOverflow: true ); if (!queued) { registration.Dispose(); taskCompletionSource.TrySetException( new InvalidOperationException(BuildOverflowMessage()) ); } return taskCompletionSource.Task; } private static void CompleteFromTask( Task task, TaskCompletionSource completion, CancellationToken cancellationToken ) { if (task == null || completion == null) { return; } if (task.IsCanceled) { if (cancellationToken.CanBeCanceled) { completion.TrySetCanceled(cancellationToken); } else { completion.TrySetCanceled(); } return; } if (task.IsFaulted) { AggregateException aggregateException = task.Exception; if (aggregateException != null) { AggregateException flattened = aggregateException.Flatten(); completion.TrySetException(flattened.InnerExceptions); } else { completion.TrySetException( new InvalidOperationException("Dispatcher task faulted.") ); } return; } completion.TrySetResult(true); } /// /// Posts a function to run on the main thread and returns its result via Task. /// public Task Post(Func func) { if (func == null) { throw new ArgumentNullException(nameof(func)); } TaskCompletionSource taskCompletionSource = new( TaskCreationOptions.RunContinuationsAsynchronously ); bool queued = Enqueue( () => { try { T result = func(); taskCompletionSource.TrySetResult(result); } catch (Exception e) { taskCompletionSource.TrySetException(e); } }, logOverflow: true ); if (!queued) { taskCompletionSource.TrySetException( new InvalidOperationException(BuildOverflowMessage()) ); } return taskCompletionSource.Task; } #if UNITY_EDITOR private void ApplyEditorHideFlags() { if (!Application.isPlaying) { hideFlags = EditorDispatcherHideFlags; } } #endif } }