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();
}
}
}