using System; using System.Collections.Generic; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor.Commands; using Unity.Pipeline.Models; using Unity.Pipeline.Security; using UnityEditor; using UnityEngine; #if UNITY_6000_5_OR_NEWER using Unity.Scripting.LifecycleManagement; #endif namespace Unity.Pipeline.Editor { #if UNITY_6000_5_OR_NEWER [NoAutoStaticsCleanup] #endif class EditorPipelineServer : BasePipelineServer { private InstanceDescriptor m_InstanceDescriptor; /// /// Guards mutate-then-write of m_InstanceDescriptor: /api/status and /api/editor_status /// both reach UpdateHeartBeat concurrently now, and InstanceDescriptor.WriteToProjectRoot's /// own lock only covers the write, not the field mutations feeding it. /// private readonly object m_HeartbeatLock = new object(); /// /// One-way settle latch (AUTHAPI-35). On a cold project import the server starts (and /// writes its descriptor) while the Editor is still importing assets / compiling scripts, /// so early main-thread commands land in a not-quite-ready Editor and fail opaquely. The /// latch stays false until the Editor is first seen idle after startup, and is scoped to /// the EDITOR SESSION via : once any server instance has /// seen the Editor idle, every later instance (domain-reload restart, test server) starts /// settled — a server coming up mid-session while a compile/import happens to be in /// flight (script-creating test fixtures, recompile loops) must NOT re-arm the gate. /// Volatile: set on the main thread, read from HTTP request threads. /// private volatile bool m_Settled; private bool m_SettleTickSubscribed; /// /// SessionState marker: the Editor has been seen idle at least once this session, i.e. /// the post-startup settle window is over for the whole session. SessionState survives /// domain reloads and dies with the Editor process — exactly the settle window's scope. /// Read/written on the main thread only (Start and the update tick); request threads /// only read the volatile snapshot. /// private const string SettledSessionKey = "Unity.Pipeline.EditorSessionSettled"; /// /// Commands that must stay servable while the Editor is settling: editor_status is the /// status surface callers poll to observe the busy/settling state itself. /// Background (MainThreadRequired = false) commands are always exempt and don't need /// listing here. /// private static readonly HashSet CommandsServiceableWhileSettling = new HashSet { "editor_status" }; /// /// editor_status's fields as of the instant a dialog was about to be shown — see /// . Once the dialog actually blocks the main /// thread, editor_status can no longer run for real (unlike the settle case above, where /// the thread is merely busy, not stuck inside a native message loop), so the dialog gate /// serves this snapshot instead. Nothing it describes (compiling/domain reload/play mode) /// can change while the thread that would update it is stuck, so it stays correct for the /// whole blocked window. Null until the first dialog of the session is about to be shown. /// private StatusResponse m_LastStatusBeforeDialog; /// When true, every command transaction is written to the project transaction log. public bool LogRequestsResponses { get; set; } /// /// Whether /api/status's ordinary (non-settling, non-dialog-blocked) case should report /// "ready" rather than "error". Defaults to whether the instance descriptor was actually /// created; a -false test double that is genuinely running /// can override this instead of faking a descriptor just to exercise that branch. /// protected virtual bool HasInstanceDescriptor => m_InstanceDescriptor != null; /// UTC time this server instance started listening. public override DateTime StartedAt => m_InstanceDescriptor == null ? new DateTime() : m_InstanceDescriptor.StartedAt; /// /// Whether the Editor has been seen idle (not importing, not compiling) at least once /// this editor session. Until then the server is "settling": main-thread commands are /// rejected with a retryable busy signal instead of executing into a half-ready Editor. /// Virtual so tests can pin the unsettled state. /// protected virtual bool IsSettled => m_Settled; /// /// Whether this Editor session has already completed its settle window (the SessionState /// marker any settling server sets on first idle). Main thread only. Exposed protected so /// tests can assert it as a precondition before probing the latch's immediate-settle path. /// protected static bool IsSessionSettled => SessionState.GetBool(SettledSessionKey, false); /// Arm the post-startup settle latch. protected override void ServerStarted() { InitializeSettleLatch(); } /// /// Whether the Editor was launched with -automated. A Unity console warning for this fact /// is unactionable (nobody watches the console in an agent-driven workflow) and, worse, was /// logged once per constructed server instance — the isolated test servers built per test /// (see TestEditorPipelineServer) spammed it dozens of times per suite run. The fact is /// instead surfaced as structured data on the instance descriptor (see /// ) — the one thing every client reads before it can /// connect at all (UUM-149977). /// internal static bool DetectAutomatedMode(string[] commandLineArgs) { foreach (var arg in commandLineArgs) { if (arg == "-automated") return true; } return false; } /// Unsubscribe the settle-tick handler. protected override void ServerStopped() { UnsubscribeSettleTick(); } /// /// Arm or immediately release the settle latch. Start() runs on the main thread, so the /// Editor state and SessionState are readable directly. A server starts settled when the /// session has already settled once (mid-session restarts after domain reloads, test /// servers started while a test's own compile/import is in flight) or when the Editor is /// idle right now — zero behavior change in both cases. Only a server started before the /// session's first idle moment (cold import: [InitializeOnLoad] runs inside the startup /// AssetDatabase refresh) arms the gate, and settles on the first update tick that sees /// the Editor idle. /// private void InitializeSettleLatch() { if (SessionState.GetBool(SettledSessionKey, false) || (!EditorApplication.isCompiling && !EditorApplication.isUpdating)) { MarkSettled(); return; } m_SettleTickSubscribed = true; EditorApplication.update += SettleTick; } private void SettleTick() { if (EditorApplication.isCompiling || EditorApplication.isUpdating) return; MarkSettled(); UnsubscribeSettleTick(); } /// /// Release this instance's latch and mark the whole session settled (main thread only). /// private void MarkSettled() { m_Settled = true; SessionState.SetBool(SettledSessionKey, true); } private void UnsubscribeSettleTick() { if (!m_SettleTickSubscribed) return; m_SettleTickSubscribed = false; EditorApplication.update -= SettleTick; } /// /// Busy gate (see ): while settling, reject /// main-thread commands with a retryable busy signal — during the post-start import/compile /// window they would otherwise execute into a half-ready Editor and fail with an opaque /// null-data envelope (AUTHAPI-35). Background commands (status polling: recompile_status, /// package_status, console, ...) and editor_status stay servable so callers can observe /// progress. Reads only the volatile latch — safe on the request thread. /// /// The command about to be dispatched. /// A busy reason while settling, or null once the Editor has been seen idle. protected override string GetBusyReason(CommandInfo command) { if (IsSettled) return null; if (!command.MainThreadRequired || CommandsServiceableWhileSettling.Contains(command.Name)) return null; return "The Editor is still settling after startup (importing assets / compiling scripts), " + "so main-thread commands are not serviceable yet. Retry shortly, or poll /api/status until it reports 'ready'."; } /// /// Called by EditorDialogStateMirror right before a dialog blocks the main thread (while /// it's still free to read live Editor state) — captures editor_status's fields for /// to serve while the dialog is open. /// internal void SnapshotStatusForDialogGate() { m_LastStatusBeforeDialog = EditorStatusCommand.GetEditorStatus(); } /// /// editor_status is the one MainThreadRequired command the dialog gate doesn't hard-fail: /// it can't run for real (see 's doc), but its answer /// is a snapshot taken the instant before the block started, which stays accurate for the /// whole window. No snapshot exists only if a dialog opened before this server ever saw one /// coming (should not happen in practice — EditorDialogStateMirror snapshots on every /// dialogWillShow), in which case this falls back to the normal 503. /// internal override object TryGetDialogBlockedFallback(CommandInfo command, DialogInfo blockingDialog) { if (command.Name != "editor_status" || m_LastStatusBeforeDialog == null) return null; return new StatusResponse { Status = "blocked_by_dialog", Compiling = m_LastStatusBeforeDialog.Compiling, DomainReloadInProgress = m_LastStatusBeforeDialog.DomainReloadInProgress, PlayMode = m_LastStatusBeforeDialog.PlayMode, LastHeartbeat = DateTime.UtcNow, ProjectPath = m_LastStatusBeforeDialog.ProjectPath, UnityVersion = m_LastStatusBeforeDialog.UnityVersion, Dialog = BuildDialogPayload(blockingDialog) }; } /// /// Post-command work, on the HTTP thread. /// /// The transaction log runs here directly rather than crossing to the main thread with the /// analytics: its append is thread-safe file I/O by design (see /// ), and driving it from the editor update loop would /// put a read-modify-write of the log on every frame that served a command. An info with no /// request JSON is a detached job reporting its real result, which was never part of a /// transaction. /// protected override void OnCommandDone(in CommandExecutionInfo info) { if (LogRequestsResponses && info.RequestJson != null) PipelineTransactionLog.Append(info.RequestJson, info.ResponseJson); // Requests rejected before anything ran carry no command, and nothing on the main-thread // side has anything to say about them. if (info.Command == null) return; // Copied out of the `in` parameter because a lambda cannot capture one. var posted = info; Dispatcher.Post(() => OnCommandDoneMainThread(posted)); } /// /// Post-command work that needs the main thread, run on the next dispatcher pump. Analytics /// lives here, and so does anything else added later that has to touch Unity APIs once a /// command is done. Virtual so a test server can opt out wholesale. /// protected virtual void OnCommandDoneMainThread(in CommandExecutionInfo info) { PipelineAnalytics.RecordCommandExecuted(info); } // Runtime-only commands (eval, codereload_status, …) are part of the Player surface; don't // advertise them when a client is connected to the Editor. /// False: Editor clients don't see runtime-only commands (they belong to the Player surface). protected override bool IncludeRuntimeOnlyCommands => false; /// Write the shared instance descriptor file so clients can discover this server. protected override void CreateInstanceDescriptor() { // Create and write instance descriptor for CLI discovery var automated = DetectAutomatedMode(System.Environment.GetCommandLineArgs()); m_InstanceDescriptor = InstanceDescriptor.CreateCurrent(Port, automated); InstanceDescriptor.WriteToProjectRoot(m_InstanceDescriptor); } /// Remove the shared instance descriptor file on shutdown. protected override void DeleteInstanceDescriptor() { // Clean up instance descriptor file if (m_InstanceDescriptor != null) { InstanceDescriptor.RemoveFromProjectRoot(m_InstanceDescriptor.ProjectPath); } m_InstanceDescriptor = null; } /// Refresh the descriptor's heartbeat timestamp and current token. protected override void UpdateHeartBeat() { // Update heartbeat in instance descriptor if (m_InstanceDescriptor == null) return; lock (m_HeartbeatLock) { // Keep the port file's token current for discovery only — GetToken() validates the // live token, so this just catches up to a rotation on the next heartbeat. m_InstanceDescriptor.EvalToken = SecurityTokenManager.GetOrCreateToken(); m_InstanceDescriptor.LastHeartbeat = DateTime.UtcNow; try { InstanceDescriptor.WriteToProjectRoot(m_InstanceDescriptor); } catch (Exception ex) { Debug.LogWarning($"Failed to update instance descriptor: {ex.Message}"); } } } /// Build the Editor-specific status/heartbeat payload for /api/status, including the settle state. /// The status payload. protected override object GetServerStatus() { UpdateHeartBeat(); // "blocked_by_dialog" is checked first: a modal dialog blocks the main thread // regardless of settle state, and is the more actionable fact for a caller — settling // resolves on its own, a dialog needs a human. Read off DialogStateTracker's lock, same // as /api/dialog, so this stays fast and needs no main-thread access. "settling" is // checked next: on a cold import the settle window precedes normal operation, and // clients waiting for "ready" (instead of firing commands blindly) land after the // Editor is actually serviceable (AUTHAPI-35). var openDialogs = Dialogs.CurrentlyOpen; string status = openDialogs.Count > 0 ? "blocked_by_dialog" : !IsSettled ? "settling" : (HasInstanceDescriptor ? "ready" : "error"); // Keep the common case's shape unchanged (no "dialog" key at all) rather than // always emitting "dialog": null — this is a widely-polled endpoint, not worth // changing its byte shape for every request just to cover the rare blocked case. // Gated on status itself, not openDialogs.Count, so a dialog that opens during the // settle window doesn't attach a "dialog" key to a "settling" response (StatusResponse's // dialog field is documented as present only when status is "blocked_by_dialog"). if (status == "blocked_by_dialog") { return new { status, lastHeartbeat = m_InstanceDescriptor?.LastHeartbeat, capabilities = Capabilities, dialog = BasePipelineServer.BuildDialogPayload(openDialogs[0]) }; } return new { status, lastHeartbeat = m_InstanceDescriptor?.LastHeartbeat, capabilities = Capabilities }; } /// The bearer token clients must present to authenticate requests. /// The current live token. protected override string GetToken() { // Validate the live token so a rotation/revocation takes effect on the next request (not // the next heartbeat). The descriptor's EvalToken is discovery-only. return SecurityTokenManager.GetOrCreateToken(); } } }