using System; using System.Collections.Generic; using System.Threading; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Unity.Pipeline; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor.Commands.Capture; using Unity.Pipeline.Models; namespace Unity.Pipeline.Editor.Commands { /// /// Server-side condition waiting (AUTHAPI-25): wait_for blocks until a runtime/editor /// member condition holds — evaluated per editor frame on the main thread — then optionally acts /// in that same frame (on_met.capture/pause). This replaces the dominant agent /// anti-pattern of client-side polling loops (read a value via eval, sleep in the client, repeat), /// which is slow and can miss short-lived states across client round-trips. /// /// The wait runs off the main thread (MainThreadRequired = false) and marshals each poll /// onto the main thread via the server dispatcher between sleeps, so the editor itself never /// freezes. BUT a synchronous wait holds the exec gate for its whole duration: /api/exec /// serializes commands, so every other command — including one whose effect the condition is /// waiting for — queues behind it until it resolves (a self-deadlock that only breaks at the /// timeout). Synchronous waits are therefore only for SHORT, self-contained conditions; pass /// async=true for anything else to get a wait_id back immediately and poll /// wait_status / cancel with wait_cancel while other commands keep executing. /// static class WaitForCommands { /// Poll interval clamp: below ~a frame is pointless, above 5s risks missing states and starves the timeout check. internal const double MinPollIntervalMs = 16; internal const double MaxPollIntervalMs = 5000; private static readonly string[] s_ValidOps = { "equals", "notEquals", "greaterThan", "lessThan", "contains", "changed" }; /// /// The action used for on_met.capture, indirected so tests can observe that it runs in /// the same frame the condition holds without requiring a GPU (the real capture path needs /// one). Mirrors RecompileCommand.s_FocusAction. /// internal static Func s_CaptureAction = DefaultCapture; /// Grace budget for the never-observed final look on a sync timeout (see the loop). private const int FinalLookGraceMs = 1000; [CliCommand("wait_for", "Wait server-side until a member condition holds, then optionally act in the same frame. Sync waits hold the exec queue (every other command waits behind them, including one the condition may depend on) — keep them short and self-contained; set async=true and poll wait_status for anything else.", MainThreadRequired = false, Tags = new[] { "wait" })] public static object WaitFor( [CliArg("condition", "Condition to wait for: { member, target?, findType?, op, value }. member is a dotted path (static, or instance under target/findType); op is equals|notEquals|greaterThan|lessThan|contains|changed.", Required = true)] WaitConditionInput condition = null, [CliArg("timeout_s", "Maximum seconds to wait (default 30, clamped to 0-600; must be finite).")] double timeoutS = 30, [CliArg("poll_interval_ms", "How often to re-check the condition, in milliseconds (default 100, clamped to 16-5000; must be finite).")] double pollIntervalMs = 100, [CliArg("on_met", "Follow-up run in the same editor frame the condition first holds: { capture: { view, source, save_path, width, height, include_image }, pause }.")] WaitOnMetInput onMet = null, [CliArg("return_history", "Include the observed value/timestamp samples (a ring buffer of the last 200, so the tail near the transition is kept).")] bool returnHistory = false, [CliArg("tolerate_missing", "Treat resolution failures (type/member/instance not found) as condition-not-met and retry until timeout, instead of failing fast. Enables waiting for something to exist (e.g. a spawned object via findType).")] bool tolerateMissing = false, [CliArg("async", "Return a wait_id immediately and evaluate in the background; poll wait_status and cancel with wait_cancel. Use this for long waits and whenever the condition depends on a command you still need to issue — a sync wait blocks it.")] bool async = false) { var validationError = ValidateCondition(condition); if (validationError != null) throw new ArgumentException(validationError); if (double.IsNaN(timeoutS) || double.IsInfinity(timeoutS)) throw new ArgumentException("timeout_s must be a finite number of seconds (0-600)."); if (double.IsNaN(pollIntervalMs) || double.IsInfinity(pollIntervalMs)) throw new ArgumentException($"poll_interval_ms must be a finite number of milliseconds ({MinPollIntervalMs}-{MaxPollIntervalMs})."); var timeoutMs = Clamp(timeoutS, 0, 600) * 1000.0; var interval = Clamp(pollIntervalMs, MinPollIntervalMs, MaxPollIntervalMs); var job = new WaitJob { Id = Guid.NewGuid().ToString("N"), Condition = condition, Plan = new WaitConditionPlan(condition), OnMet = onMet, ReturnHistory = returnHistory, TolerateMissing = tolerateMissing, IsAsync = async, TimeoutMs = timeoutMs, PollIntervalMs = interval, StartedAt = DateTime.UtcNow }; if (!WaitRegistry.TryRegister(job, out var registerError)) throw new InvalidOperationException(registerError); if (async) return job.ToResult(includeWaitId: true); try { while (true) { if (PipelineCancellation.IsCancellationRequested) { // A detached ("job": true) sync wait stays cancelable via /api/job/cancel. job.MarkCanceled("Wait canceled by job cancellation request."); break; } try { // Bound the per-poll marshal by the wait's own remaining budget (min 1 ms, // capped at the dispatcher default) instead of inheriting the dispatcher's // 60s default: a stalled main thread must not hold the exec gate for 60s // when this wait's whole timeout_s is shorter. var marshalBudgetMs = (int)Math.Max(1, Math.Min(timeoutMs - job.ElapsedMs, 60000)); RunOnMainThread(job.PollOnce, marshalBudgetMs); } catch (TimeoutException) { // A long main-thread stall (import, modal bake, ...) timed out the per-poll // marshal after the dispatcher's default budget. That is a missed poll, not // a failed wait — keep going until the wait's own budget expires. } catch (OperationCanceledException) { // The dispatcher is shutting down; never fall back to evaluating Unity // state on this pool thread. job.MarkInterrupted("Wait interrupted: the server is stopping."); break; } if (job.IsTerminal) break; var remainingMs = timeoutMs - job.ElapsedMs; if (remainingMs <= 0) { // Never resolve timedOut having observed NOTHING: if every earlier poll // missed (main thread busy, tight budget), grant one final look with a // small grace so the result carries a real value/framesObserved — parity // with the Tick path's final look. if (job.FramesObserved == 0) { try { RunOnMainThread(job.PollOnce, FinalLookGraceMs); } catch (TimeoutException) { /* still stalled; report the bare timeout */ } catch (OperationCanceledException) { job.MarkInterrupted("Wait interrupted: the server is stopping."); break; } } if (!job.IsTerminal) job.MarkTimedOut(); break; } // Never sleep past the remaining budget (a large interval must not stretch a // short timeout), and wake early if the surrounding job is canceled. if (!SleepUnlessCanceled(Math.Min(interval, remainingMs), job)) { job.MarkCanceled("Wait canceled by job cancellation request."); break; } } return job.ToResult(includeWaitId: false); } finally { // A synchronous wait is fully reported in its own response; do not retain it. WaitRegistry.Remove(job.Id); } } /// /// Sleep for in short slices, returning false as soon as cooperative /// job cancellation is requested, and returning early (true) the moment /// turns terminal — a domain-reload/play-mode interruption landing /// mid-sleep must not wait out the rest of a poll interval (up to 5s) before the caller /// notices; the loop re-checks IsTerminal at its top. /// private static bool SleepUnlessCanceled(double ms, WaitJob job) { var remaining = ms; while (remaining > 0) { if (PipelineCancellation.IsCancellationRequested) return false; if (job.IsTerminal) return true; // Ceiling, never truncation: a fractional remaining (e.g. timeout_s=32.3 ends // as ...999.9996 ms) would truncate to a 0 slice that never decrements // `remaining` — an infinite spin that never releases the exec gate. var slice = (int)Math.Ceiling(Math.Min(100.0, remaining)); Thread.Sleep(slice); remaining -= slice; } return !PipelineCancellation.IsCancellationRequested; } [CliCommand("wait_status", "Get the status/result of an async wait started with wait_for (async=true).", MainThreadRequired = false, Tags = new[] { "wait" })] public static object WaitStatus( [CliArg("wait_id", "The wait id returned by wait_for when async=true.", Required = true)] string waitId = null) { if (string.IsNullOrEmpty(waitId)) throw new ArgumentException("wait_id is required."); if (!WaitRegistry.TryGet(waitId, out var job)) return new WaitResult { WaitId = waitId, State = "not_found", Error = "No wait with that id (async waits do not survive domain reloads; completed waits are retained up to a bounded count and pruned oldest-first when a new wait is submitted)." }; return job.ToResult(includeWaitId: true); } [CliCommand("wait_cancel", "Cancel an async wait started with wait_for (async=true).", MainThreadRequired = false, Tags = new[] { "wait" })] public static object WaitCancel( [CliArg("wait_id", "The wait id to cancel.", Required = true)] string waitId = null) { if (string.IsNullOrEmpty(waitId)) throw new ArgumentException("wait_id is required."); if (!WaitRegistry.Cancel(waitId, out var job)) return new WaitResult { WaitId = waitId, State = "not_found", Error = "No wait with that id." }; return job.ToResult(includeWaitId: true); } /// Validate the condition's shape up front so bad parameters fail fast as a 400 (not a wait result). internal static string ValidateCondition(WaitConditionInput condition) { if (condition == null) return "condition is required."; if (string.IsNullOrWhiteSpace(condition.Member)) return "condition.member is required."; if (condition.Target != null && !condition.Target.IsEmpty && !string.IsNullOrEmpty(condition.FindType)) return "condition.target and condition.findType are mutually exclusive — set one or the other " + "(the evaluator would otherwise silently prefer target and observe the wrong object)."; var op = string.IsNullOrEmpty(condition.Op) ? "equals" : condition.Op.Trim(); if (Array.IndexOf(s_ValidOps, op) < 0) return $"condition.op '{op}' is invalid. Use one of: {string.Join(", ", s_ValidOps)}."; // Value is a JToken precisely so an OMITTED key (null reference) is distinguishable // from an EXPLICIT JSON null (JTokenType.Null): omitting the operand is always an // error outside op=changed (a forgotten value must not silently become an // equals-null wait that can resolve immediately), while an explicit null is a // meaningful equality operand — "wait until destroyed/null". if (op != "changed" && condition.Value == null) return $"condition.value is required for op '{op}' (send an explicit null to compare against null)."; if ((op == "greaterThan" || op == "lessThan" || op == "contains") && condition.Value != null && condition.Value.Type == JTokenType.Null) return $"condition.value cannot be null for op '{op}'."; return null; } /// /// Run on the editor main thread via the executing server's /// dispatcher (or directly when already on it / no server, e.g. a direct unit-test call), /// waiting at most for the marshal — callers bound it by the /// wait's own remaining budget so a stalled main thread cannot hold the exec gate past /// this wait's timeout_s (the resulting is a missed poll). /// Throws when the dispatcher is shut down instead /// of falling back to evaluating Unity state on this pool thread — the caller resolves the /// wait as interrupted ("server stopping"). /// private static void RunOnMainThread(Action action, int timeoutMs) { var server = BasePipelineServer.CurrentServer; if (server == null) { // Direct in-process invocation (unit tests) on the test's main thread. action(); return; } if (server.Dispatcher.IsMainThread()) { action(); return; } if (!server.Dispatcher.IsInitialized) throw new OperationCanceledException("The server's main-thread dispatcher is shut down (server stopping)."); try { server.Dispatcher.Invoke(action, timeoutMs); } catch (InvalidOperationException) { // Shutdown() can flip Dispatcher.IsInitialized to false in the window between the // check above and this call; Invoke then throws InvalidOperationException instead // of running. Same outcome as catching it above: report as interrupted, not an // unhandled fault escaping the command. throw new OperationCanceledException("The server's main-thread dispatcher is shut down (server stopping)."); } } /// Reduce an observed value to a small, JSON-friendly form (enum names, primitives, or ToString()). internal static object Jsonify(object value) { if (value == null) return null; if (value is Enum e) return e.ToString(); if (value is string || value is bool || value is decimal || value.GetType().IsPrimitive) return value; if (value is UnityEngine.Object unityObject) // A cached LastValue/InitialValue can hold a reference that was alive when read // but has since been destroyed (e.g. a retained terminal job's value re-serialized // by a later wait_status call) — `!= null` misses Unity's fake-null and .ToString() // on a destroyed native object throws instead of serializing. return unityObject ? unityObject.ToString() : null; return value.ToString(); } internal static WaitCaptureResult RunCapture(WaitCaptureInput capture) => s_CaptureAction(capture); /// Test hook: restore the real capture action after a test swaps in a stub. internal static void ResetCaptureActionForTests() => s_CaptureAction = DefaultCapture; private static WaitCaptureResult DefaultCapture(WaitCaptureInput capture) { var view = (capture.View ?? "game").Trim().ToLowerInvariant(); if (view != "game" && view != "scene") throw new ArgumentException( $"Unknown view '{capture.View}' for on_met.capture. Use 'game' or 'scene'."); var width = capture.Width <= 0 ? 1280 : capture.Width; var height = capture.Height <= 0 ? 720 : capture.Height; var source = string.IsNullOrWhiteSpace(capture.Source) ? "camera" : capture.Source.Trim().ToLowerInvariant(); if (source != "camera" && source != "screen") throw new ArgumentException( $"Unknown source '{capture.Source}' for on_met.capture. Use 'camera' or 'screen'."); CaptureResult result = view == "scene" ? CaptureCommands.CaptureSceneView(width, height, capture.SavePath, capture.IncludeImage, 0) : CaptureCommands.CaptureGameView(width, height, null, capture.SavePath, capture.IncludeImage, 0, source); return new WaitCaptureResult { SavedPath = result.SavedPath, Base64 = result.Base64, Bytes = result.Bytes, Source = result.Source, Width = result.Width, Height = result.Height }; } private static double Clamp(double value, double min, double max) { if (value < min) return min; if (value > max) return max; return value; } } /// The condition wait_for polls: which member to read, on which object, and how to compare it. class WaitConditionInput : IStructuredCommandInput { [CliArg("member", "Dotted member path. With no target/findType it is a fully-qualified static path (e.g. \"UnityEditor.EditorApplication.isPlaying\"); with target/findType it walks instance members (e.g. \"State\" or \"Health.CurrentHealth\"). Read-only members only.", Required = true)] public string Member { get; set; } [CliArg("target", "Object handle (instanceId / hierarchyPath / guid / path / globalId) to read instance members from. Omit for a static member path or to use findType.")] public ObjectRef Target { get; set; } [CliArg("findType", "Fully-qualified UnityEngine.Object type name; the member walks from its first loaded instance. Alternative to target.")] public string FindType { get; set; } [CliArg("op", "Comparison: equals (default) | notEquals | greaterThan | lessThan | contains (strings) | changed (met on first change from the initially observed value; needs no value). changed is for value-like members (primitives/enums/strings); content mutations inside a reference-typed member are not detected.")] public string Op { get; set; } = "equals"; [CliArg("value", "Comparison operand, coerced to the member's type (primitive, enum name, or string). Required for every op except 'changed'. equals/notEquals accept an EXPLICIT null (e.g. wait-until-destroyed: a destroyed leaf value observes as null); omitting the key entirely is rejected.")] public JToken Value { get; set; } } /// Follow-up actions run in the same editor frame the condition first holds. class WaitOnMetInput : IStructuredCommandInput { [CliArg("capture", "Capture the game or scene view the moment the condition holds (reuses the capture command internals).")] public WaitCaptureInput Capture { get; set; } [CliArg("pause", "Pause play mode on the frame the condition holds (no-op outside play mode).")] public bool Pause { get; set; } } /// Capture options for on_met.capture (mirrors the capture commands' parameters). class WaitCaptureInput : IStructuredCommandInput { [CliArg("view", "Which view to capture: game (default) or scene.")] public string View { get; set; } = "game"; [CliArg("source", "Game view only: 'camera' (default) renders a camera and misses Screen Space - Overlay UI; 'screen' captures the composited game view incl. overlay canvases (HUDs), Play Mode only.")] public string Source { get; set; } [CliArg("save_path", "Project-relative path to write the PNG (e.g. Screenshots/victory.png).")] public string SavePath { get; set; } [CliArg("width", "Output width in px (default 1280).")] public int Width { get; set; } = 1280; [CliArg("height", "Output height in px (default 720).")] public int Height { get; set; } = 720; [CliArg("include_image", "Also return the PNG inline as base64 (default false: path-only when save_path is set).")] public bool IncludeImage { get; set; } } /// Result of a wait_for (sync response, or a snapshot from wait_status/wait_cancel). [Serializable] class WaitResult { /// Async wait id (present for async submissions and status/cancel snapshots). [JsonProperty("waitId", NullValueHandling = NullValueHandling.Ignore)] public string WaitId { get; set; } /// pending | met | timedOut | interrupted | canceled | failed | not_found. [JsonProperty("state")] public string State { get; set; } [JsonProperty("met")] public bool Met { get; set; } [JsonProperty("timedOut")] public bool TimedOut { get; set; } [JsonProperty("interrupted", NullValueHandling = NullValueHandling.Ignore)] public bool? Interrupted { get; set; } [JsonProperty("member", NullValueHandling = NullValueHandling.Ignore)] public string Member { get; set; } [JsonProperty("op", NullValueHandling = NullValueHandling.Ignore)] public string Op { get; set; } /// The most recently observed value (the value at the moment the wait resolved). [JsonProperty("value")] public object Value { get; set; } /// The first observed value (baseline for op=changed). [JsonProperty("initialValue")] public object InitialValue { get; set; } [JsonProperty("elapsedMs")] public long ElapsedMs { get; set; } [JsonProperty("framesObserved")] public int FramesObserved { get; set; } [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] public string Error { get; set; } /// Set when the condition HELD but the on_met follow-up (capture/pause) threw — /// the wait is still met; check this before trusting capture. [JsonProperty("onMetError", NullValueHandling = NullValueHandling.Ignore)] public string OnMetError { get; set; } /// Non-fatal resolution note (e.g. an ambiguous findType match bound deterministically). [JsonProperty("note", NullValueHandling = NullValueHandling.Ignore)] public string Note { get; set; } [JsonProperty("capture", NullValueHandling = NullValueHandling.Ignore)] public WaitCaptureResult Capture { get; set; } [JsonProperty("history", NullValueHandling = NullValueHandling.Ignore)] public List History { get; set; } } /// The PNG written / rendered by an on_met.capture. [Serializable] class WaitCaptureResult { [JsonProperty("savedPath", NullValueHandling = NullValueHandling.Ignore)] public string SavedPath { get; set; } [JsonProperty("base64", NullValueHandling = NullValueHandling.Ignore)] public string Base64 { get; set; } [JsonProperty("bytes")] public int Bytes { get; set; } [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] public string Source { get; set; } [JsonProperty("width")] public int Width { get; set; } [JsonProperty("height")] public int Height { get; set; } } /// One observed sample (value + elapsed time) recorded when return_history is set. [Serializable] class WaitHistorySample { [JsonProperty("value")] public object Value { get; set; } [JsonProperty("elapsedMs")] public long ElapsedMs { get; set; } } }