using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using UnityEngine; namespace Unity.Pipeline.Threading { /// /// Dispatches work to Unity's main thread from background threads (required for accessing Unity /// APIs from HTTP request handlers). /// /// Each pipeline server owns its own instance (no global singleton): it is initialized on Start /// and pumped from the main thread — auto-pumped via EditorApplication.update in the editor, and /// by RuntimePipelineDriver.Update in a player. /// public class Dispatcher { private readonly ConcurrentQueue m_WorkQueue = new ConcurrentQueue(); private volatile bool m_IsInitialized; private int m_MainThreadId = -1; /// True once has run and this dispatcher is pumping its queue. public bool IsInitialized => m_IsInitialized; /// /// Initialize the dispatcher. Must be called from Unity's main thread. /// public void Initialize() { if (m_IsInitialized) return; m_MainThreadId = Thread.CurrentThread.ManagedThreadId; m_IsInitialized = true; #if UNITY_EDITOR UnityEditor.EditorApplication.update += ProcessWorkQueue; #endif } /// /// Shutdown the dispatcher and cancel any pending work. /// public void Shutdown() { if (!m_IsInitialized) return; #if UNITY_EDITOR UnityEditor.EditorApplication.update -= ProcessWorkQueue; #endif while (m_WorkQueue.TryDequeue(out var item)) { try { item.SetException(new OperationCanceledException("Dispatcher is shutting down")); } catch { } } m_IsInitialized = false; } /// /// Execute a function on the main thread and return the result (synchronous wait). /// /// The function's return type. /// Function to run on the main thread. /// Time to wait before throwing . /// The function's result. public T Invoke(Func function, int timeoutMs = 60000) { if (!m_IsInitialized) throw new InvalidOperationException("Dispatcher must be initialized first"); if (IsMainThread()) return function(); var workItem = new WorkItem(function); m_WorkQueue.Enqueue(workItem); var startTime = DateTime.UtcNow; var task = workItem.TaskCompletionSource.Task; while (!task.IsCompleted) { if ((DateTime.UtcNow - startTime).TotalMilliseconds > timeoutMs) { // No-op if execution already started on the main thread — only prevents a // still-queued item from running after we've reported the timeout. workItem.TryCancel(); throw new TimeoutException($"Main thread operation timed out after {timeoutMs}ms"); } Thread.Sleep(1); } if (task.IsFaulted) throw task.Exception?.GetBaseException() ?? new Exception("Unknown error"); if (task.IsCanceled) throw new OperationCanceledException("Main thread operation was cancelled"); return task.Result; } /// /// Execute an action on the main thread. /// /// Action to run on the main thread. /// Time to wait before throwing . public void Invoke(Action action, int timeoutMs = 60000) { Invoke(() => { action(); return null; }, timeoutMs); } /// /// Execute a function on the main thread and return the result (async version). /// /// The function's return type. /// Function to run on the main thread. /// Time to wait before throwing . /// The function's result. public async Task InvokeAsync(Func function, int timeoutMs = 60000) { return await Task.Run(() => Invoke(function, timeoutMs)); } /// /// Execute an action on the main thread (async version). /// /// Action to run on the main thread. /// Time to wait before throwing . /// A task that completes once the action has run on the main thread. public async Task InvokeAsync(Action action, int timeoutMs = 60000) { await Task.Run(() => Invoke(action, timeoutMs)); } /// /// Queue an action to run on the main thread and return immediately. /// /// For work with no caller left to return to — reporting, logging, telemetry, anything /// whose result nobody reads. is wrong for that: it parks /// the calling thread until the next pump, and on a request thread that delay is paid by /// whoever is waiting on the response. InvokeAsync only moves the same wait onto a /// threadpool thread. /// /// The action always runs on a later pump, never inline, even when called from the main /// thread, so posted work runs in the order it was posted. It has nowhere to report a /// failure, so an exception is logged and swallowed rather than propagated. Work still /// queued when the dispatcher shuts down is dropped, and posting to a dispatcher that is /// not running is a no-op for the same reason — losing a report is always preferable to /// throwing at a caller that was only trying to record something. /// /// Action to run on the main thread on a later pump. public void Post(Action action) { if (action == null) throw new ArgumentNullException(nameof(action)); if (!m_IsInitialized) return; m_WorkQueue.Enqueue(new PostedWorkItem(action)); } /// /// Check if we're currently on Unity's main thread. /// /// True if the calling thread is the main thread captured by . public bool IsMainThread() { return m_MainThreadId != -1 && Thread.CurrentThread.ManagedThreadId == m_MainThreadId; } /// /// Process queued work items. Called from EditorApplication.update or MonoBehaviour.Update. /// /// Max items to process per call, to limit frame-rate impact. public void ProcessWorkQueue(int maxItemsPerFrame) { int processedCount = 0; while (processedCount < maxItemsPerFrame && m_WorkQueue.TryDequeue(out var workItem)) { try { workItem.Execute(); } catch (Exception ex) { Debug.LogError($"Dispatcher work item failed: {ex.Message}"); workItem.SetException(ex); } processedCount++; } } /// Process queued work items, up to a default limit of 10 per call. public void ProcessWorkQueue() { ProcessWorkQueue(10); } private abstract class WorkItem { private const int Pending = 0; private const int Started = 1; private const int Canceled = 2; private int m_State; /// Marks the item canceled if it hasn't started executing yet. Returns false /// (no-op) once has already claimed it. public bool TryCancel() => Interlocked.CompareExchange(ref m_State, Canceled, Pending) == Pending; public void Execute() { if (Interlocked.CompareExchange(ref m_State, Started, Pending) != Pending) return; // Canceled before it could start. ExecuteCore(); } protected abstract void ExecuteCore(); public abstract void SetException(Exception exception); } /// /// A work item. Unlike it carries no /// TaskCompletionSource: nobody is waiting on the result, so a fault has nowhere to go but /// the log — and faulting a TCS no one awaits would surface later as an unobserved task /// exception. A shutdown cancellation simply drops the work. /// private class PostedWorkItem : WorkItem { private readonly Action m_Action; public PostedWorkItem(Action action) { m_Action = action; } protected override void ExecuteCore() { try { m_Action(); } catch (Exception ex) { Debug.LogWarning($"Dispatcher posted work failed: {ex.Message}"); } } public override void SetException(Exception exception) { // Nobody is waiting on this item, so there is nothing to hand the exception to. } } private class WorkItem : WorkItem { private readonly Func m_Function; public TaskCompletionSource TaskCompletionSource { get; } public WorkItem(Func function) { m_Function = function ?? throw new ArgumentNullException(nameof(function)); TaskCompletionSource = new TaskCompletionSource(); } protected override void ExecuteCore() { try { var result = m_Function(); TaskCompletionSource.SetResult(result); } catch (Exception ex) { TaskCompletionSource.SetException(ex); } } public override void SetException(Exception exception) { TaskCompletionSource.SetException(exception); } } } }