using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Unity.Pipeline; using UnityEditor; using UnityEngine; namespace Unity.Pipeline.Editor.Commands { /// /// One active or completed wait tracked by (AUTHAPI-25). /// /// The condition is evaluated by , which MUST run on the main thread: the /// synchronous wait_for path marshals it there via the server dispatcher between sleeps /// (so the main thread is never held hostage), while an async=true wait is polled by /// on EditorApplication.update. Any follow-up action /// (on_met.capture/pause) runs inside the same call that /// first observes the condition true, so it acts in that same editor frame. /// internal sealed class WaitJob { internal const int MaxHistorySamples = 200; public string Id; public WaitConditionInput Condition; public WaitConditionPlan Plan; public WaitOnMetInput OnMet; public bool ReturnHistory; public bool IsAsync; public bool TolerateMissing; public double PollIntervalMs; public double TimeoutMs; public DateTime StartedAt; /// Guards mutable state below; read from the request/background thread, mutated on the main thread. public readonly object Gate = new object(); // --- guarded by Gate --- /// Single source of truth for the lifecycle. Every public view (the five /// booleans, the wire string, IsTerminal) derives from it, so a state transition can never /// be half-applied and the terminal guard lives in exactly one place. private WaitState m_State = WaitState.Pending; /// Set while still-Pending but already committed to running the on_met /// follow-up (its pause/capture side effects are about to happen for real, off the lock). /// Blocks the same way a non-Pending state does, so a /// concurrent cancel/interrupt/timeout cannot claim the job mid-follow-up: the follow-up's /// side effects have already fired and must not be reported as if they never happened. private bool m_Resolving; public object InitialValue; public bool HaveInitial; public object LastValue; public string Error; public string ResolutionNote; public int FramesObserved; /// Error from the on_met follow-up (capture/pause) when the condition held but the /// follow-up threw — reported as onMetError, distinct from . public string FollowupError; public WaitCaptureResult Capture; public readonly List History = new List(); public DateTime? LastPollAt; /// UTC time of the terminal transition; freezes so a /// completed wait's elapsed time stops growing while it sits retained for wait_status. private DateTime? m_TerminalAtUtc; // --- end guarded --- /// NOTE: terminal transitions must go through — /// it also checks (committed-to-on_met, not yet Met); a path /// testing only m_State != Pending would reopen the cancel-vs-met race. internal enum WaitState { Pending, Met, TimedOut, Interrupted, Canceled, Failed } public bool Met => ReadState() == WaitState.Met; public bool TimedOut => ReadState() == WaitState.TimedOut; public bool Interrupted => ReadState() == WaitState.Interrupted; public bool Canceled => ReadState() == WaitState.Canceled; public bool Failed => ReadState() == WaitState.Failed; public bool IsTerminal => ReadState() != WaitState.Pending; /// Wire form: "pending" | "met" | "timedOut" | "interrupted" | "canceled" | "failed". public string State => StateName(ReadState()); private WaitState ReadState() { lock (Gate) { return m_State; } } private static string StateName(WaitState state) { switch (state) { case WaitState.Met: return "met"; case WaitState.TimedOut: return "timedOut"; case WaitState.Interrupted: return "interrupted"; case WaitState.Canceled: return "canceled"; case WaitState.Failed: return "failed"; default: return "pending"; } } /// Milliseconds since the wait started, frozen at the terminal transition. public long ElapsedMs { get { lock (Gate) { var end = m_TerminalAtUtc ?? DateTime.UtcNow; return (long)(end - StartedAt).TotalMilliseconds; } } } /// Record the terminal timestamp. Must be called with held. private void MarkTerminalLocked() { if (m_TerminalAtUtc == null) m_TerminalAtUtc = DateTime.UtcNow; } /// UTC time this wait became terminal, or null while still pending. Set atomically /// with the state transition (see ), so any job observed as /// already has this set — used to prune the OLDEST-COMPLETED jobs /// first, not the oldest-SUBMITTED ones (a long-running wait can have the earliest /// in the registry yet be the most recently finished). public DateTime? TerminalAtUtc { get { lock (Gate) { return m_TerminalAtUtc; } } } /// /// Evaluate the condition once on the main thread. Reads the member, records the observation, /// and — on the first frame the condition holds — runs the follow-up action and transitions /// to met. Resolution failures transition to failed (never to timedOut) /// unless tolerate_missing is set, in which case they are recorded as the last error /// and retried as condition-not-met (enables "wait until X exists"). No-op once the job is /// already terminal. /// internal void PollOnce() { lock (Gate) { if (m_State != WaitState.Pending) return; LastPollAt = DateTime.UtcNow; } var plan = Plan ?? (Plan = new WaitConditionPlan(Condition)); object value; Type memberType; try { value = WaitConditionEvaluator.ReadMember(plan, out memberType); } catch (Exception ex) { // WaitEvaluationException is the evaluator's own structured failure; anything else // is a member getter throwing (reflection wraps those in TargetInvocationException) // — a property body that throws, a MissingReferenceException, etc. Both must // resolve the wait (or be tolerated), never escape: in the async Tick path an // escaped exception is swallowed by Unity's event dispatch and the job would just // time out with no cause; in the sync path it would propagate out of WaitFor raw. var inner = (ex as System.Reflection.TargetInvocationException)?.InnerException ?? ex; var resolutionFailure = inner as WaitEvaluationException; var isResolutionFailure = resolutionFailure != null; var message = isResolutionFailure ? inner.Message : $"Condition read threw {inner.GetType().Name}: {inner.Message}"; lock (Gate) { if (m_State != WaitState.Pending) return; // canceled/interrupted while the poll was reading FramesObserved++; if (TolerateMissing && isResolutionFailure && !resolutionFailure.IsPermanent) { // tolerate_missing is scoped to the evaluator's STRUCTURED resolution // failures (type/member/instance not found) — its documented contract. // A member getter that THROWS is a real failure, not "does not exist // yet": retrying it would leave the wait pending until timeout and // bury the actual exception. Nor is a PERMANENT resolution failure (a // malformed path, a findType naming a non-Object type, a member that // doesn't exist on the resolved type) — no amount of retrying makes those // resolvable, so tolerating them would just bury a real misconfiguration // until the timeout instead of failing fast. Error = message; return; } m_State = WaitState.Failed; Error = message; MarkTerminalLocked(); } return; } object initialSnapshot; bool haveInitial; lock (Gate) { if (m_State != WaitState.Pending) return; // canceled/interrupted while the poll was reading FramesObserved++; // A successful read supersedes any tolerated resolution error recorded above. Error = null; ResolutionNote = plan.ResolutionNote; if (!HaveInitial) { InitialValue = value; HaveInitial = true; } LastValue = value; initialSnapshot = InitialValue; haveInitial = HaveInitial; if (ReturnHistory) { // Ring buffer keeping the LAST samples: the tail near the transition is what // diagnoses a wait, not the first 200 identical pre-transition observations. if (History.Count >= MaxHistorySamples) History.RemoveAt(0); History.Add(new WaitHistorySample { Value = WaitForCommands.Jsonify(value), ElapsedMs = ElapsedMs }); } } bool met; string evalError; try { met = WaitConditionEvaluator.Evaluate(Condition.Op, value, Condition.Value, memberType, initialSnapshot, haveInitial, out evalError); } catch (Exception ex) { met = false; evalError = ex.Message; } if (!string.IsNullOrEmpty(evalError)) { lock (Gate) { if (m_State != WaitState.Pending) return; if (TolerateMissing) { // Same contract as tolerated resolution failures: "cannot compare YET" // (e.g. greaterThan while a nullable field is still null) retries until // the budget expires, with the last error surfaced on timeout. The // permanently-misconfigured variant (contains on a non-string member) // spins the budget instead of failing fast — the same indistinguishable // transient-vs-permanent tradeoff as null intermediates, accepted and // documented. Getter exceptions still fail immediately (handled above). Error = evalError; return; } m_State = WaitState.Failed; Error = evalError; MarkTerminalLocked(); } return; } if (!met) return; // wait_cancel/MarkInterrupted/MarkTimedOut run on a different thread and can land any // time from here on. Claim the job now (still Pending, so a status read in this window // sees "pending") BEFORE running the on_met follow-up's side effects: this claim, not a // state check either side of the follow-up, is what must be atomic with "am I about to // pause/capture for real". Checking-then-running-then-checking-again left a window // where a cancel could land IN BETWEEN — the pause/screenshot would still fire for // real, but the job would then commit to canceled, silently discarding all trace that a // real side effect happened (see review round 5). Once claimed, MarkCanceled / // MarkInterrupted / MarkTimedOut all no-op (see TryEnterTerminal) for the rest of this // poll, so the final commit below needs no re-check. lock (Gate) { if (m_State != WaitState.Pending) return; m_Resolving = true; } // Same-frame follow-up: this runs on the main thread within the poll that first saw the // condition true, so a capture reflects that frame and a pause takes effect immediately. WaitCaptureResult capture = null; string followupError = null; if (OnMet != null) { try { if (OnMet.Pause && Application.isPlaying) EditorApplication.isPaused = true; if (OnMet.Capture != null) capture = WaitForCommands.RunCapture(OnMet.Capture); } catch (Exception ex) { followupError = $"on_met action failed: {ex.Message}"; } } lock (Gate) { m_State = WaitState.Met; m_Resolving = false; Capture = capture; // The CONDITION held — the wait is met; a failed follow-up (capture/pause) is // reported on its own field, never in Error: docs define error as the reason a // wait FAILED, and stashing it there made a met-with-broken-capture reply look // like a documented failure shape while state said otherwise. FollowupError = followupError; MarkTerminalLocked(); } } /// /// Claim the terminal transition to , running /// (e.g. to stamp ) under the same lock before freezing . /// No-ops once the job is already terminal, OR mid-on_met-follow-up (see /// ) — a wait that already committed to running real side effects /// must finish committing to met, never be re-claimed as canceled/interrupted/timedOut. /// private void TryEnterTerminal(WaitState state, Action onClaimed = null) { lock (Gate) { if (m_State != WaitState.Pending || m_Resolving) return; m_State = state; onClaimed?.Invoke(); MarkTerminalLocked(); } } internal void MarkTimedOut() => TryEnterTerminal(WaitState.TimedOut); internal void MarkCanceled(string reason = null) => TryEnterTerminal(WaitState.Canceled, () => { if (!string.IsNullOrEmpty(reason)) Error = reason; }); internal void MarkInterrupted(string reason = null) => TryEnterTerminal(WaitState.Interrupted, () => { // The interruption cause always wins: a stale tolerated-resolution message left by // an earlier poll must not masquerade as the reason the wait ended. Error = !string.IsNullOrEmpty(reason) ? reason : "Wait interrupted by a domain reload or exiting play mode."; }); internal WaitResult ToResult(bool includeWaitId) { lock (Gate) { return new WaitResult { WaitId = includeWaitId ? Id : null, State = State, Met = Met, TimedOut = TimedOut, Interrupted = Interrupted ? (bool?)true : null, Member = Condition?.Member, Op = string.IsNullOrEmpty(Condition?.Op) ? "equals" : Condition.Op, Value = HaveInitial ? WaitForCommands.Jsonify(LastValue) : null, InitialValue = HaveInitial ? WaitForCommands.Jsonify(InitialValue) : null, ElapsedMs = ElapsedMs, FramesObserved = FramesObserved, Error = Error, OnMetError = FollowupError, Note = ResolutionNote, Capture = Capture, History = (ReturnHistory && History.Count > 0) ? new List(History) : null }; } } } /// /// Process-wide registry of s (AUTHAPI-25). Async waits are polled here on /// EditorApplication.update; synchronous waits register too (so the concurrency cap and /// interruption hooks cover them) but are driven by their own command loop. A domain reload or /// exiting play mode interrupts every active wait so it resolves to a structured /// interrupted result rather than hanging or leaking. ENTERING play mode does not /// interrupt: with domain reload disabled a wait survives into play mode; with reload enabled, /// beforeAssemblyReload interrupts it anyway. /// /// PROCESS-WIDE by design — deliberately unlike , which is /// per-server so one server's command results never cross into another. A wait has no /// per-server state to isolate: it observes process-global editor state, is polled by the /// single EditorApplication.update pump, and is interrupted by process-level events /// (domain reload, play-mode exit). Ids are GUIDs, so waits from different servers cannot /// collide, and intentionally bounds the shared update pump /// rather than acting as a per-server quota. If true per-server isolation ever becomes a /// requirement, scope this like JobRegistry at that point. /// /// In-memory: a domain reload discards pending waits the same way it discards every other /// static — a poller then gets a clear "not found / does not survive domain reloads" result. /// internal static class WaitRegistry { /// Maximum simultaneously-active (non-terminal) waits; a further submission is rejected. internal const int MaxConcurrentWaits = 8; /// Completed async jobs retained (for wait_status) beyond this count are pruned oldest-first. internal const int MaxRetainedTerminal = 32; private static readonly Dictionary s_Jobs = new Dictionary(); private static readonly object s_Gate = new object(); /// /// Register a job, unless waits are already active (returns /// false with a message the caller surfaces as a structured error). /// internal static bool TryRegister(WaitJob job, out string error) { lock (s_Gate) { PruneTerminal(); var active = s_Jobs.Values.Count(j => !j.IsTerminal); if (active >= MaxConcurrentWaits) { error = $"Too many concurrent waits: {active} active (max {MaxConcurrentWaits}). Cancel one with wait_cancel or wait for one to complete."; return false; } s_Jobs[job.Id] = job; } error = null; return true; } internal static bool TryGet(string id, out WaitJob job) { lock (s_Gate) { return s_Jobs.TryGetValue(id ?? string.Empty, out job); } } /// /// Cancel a tracked wait by id. Returns true whenever the id is known — including when the /// job had already reached a terminal state before this call: /// no-ops in that case, so 's state stays whatever it actually resolved /// to (never forced to "canceled") — see the honest-cancel-result note in wait.md. /// internal static bool Cancel(string id, out WaitJob job) { if (!TryGet(id, out job)) return false; job.MarkCanceled(); return true; } internal static void Remove(string id) { lock (s_Gate) { s_Jobs.Remove(id ?? string.Empty); } } internal static int ActiveCount { get { lock (s_Gate) { return s_Jobs.Values.Count(j => !j.IsTerminal); } } } /// /// Poll all active async waits whose poll interval has elapsed, and time out any that have /// exceeded their budget. Subscribed to EditorApplication.update; also callable /// directly from tests to drive async waits deterministically. /// internal static void Tick() { List pending = null; lock (s_Gate) { foreach (var job in s_Jobs.Values) { if (job.IsAsync && !job.IsTerminal) (pending ?? (pending = new List())).Add(job); } } if (pending != null) { foreach (var job in pending) { if (job.ElapsedMs >= job.TimeoutMs) { // Final look, unconditionally — full parity with the sync loop, which polls // before every deadline check. A condition that flipped true between the // last scheduled poll and the deadline must resolve met, not timedOut (and // a never-polled job still reports a real value/framesObserved). job.PollOnce(); if (!job.IsTerminal) job.MarkTimedOut(); continue; } double sincePoll; lock (job.Gate) { sincePoll = job.LastPollAt.HasValue ? (DateTime.UtcNow - job.LastPollAt.Value).TotalMilliseconds : double.MaxValue; } if (sincePoll >= job.PollIntervalMs) job.PollOnce(); } } } /// /// Subscriptions are installed unconditionally once per domain load, on the main thread — /// the same idiom as every other EditorApplication.update poller in this package /// (bake status, build, target switch). and /// no-op when nothing is registered, so the permanent hooks cost one delegate call per /// update. This replaces a lazy install/uninstall state machine whose main-thread marshal /// could fail while the very jobs that needed the pump sat frozen. /// [InitializeOnLoadMethod] private static void HookOnDomainLoad() { EditorApplication.update += Tick; AssemblyReloadEvents.beforeAssemblyReload += InterruptAll; EditorApplication.playModeStateChanged += OnPlayModeStateChanged; } private static void OnPlayModeStateChanged(PlayModeStateChange change) { // Interrupt only when LEAVING play mode: the objects a wait observes are torn down // then. Entering play mode does not interrupt — with domain reload disabled the wait // survives into play mode (the common wait-for-gameplay-state case); with reload // enabled, beforeAssemblyReload interrupts it anyway. if (change == PlayModeStateChange.ExitingPlayMode) InterruptAll(); } private static void InterruptAll() { List active; lock (s_Gate) { active = s_Jobs.Values.Where(j => !j.IsTerminal).ToList(); } foreach (var job in active) job.MarkInterrupted(); } private static void PruneTerminal() { // Oldest-COMPLETED first, not oldest-submitted: a long-running wait can have the // earliest StartedAt of any job in the registry yet be the most recently finished, and // sorting by StartedAt would evict it the instant it resolves while far-shorter waits // that finished long ago (but were submitted later) survive. var terminal = s_Jobs.Values .Where(j => j.IsTerminal) .OrderBy(j => j.TerminalAtUtc) .ToList(); var excess = terminal.Count - MaxRetainedTerminal; for (var i = 0; i < excess; i++) s_Jobs.Remove(terminal[i].Id); } /// Test hook: forget every tracked wait (main thread). The editor hooks installed /// by are permanent by design and deliberately NOT removed — /// do not re-subscribe them from tests. internal static void ResetForTests() { lock (s_Gate) { s_Jobs.Clear(); } } /// Test hook: exercise the domain-reload/play-mode interruption transition without a live reload. internal static void InterruptAllForTests() => InterruptAll(); /// Test hook: exercise the play-mode filter (interrupt on exit only) without entering play mode. internal static void SimulatePlayModeChangeForTests(PlayModeStateChange change) => OnPlayModeStateChanged(change); } }