Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// One active or completed wait tracked by <see cref="WaitRegistry"/> (AUTHAPI-25).
|
||||
///
|
||||
/// The condition is evaluated by <see cref="PollOnce"/>, which MUST run on the main thread: the
|
||||
/// synchronous <c>wait_for</c> path marshals it there via the server dispatcher between sleeps
|
||||
/// (so the main thread is never held hostage), while an <c>async=true</c> wait is polled by
|
||||
/// <see cref="WaitRegistry.Tick"/> on <c>EditorApplication.update</c>. Any follow-up action
|
||||
/// (<c>on_met.capture</c>/<c>pause</c>) runs inside the same <see cref="PollOnce"/> call that
|
||||
/// first observes the condition true, so it acts in that same editor frame.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Guards mutable state below; read from the request/background thread, mutated on the main thread.</summary>
|
||||
public readonly object Gate = new object();
|
||||
|
||||
// --- guarded by Gate ---
|
||||
/// <summary>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.</summary>
|
||||
private WaitState m_State = WaitState.Pending;
|
||||
/// <summary>Set while still-<c>Pending</c> but already committed to running the <c>on_met</c>
|
||||
/// follow-up (its pause/capture side effects are about to happen for real, off the lock).
|
||||
/// Blocks <see cref="TryEnterTerminal"/> 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.</summary>
|
||||
private bool m_Resolving;
|
||||
public object InitialValue;
|
||||
public bool HaveInitial;
|
||||
public object LastValue;
|
||||
public string Error;
|
||||
public string ResolutionNote;
|
||||
public int FramesObserved;
|
||||
|
||||
/// <summary>Error from the on_met follow-up (capture/pause) when the condition held but the
|
||||
/// follow-up threw — reported as <c>onMetError</c>, distinct from <see cref="Error"/>.</summary>
|
||||
public string FollowupError;
|
||||
public WaitCaptureResult Capture;
|
||||
public readonly List<WaitHistorySample> History = new List<WaitHistorySample>();
|
||||
public DateTime? LastPollAt;
|
||||
/// <summary>UTC time of the terminal transition; freezes <see cref="ElapsedMs"/> so a
|
||||
/// completed wait's elapsed time stops growing while it sits retained for wait_status.</summary>
|
||||
private DateTime? m_TerminalAtUtc;
|
||||
// --- end guarded ---
|
||||
|
||||
/// <summary>NOTE: terminal transitions must go through <see cref="TryEnterTerminal"/> —
|
||||
/// it also checks <see cref="m_Resolving"/> (committed-to-on_met, not yet Met); a path
|
||||
/// testing only <c>m_State != Pending</c> would reopen the cancel-vs-met race.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Wire form: "pending" | "met" | "timedOut" | "interrupted" | "canceled" | "failed".</summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Milliseconds since the wait started, frozen at the terminal transition.</summary>
|
||||
public long ElapsedMs
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
var end = m_TerminalAtUtc ?? DateTime.UtcNow;
|
||||
return (long)(end - StartedAt).TotalMilliseconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Record the terminal timestamp. Must be called with <see cref="Gate"/> held.</summary>
|
||||
private void MarkTerminalLocked()
|
||||
{
|
||||
if (m_TerminalAtUtc == null)
|
||||
m_TerminalAtUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>UTC time this wait became terminal, or null while still pending. Set atomically
|
||||
/// with the state transition (see <see cref="MarkTerminalLocked"/>), so any job observed as
|
||||
/// <see cref="IsTerminal"/> 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
|
||||
/// <see cref="StartedAt"/> in the registry yet be the most recently finished).</summary>
|
||||
public DateTime? TerminalAtUtc
|
||||
{
|
||||
get { lock (Gate) { return m_TerminalAtUtc; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>met</c>. Resolution failures transition to <c>failed</c> (never to <c>timedOut</c>)
|
||||
/// unless <c>tolerate_missing</c> 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Claim the terminal transition to <paramref name="state"/>, running <paramref name="onClaimed"/>
|
||||
/// (e.g. to stamp <see cref="Error"/>) under the same lock before freezing <see cref="ElapsedMs"/>.
|
||||
/// No-ops once the job is already terminal, OR mid-<c>on_met</c>-follow-up (see
|
||||
/// <see cref="m_Resolving"/>) — a wait that already committed to running real side effects
|
||||
/// must finish committing to <c>met</c>, never be re-claimed as canceled/interrupted/timedOut.
|
||||
/// </summary>
|
||||
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<WaitHistorySample>(History)
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide registry of <see cref="WaitJob"/>s (AUTHAPI-25). Async waits are polled here on
|
||||
/// <c>EditorApplication.update</c>; 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
|
||||
/// <c>interrupted</c> 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,
|
||||
/// <c>beforeAssemblyReload</c> interrupts it anyway.
|
||||
///
|
||||
/// PROCESS-WIDE by design — deliberately unlike <see cref="PipelineJobRegistry"/>, 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 <c>EditorApplication.update</c> pump, and is interrupted by process-level events
|
||||
/// (domain reload, play-mode exit). Ids are GUIDs, so waits from different servers cannot
|
||||
/// collide, and <see cref="MaxConcurrentWaits"/> 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.
|
||||
/// </summary>
|
||||
internal static class WaitRegistry
|
||||
{
|
||||
/// <summary>Maximum simultaneously-active (non-terminal) waits; a further submission is rejected.</summary>
|
||||
internal const int MaxConcurrentWaits = 8;
|
||||
|
||||
/// <summary>Completed async jobs retained (for wait_status) beyond this count are pruned oldest-first.</summary>
|
||||
internal const int MaxRetainedTerminal = 32;
|
||||
|
||||
private static readonly Dictionary<string, WaitJob> s_Jobs = new Dictionary<string, WaitJob>();
|
||||
private static readonly object s_Gate = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Register a job, unless <see cref="MaxConcurrentWaits"/> waits are already active (returns
|
||||
/// false with a message the caller surfaces as a structured error).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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: <see cref="WaitJob.MarkCanceled"/>
|
||||
/// no-ops in that case, so <paramref name="job"/>'s state stays whatever it actually resolved
|
||||
/// to (never forced to "canceled") — see the honest-cancel-result note in wait.md.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Poll all active async waits whose poll interval has elapsed, and time out any that have
|
||||
/// exceeded their budget. Subscribed to <c>EditorApplication.update</c>; also callable
|
||||
/// directly from tests to drive async waits deterministically.
|
||||
/// </summary>
|
||||
internal static void Tick()
|
||||
{
|
||||
List<WaitJob> pending = null;
|
||||
lock (s_Gate)
|
||||
{
|
||||
foreach (var job in s_Jobs.Values)
|
||||
{
|
||||
if (job.IsAsync && !job.IsTerminal)
|
||||
(pending ?? (pending = new List<WaitJob>())).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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscriptions are installed unconditionally once per domain load, on the main thread —
|
||||
/// the same idiom as every other <c>EditorApplication.update</c> poller in this package
|
||||
/// (bake status, build, target switch). <see cref="Tick"/> and <see cref="InterruptAll"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<WaitJob> 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);
|
||||
}
|
||||
|
||||
/// <summary>Test hook: forget every tracked wait (main thread). The editor hooks installed
|
||||
/// by <see cref="HookOnDomainLoad"/> are permanent by design and deliberately NOT removed —
|
||||
/// do not re-subscribe them from tests.</summary>
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
lock (s_Gate)
|
||||
{
|
||||
s_Jobs.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test hook: exercise the domain-reload/play-mode interruption transition without a live reload.</summary>
|
||||
internal static void InterruptAllForTests() => InterruptAll();
|
||||
|
||||
/// <summary>Test hook: exercise the play-mode filter (interrupt on exit only) without entering play mode.</summary>
|
||||
internal static void SimulatePlayModeChangeForTests(PlayModeStateChange change) => OnPlayModeStateChanged(change);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user