Files
AIGame/AIClient/Packages/com.unity.pipeline/Editor/Commands/WaitForCommands.cs
T

516 lines
26 KiB
C#

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
{
/// <summary>
/// Server-side condition waiting (AUTHAPI-25): <c>wait_for</c> blocks until a runtime/editor
/// member condition holds — evaluated per editor frame on the main thread — then optionally acts
/// in that same frame (<c>on_met.capture</c>/<c>pause</c>). 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 (<c>MainThreadRequired = false</c>) 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: <c>/api/exec</c>
/// 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
/// <c>async=true</c> for anything else to get a <c>wait_id</c> back immediately and poll
/// <c>wait_status</c> / cancel with <c>wait_cancel</c> while other commands keep executing.
/// </summary>
static class WaitForCommands
{
/// <summary>Poll interval clamp: below ~a frame is pointless, above 5s risks missing states and starves the timeout check.</summary>
internal const double MinPollIntervalMs = 16;
internal const double MaxPollIntervalMs = 5000;
private static readonly string[] s_ValidOps =
{ "equals", "notEquals", "greaterThan", "lessThan", "contains", "changed" };
/// <summary>
/// The action used for <c>on_met.capture</c>, 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 <c>RecompileCommand.s_FocusAction</c>.
/// </summary>
internal static Func<WaitCaptureInput, WaitCaptureResult> s_CaptureAction = DefaultCapture;
/// <summary>Grace budget for the never-observed final look on a sync timeout (see the loop).</summary>
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);
}
}
/// <summary>
/// Sleep for <paramref name="ms"/> in short slices, returning false as soon as cooperative
/// job cancellation is requested, and returning early (true) the moment
/// <paramref name="job"/> 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.
/// </summary>
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);
}
/// <summary>Validate the condition's shape up front so bad parameters fail fast as a 400 (not a wait result).</summary>
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;
}
/// <summary>
/// Run <paramref name="action"/> 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 <paramref name="timeoutMs"/> 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 <see cref="TimeoutException"/> is a missed poll).
/// Throws <see cref="OperationCanceledException"/> 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").
/// </summary>
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).");
}
}
/// <summary>Reduce an observed value to a small, JSON-friendly form (enum names, primitives, or ToString()).</summary>
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);
/// <summary>Test hook: restore the real capture action after a test swaps in a stub.</summary>
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;
}
}
/// <summary>The condition <c>wait_for</c> polls: which member to read, on which object, and how to compare it.</summary>
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; }
}
/// <summary>Follow-up actions run in the same editor frame the condition first holds.</summary>
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; }
}
/// <summary>Capture options for <c>on_met.capture</c> (mirrors the capture commands' parameters).</summary>
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; }
}
/// <summary>Result of a <c>wait_for</c> (sync response, or a snapshot from <c>wait_status</c>/<c>wait_cancel</c>).</summary>
[Serializable]
class WaitResult
{
/// <summary>Async wait id (present for async submissions and status/cancel snapshots).</summary>
[JsonProperty("waitId", NullValueHandling = NullValueHandling.Ignore)]
public string WaitId { get; set; }
/// <summary>pending | met | timedOut | interrupted | canceled | failed | not_found.</summary>
[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; }
/// <summary>The most recently observed value (the value at the moment the wait resolved).</summary>
[JsonProperty("value")]
public object Value { get; set; }
/// <summary>The first observed value (baseline for op=changed).</summary>
[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; }
/// <summary>Set when the condition HELD but the on_met follow-up (capture/pause) threw —
/// the wait is still met; check this before trusting <c>capture</c>.</summary>
[JsonProperty("onMetError", NullValueHandling = NullValueHandling.Ignore)]
public string OnMetError { get; set; }
/// <summary>Non-fatal resolution note (e.g. an ambiguous findType match bound deterministically).</summary>
[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<WaitHistorySample> History { get; set; }
}
/// <summary>The PNG written / rendered by an <c>on_met.capture</c>.</summary>
[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; }
}
/// <summary>One observed sample (value + elapsed time) recorded when return_history is set.</summary>
[Serializable]
class WaitHistorySample
{
[JsonProperty("value")]
public object Value { get; set; }
[JsonProperty("elapsedMs")]
public long ElapsedMs { get; set; }
}
}