597 lines
27 KiB
C#
597 lines
27 KiB
C#
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);
|
|
}
|
|
}
|