2714 lines
130 KiB
C#
2714 lines
130 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Net;
|
||
using System.Text;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Unity.Pipeline.Models;
|
||
using Unity.Pipeline.Commands;
|
||
using Unity.Pipeline.Security;
|
||
using Unity.Pipeline.Threading;
|
||
using UnityEngine;
|
||
using Newtonsoft.Json;
|
||
using Newtonsoft.Json.Linq;
|
||
|
||
namespace Unity.Pipeline
|
||
{
|
||
/// <summary>
|
||
/// HTTP server that enables CLI tools to execute commands in Unity Editor.
|
||
/// Represents an Instance that can serve as a Pipeline Element for automation.
|
||
/// </summary>
|
||
public abstract class BasePipelineServer
|
||
{
|
||
private const long DefaultMaxRequestBodyBytes = 1 * 1024 * 1024;
|
||
|
||
/// <summary>
|
||
/// Maximum accepted request body size. Larger requests are rejected with 413, bounding the memory
|
||
/// one remote request can force the server to buffer. Settable so a server whose clients
|
||
/// legitimately post larger documents can raise it without every other server paying for it.
|
||
/// </summary>
|
||
public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes;
|
||
|
||
/// <summary>
|
||
/// Upper bound on how many bytes we read-and-discard from an over-limit request body before
|
||
/// sending the 413. HttpListener resets the TCP connection if we close the response while the
|
||
/// client is still uploading, and the client then sees a connection error instead of the 413.
|
||
/// Draining lets the client finish so it can read the response. Kept comfortably above
|
||
/// <see cref="MaxRequestBodyBytes"/> to cover clients only slightly over the cap, while still
|
||
/// bounding the work a single abusive request can force (a larger body simply resets).
|
||
/// </summary>
|
||
private const long MaxDrainBytes = 8 * 1024 * 1024;
|
||
|
||
/// <summary>
|
||
/// Maximum accepted body size for /api/job/cancel (4 KiB). The body is just an
|
||
/// <c>{"id": "..."}</c> object, so it needs far less headroom than <see cref="MaxRequestBodyBytes"/>.
|
||
/// </summary>
|
||
private const long MaxCancelBodyBytes = 4 * 1024;
|
||
|
||
/// <summary>
|
||
/// Dispatcher wait for a detached job's main-thread execution. Nothing is synchronously
|
||
/// waiting on this thread (the job's HTTP response was already sent), so this only needs
|
||
/// to be larger than any command's own timeout (e.g. eval's 24h cap) rather than bounded
|
||
/// by an HTTP-facing default.
|
||
/// </summary>
|
||
private const int UnboundedJobDispatcherTimeoutMs = int.MaxValue;
|
||
|
||
private bool m_IsRunning;
|
||
private int m_Port;
|
||
|
||
/// <summary>
|
||
/// Serializes /api/exec command execution now that requests are processed
|
||
/// concurrently: execs queue exactly as they did when the accept loop was serial,
|
||
/// while read-only endpoints (/api/status, /api/progress, …) answer immediately.
|
||
/// </summary>
|
||
private readonly SemaphoreSlim m_ExecGate = new SemaphoreSlim(1, 1);
|
||
private HttpListener m_HttpListener;
|
||
private readonly Dispatcher m_Dispatcher = new Dispatcher();
|
||
|
||
/// <summary>This server's own progress-reporting state (see CliProgress).</summary>
|
||
internal CliProgressState Progress { get; } = new CliProgressState();
|
||
|
||
/// <summary>This server's own modal-dialog state (see EditorDialogStateMirror, Task 7).</summary>
|
||
internal DialogStateTracker Dialogs { get; } = new DialogStateTracker();
|
||
|
||
/// <summary>This server's own detached-job registry (see PipelineCancellation, /api/job).</summary>
|
||
internal PipelineJobRegistry JobRegistry { get; } = new PipelineJobRegistry();
|
||
|
||
/// <summary>
|
||
/// The server instance currently executing a command on this thread, if any. Set only
|
||
/// around the actual command invocation (see <see cref="ExecuteCommandDirect"/>) so
|
||
/// CliProgress.Report/PipelineCancellation — called from arbitrary command code with no
|
||
/// reference to "which server is running me" — resolve to the right instance. Correct
|
||
/// regardless of which physical thread ends up running the command, since the push/read/pop
|
||
/// all happen within one synchronous call frame; a command that awaits and resumes on a
|
||
/// different thread before calling either API would not see it (no command does today).
|
||
/// </summary>
|
||
[ThreadStatic] private static BasePipelineServer m_CurrentServer;
|
||
internal static BasePipelineServer CurrentServer => m_CurrentServer;
|
||
|
||
private bool m_WatchdogEnabled;
|
||
private bool m_WatchdogArmed;
|
||
private DateTime m_LastWatchdogCheck;
|
||
|
||
/// <summary>
|
||
/// This server's own main-thread dispatcher. Each server instance owns one (no global
|
||
/// singleton), so tests that start their own server never affect any other server's
|
||
/// dispatch. Pump it via ProcessWorkQueue from the main thread (auto-pumped on
|
||
/// EditorApplication.update in the editor; pumped by RuntimePipelineDriver.Update in a
|
||
/// player; pumped explicitly by tests).
|
||
/// </summary>
|
||
public Dispatcher Dispatcher => m_Dispatcher;
|
||
|
||
/// <summary>
|
||
/// Whether the server is running AND its HTTP listener is actually listening. More accurate
|
||
/// than the internal running flag alone: returns false if the listener was stopped/disposed
|
||
/// (e.g. by a domain reload) even when the flag is stale-true. Cheap and non-blocking.
|
||
///
|
||
/// A self-HTTP probe was considered for "does it actually respond" but rejected: the server
|
||
/// processes requests strictly one-at-a-time (HandleRequests awaits ProcessRequest), so a
|
||
/// self-probe deadlocks if issued from within a handler and false-negatives whenever a
|
||
/// request is in flight — unsafe for a watchdog. IsListening reliably catches the realistic
|
||
/// failure (listener stopped by a domain reload), which is what we need.
|
||
/// </summary>
|
||
public bool IsRunning => m_IsRunning && m_HttpListener != null && m_HttpListener.IsListening;
|
||
|
||
/// <summary>
|
||
/// When enabled, the server periodically checks its own HTTP listener and re-opens it in
|
||
/// place if it died without going through <see cref="Stop"/> (e.g. an unexpected listener
|
||
/// fault). The server instance survives such failures — only the listener dies — so it can
|
||
/// self-heal without an external restart. Default OFF: transient/test servers must not
|
||
/// watchdog. The editor owner enables it for the live server.
|
||
///
|
||
/// Setting this while the server is running arms/disarms the watchdog immediately; otherwise
|
||
/// it takes effect on the next <see cref="Start"/>. Domain reloads are NOT handled here (the
|
||
/// instance is torn down and recreated by [InitializeOnLoad]); the watchdog only revives a
|
||
/// listener that died while the instance is still alive.
|
||
/// </summary>
|
||
public bool WatchdogEnabled
|
||
{
|
||
get => m_WatchdogEnabled;
|
||
set
|
||
{
|
||
if (m_WatchdogEnabled == value)
|
||
return;
|
||
m_WatchdogEnabled = value;
|
||
if (!m_IsRunning)
|
||
return;
|
||
if (value)
|
||
ArmWatchdog();
|
||
else
|
||
DisarmWatchdog();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// How often the watchdog checks the listener (seconds). Default 5.
|
||
/// </summary>
|
||
public double WatchdogIntervalSeconds { get; set; } = 5.0;
|
||
|
||
/// <summary>
|
||
/// Port number the server is listening on. 0 if not running.
|
||
/// Range: 7800-7899 for Editor, 7900-7999 for Runtime (avoids unity-tools port range 37800-37899).
|
||
/// </summary>
|
||
public int Port => m_Port;
|
||
|
||
/// <summary>UTC time this server instance started listening.</summary>
|
||
public abstract DateTime StartedAt { get; }
|
||
|
||
/// <summary>
|
||
/// Whether this server writes/deletes the shared instance descriptor (.unity-pipeline-port).
|
||
/// Test servers override this to false so they never clobber the live server's descriptor —
|
||
/// the test already knows its port, so no discovery file is needed.
|
||
/// </summary>
|
||
protected virtual bool WritesDescriptor => true;
|
||
|
||
/// <summary>
|
||
/// Whether this server advertises commands marked [CliCommand(RuntimeOnly = true)] in
|
||
/// its /api/commands listing. Runtime servers list them; Editor servers hide them so a
|
||
/// client connected to an Editor only sees the Editor command surface.
|
||
/// </summary>
|
||
protected virtual bool IncludeRuntimeOnlyCommands => true;
|
||
|
||
/// <summary>
|
||
/// Whether requests from a sandboxed browser frame (Origin: null) are accepted. Off by default;
|
||
/// an ordinary web page is refused either way, and a sandboxed one still needs the bearer token,
|
||
/// which lives in a file no browser can read.
|
||
/// </summary>
|
||
public bool AllowSandboxedBrowserClients
|
||
{
|
||
get => m_AllowSandboxedBrowserClients;
|
||
set => m_AllowSandboxedBrowserClients = value;
|
||
}
|
||
|
||
// Read on the listener thread, so volatile: a value set on the main thread must be seen there.
|
||
private volatile bool m_AllowSandboxedBrowserClients;
|
||
|
||
/// <summary>The only Origin a sandboxed frame reports; a real page sends its own origin.</summary>
|
||
private const string SandboxedOrigin = "null";
|
||
|
||
/// <summary>Write the shared instance descriptor file so clients can discover this server.</summary>
|
||
protected abstract void CreateInstanceDescriptor();
|
||
/// <summary>Remove the shared instance descriptor file on shutdown.</summary>
|
||
protected abstract void DeleteInstanceDescriptor();
|
||
/// <summary>Refresh the descriptor's heartbeat timestamp so discovery can detect a live server.</summary>
|
||
protected abstract void UpdateHeartBeat();
|
||
/// <summary>Build the host-specific payload for <c>/api/status</c> (Editor vs. Player fields differ).</summary>
|
||
/// <returns>The status payload.</returns>
|
||
protected abstract object GetServerStatus();
|
||
/// <summary>The bearer token clients must present to authenticate requests.</summary>
|
||
/// <returns>The current token.</returns>
|
||
protected abstract string GetToken();
|
||
|
||
/// <summary>
|
||
/// Optional wire features this server understands, advertised on /api/status and in the
|
||
/// port descriptor.
|
||
///
|
||
/// One array feeds both carriers so they cannot drift: a server that claims a capability
|
||
/// it lacks is worse than one that claims nothing, because a client's fallback is keyed on
|
||
/// the claim. Absence of the key entirely means the server is too old to have it, and
|
||
/// clients must then assume no raw command-line support.
|
||
/// </summary>
|
||
internal static readonly string[] Capabilities = { "exec.argv", "exec.commandLine" };
|
||
|
||
/// <summary>Hook invoked once the listener is accepting requests. No-op by default.</summary>
|
||
protected virtual void ServerStarted()
|
||
{
|
||
|
||
}
|
||
|
||
/// <summary>
|
||
/// Busy probe for the /api/exec gate: return a non-null, human-readable reason when the
|
||
/// host cannot service <paramref name="command"/> right now (e.g. the Editor is still
|
||
/// importing/compiling while settling after a cold start), or null to let it run. A busy
|
||
/// command is rejected with HTTP 503 and a structured, retryable envelope — before sync
|
||
/// execution and before a detached job is created — instead of executing into a
|
||
/// half-ready host and failing opaquely (AUTHAPI-35). Called on the request thread, so
|
||
/// implementations must only read thread-safe state. Default: never busy.
|
||
/// </summary>
|
||
/// <param name="command">The command about to be dispatched.</param>
|
||
/// <returns>A human-readable busy reason, or null to let the command run.</returns>
|
||
protected virtual string GetBusyReason(CommandInfo command) => null;
|
||
|
||
/// <summary>
|
||
/// For a command otherwise blocked by the dialog gate, an optional cached-but-still-valid
|
||
/// result to serve as a normal 200 success instead of the 503 (e.g. editor_status: nothing
|
||
/// about compilation/play-mode/domain-reload can change while the main thread is stuck
|
||
/// inside the dialog's native message loop, so a snapshot taken just before it blocked
|
||
/// stays correct for the whole window — see EditorPipelineServer's override). Called on the
|
||
/// request thread, so implementations must only read thread-safe state. Default: no
|
||
/// fallback exists, so every MainThreadRequired command stays hard-gated.
|
||
///
|
||
/// Internal, not protected: DialogInfo is internal, and a protected member can't expose a
|
||
/// less-accessible type in its signature — a subclass outside this assembly (without
|
||
/// InternalsVisibleTo) couldn't reference DialogInfo to override it. EditorPipelineServer
|
||
/// overrides this from Unity.Pipeline.Editor, which already has that grant (same as
|
||
/// Dialogs and BuildDialogPayload above).
|
||
/// </summary>
|
||
internal virtual object TryGetDialogBlockedFallback(CommandInfo command, DialogInfo blockingDialog) => null;
|
||
|
||
/// <summary>Hook invoked once the listener has stopped accepting requests. No-op by default.</summary>
|
||
protected virtual void ServerStopped()
|
||
{
|
||
|
||
}
|
||
|
||
/// <summary>
|
||
/// This server's current auth token. Exposed to the test assembly (via InternalsVisibleTo)
|
||
/// so the test client can authenticate without re-deriving the token.
|
||
/// </summary>
|
||
internal string Token => GetToken();
|
||
|
||
/// <summary>
|
||
/// Whether <see cref="Port"/> was picked from this server's range rather than requested by
|
||
/// the caller. Recorded at bind time, so it still describes the running listener after the
|
||
/// settings asset is edited.
|
||
/// </summary>
|
||
internal bool PortAutoAssigned { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Start the HTTP server on the specified port or auto-assign from range.
|
||
/// </summary>
|
||
/// <param name="port">Port to bind to, or 0 for auto-assignment from server-specific range</param>
|
||
public void Start(int port = 0)
|
||
{
|
||
if (m_IsRunning)
|
||
return;
|
||
|
||
m_Port = port == 0 ? FindAvailablePort() : port;
|
||
PortAutoAssigned = port == 0;
|
||
|
||
try
|
||
{
|
||
// Initialize this server's own dispatcher (captures the main thread; Start() is
|
||
// always called from the main thread).
|
||
m_Dispatcher.Initialize();
|
||
|
||
// Warm the token cache on the main thread before the listener accepts requests:
|
||
// per-request auth runs on background threads and the Editor token is SessionState-
|
||
// backed (main-thread-only). Re-runs after every domain reload via [InitializeOnLoad].
|
||
SecurityTokenManager.GetOrCreateToken();
|
||
|
||
// Mark running before opening the listener so HandleRequests' loop guard stays true
|
||
// as soon as it starts on the threadpool.
|
||
m_IsRunning = true;
|
||
OpenListener();
|
||
|
||
System.Console.WriteLine($"Start HTTP server: port:{m_Port}");
|
||
|
||
if (WritesDescriptor)
|
||
CreateInstanceDescriptor();
|
||
|
||
ArmWatchdog();
|
||
|
||
ServerStarted();
|
||
}
|
||
catch (Exception)
|
||
{
|
||
m_IsRunning = false;
|
||
m_HttpListener?.Stop();
|
||
m_HttpListener = null;
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Create the HTTP listener, bind it, and start the request-handling loop. Extracted from
|
||
/// <see cref="Start"/> so the watchdog can re-open a dead listener in place without
|
||
/// re-running the rest of startup (dispatcher init, descriptor). Caller sets m_IsRunning.
|
||
/// </summary>
|
||
private void OpenListener()
|
||
{
|
||
m_HttpListener = new HttpListener();
|
||
AddLoopbackPrefixes(m_HttpListener, m_Port);
|
||
m_HttpListener.Start();
|
||
|
||
// Start request handling
|
||
_ = Task.Run(HandleRequests);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bind a wildcard-host prefix so the listener accepts any Host header ("127.0.0.1",
|
||
/// "localhost", "::1") no matter how a client — or its DNS resolver — resolves the name.
|
||
/// This is required because Mono's HttpListener binds a hostname prefix to a single resolved
|
||
/// address family and then matches the request's Host <em>literally</em> per prefix: an
|
||
/// explicit "http://127.0.0.1/" prefix rejects "Host: localhost" (and vice-versa) with a
|
||
/// 400. A wildcard sidesteps that. The wildcard binds all interfaces at the socket level, so
|
||
/// access is confined to the local machine by the loopback check in
|
||
/// <see cref="ProcessRequest"/> (plus bearer-token auth).
|
||
/// </summary>
|
||
private static void AddLoopbackPrefixes(HttpListener listener, int port)
|
||
{
|
||
listener.Prefixes.Add($"http://+:{port}/");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stop the HTTP server and clean up resources.
|
||
/// </summary>
|
||
public void Stop()
|
||
{
|
||
if (!m_IsRunning)
|
||
return;
|
||
|
||
m_IsRunning = false;
|
||
|
||
DisarmWatchdog();
|
||
System.Console.WriteLine("Pipeline Server stopped");
|
||
|
||
try
|
||
{
|
||
if (WritesDescriptor)
|
||
DeleteInstanceDescriptor();
|
||
|
||
m_HttpListener?.Stop();
|
||
m_HttpListener?.Close();
|
||
|
||
ServerStopped();
|
||
}
|
||
catch
|
||
{
|
||
// Ignore cleanup errors
|
||
}
|
||
finally
|
||
{
|
||
m_HttpListener = null;
|
||
m_Dispatcher.Shutdown();
|
||
}
|
||
// Note: Keep port value for diagnostic purposes
|
||
}
|
||
|
||
/// <summary>
|
||
/// Arm the watchdog (no-op if disabled or already armed). In the editor the tick rides
|
||
/// EditorApplication.update; in a player it is driven by RuntimePipelineDriver.Update via
|
||
/// <see cref="WatchdogTick"/>.
|
||
/// </summary>
|
||
private void ArmWatchdog()
|
||
{
|
||
if (!m_WatchdogEnabled || m_WatchdogArmed)
|
||
return;
|
||
|
||
m_WatchdogArmed = true;
|
||
m_LastWatchdogCheck = DateTime.UtcNow;
|
||
#if UNITY_EDITOR
|
||
UnityEditor.EditorApplication.update += WatchdogTick;
|
||
#endif
|
||
}
|
||
|
||
private void DisarmWatchdog()
|
||
{
|
||
if (!m_WatchdogArmed)
|
||
return;
|
||
|
||
m_WatchdogArmed = false;
|
||
#if UNITY_EDITOR
|
||
UnityEditor.EditorApplication.update -= WatchdogTick;
|
||
#endif
|
||
}
|
||
|
||
/// <summary>
|
||
/// Watchdog heartbeat: throttled to <see cref="WatchdogIntervalSeconds"/>, re-opens the HTTP
|
||
/// listener in place if it died while the server is still meant to be running. Safe to call
|
||
/// every frame. In the editor it is subscribed to EditorApplication.update by ArmWatchdog;
|
||
/// in a player RuntimePipelineDriver.Update calls it.
|
||
/// </summary>
|
||
public void WatchdogTick()
|
||
{
|
||
if (!m_WatchdogArmed)
|
||
return;
|
||
|
||
#if UNITY_EDITOR
|
||
// Don't fight domain reloads / asset updates — the listener is expected to be torn down
|
||
// then, and [InitializeOnLoad] recreates the server afterwards.
|
||
if (UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating)
|
||
return;
|
||
#endif
|
||
|
||
var now = DateTime.UtcNow;
|
||
if ((now - m_LastWatchdogCheck).TotalSeconds < WatchdogIntervalSeconds)
|
||
return;
|
||
m_LastWatchdogCheck = now;
|
||
|
||
if (m_HttpListener != null && m_HttpListener.IsListening)
|
||
return; // healthy
|
||
|
||
try
|
||
{
|
||
// Dispose the dead listener so it releases the port binding before we re-bind a new
|
||
// one on the same port (a stopped-but-not-closed listener can keep the port held).
|
||
try { m_HttpListener?.Close(); } catch { }
|
||
m_HttpListener = null;
|
||
|
||
// The instance is alive and the watchdog is armed → the server is meant to be
|
||
// listening. Re-open the listener in place.
|
||
m_IsRunning = true;
|
||
OpenListener();
|
||
Debug.Log($"Pipeline watchdog re-opened HTTP listener on port {m_Port}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"Pipeline watchdog failed to re-open listener on port {m_Port}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle incoming HTTP requests with routing to appropriate endpoints.
|
||
/// </summary>
|
||
private async Task HandleRequests()
|
||
{
|
||
while (m_IsRunning && m_HttpListener != null && m_HttpListener.IsListening)
|
||
{
|
||
try
|
||
{
|
||
var context = await m_HttpListener.GetContextAsync();
|
||
// Process detached so the accept loop keeps serving while a long command
|
||
// holds its /api/exec connection open. Without this, ANY in-flight exec
|
||
// blocked every other request — including /api/status probes and the
|
||
// /api/progress polls that exist precisely for that situation (CLI-488;
|
||
// the "editor is unresponsive until the command finishes" report in
|
||
// CLI-335). Command execution itself stays strictly serialized via
|
||
// m_ExecGate below, so exec ordering semantics are unchanged.
|
||
_ = ProcessRequestDetached(context);
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
// Listener was stopped, exit gracefully
|
||
break;
|
||
}
|
||
catch (HttpListenerException)
|
||
{
|
||
// Listener was stopped, exit gracefully
|
||
break;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError(ex);
|
||
}
|
||
}
|
||
m_IsRunning = false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Run ProcessRequest without the accept loop awaiting it; a per-request failure is
|
||
/// logged and must never tear down the listener loop.
|
||
/// </summary>
|
||
private async Task ProcessRequestDetached(HttpListenerContext context)
|
||
{
|
||
try
|
||
{
|
||
await ProcessRequest(context);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError(ex);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Process individual HTTP request and route to appropriate handler.
|
||
/// </summary>
|
||
/// <param name="context">The incoming HTTP request/response context.</param>
|
||
/// <returns>A task that completes once the response has been written.</returns>
|
||
protected virtual async Task ProcessRequest(HttpListenerContext context)
|
||
{
|
||
var request = context.Request;
|
||
var response = context.Response;
|
||
|
||
try
|
||
{
|
||
// Confine access to the local machine. The listener binds a wildcard host (see
|
||
// AddLoopbackPrefixes) so it accepts any Host header, which also means it accepts
|
||
// connections on non-loopback interfaces — reject anything that isn't loopback
|
||
// before doing any other work. Bearer-token auth is still enforced below.
|
||
var remoteAddress = request.RemoteEndPoint?.Address;
|
||
if (remoteAddress == null || !IPAddress.IsLoopback(remoteAddress))
|
||
{
|
||
await SendStatusResponse(response, 403, "Forbidden", "Only loopback connections are allowed");
|
||
return;
|
||
}
|
||
|
||
// Reject browser-originated requests. Legitimate non-browser clients (CLI, CI) never
|
||
// send an Origin header; emitting no CORS headers and refusing any request that
|
||
// carries one prevents a website in the developer's browser from reaching this
|
||
// local server (and short-circuits CORS preflights). A server that opts into
|
||
// AllowSandboxedBrowserClients additionally admits Origin: null.
|
||
var origin = request.Headers["Origin"];
|
||
if (!string.IsNullOrEmpty(origin))
|
||
{
|
||
if (!AllowSandboxedBrowserClients || origin != SandboxedOrigin)
|
||
{
|
||
await SendStatusResponse(response, 403, "Forbidden", "Cross-origin requests are not allowed");
|
||
return;
|
||
}
|
||
|
||
// "*" rather than echoing "null": the gate above already decides who is served, no
|
||
// credentials are involved, and it is better supported for an opaque origin.
|
||
response.AddHeader("Access-Control-Allow-Origin", "*");
|
||
response.AddHeader("Vary", "Origin");
|
||
}
|
||
|
||
// Preflight is answered before auth by necessity: the browser sends OPTIONS with no
|
||
// Authorization header, so requiring the token here would fail every real request.
|
||
if (request.HttpMethod == "OPTIONS")
|
||
{
|
||
if (string.IsNullOrEmpty(origin))
|
||
{
|
||
await SendStatusResponse(response, 405, "Method Not Allowed", "OPTIONS is only used for CORS preflight");
|
||
return;
|
||
}
|
||
|
||
response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||
response.AddHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
||
response.AddHeader("Access-Control-Max-Age", "600");
|
||
// Chrome's Private Network Access: a page on the public internet reaching a loopback
|
||
// address must be granted it explicitly, or the preflight fails.
|
||
if (!string.IsNullOrEmpty(request.Headers["Access-Control-Request-Private-Network"]))
|
||
response.AddHeader("Access-Control-Allow-Private-Network", "true");
|
||
response.StatusCode = 204;
|
||
response.ContentLength64 = 0;
|
||
response.OutputStream.Close();
|
||
return;
|
||
}
|
||
|
||
// Authenticate every request with the bearer token before routing.
|
||
if (!IsAuthorized(request))
|
||
{
|
||
await SendStatusResponse(response, 401, "Unauthorized", "Missing or invalid authentication token");
|
||
return;
|
||
}
|
||
|
||
// Route to appropriate endpoint
|
||
var path = request.Url.AbsolutePath.ToLowerInvariant();
|
||
|
||
switch (path)
|
||
{
|
||
case "/api/status":
|
||
await HandleStatusRequest(response);
|
||
break;
|
||
case "/api/editor_status":
|
||
await HandleEditorStatusRequest(response);
|
||
break;
|
||
case "/api/commands":
|
||
await HandleCommandsRequest(request, response);
|
||
break;
|
||
case "/api/exec":
|
||
if (request.HttpMethod == "POST")
|
||
await HandleExecRequest(request, response);
|
||
else
|
||
await HandleMethodNotAllowed(response, "POST");
|
||
break;
|
||
case "/api/test-status":
|
||
await HandleTestStatusRequest(response);
|
||
break;
|
||
case "/api/progress":
|
||
await HandleProgressRequest(request, response);
|
||
break;
|
||
case "/api/dialog":
|
||
await HandleDialogRequest(response);
|
||
break;
|
||
case "/api/job":
|
||
if (request.HttpMethod == "GET")
|
||
await HandleJobStatusRequest(request, response);
|
||
else
|
||
await HandleMethodNotAllowed(response, "GET");
|
||
break;
|
||
case "/api/job/cancel":
|
||
if (request.HttpMethod == "POST")
|
||
await HandleJobCancelRequest(request, response);
|
||
else
|
||
await HandleMethodNotAllowed(response, "POST");
|
||
break;
|
||
default:
|
||
await HandleNotFound(response);
|
||
break;
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
// A silent 500 leaves the client with nothing to go on; name the request and the fault.
|
||
Debug.LogError($"Pipeline: unhandled error serving {request.HttpMethod} "
|
||
+ $"{request.Url?.AbsolutePath}: {e}");
|
||
|
||
// Ensure response is always closed
|
||
try
|
||
{
|
||
if (response.OutputStream.CanWrite)
|
||
{
|
||
response.StatusCode = 500;
|
||
response.OutputStream.Close();
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validate the request's bearer token against this server's token.
|
||
/// </summary>
|
||
private bool IsAuthorized(HttpListenerRequest request)
|
||
{
|
||
var header = request.Headers["Authorization"];
|
||
if (string.IsNullOrEmpty(header))
|
||
return false;
|
||
|
||
const string prefix = "Bearer ";
|
||
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||
return false;
|
||
|
||
var token = header.Substring(prefix.Length).Trim();
|
||
return SecurityTokenManager.ConstantTimeEquals(token, GetToken());
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send a structured JSON error response with an explicit HTTP status code.
|
||
/// </summary>
|
||
private async Task SendStatusResponse(HttpListenerResponse response, int statusCode, string error, string details)
|
||
{
|
||
try
|
||
{
|
||
response.StatusCode = statusCode;
|
||
response.ContentType = "application/json";
|
||
var json = JsonConvert.SerializeObject(BaseResponse.Failure(error, details), Formatting.Indented);
|
||
var buffer = Encoding.UTF8.GetBytes(json);
|
||
response.ContentLength64 = buffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||
response.OutputStream.Close();
|
||
}
|
||
catch
|
||
{
|
||
try { response.OutputStream.Close(); } catch { }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parse the <c>omit_nulls</c> query parameter (snake_case like the other query
|
||
/// parameters, e.g. group_by) — the GET-endpoint equivalent of exec's <c>omitNulls</c>
|
||
/// JSON body flag, for /api/job, /api/job/cancel, and /api/progress. Accepted values:
|
||
/// "true"/"false", case-insensitive; absent means false. Any other value (e.g.
|
||
/// <c>omit_nulls=1</c>) is NOT silently coerced to false: it yields false plus a
|
||
/// <paramref name="warning"/> the caller surfaces in the response's "warnings" array, so
|
||
/// an agent learns the accepted spelling instead of guessing why nulls are still present.
|
||
/// (Exec body requests don't need this: JSON deserialization coerces their boolean.)
|
||
/// </summary>
|
||
private static bool ParseOmitNulls(HttpListenerRequest request, out string warning)
|
||
{
|
||
warning = null;
|
||
var raw = request.QueryString["omit_nulls"];
|
||
if (raw == null)
|
||
{
|
||
// A bare "?omit_nulls" (no '=') is not the same as absent: .NET files valueless
|
||
// query tokens under the null key, so look there to warn instead of silently
|
||
// treating an intended opt-in as false.
|
||
var valueless = request.QueryString.GetValues(null);
|
||
if (valueless != null && Array.IndexOf(valueless, "omit_nulls") >= 0)
|
||
warning = "omit_nulls given without a value ignored; use omit_nulls=true";
|
||
return false;
|
||
}
|
||
if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
if (string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase))
|
||
return false;
|
||
|
||
warning = $"omit_nulls='{raw}' ignored; expected 'true' or 'false'";
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Serialize <paramref name="payload"/> and send it as the JSON body. Null fields are
|
||
/// INCLUDED by default — on /api/job and /api/progress every field is payload (there is
|
||
/// no envelope/payload split like /api/exec's), and an absent key is indistinguishable
|
||
/// from a nonexistent or misspelled one. Callers pass <paramref name="omitNulls"/> from
|
||
/// the endpoint's <c>omit_nulls</c> query parameter (see <see cref="ParseOmitNulls"/>)
|
||
/// to opt out (AUTHAPI-21).
|
||
/// Shares <see cref="SendStatusResponse"/>'s write guard: a failure while writing
|
||
/// (e.g. the client disconnected mid-response) is swallowed here instead of propagating
|
||
/// into the caller's own catch block, which would otherwise attempt a second response on
|
||
/// the now-dead connection.
|
||
/// </summary>
|
||
private async Task SendJsonResponse(HttpListenerResponse response, int statusCode, object payload, bool omitNulls = false)
|
||
{
|
||
try
|
||
{
|
||
var json = JsonConvert.SerializeObject(payload,
|
||
new JsonSerializerSettings
|
||
{
|
||
NullValueHandling = omitNulls ? NullValueHandling.Ignore : NullValueHandling.Include
|
||
});
|
||
|
||
response.StatusCode = statusCode;
|
||
response.ContentType = "application/json";
|
||
var buffer = Encoding.UTF8.GetBytes(json);
|
||
response.ContentLength64 = buffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||
response.OutputStream.Close();
|
||
}
|
||
catch
|
||
{
|
||
try { response.OutputStream.Close(); } catch { }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle /api/status endpoint - returns basic server health information.
|
||
/// No Editor API access required, always fast response.
|
||
/// </summary>
|
||
private async Task HandleStatusRequest(HttpListenerResponse response)
|
||
{
|
||
try
|
||
{
|
||
var basicStatus = GetServerStatus();
|
||
var json = JsonConvert.SerializeObject(basicStatus, Formatting.Indented);
|
||
|
||
response.StatusCode = 200;
|
||
response.ContentType = "application/json";
|
||
|
||
var buffer = Encoding.UTF8.GetBytes(json);
|
||
response.ContentLength64 = buffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||
response.OutputStream.Close();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"HandleStatusRequest failed: {ex.Message}");
|
||
await SendErrorResponse(response, "Status Error", $"Failed to get status: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle /api/editor_status endpoint - returns detailed Editor state via command execution.
|
||
/// Executes the "editor_status" command to get rich Editor information.
|
||
/// </summary>
|
||
private async Task HandleEditorStatusRequest(HttpListenerResponse response)
|
||
{
|
||
try
|
||
{
|
||
// TODO: should this be implemented as a command??
|
||
|
||
// Queue behind the same gate as /api/exec so this doesn't run concurrently with
|
||
// an in-flight exec (or another editor_status/test_status call): it dispatches
|
||
// onto the main thread just like exec does, so left ungated it would race rather
|
||
// than queue the way it did under the old serial accept loop.
|
||
object result;
|
||
await m_ExecGate.WaitAsync();
|
||
try
|
||
{
|
||
result = await ExecuteCommandByName("editor_status", new JObject());
|
||
}
|
||
finally
|
||
{
|
||
m_ExecGate.Release();
|
||
}
|
||
|
||
// The editor_status command returns a StatusResponse directly
|
||
var editorStatus = result as StatusResponse;
|
||
if (editorStatus != null)
|
||
{
|
||
// Update with server-specific information
|
||
UpdateStatusWithServerInfo(editorStatus);
|
||
|
||
var json = JsonConvert.SerializeObject(editorStatus, Formatting.Indented);
|
||
|
||
response.StatusCode = 200;
|
||
response.ContentType = "application/json";
|
||
|
||
var buffer = Encoding.UTF8.GetBytes(json);
|
||
response.ContentLength64 = buffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||
response.OutputStream.Close();
|
||
}
|
||
else
|
||
{
|
||
Debug.LogError($"HandleEditorStatusRequest: editor_status command returned wrong type: {result?.GetType().FullName ?? "null"}");
|
||
await SendErrorResponse(response, "Editor Status Error", "editor_status command did not return valid StatusResponse");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var errorMessage = !string.IsNullOrEmpty(ex.Message) ? ex.Message : "[EMPTY EXCEPTION MESSAGE]";
|
||
Debug.LogError($"HandleEditorStatusRequest failed:");
|
||
Debug.LogError($" Exception Type: {ex.GetType().FullName}");
|
||
Debug.LogError($" Message: '{errorMessage}'");
|
||
Debug.LogError($" Full Exception: {ex}");
|
||
|
||
await SendErrorResponse(response, "Editor Status Error", $"Failed to get editor status: {errorMessage}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Update StatusResponse with server-specific information.
|
||
/// Used by /api/editor_status to add server metadata to command result.
|
||
/// </summary>
|
||
private void UpdateStatusWithServerInfo(StatusResponse editorStatus)
|
||
{
|
||
UpdateHeartBeat();
|
||
// Ensure heartbeat is current
|
||
editorStatus.LastHeartbeat = DateTime.UtcNow;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle /api/commands endpoint - returns the available CLI commands.
|
||
///
|
||
/// Optional query parameters (filters combine with AND):
|
||
/// - detail: 'full' (default) includes parameters and the generated JSON schema;
|
||
/// 'compact' returns a lightweight index (name, description, tags, package) so a
|
||
/// client can cheaply browse, then fetch full detail only for the commands it
|
||
/// intends to invoke.
|
||
/// - query: case-insensitive substring match on name, description, or any tag.
|
||
/// - tag: scope to a tag subtree via segment-aware prefix match ('assets' matches
|
||
/// 'assets' and 'assets/import' but not 'assetsx').
|
||
/// - group_by: 'flat' (default) returns a 'commands' array; 'package' and 'tag'
|
||
/// return a 'groups' array instead ('tag' is a nested tree mirroring tag/subtag;
|
||
/// untagged commands land in a node with an empty tag).
|
||
/// - sort: 'name' (default) or 'package' (originating package, name as tiebreak);
|
||
/// order: 'asc' (default) or 'desc'. Sorting applies to the flat list before
|
||
/// pagination and grouping.
|
||
/// - offset / limit: paginate the filtered, sorted flat list (applied before
|
||
/// grouping, so pages are deterministic); 'total' reports the match count before
|
||
/// pagination while 'count' is the number of commands actually returned.
|
||
/// </summary>
|
||
private async Task HandleCommandsRequest(HttpListenerRequest request, HttpListenerResponse response)
|
||
{
|
||
try
|
||
{
|
||
var queryString = request.QueryString;
|
||
|
||
var detail = queryString["detail"] ?? "full";
|
||
bool fullDetail;
|
||
if (string.Equals(detail, "full", StringComparison.OrdinalIgnoreCase))
|
||
fullDetail = true;
|
||
else if (string.Equals(detail, "compact", StringComparison.OrdinalIgnoreCase))
|
||
fullDetail = false;
|
||
else
|
||
{
|
||
await SendStatusResponse(response, 400, "Invalid Request",
|
||
$"Unknown detail value '{detail}'. Accepted values: compact, full");
|
||
return;
|
||
}
|
||
|
||
var groupBy = queryString["group_by"] ?? "flat";
|
||
var groupByPackage = string.Equals(groupBy, "package", StringComparison.OrdinalIgnoreCase);
|
||
var groupByTag = string.Equals(groupBy, "tag", StringComparison.OrdinalIgnoreCase);
|
||
if (!groupByPackage && !groupByTag && !string.Equals(groupBy, "flat", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
await SendStatusResponse(response, 400, "Invalid Request",
|
||
$"Unknown group_by value '{groupBy}'. Accepted values: flat, package, tag");
|
||
return;
|
||
}
|
||
|
||
var sort = queryString["sort"] ?? "name";
|
||
var sortByPackage = string.Equals(sort, "package", StringComparison.OrdinalIgnoreCase);
|
||
if (!sortByPackage && !string.Equals(sort, "name", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
await SendStatusResponse(response, 400, "Invalid Request",
|
||
$"Unknown sort value '{sort}'. Accepted values: name, package");
|
||
return;
|
||
}
|
||
|
||
var order = queryString["order"] ?? "asc";
|
||
var descending = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
|
||
if (!descending && !string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
await SendStatusResponse(response, 400, "Invalid Request",
|
||
$"Unknown order value '{order}'. Accepted values: asc, desc");
|
||
return;
|
||
}
|
||
|
||
var offset = 0;
|
||
if (queryString["offset"] != null
|
||
&& (!int.TryParse(queryString["offset"], out offset) || offset < 0))
|
||
{
|
||
await SendStatusResponse(response, 400, "Invalid Request",
|
||
$"Invalid offset value '{queryString["offset"]}'. Expected a non-negative integer");
|
||
return;
|
||
}
|
||
|
||
int? limit = null;
|
||
if (queryString["limit"] != null)
|
||
{
|
||
if (!int.TryParse(queryString["limit"], out var parsedLimit) || parsedLimit < 0)
|
||
{
|
||
await SendStatusResponse(response, 400, "Invalid Request",
|
||
$"Invalid limit value '{queryString["limit"]}'. Expected a non-negative integer");
|
||
return;
|
||
}
|
||
limit = parsedLimit;
|
||
}
|
||
|
||
var query = queryString["query"];
|
||
var tag = queryString["tag"];
|
||
|
||
var filtered = CommandRegistry.DiscoverCommands()
|
||
.Where(c => IncludeRuntimeOnlyCommands || !c.RuntimeOnly)
|
||
.Where(c => MatchesQuery(c, query) && MatchesTagSubtree(c, tag));
|
||
var matching = SortCommands(filtered, sortByPackage, descending).ToList();
|
||
|
||
// Paginate the flat, sorted list before any grouping so pages are deterministic.
|
||
IEnumerable<CommandInfo> window = matching.Skip(offset);
|
||
if (limit.HasValue)
|
||
window = window.Take(limit.Value);
|
||
var page = window.ToList();
|
||
|
||
Func<CommandInfo, object> project = c =>
|
||
fullDetail ? BuildFullCommandResponse(c) : BuildCompactCommandResponse(c);
|
||
|
||
var responseData = new Dictionary<string, object>();
|
||
if (groupByPackage)
|
||
responseData["groups"] = BuildPackageGroups(page, project);
|
||
else if (groupByTag)
|
||
responseData["groups"] = BuildTagTree(page, project);
|
||
else
|
||
responseData["commands"] = page.Select(project).ToList();
|
||
responseData["count"] = page.Count;
|
||
responseData["total"] = matching.Count;
|
||
responseData["offset"] = offset;
|
||
responseData["limit"] = limit;
|
||
responseData["server"] = new
|
||
{
|
||
version = "0.0.1", // TODO: Get from package.json
|
||
port = m_Port,
|
||
startTime = StartedAt
|
||
};
|
||
|
||
var json = JsonConvert.SerializeObject(responseData, Formatting.Indented);
|
||
|
||
response.StatusCode = 200;
|
||
response.ContentType = "application/json";
|
||
|
||
var buffer = Encoding.UTF8.GetBytes(json);
|
||
response.ContentLength64 = buffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||
response.OutputStream.Close();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"Failed to handle /api/commands request: {ex.Message}");
|
||
|
||
// Return error response
|
||
response.StatusCode = 500;
|
||
response.ContentType = "application/json";
|
||
|
||
var errorResponse = new { error = "Internal server error", message = ex.Message };
|
||
var errorJson = JsonConvert.SerializeObject(errorResponse);
|
||
var errorBuffer = Encoding.UTF8.GetBytes(errorJson);
|
||
response.ContentLength64 = errorBuffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(errorBuffer, 0, errorBuffer.Length);
|
||
response.OutputStream.Close();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build the lightweight index entry for a single command (detail=compact).
|
||
/// </summary>
|
||
private static object BuildCompactCommandResponse(CommandInfo command)
|
||
{
|
||
return new
|
||
{
|
||
name = command.Name,
|
||
description = command.Description,
|
||
tags = command.Tags,
|
||
package = command.Package
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build the complete JSON response object for a single command (detail=full).
|
||
/// </summary>
|
||
private object BuildFullCommandResponse(CommandInfo command)
|
||
{
|
||
var entry = BuildCommandEntry(command);
|
||
entry["schema"] = JsonSchemaGenerator.GenerateCommandSchema(command);
|
||
return entry;
|
||
}
|
||
|
||
/// <summary>
|
||
/// One command's catalog entry: everything /api/commands serves for it except the
|
||
/// generated JSON <c>schema</c>.
|
||
///
|
||
/// Shared by the catalog and by an argument-error envelope, so a client's entry renderer
|
||
/// consumes both unchanged and the two cannot drift. The error path takes this directly
|
||
/// rather than building the full response and dropping the schema, because generating and
|
||
/// serializing a schema nobody reads is pure waste on the interactive retry path.
|
||
/// </summary>
|
||
private JObject BuildCommandEntry(CommandInfo command)
|
||
{
|
||
return JObject.FromObject(new
|
||
{
|
||
name = command.Name,
|
||
description = command.Description,
|
||
tags = command.Tags,
|
||
package = command.Package,
|
||
mainThreadRequired = command.MainThreadRequired,
|
||
runtimeOnly = command.RuntimeOnly,
|
||
parameters = command.Parameters.Select(p => new
|
||
{
|
||
name = p.Name,
|
||
description = p.Description,
|
||
type = p.ParameterType.Name,
|
||
required = p.Required,
|
||
defaultValue = p.DefaultValue
|
||
}).ToList()
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Attaches the bound-parameter echo for raw-form requests, and only for those, leaving
|
||
/// the structured path's envelope unchanged.
|
||
///
|
||
/// The value is exactly what the binder stored, so the echo cannot disagree with what the
|
||
/// command executes with. A client that does not bind locally has no other way to report
|
||
/// what the command received.
|
||
/// </summary>
|
||
private static CommandExecutionResponse WithBoundParameters(
|
||
CommandExecutionResponse response, CommandExecutionRequest request)
|
||
{
|
||
if (request.IsRawForm)
|
||
response.BoundParameters = request.Parameters ?? new JObject();
|
||
return response;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders argument problems as English prose for <c>errorDetails</c>.
|
||
///
|
||
/// This is the fallback, not the primary channel: a client that understands
|
||
/// <c>argProblems</c> renders and localizes them itself. This text serves curl, MCP, and
|
||
/// any client that meets a <c>kind</c> it does not recognize, so it must stay useful on
|
||
/// its own.
|
||
/// </summary>
|
||
private static string DescribeArgProblems(CommandInfo command, List<ArgProblem> problems)
|
||
{
|
||
if (problems == null || problems.Count == 0)
|
||
return "Invalid arguments";
|
||
|
||
var parts = new List<string>(problems.Count);
|
||
foreach (var problem in problems)
|
||
{
|
||
switch (problem.Kind)
|
||
{
|
||
case ArgProblemKind.EmptyName:
|
||
parts.Add($"'{problem.Token}' is not a valid flag: the name is empty.");
|
||
break;
|
||
case ArgProblemKind.EmptyValue:
|
||
parts.Add($"--{problem.Name} needs a value.");
|
||
break;
|
||
case ArgProblemKind.SingleDash:
|
||
parts.Add($"'{problem.Token}' is not a valid flag: single-dash flags are not supported. Use a double dash, or pass it after -- to send it as a value.");
|
||
break;
|
||
case ArgProblemKind.Duplicate:
|
||
parts.Add($"--{problem.Name} was given more than once.");
|
||
break;
|
||
case ArgProblemKind.UnknownName:
|
||
parts.Add(problem.Suggestion != null
|
||
? $"{command.Name} has no parameter --{problem.Name}. Did you mean --{problem.Suggestion}?"
|
||
: $"{command.Name} has no parameter --{problem.Name}. Valid parameters: {DescribeParameterNames(command)}.");
|
||
break;
|
||
case ArgProblemKind.BareAssignment:
|
||
parts.Add($"'{problem.Token}' looks like a flag written without dashes. Use --name value instead.");
|
||
break;
|
||
case ArgProblemKind.ExcessPositional:
|
||
parts.Add($"{command.Name} takes {problem.Capacity} argument(s) but {problem.Given} were given (starting at '{problem.Token}').");
|
||
break;
|
||
case ArgProblemKind.PositionalConflict:
|
||
parts.Add($"--{problem.Name} is already set by an explicit flag, so there is no slot for '{problem.Token}'.");
|
||
break;
|
||
case ArgProblemKind.TypeMismatch:
|
||
parts.Add($"--{problem.Name} expects {problem.ExpectedType}, but got '{problem.Token}'.{DescribeValidValues(command, problem.Name)}");
|
||
break;
|
||
default:
|
||
parts.Add($"{problem.Kind}: {problem.Token ?? problem.Name}");
|
||
break;
|
||
}
|
||
}
|
||
|
||
return string.Join(" ", parts);
|
||
}
|
||
|
||
/// <summary>Every declared parameter of the command, flag-spelled, or "(none)".</summary>
|
||
private static string DescribeParameterNames(CommandInfo command)
|
||
{
|
||
if (command.Parameters.Count == 0)
|
||
return "(none)";
|
||
return string.Join(", ", command.Parameters.Select(p => "--" + p.Name));
|
||
}
|
||
|
||
/// <summary>
|
||
/// For an enum parameter, the legal names, following the same enumerate-the-valid-set
|
||
/// convention used for unknown command names and query values. Empty for every other type.
|
||
/// </summary>
|
||
private static string DescribeValidValues(CommandInfo command, string parameterName)
|
||
{
|
||
foreach (var parameter in command.Parameters)
|
||
{
|
||
if (!string.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
var type = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType;
|
||
if (type.IsEnum)
|
||
return " Valid values: " + string.Join(", ", Enum.GetNames(type)) + ".";
|
||
break;
|
||
}
|
||
return string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Order the filtered command list: by name, or by originating package with name as
|
||
/// tiebreak. Both keys follow the requested direction.
|
||
/// </summary>
|
||
private static IEnumerable<CommandInfo> SortCommands(IEnumerable<CommandInfo> commands, bool byPackage, bool descending)
|
||
{
|
||
if (byPackage)
|
||
{
|
||
var byPkg = descending
|
||
? commands.OrderByDescending(c => c.Package ?? string.Empty, StringComparer.Ordinal)
|
||
: commands.OrderBy(c => c.Package ?? string.Empty, StringComparer.Ordinal);
|
||
return descending
|
||
? byPkg.ThenByDescending(c => c.Name, StringComparer.Ordinal)
|
||
: byPkg.ThenBy(c => c.Name, StringComparer.Ordinal);
|
||
}
|
||
return descending
|
||
? commands.OrderByDescending(c => c.Name, StringComparer.Ordinal)
|
||
: commands.OrderBy(c => c.Name, StringComparer.Ordinal);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Case-insensitive substring match on the command's name, description, or any tag.
|
||
/// A null/empty query matches everything.
|
||
/// </summary>
|
||
private static bool MatchesQuery(CommandInfo command, string query)
|
||
{
|
||
if (string.IsNullOrEmpty(query))
|
||
return true;
|
||
return command.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0
|
||
|| command.Description.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0
|
||
|| command.Tags.Any(t => t.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Segment-aware tag subtree match: 'assets' matches tags 'assets' and 'assets/import'
|
||
/// but not 'assetsx'. A null/empty tag matches everything.
|
||
/// </summary>
|
||
private static bool MatchesTagSubtree(CommandInfo command, string tag)
|
||
{
|
||
if (string.IsNullOrEmpty(tag))
|
||
return true;
|
||
return command.Tags.Any(t => t.Equals(tag, StringComparison.OrdinalIgnoreCase)
|
||
|| t.StartsWith(tag + "/", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Group a page of commands by originating package, sorted by package name.
|
||
/// </summary>
|
||
private static List<object> BuildPackageGroups(List<CommandInfo> commands, Func<CommandInfo, object> project)
|
||
{
|
||
return commands
|
||
.GroupBy(c => c.Package ?? string.Empty)
|
||
.OrderBy(g => g.Key, StringComparer.Ordinal)
|
||
.Select(g => (object)new
|
||
{
|
||
package = g.Key,
|
||
count = g.Count(),
|
||
commands = g.Select(project).ToList()
|
||
})
|
||
.ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Node of the group_by=tag tree. A command appears in the node of each tag it carries;
|
||
/// untagged commands land in a top-level node with an empty tag.
|
||
/// </summary>
|
||
private class TagTreeNode
|
||
{
|
||
public readonly List<CommandInfo> Commands = new List<CommandInfo>();
|
||
public readonly SortedDictionary<string, TagTreeNode> Children =
|
||
new SortedDictionary<string, TagTreeNode>(StringComparer.Ordinal);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build the nested group_by=tag tree mirroring the tag/subtag hierarchy. Each node's
|
||
/// 'count' covers its whole subtree (a command tagged with several tags under the same
|
||
/// node counts once per tag entry).
|
||
/// </summary>
|
||
private static List<object> BuildTagTree(List<CommandInfo> commands, Func<CommandInfo, object> project)
|
||
{
|
||
var root = new TagTreeNode();
|
||
foreach (var command in commands)
|
||
{
|
||
if (command.Tags.Count == 0)
|
||
{
|
||
GetOrAddChild(root, string.Empty).Commands.Add(command);
|
||
continue;
|
||
}
|
||
foreach (var tag in command.Tags)
|
||
{
|
||
var node = root;
|
||
foreach (var segment in tag.Split('/'))
|
||
node = GetOrAddChild(node, segment);
|
||
node.Commands.Add(command);
|
||
}
|
||
}
|
||
return root.Children
|
||
.Select(kv => ToTagGroup(kv.Key, kv.Value, project))
|
||
.ToList();
|
||
}
|
||
|
||
private static TagTreeNode GetOrAddChild(TagTreeNode node, string key)
|
||
{
|
||
if (!node.Children.TryGetValue(key, out var child))
|
||
{
|
||
child = new TagTreeNode();
|
||
node.Children[key] = child;
|
||
}
|
||
return child;
|
||
}
|
||
|
||
private static object ToTagGroup(string path, TagTreeNode node, Func<CommandInfo, object> project)
|
||
{
|
||
return new
|
||
{
|
||
tag = path,
|
||
count = SubtreeCommandCount(node),
|
||
commands = node.Commands.Select(project).ToList(),
|
||
children = node.Children
|
||
.Select(kv => ToTagGroup(path.Length == 0 ? kv.Key : path + "/" + kv.Key, kv.Value, project))
|
||
.ToList()
|
||
};
|
||
}
|
||
|
||
private static int SubtreeCommandCount(TagTreeNode node)
|
||
{
|
||
return node.Commands.Count + node.Children.Values.Sum(SubtreeCommandCount);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle 404 Not Found responses.
|
||
/// </summary>
|
||
private async Task HandleNotFound(HttpListenerResponse response)
|
||
{
|
||
response.StatusCode = 404;
|
||
response.ContentType = "text/plain";
|
||
|
||
var responseText = "Not Found";
|
||
var buffer = Encoding.UTF8.GetBytes(responseText);
|
||
response.ContentLength64 = buffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||
response.OutputStream.Close();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle 405 Method Not Allowed responses.
|
||
/// </summary>
|
||
private async Task HandleMethodNotAllowed(HttpListenerResponse response, string allowedMethod)
|
||
{
|
||
await SendErrorResponse(response, "Method Not Allowed",
|
||
$"This endpoint only supports {allowedMethod} requests", statusCode: 405);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle /api/test-status endpoint - returns status of async test execution.
|
||
/// </summary>
|
||
private async Task HandleTestStatusRequest(HttpListenerResponse response)
|
||
{
|
||
try
|
||
{
|
||
// Queue behind the same gate as /api/exec/editor_status — see the comment in
|
||
// HandleEditorStatusRequest. (In practice this rarely blocks: the async-test-status
|
||
// workflow polls this only once run_tests --async_tests has already returned and
|
||
// released the gate.)
|
||
object result;
|
||
await m_ExecGate.WaitAsync();
|
||
try
|
||
{
|
||
result = await ExecuteCommandByName("test_status", new JObject());
|
||
}
|
||
finally
|
||
{
|
||
m_ExecGate.Release();
|
||
}
|
||
|
||
string jsonResponse;
|
||
if (result is string statusString)
|
||
{
|
||
// test_status command returns JSON string directly
|
||
jsonResponse = statusString;
|
||
}
|
||
else
|
||
{
|
||
// Fallback - serialize whatever was returned
|
||
jsonResponse = JsonConvert.SerializeObject(result ?? new { status = "no_tests", message = "No test run in progress" });
|
||
}
|
||
|
||
response.StatusCode = 200;
|
||
response.ContentType = "application/json";
|
||
|
||
var buffer = Encoding.UTF8.GetBytes(jsonResponse);
|
||
response.ContentLength64 = buffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||
response.OutputStream.Close();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"HandleTestStatusRequest failed: {ex.Message}");
|
||
await SendErrorResponse(response, "Test Status Error", $"Failed to get test status: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs <paramref name="body"/> under the exec gate: tracks the in-flight count for
|
||
/// /api/progress ("active" while any exec runs) via CliProgress's own atomic
|
||
/// BeginExecutionCount/EndExecutionCount, owns CliProgress for the duration
|
||
/// (BeginExecution/EndExecution) so a queued execution can't surface the previous one's
|
||
/// stale progress, and resets the explicit report when the last in-flight execution
|
||
/// completes. Shared by the synchronous /api/exec path and <see cref="RunJobDetached"/> so
|
||
/// the two copies of this scaffold can't drift apart.
|
||
/// </summary>
|
||
/// <param name="executionId">Id CliProgress.Report calls during this execution are attributed to.</param>
|
||
/// <param name="body">The gated work to run once the exec gate is acquired.</param>
|
||
/// <param name="preStartCheck">Runs after the gate is acquired but before
|
||
/// CliProgress.BeginExecution; returning false skips <paramref name="body"/> entirely
|
||
/// (used by the job path to honor a cancellation requested while still queued).</param>
|
||
private async Task ExecuteGated(string executionId, Func<Task> body, Func<bool> preStartCheck = null)
|
||
{
|
||
Progress.BeginExecutionCount();
|
||
try
|
||
{
|
||
await m_ExecGate.WaitAsync();
|
||
try
|
||
{
|
||
if (preStartCheck != null && !preStartCheck())
|
||
return;
|
||
|
||
Progress.BeginExecution(executionId);
|
||
try
|
||
{
|
||
await body();
|
||
}
|
||
finally
|
||
{
|
||
Progress.EndExecution(executionId);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
m_ExecGate.Release();
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
Progress.EndExecutionCount();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Execute a detached job (CLI-335). A cancellation requested while the job is still
|
||
/// queued prevents it from starting; a running job is only cooperatively cancelable (see
|
||
/// PipelineCancellation).
|
||
/// </summary>
|
||
private async Task RunJobDetached(PipelineJobRecord record, CommandInfo command, CommandExecutionRequest commandRequest)
|
||
{
|
||
// Set once the command actually starts, so a job canceled while still queued reports
|
||
// nothing at all.
|
||
var ran = false;
|
||
var succeeded = false;
|
||
var timer = new System.Diagnostics.Stopwatch();
|
||
try
|
||
{
|
||
await ExecuteGated(record.Id, async () =>
|
||
{
|
||
JobRegistry.MarkRunning(record);
|
||
ran = true;
|
||
timer.Start();
|
||
try
|
||
{
|
||
var result = await ExecuteCommandByName(command, commandRequest.Parameters,
|
||
UnboundedJobDispatcherTimeoutMs);
|
||
if (record.CancellationRequested)
|
||
{
|
||
// The code honored (or raced) a cancellation request; report
|
||
// canceled rather than a half-relevant result.
|
||
JobRegistry.MarkCanceled(record);
|
||
}
|
||
else
|
||
{
|
||
succeeded = IsSuccessfulResult(result);
|
||
JobRegistry.MarkCompleted(record, result);
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
JobRegistry.MarkCanceled(record);
|
||
}
|
||
catch (ArgumentException ex)
|
||
{
|
||
// Match the synchronous handler, which classifies ArgumentException as
|
||
// "Parameter Validation Failed": without this arm a client polling
|
||
// GET /api/job cannot tell bad input from a command that ran and failed.
|
||
if (record.CancellationRequested)
|
||
{
|
||
JobRegistry.MarkCanceled(record);
|
||
}
|
||
else
|
||
{
|
||
JobRegistry.MarkFailed(record, "Parameter Validation Failed", ex.Message);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// The command layer wraps thrown exceptions (an
|
||
// OperationCanceledException from PipelineCancellation surfaces as
|
||
// a wrapped execution failure) — after a cancellation request, any
|
||
// failure is reported as the cancellation taking effect.
|
||
if (record.CancellationRequested)
|
||
{
|
||
JobRegistry.MarkCanceled(record);
|
||
}
|
||
else
|
||
{
|
||
JobRegistry.MarkFailed(record, "Command Execution Failed", ex.Message);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
timer.Stop();
|
||
}
|
||
},
|
||
preStartCheck: () =>
|
||
{
|
||
if (record.CancellationRequested)
|
||
{
|
||
JobRegistry.MarkCanceled(record);
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// The runner is fire-and-forget; a failure here must be recorded, never thrown.
|
||
Debug.LogError($"RunJobDetached failed: {ex.Message}");
|
||
JobRegistry.MarkFailed(record, "Internal Server Error", ex.Message);
|
||
}
|
||
|
||
// The submission already reported its own transaction, which could only say the job was
|
||
// queued. This is the point where the command's real result is known.
|
||
if (ran)
|
||
OnCommandDone(CommandExecutionInfo.ForDetachedJob(command, succeeded, timer.ElapsedMilliseconds,
|
||
commandRequest.Parameters));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Project a progress snapshot to the <c>{title,info,current,total,pct}</c> wire shape
|
||
/// shared by /api/progress and a running job's <c>progress</c> field — kept as one helper
|
||
/// so the two can't drift out of sync with each other or the documented contract.
|
||
/// </summary>
|
||
private static object BuildProgressPayload(CliProgressState.Snapshot snapshot)
|
||
{
|
||
if (!snapshot.HasReport)
|
||
return null;
|
||
|
||
return new
|
||
{
|
||
title = snapshot.Title,
|
||
info = snapshot.Info,
|
||
current = snapshot.Current,
|
||
total = snapshot.Total,
|
||
pct = snapshot.Progress01
|
||
};
|
||
}
|
||
|
||
/// <summary>Serialize one job record to the wire shape shared by the job endpoints.</summary>
|
||
private object BuildJobResponse(PipelineJobRecord record, string warning = null)
|
||
{
|
||
lock (record.Gate)
|
||
{
|
||
var running = record.State == PipelineJobState.Running;
|
||
var progress = running ? BuildProgressPayload(Progress.Current) : null;
|
||
|
||
return new
|
||
{
|
||
jobId = record.Id,
|
||
command = record.Command,
|
||
state = record.State.ToString().ToLowerInvariant(),
|
||
cancellationRequested = record.CancellationRequested,
|
||
enqueuedAt = record.EnqueuedAt,
|
||
startedAt = record.StartedAt,
|
||
completedAt = record.CompletedAt,
|
||
result = record.Result,
|
||
error = record.Error,
|
||
errorDetails = record.ErrorDetails,
|
||
progress,
|
||
// Corrective guidance for the caller (e.g. an unparseable omit_nulls value);
|
||
// null when there is nothing to say.
|
||
warnings = warning == null ? null : new[] { warning }
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle GET /api/job?id=… — a detached job's state, progress, and (once terminal)
|
||
/// its retained result (CLI-335 poll/reattach). Served on the listener thread.
|
||
/// </summary>
|
||
private async Task HandleJobStatusRequest(HttpListenerRequest request, HttpListenerResponse response)
|
||
{
|
||
try
|
||
{
|
||
var id = request.QueryString["id"];
|
||
if (string.IsNullOrEmpty(id))
|
||
{
|
||
await SendStatusResponse(response, 400, "Bad Request", "Query parameter 'id' is required");
|
||
return;
|
||
}
|
||
if (!JobRegistry.TryGet(id, out var record))
|
||
{
|
||
await SendStatusResponse(response, 404, "Job Not Found", $"No job with id '{id}' (jobs do not survive domain reloads and are pruned after retention)");
|
||
return;
|
||
}
|
||
|
||
var omitNulls = ParseOmitNulls(request, out var omitNullsWarning);
|
||
await SendJsonResponse(response, 200, BuildJobResponse(record, omitNullsWarning), omitNulls);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"HandleJobStatusRequest failed: {ex.Message}");
|
||
await SendErrorResponse(response, "Job Status Error", $"Failed to get job status: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle POST /api/job/cancel — request cancellation of a detached job (CLI-335).
|
||
/// A queued job never starts; a running job gets the cooperative
|
||
/// PipelineCancellation flag (synchronous code cannot be aborted from outside).
|
||
/// </summary>
|
||
private async Task HandleJobCancelRequest(HttpListenerRequest request, HttpListenerResponse response)
|
||
{
|
||
try
|
||
{
|
||
if (request.ContentLength64 > MaxCancelBodyBytes)
|
||
{
|
||
await DrainRequestBody(request, MaxDrainBytes);
|
||
await SendStatusResponse(response, 413, "Payload Too Large",
|
||
$"Request body exceeds the maximum allowed size of {MaxCancelBodyBytes} bytes");
|
||
return;
|
||
}
|
||
|
||
string body;
|
||
try
|
||
{
|
||
using (var limited = new MaxLengthStream(request.InputStream, MaxCancelBodyBytes, leaveOpen: true))
|
||
using (var reader = new StreamReader(limited))
|
||
{
|
||
body = await reader.ReadToEndAsync();
|
||
}
|
||
}
|
||
catch (RequestTooLargeException ex)
|
||
{
|
||
await DrainRequestBody(request, MaxDrainBytes);
|
||
await SendStatusResponse(response, 413, "Payload Too Large", ex.Message);
|
||
return;
|
||
}
|
||
finally
|
||
{
|
||
request.InputStream.Dispose();
|
||
}
|
||
|
||
string id = null;
|
||
try
|
||
{
|
||
var parsed = JObject.Parse(string.IsNullOrEmpty(body) ? "{}" : body);
|
||
id = parsed["id"]?.ToString();
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
// Fall through to the missing-id error below.
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(id))
|
||
{
|
||
await SendStatusResponse(response, 400, "Bad Request", "Request body must be JSON with an 'id' field");
|
||
return;
|
||
}
|
||
if (!JobRegistry.RequestCancel(id, out var record))
|
||
{
|
||
await SendStatusResponse(response, 404, "Job Not Found", $"No job with id '{id}'");
|
||
return;
|
||
}
|
||
|
||
var omitNulls = ParseOmitNulls(request, out var omitNullsWarning);
|
||
await SendJsonResponse(response, 200, BuildJobResponse(record, omitNullsWarning), omitNulls);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"HandleJobCancelRequest failed: {ex.Message}");
|
||
await SendErrorResponse(response, "Job Cancel Error", $"Failed to cancel job: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle /api/progress endpoint — the currently executing command's task progress
|
||
/// (CLI-488): the server side of the CLI's terminal progress bars, the pipeline
|
||
/// equivalent of EditorUtility.DisplayProgressBar.
|
||
///
|
||
/// Served entirely on the listener thread from CliProgress's lock-protected snapshot —
|
||
/// never marshaled to the main thread — so it stays responsive while a long synchronous
|
||
/// command has the main thread blocked (exactly when progress matters most).
|
||
///
|
||
/// Contract (all progress fields optional; pct is 0–1; an idle server returns
|
||
/// <c>{"active":false,"progress":null}</c>):
|
||
/// <code>{"active":true,"progress":{"title":"…","info":"…","current":42,"total":100,"pct":0.42}}</code>
|
||
/// </summary>
|
||
private async Task HandleProgressRequest(HttpListenerRequest request, HttpListenerResponse response)
|
||
{
|
||
try
|
||
{
|
||
var active = Progress.IsActive;
|
||
var progress = active ? BuildProgressPayload(Progress.Current) : null;
|
||
|
||
var omitNulls = ParseOmitNulls(request, out var omitNullsWarning);
|
||
await SendJsonResponse(response, 200, new
|
||
{
|
||
active,
|
||
progress,
|
||
// Corrective guidance for the caller (e.g. an unparseable omit_nulls value);
|
||
// null when there is nothing to say.
|
||
warnings = omitNullsWarning == null ? null : new[] { omitNullsWarning }
|
||
}, omitNulls);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"HandleProgressRequest failed: {ex.Message}");
|
||
await SendErrorResponse(response, "Progress Error", $"Failed to get progress: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle /api/dialog endpoint — currently-open modal dialogs, mirroring /api/progress's
|
||
/// shape and thread-safety: served entirely from DialogStateTracker's lock-protected
|
||
/// state, never marshaled to the main thread, so it answers even while a synchronous
|
||
/// command has the main thread blocked inside a modal dialog (exactly when it matters
|
||
/// most).
|
||
///
|
||
/// Contract: {"active":true,"dialogs":[{"id","source","title","message","level","buttons","openedAt","dismissedAt"}]}
|
||
///
|
||
/// "active": false means no dialog of a COVERED kind is open, not that the Editor isn't
|
||
/// blocked by some other popup — coverage is mechanism-based (see
|
||
/// UnityEditor.EditorDialogEvents' remarks in trunk): native message boxes and EditorWindow
|
||
/// modals are covered; OS file/folder pickers and any other dialog mechanism are not. A
|
||
/// caller still needs a fallback signal (e.g. a command timeout) for the uncovered cases.
|
||
/// </summary>
|
||
private async Task HandleDialogRequest(HttpListenerResponse response)
|
||
{
|
||
try
|
||
{
|
||
var dialogs = Dialogs.CurrentlyOpen;
|
||
await SendJsonResponse(response, 200, new
|
||
{
|
||
active = dialogs.Count > 0,
|
||
dialogs = dialogs.Select(BuildDialogPayload).ToList()
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"HandleDialogRequest failed: {ex.Message}");
|
||
await SendErrorResponse(response, "Dialog Error", $"Failed to get dialog state: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Shared wire projection of a DialogInfo, used by /api/dialog, dialogsDuringExecution, the
|
||
/// busy-gate, and (internal, via InternalsVisibleTo) EditorPipelineServer's /api/status.
|
||
/// </summary>
|
||
internal static object BuildDialogPayload(DialogInfo info) => new
|
||
{
|
||
id = info.Id,
|
||
source = info.Source,
|
||
title = info.Title,
|
||
message = info.Message,
|
||
level = info.Level,
|
||
buttons = info.Buttons,
|
||
openedAt = info.OpenedAtUtc,
|
||
dismissedAt = info.DismissedAtUtc
|
||
};
|
||
|
||
/// <summary>Human-readable one-line summary of a dialog for the busy-gate message. Message/Level are null for a ManagedCustomWindow source, so both are omitted when absent.</summary>
|
||
private static string FormatDialogSummary(DialogInfo info)
|
||
{
|
||
var levelPrefix = string.IsNullOrEmpty(info.Level) ? "" : $"[{info.Level}] ";
|
||
var messageSuffix = string.IsNullOrEmpty(info.Message) ? "" : $": {info.Message}";
|
||
return $"{levelPrefix}{info.Title}{messageSuffix}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handle /api/exec endpoint - execute CLI commands with parameters.
|
||
/// </summary>
|
||
private async Task HandleExecRequest(HttpListenerRequest request, HttpListenerResponse response)
|
||
{
|
||
var cmd = "";
|
||
// Stay false for pre-parse failures (413/empty/invalid JSON), which cannot honor the
|
||
// request's flags; set from the request as soon as it deserializes — before structure
|
||
// validation, so a verbose request gets a verbose validation failure. (AUTHAPI-21)
|
||
var verbose = false;
|
||
var omitNulls = false;
|
||
string requestBody = null;
|
||
// The execution half of the info reported to OnCommandDone. Both stay at their
|
||
// defaults on every branch that answers without running a command, and the timer is
|
||
// readable from the catch blocks below because it is started inside the gated body.
|
||
CommandInfo executed = null;
|
||
JObject executedParameters = null;
|
||
var timer = new System.Diagnostics.Stopwatch();
|
||
try
|
||
{
|
||
// Reject oversized bodies up front via Content-Length (cheap, before reading anything).
|
||
if (request.ContentLength64 > MaxRequestBodyBytes)
|
||
{
|
||
// Drain the body first so the client can read the 413 (see DrainRequestBody).
|
||
await DrainRequestBody(request, MaxDrainBytes);
|
||
await SendExecResponse(response, 413,
|
||
BaseResponse.Failure("Payload Too Large",
|
||
$"Request body exceeds the maximum allowed size of {MaxRequestBodyBytes} bytes"), null);
|
||
return;
|
||
}
|
||
|
||
// Read request body, enforcing the same cap while reading in case Content-Length is
|
||
// absent or untruthful (e.g. chunked transfer-encoding).
|
||
try
|
||
{
|
||
// leaveOpen: on an over-limit read we still need the raw InputStream open to drain
|
||
// the unsent remainder before responding, so it must outlive the reader/wrapper.
|
||
using (var limited = new MaxLengthStream(request.InputStream, MaxRequestBodyBytes, leaveOpen: true))
|
||
using (var reader = new StreamReader(limited))
|
||
{
|
||
requestBody = await reader.ReadToEndAsync();
|
||
}
|
||
}
|
||
catch (RequestTooLargeException ex)
|
||
{
|
||
// Drain the body first so the client can read the 413 (see DrainRequestBody).
|
||
await DrainRequestBody(request, MaxDrainBytes);
|
||
await SendExecResponse(response, 413,
|
||
BaseResponse.Failure("Payload Too Large", ex.Message), null);
|
||
return;
|
||
}
|
||
finally
|
||
{
|
||
// MaxLengthStream left the InputStream open; dispose it now that any read/drain is done.
|
||
request.InputStream.Dispose();
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(requestBody))
|
||
{
|
||
await SendExecResponse(response, 400,
|
||
BaseResponse.Failure("Bad Request", "Request body is required"), requestBody);
|
||
return;
|
||
}
|
||
|
||
// Parse command request
|
||
CommandExecutionRequest commandRequest;
|
||
try
|
||
{
|
||
commandRequest = JsonConvert.DeserializeObject<CommandExecutionRequest>(requestBody);
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
await SendExecResponse(response, 400,
|
||
BaseResponse.Failure("Invalid JSON", $"Failed to parse request body: {ex.Message}"), requestBody);
|
||
return;
|
||
}
|
||
|
||
// A body of literal JSON "null" parses without error but deserializes to null —
|
||
// reject it here so everything below can rely on a non-null request (previously
|
||
// this slid through the null-conditional validation and NRE'd at the Command read).
|
||
if (commandRequest == null)
|
||
{
|
||
await SendExecResponse(response, 400,
|
||
BaseResponse.Failure("Invalid Request", "Request body must be a JSON object"), requestBody);
|
||
return;
|
||
}
|
||
|
||
// Honor the reply-shape flags from here on: the request has deserialized, so even
|
||
// the structure-validation failure below can respect them.
|
||
verbose = commandRequest.Verbose;
|
||
omitNulls = commandRequest.OmitNulls;
|
||
|
||
// Validate request structure
|
||
var requestValidationError = commandRequest.Validate();
|
||
if (!string.IsNullOrEmpty(requestValidationError))
|
||
{
|
||
await SendExecResponse(response, 400,
|
||
BaseResponse.Failure("Invalid Request", requestValidationError), requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
|
||
// Raw command-line forms. Tokenize `commandLine`, or take `argv` as-is (it is
|
||
// already split; re-splitting would corrupt any value containing a space), then
|
||
// normalize the request IN PLACE by assigning the command name. Normalizing here
|
||
// rather than deeper down is what leaves the rest of this method unchanged:
|
||
// RunJobDetached re-reads commandRequest, so `job` needs no special handling, and
|
||
// the CmdSuccess command echo stays correct.
|
||
//
|
||
// Placed after Validate() so transport concerns do not depend on the command
|
||
// registry, after the reply-shape flags are captured so a malformed line still
|
||
// honours verbose/omitNulls, and before ResolveCommand so an unknown raw command
|
||
// name produces the same Command Not Found envelope as the structured path.
|
||
List<string> rawTokens = null;
|
||
if (commandRequest.IsRawForm)
|
||
{
|
||
if (commandRequest.Argv != null)
|
||
{
|
||
rawTokens = commandRequest.Argv;
|
||
}
|
||
else if (!CommandLineTokenizer.TryTokenize(commandRequest.CommandLine, out rawTokens, out var tokenizeError))
|
||
{
|
||
// Reported as a malformed request shape, not as an argument error: no
|
||
// command was resolved, so there is no schema and no argProblems to carry.
|
||
// INVALID_COMMAND_ARGS promises a client both of those.
|
||
await SendExecResponse(response, 400,
|
||
BaseResponse.Failure("Invalid Request", tokenizeError),
|
||
requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
|
||
// A blank first token is as malformed as no token at all, and it is reachable:
|
||
// a commandLine of `"" --message hi` is a NONBLANK source string, so Validate()
|
||
// passes, yet it tokenizes to an empty command name. Left unchecked that became
|
||
// a Command Not Found, telling the caller an empty command is merely
|
||
// unavailable. `argv` with an empty first element is already rejected by
|
||
// Validate(), so both raw forms answer the same way here.
|
||
if (rawTokens.Count == 0 || string.IsNullOrWhiteSpace(rawTokens[0]))
|
||
{
|
||
await SendExecResponse(response, 400,
|
||
BaseResponse.Failure("Invalid Request", "Command name is required"),
|
||
requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
|
||
commandRequest.Command = rawTokens[0];
|
||
}
|
||
|
||
cmd = commandRequest.Command;
|
||
|
||
// Resolve the command once — the busy gate and the execution path share this
|
||
// single registry lookup instead of scanning twice per request. An unknown name
|
||
// throws here and surfaces as the usual Command Not Found via the catch below
|
||
// (for a "job": true submission that now happens BEFORE a job is created, so a
|
||
// misnamed command fails fast instead of producing a job that fails later).
|
||
var command = ResolveCommand(cmd);
|
||
|
||
// Bind the remaining tokens and normalize in place, so everything from
|
||
// ExtractCommandParameters downwards sees exactly one shape.
|
||
//
|
||
// Before the busy gate: a malformed command line is a deterministic client fault,
|
||
// and answering 503-retryable first would send clients into a retry loop over an
|
||
// error that will never clear. Also before job creation, so a mistyped parameter
|
||
// can never return a job id and exit 0.
|
||
if (rawTokens != null)
|
||
{
|
||
var args = rawTokens.GetRange(1, rawTokens.Count - 1);
|
||
if (!CommandLineBinder.TryBind(command, args, out var bound, out var argProblems))
|
||
{
|
||
await SendExecResponse(response, 400,
|
||
CommandExecutionResponse.CmdInvalidArgs(cmd,
|
||
DescribeArgProblems(command, argProblems), argProblems,
|
||
BuildCommandEntry(command)),
|
||
requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
|
||
commandRequest.Parameters = bound;
|
||
|
||
// Refuse a known-invalid raw submission before the busy gate and before job
|
||
// creation. Binding SUCCEEDS when a required parameter simply was not supplied
|
||
// — that rule belongs to parameter validation, not the binder — so without
|
||
// this check `{"argv":["log_editor"],"job":true}` reached job creation, was
|
||
// acknowledged with a job id and a 200, and only failed later inside the
|
||
// detached run. The client would be told a deterministically invalid request
|
||
// had been accepted.
|
||
var missingRequired = ValidateRequiredParametersBound(command, bound);
|
||
if (!string.IsNullOrEmpty(missingRequired))
|
||
{
|
||
await SendExecResponse(response, 400,
|
||
CommandExecutionResponse.CmdFailure(cmd, "Parameter Validation Failed", missingRequired),
|
||
requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Dialog busy gate: checked before the settling gate below. A modal dialog genuinely
|
||
// blocks the main thread inside a nested OS message loop (unlike settling, which
|
||
// merely delays), so it's true regardless of settle state and is the more actionable
|
||
// fact for a caller — settling resolves on its own, a dialog needs a human.
|
||
// TryGetDialogBlockedFallback is the one exception: a command whose result can be
|
||
// served from a cached snapshot instead of running for real (editor_status).
|
||
if (command.MainThreadRequired)
|
||
{
|
||
var openDialogs = Dialogs.CurrentlyOpen;
|
||
if (openDialogs.Count > 0)
|
||
{
|
||
object fallback = TryGetDialogBlockedFallback(command, openDialogs[0]);
|
||
if (fallback != null)
|
||
{
|
||
await SendExecResponse(response, 200,
|
||
WithBoundParameters(
|
||
CommandExecutionResponse.CmdSuccess(cmd, fallback), commandRequest),
|
||
requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
|
||
var summary = FormatDialogSummary(openDialogs[0]);
|
||
var separator = summary.EndsWith(".") || summary.EndsWith("!") || summary.EndsWith("?") ? " " : ". ";
|
||
var busyResponse = CommandExecutionResponse.CmdBusy(cmd,
|
||
$"A modal dialog is currently open and blocking the main thread: {summary}{separator}" +
|
||
"Poll GET /api/dialog for details, or retry once it is dismissed.",
|
||
"blocked_by_dialog");
|
||
busyResponse.Dialogs = openDialogs.Select(BuildDialogPayload).ToList();
|
||
await SendExecResponse(response, 503, busyResponse, requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Busy gate (AUTHAPI-35): while the host is settling after startup, reject
|
||
// not-yet-serviceable commands up front — before sync execution AND before a
|
||
// detached job is created (a queued job would otherwise run into the half-ready
|
||
// Editor in the background). 503 with a retryable envelope, distinguishable from
|
||
// a genuine command failure. Only the exec endpoint gates; the status endpoints
|
||
// keep working so the busy state itself stays observable.
|
||
var busyReason = GetBusyReason(command);
|
||
if (busyReason != null)
|
||
{
|
||
// The busy reply is a standard exec envelope, so it follows the request's
|
||
// reply-shape flags like every other branch: lean drops the command echo and
|
||
// envelope metadata; verbose restores them (AUTHAPI-21 x AUTHAPI-35).
|
||
await SendExecResponse(response, 503,
|
||
CommandExecutionResponse.CmdBusy(cmd, busyReason, "settling"), requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
|
||
// Detached job (CLI-335): reply with a job id immediately and run the command
|
||
// in the background — the client polls GET /api/job?id=… to reattach and
|
||
// collect the result after its own HTTP timeout would have expired.
|
||
if (commandRequest.Job)
|
||
{
|
||
if (!JobRegistry.TryCreate(commandRequest.Command, out var jobRecord))
|
||
{
|
||
await SendExecResponse(response, 429,
|
||
CommandExecutionResponse.CmdFailure(commandRequest.Command, "Too Many Queued Jobs",
|
||
"Too many jobs are queued or running; wait for some to finish and retry."), requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
_ = RunJobDetached(jobRecord, command, commandRequest);
|
||
// Standard exec envelope; the job handle is the command's "result".
|
||
await SendExecResponse(response, 200,
|
||
WithBoundParameters(
|
||
CommandExecutionResponse.CmdSuccess(commandRequest.Command,
|
||
new { jobId = jobRecord.Id, state = "queued" }), commandRequest),
|
||
requestBody, verbose, omitNulls);
|
||
return;
|
||
}
|
||
|
||
// Execute command using shared execution logic (see ExecuteGated: one command at
|
||
// a time, exactly as when the accept loop was serial).
|
||
DateTime execStartUtc = default;
|
||
object result = null;
|
||
await ExecuteGated(Guid.NewGuid().ToString("N"), async () =>
|
||
{
|
||
// Claimed here rather than before ExecuteGated, so that a request which never
|
||
// gets past the gate reports no execution at all, and so that the wait for the
|
||
// one-at-a-time gate counts as queueing rather than as time the command ran.
|
||
executed = command;
|
||
executedParameters = commandRequest.Parameters;
|
||
execStartUtc = DateTime.UtcNow;
|
||
timer.Start();
|
||
try
|
||
{
|
||
result = await ExecuteCommandByName(command, commandRequest.Parameters,
|
||
commandRequest.Timeout ?? 60000);
|
||
}
|
||
finally
|
||
{
|
||
timer.Stop();
|
||
}
|
||
});
|
||
|
||
// Send success response, attaching any dialog(s) that opened during this call
|
||
// (dialogsDuringExecution) so a caller learns about them even without polling
|
||
// /api/dialog concurrently while the call was in flight.
|
||
var successResponse = WithBoundParameters(
|
||
CommandExecutionResponse.CmdSuccess(commandRequest.Command, result), commandRequest);
|
||
var dialogEvents = Dialogs.EventsSince(execStartUtc);
|
||
if (dialogEvents.Count > 0)
|
||
successResponse.DialogsDuringExecution = dialogEvents.Select(BuildDialogPayload).ToList();
|
||
|
||
await SendExecResponse(response, 200, successResponse, requestBody, verbose, omitNulls,
|
||
executed, IsSuccessfulResult(result), timer.ElapsedMilliseconds, commandRequest.Parameters);
|
||
}
|
||
catch (ArgumentException ex)
|
||
{
|
||
// Parameter validation errors
|
||
await SendExecResponse(response, 400,
|
||
CommandExecutionResponse.CmdFailure(cmd, "Parameter Validation Failed", ex.Message), requestBody, verbose, omitNulls,
|
||
executed, false, timer.ElapsedMilliseconds, executedParameters);
|
||
}
|
||
catch (InvalidOperationException ex) when (ex.Message.Contains("No command named"))
|
||
{
|
||
// Command not found errors
|
||
await SendExecResponse(response, 400,
|
||
CommandExecutionResponse.CmdFailure(cmd, "Command Not Found", ex.Message), requestBody, verbose, omitNulls,
|
||
executed, false, timer.ElapsedMilliseconds, executedParameters);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
// Command execution errors
|
||
await SendExecResponse(response, 400,
|
||
CommandExecutionResponse.CmdFailure(cmd, "Command Execution Failed", ex.Message), requestBody, verbose, omitNulls,
|
||
executed, false, timer.ElapsedMilliseconds, executedParameters);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"Failed to handle /api/exec request: {ex.Message}");
|
||
await SendExecResponse(response, 400,
|
||
CommandExecutionResponse.CmdFailure(cmd, "Internal Server Error", ex.Message), requestBody, verbose, omitNulls,
|
||
executed, false, timer.ElapsedMilliseconds, executedParameters);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read and discard the remaining request body, up to <paramref name="maxDrainBytes"/> bytes.
|
||
/// When we reject a request early (e.g. 413) the client may still be uploading; if we close the
|
||
/// response while unread data is in flight, HttpListener resets the TCP connection and the client
|
||
/// sees a connection error instead of our HTTP response. Draining lets the upload complete so the
|
||
/// response is delivered. Best-effort and bounded: a body larger than the budget stops draining
|
||
/// (and may reset), which is acceptable for an abusive request.
|
||
/// </summary>
|
||
private static async Task DrainRequestBody(HttpListenerRequest request, long maxDrainBytes)
|
||
{
|
||
try
|
||
{
|
||
var input = request.InputStream;
|
||
var buffer = new byte[16 * 1024];
|
||
long drained = 0;
|
||
while (drained < maxDrainBytes)
|
||
{
|
||
var read = await input.ReadAsync(buffer, 0, buffer.Length);
|
||
if (read <= 0)
|
||
break;
|
||
drained += read;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// Best-effort: if the client already closed the connection or the stream is gone,
|
||
// there is nothing left to drain and the response send below will handle the rest.
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send error response with structured JSON format.
|
||
/// </summary>
|
||
private async Task SendErrorResponse(HttpListenerResponse response, string error, string details = null, int statusCode = 400)
|
||
{
|
||
try
|
||
{
|
||
var errorResponse = BaseResponse.Failure(error, details);
|
||
await SendResponse(response, statusCode, errorResponse);
|
||
}
|
||
catch
|
||
{
|
||
// Ignore errors in error handling
|
||
}
|
||
}
|
||
|
||
private async Task SendResponse(HttpListenerResponse response, int statusCode, BaseResponse pipelineResponse)
|
||
{
|
||
response.StatusCode = statusCode;
|
||
response.ContentType = "application/json";
|
||
var json = JsonConvert.SerializeObject(pipelineResponse, Formatting.Indented);
|
||
var buffer = Encoding.UTF8.GetBytes(json);
|
||
response.ContentLength64 = buffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||
response.OutputStream.Close();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Send an /api/exec response and report the finished interaction to
|
||
/// <see cref="OnCommandDone"/>. Single send point for the exec handler so every branch
|
||
/// (success and error) is captured uniformly.
|
||
///
|
||
/// The executed* arguments carry the execution half of the info. Branches that reject a
|
||
/// request before any command runs (413, malformed body, busy host, job queue full) leave
|
||
/// them at their defaults, which reports an info with no command.
|
||
/// </summary>
|
||
private async Task SendExecResponse(HttpListenerResponse response, int statusCode,
|
||
BaseResponse body, string requestJson, bool verbose = false, bool omitNulls = false,
|
||
CommandInfo executedCommand = null, bool executedSuccess = false, long executedDurationMs = 0,
|
||
JObject executedParameters = null)
|
||
{
|
||
response.StatusCode = statusCode;
|
||
response.ContentType = "application/json";
|
||
// Lean, compact serialization by default; the request's `verbose` flag opts into the
|
||
// full envelope and `omitNulls` drops payload nulls. Single contract shared with the
|
||
// byte-size regression test. (AUTHAPI-21)
|
||
var json = ExecResponseSerializer.Serialize(body, verbose, omitNulls);
|
||
var buffer = Encoding.UTF8.GetBytes(json);
|
||
response.ContentLength64 = buffer.Length;
|
||
|
||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||
response.OutputStream.Close();
|
||
|
||
OnCommandDone(new CommandExecutionInfo(requestJson, json, executedCommand, executedSuccess, executedDurationMs,
|
||
executedParameters));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Hook invoked once per finished /api/exec interaction, and once more when a detached job
|
||
/// completes. The single place to hang post-command work: the Editor server writes the
|
||
/// transaction log here and queues its analytics from here. No-op by default (the Player
|
||
/// does neither).
|
||
///
|
||
/// Runs on the background HTTP thread, so an implementation must either stick to
|
||
/// thread-safe work or marshal to the main thread itself. It must also never throw —
|
||
/// an exception here would surface as a failed request for work the caller never asked for.
|
||
/// </summary>
|
||
/// <param name="info">The finished interaction: its transaction, its execution, or both.</param>
|
||
protected virtual void OnCommandDone(in CommandExecutionInfo info) { }
|
||
|
||
/// <summary>
|
||
/// Whether a command reported success. A command fails in two ways: by throwing, which the
|
||
/// caller has already accounted for, or by RETURNING a response that carries its own
|
||
/// failure — eval, code reload, run_script and test runs all do the latter, so nothing
|
||
/// throws and the outer envelope says success while the inner result says otherwise.
|
||
/// </summary>
|
||
private static bool IsSuccessfulResult(object result)
|
||
{
|
||
switch (result)
|
||
{
|
||
case CommandExecutionResponse commandResponse:
|
||
return commandResponse.Success;
|
||
case BaseResponse baseResponse:
|
||
return string.IsNullOrEmpty(baseResponse.Error);
|
||
default:
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Convert a single JSON parameter token to the command's parameter type.
|
||
///
|
||
/// Agents and the CLI frequently pass structured parameters (e.g. <c>float[]</c> position,
|
||
/// <c>JObject</c> properties) as a JSON-ENCODED STRING — e.g. position <c>"[0,0,0]"</c> or
|
||
/// properties <c>"{\"m_Mass\":0.17}"</c>. Newtonsoft's <c>JValue(string).ToObject(float[])</c>
|
||
/// (and likewise for <c>JObject</c>) returns null, so the parameter silently drops out:
|
||
/// set_transform applies nothing and set_component_properties fails Required-parameter
|
||
/// validation (CLI-219 / CLI-220). To fix this generally — without special-casing any command —
|
||
/// when the token is a string but the target type is NOT string AND the trimmed string starts
|
||
/// with '{' or '[', we re-parse it as a JSON document before converting.
|
||
///
|
||
/// The '{'/'[' guard is deliberate and narrow: ordinary string params (and ObjectRef string
|
||
/// handles like "/Player", "Assets/Foo.prefab", "guid:..." — none of which start with '{'/'[')
|
||
/// fall straight through to <c>token.ToObject</c>, so the class-level
|
||
/// <see cref="Unity.Pipeline.Models.ObjectRefConverter"/> and plain-string parameters keep
|
||
/// working unchanged. A re-parse that fails falls through to the normal conversion path (the
|
||
/// caller's try/catch then handles any remaining failure).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Internal rather than private so <see cref="CommandLineBinder"/> can DRY-RUN the exact
|
||
/// converter the executor will use. Binding against a different conversion would let a
|
||
/// command line pass validation and then fail during execution.
|
||
/// </remarks>
|
||
internal static object ConvertParameterToken(Newtonsoft.Json.Linq.JToken token, System.Type targetType)
|
||
{
|
||
if (token == null)
|
||
return null;
|
||
|
||
if (token.Type == Newtonsoft.Json.Linq.JTokenType.String
|
||
&& targetType != typeof(string)
|
||
&& !targetType.IsPrimitive
|
||
&& !targetType.IsEnum)
|
||
{
|
||
var s = token.Value<string>();
|
||
if (!string.IsNullOrWhiteSpace(s))
|
||
{
|
||
var t = s.TrimStart();
|
||
if (t.Length > 0 && (t[0] == '{' || t[0] == '['))
|
||
{
|
||
try { return Newtonsoft.Json.Linq.JToken.Parse(s).ToObject(targetType); }
|
||
catch (Newtonsoft.Json.JsonException) { /* fall through to the direct conversion */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
return token.ToObject(targetType);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extract command parameters from JSON request and convert to appropriate types.
|
||
/// <para><paramref name="conversionError"/> reports arguments that could not be converted to
|
||
/// the parameter's type. Substituting the default there gives the caller a different result
|
||
/// than it asked for with no way to detect it — an ignored <c>limit</c> returns more than was
|
||
/// asked, an ignored <c>timeout</c> waits longer than allowed. The slot still gets the default
|
||
/// so the array stays well-formed, but the callers reject the request.</para>
|
||
/// </summary>
|
||
private object[] ExtractCommandParameters(CommandInfo command, Newtonsoft.Json.Linq.JObject parametersJson, out string conversionError)
|
||
{
|
||
StringBuilder conversionErrors = null;
|
||
var parameterValues = new object[command.Parameters.Count];
|
||
|
||
for (int i = 0; i < command.Parameters.Count; i++)
|
||
{
|
||
var paramInfo = command.Parameters[i];
|
||
var paramName = paramInfo.Name;
|
||
|
||
// Try to get value from JSON parameters
|
||
object jsonValue = null;
|
||
if (parametersJson != null && parametersJson.ContainsKey(paramName))
|
||
{
|
||
var token = parametersJson[paramName];
|
||
try
|
||
{
|
||
jsonValue = ConvertParameterToken(token, paramInfo.ParameterType);
|
||
|
||
// A converter can DECLINE a token by returning null instead of throwing —
|
||
// ObjectRefConverter does exactly that for unsupported kinds, so
|
||
// {"parent": true} would otherwise read as an omitted argument and the
|
||
// command would run against the default. Anything that converts to null
|
||
// without being one of the intentional null forms is a conversion failure.
|
||
if (jsonValue == null && !IsIntentionalNullToken(token))
|
||
{
|
||
RecordConversionError(ref conversionErrors, paramName, paramInfo,
|
||
$"unsupported JSON value ({token.Type})");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Record it rather than logging and moving on: a console warning is invisible
|
||
// to the HTTP caller, which is the only party that can act on it.
|
||
RecordConversionError(ref conversionErrors, paramName, paramInfo, ex.Message);
|
||
}
|
||
}
|
||
|
||
// Use provided value or default value
|
||
if (jsonValue != null)
|
||
{
|
||
parameterValues[i] = jsonValue;
|
||
}
|
||
else if (paramInfo.DefaultValue != null)
|
||
{
|
||
parameterValues[i] = paramInfo.DefaultValue;
|
||
}
|
||
else
|
||
{
|
||
// Use type's default value for value types, null for reference types
|
||
parameterValues[i] = paramInfo.ParameterType.IsValueType
|
||
? Activator.CreateInstance(paramInfo.ParameterType)
|
||
: null;
|
||
}
|
||
}
|
||
|
||
conversionError = conversionErrors?.ToString();
|
||
return parameterValues;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The token forms a converter may legitimately turn into null, as opposed to declining a
|
||
/// value it cannot represent: an explicit JSON null, and an empty or whitespace-only string.
|
||
/// The latter is a documented contract, not a quirk — <c>ObjectRefConverter.FromString</c>
|
||
/// returns null for it, and <c>set_parent</c> advertises "Omit (or empty) to move the object
|
||
/// to the scene root", so <c>{"parent":""}</c> must keep detaching rather than 400.
|
||
/// </summary>
|
||
private static bool IsIntentionalNullToken(JToken token)
|
||
{
|
||
if (token == null || token.Type == JTokenType.Null)
|
||
return true;
|
||
|
||
return token.Type == JTokenType.String && string.IsNullOrWhiteSpace(token.Value<string>());
|
||
}
|
||
|
||
/// <summary>Appends one parameter-conversion failure to the accumulated report.</summary>
|
||
private static void RecordConversionError(
|
||
ref StringBuilder errors, string paramName, CommandParameterInfo paramInfo, string detail)
|
||
{
|
||
if (errors == null)
|
||
errors = new StringBuilder();
|
||
else
|
||
errors.Append("; ");
|
||
errors.Append(
|
||
$"Parameter '{paramName}' could not be converted to {paramInfo.ParameterType.Name}: {detail}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// The one wording for a missing required parameter. Shared by the two validators below so
|
||
/// the structured and raw request forms cannot diagnose the same mistake differently.
|
||
/// </summary>
|
||
private static string MissingRequiredParameterMessage(string parameterName)
|
||
{
|
||
return $"Required parameter '{parameterName}' is missing or empty";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates required parameters against a BOUND parameter object, before anything runs.
|
||
///
|
||
/// <see cref="ValidateCommandParameters"/> applies the same rule to already-extracted CLR
|
||
/// values, but it runs inside command execution — which for a detached job is after the job
|
||
/// id has already been handed to the client. A raw submission missing a required parameter
|
||
/// would be acknowledged as accepted and only fail later, in the background. Checking the
|
||
/// bound object first lets that request be refused up front, with the same envelope the
|
||
/// synchronous path produces.
|
||
///
|
||
/// The rule itself is shared, not reimplemented: both this and the extracted-value check
|
||
/// normalize to a CLR value and call <see cref="IsMissingRequiredValue"/>, so the raw and
|
||
/// structured paths cannot drift on what counts as missing.
|
||
/// </summary>
|
||
private static string ValidateRequiredParametersBound(CommandInfo command, JObject parameters)
|
||
{
|
||
for (var i = 0; i < command.Parameters.Count; i++)
|
||
{
|
||
var paramInfo = command.Parameters[i];
|
||
if (!paramInfo.Required)
|
||
continue;
|
||
|
||
if (IsMissingRequiredValue(AsRequiredValue(parameters?[paramInfo.Name])))
|
||
{
|
||
return MissingRequiredParameterMessage(paramInfo.Name);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The one rule for "this required parameter was not supplied": absent, null, or the empty
|
||
/// string. Both the bound-JSON and extracted-CLR checks normalize to a CLR value and share
|
||
/// this, so a future change to what counts as missing cannot be applied to one path and
|
||
/// forgotten on the other.
|
||
/// </summary>
|
||
private static bool IsMissingRequiredValue(object value)
|
||
{
|
||
return value == null || (value is string text && string.IsNullOrEmpty(text));
|
||
}
|
||
|
||
/// <summary>
|
||
/// A bound JSON value as the CLR value <see cref="IsMissingRequiredValue"/> expects. A JSON
|
||
/// null is a CLR null; a JSON string is its text; anything else is present by definition
|
||
/// and stands in for itself.
|
||
/// </summary>
|
||
private static object AsRequiredValue(JToken value)
|
||
{
|
||
if (value == null || value.Type == JTokenType.Null)
|
||
return null;
|
||
return value.Type == JTokenType.String ? value.Value<string>() : (object)value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validate that all required command parameters are provided.
|
||
/// </summary>
|
||
private string ValidateCommandParameters(CommandInfo command, object[] parameters)
|
||
{
|
||
for (int i = 0; i < command.Parameters.Count; i++)
|
||
{
|
||
var paramInfo = command.Parameters[i];
|
||
|
||
if (paramInfo.Required && IsMissingRequiredValue(parameters[i]))
|
||
{
|
||
return MissingRequiredParameterMessage(paramInfo.Name);
|
||
}
|
||
}
|
||
|
||
return null; // No validation errors
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve a command name to its <see cref="CommandInfo"/>, or throw the standard
|
||
/// Command Not Found error. Single registry scan — /api/exec resolves once and shares
|
||
/// the result between the busy gate and execution.
|
||
/// </summary>
|
||
private static CommandInfo ResolveCommand(string commandName)
|
||
{
|
||
var commands = CommandRegistry.DiscoverCommands().ToList();
|
||
var command = commands.FirstOrDefault(c => c.Name == commandName);
|
||
if (command == null)
|
||
{
|
||
var availableCommands = string.Join(", ", commands.Select(c => c.Name));
|
||
var errorMessage = $"No command named '{commandName}' is available. Available: [{availableCommands}]";
|
||
Debug.LogError($"ExecuteCommandByName: {errorMessage}");
|
||
throw new InvalidOperationException(errorMessage);
|
||
}
|
||
return command;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Execute a command by name with JSON parameters.
|
||
/// Handles command lookup, parameter validation, and execution.
|
||
/// Used by callers that only hold a name (status endpoints, detached job runner); the
|
||
/// /api/exec handler resolves the command itself and uses the CommandInfo overload.
|
||
/// </summary>
|
||
private async Task<object> ExecuteCommandByName(string commandName, JObject parametersJson, int dispatcherTimeoutMs = 60000)
|
||
{
|
||
return await ExecuteCommandByName(ResolveCommand(commandName), parametersJson, dispatcherTimeoutMs);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Execute a pre-resolved command with JSON parameters (parameter extraction, required
|
||
/// validation, threading).
|
||
/// </summary>
|
||
private async Task<object> ExecuteCommandByName(CommandInfo command, JObject parametersJson, int dispatcherTimeoutMs = 60000)
|
||
{
|
||
// Extract parameters
|
||
var parameters = ExtractCommandParameters(command, parametersJson, out var conversionError);
|
||
|
||
// Reject unconvertible arguments rather than silently running with the default
|
||
if (!string.IsNullOrEmpty(conversionError))
|
||
{
|
||
Debug.LogError($"ExecuteCommandByName: Parameter conversion failed: {conversionError}");
|
||
throw new ArgumentException(conversionError);
|
||
}
|
||
|
||
// Validate required parameters
|
||
var validationError = ValidateCommandParameters(command, parameters);
|
||
if (!string.IsNullOrEmpty(validationError))
|
||
{
|
||
Debug.LogError($"ExecuteCommandByName: Parameter validation failed: {validationError}");
|
||
throw new ArgumentException(validationError);
|
||
}
|
||
|
||
// Execute command with appropriate threading
|
||
return await ExecuteCommand(command, parameters, dispatcherTimeoutMs);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Execute the command method with provided parameters.
|
||
/// Handles main thread execution if required.
|
||
/// </summary>
|
||
private async Task<object> ExecuteCommand(CommandInfo command, object[] parameters, int dispatcherTimeoutMs = 60000)
|
||
{
|
||
object raw;
|
||
if (command.MainThreadRequired)
|
||
{
|
||
// Execute on the main thread using THIS server's dispatcher.
|
||
if (m_Dispatcher.IsMainThread())
|
||
{
|
||
raw = ExecuteCommandDirect(command, parameters);
|
||
}
|
||
else
|
||
{
|
||
// If the command declares its own int "timeout" parameter (e.g. eval/eval_file),
|
||
// honor it as the wait budget instead of the caller-provided dispatcher budget
|
||
// (UUM-148641) — that's the only mechanism that actually enforces such a
|
||
// command's requested deadline end-to-end, in both directions: a request below
|
||
// the dispatcher default must time out early, not wait it out. Detached jobs
|
||
// are the one exception: they pass an unbounded budget (CLI-335) and rely on
|
||
// cooperative cancellation, so the requested value must not re-bound them.
|
||
var requestedTimeoutMs = ResolveRequestedTimeoutMs(command, parameters);
|
||
var waitBudgetMs = dispatcherTimeoutMs == UnboundedJobDispatcherTimeoutMs
|
||
? dispatcherTimeoutMs
|
||
: requestedTimeoutMs ?? dispatcherTimeoutMs;
|
||
raw = await Task.Run(() => m_Dispatcher.Invoke(() => ExecuteCommandDirect(command, parameters), waitBudgetMs));
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Execute on background thread
|
||
raw = await Task.Run(() => ExecuteCommandDirect(command, parameters));
|
||
}
|
||
|
||
return await UnwrapResult(raw);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Commands whose own "timeout" parameter is documented in milliseconds and is meant to bound
|
||
/// the whole main-thread call. Other commands happen to also declare a "timeout" parameter
|
||
/// (e.g. run_tests, in seconds) with different semantics, so this can't be a blanket
|
||
/// name/type match — it has to be opted into per command.
|
||
/// </summary>
|
||
private static readonly HashSet<string> CommandsWithMillisecondTimeoutParameter = new HashSet<string> { "eval", "eval_file", "run_script" };
|
||
|
||
/// <summary>
|
||
/// For commands in <see cref="CommandsWithMillisecondTimeoutParameter"/>, return the value the
|
||
/// caller passed for their own "timeout" parameter. Returns null otherwise, in which case the
|
||
/// caller should fall back to Dispatcher.Invoke's own default wait.
|
||
/// </summary>
|
||
private static int? ResolveRequestedTimeoutMs(CommandInfo command, object[] parameters)
|
||
{
|
||
if (!CommandsWithMillisecondTimeoutParameter.Contains(command.Name))
|
||
return null;
|
||
|
||
for (int i = 0; i < command.Parameters.Count; i++)
|
||
{
|
||
// eval/eval_file name their budget "timeout"; run_script names it "timeout_ms".
|
||
var pName = command.Parameters[i].Name;
|
||
if ((pName == "timeout" || pName == "timeout_ms") && command.Parameters[i].ParameterType == typeof(int))
|
||
{
|
||
// A non-positive value cannot be a meaningful wait budget. Forwarding it would
|
||
// make Dispatcher.Invoke fail with its own opaque timeout before the command
|
||
// ever runs — fall back to the default budget instead, so the command's own
|
||
// friendly "timeout must be between …" validation is what answers the caller.
|
||
var requested = (int)parameters[i];
|
||
return requested > 0 ? requested : (int?)null;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Commands declared as `async Task`/`Task<T>` return their Task from reflection Invoke.
|
||
/// Await it here (on the calling background thread, leaving the main thread free to pump the
|
||
/// dispatcher) so the actual value is serialized rather than the Task itself, and so a faulted
|
||
/// command surfaces its real exception to the request handler instead of being masked when
|
||
/// Newtonsoft reads Task.Result during serialization.
|
||
/// </summary>
|
||
private static async Task<object> UnwrapResult(object result)
|
||
{
|
||
if (result is Task task)
|
||
{
|
||
await task.ConfigureAwait(false);
|
||
// Task<T> exposes a Result property; non-generic Task does not.
|
||
var resultProperty = task.GetType().GetProperty("Result");
|
||
return resultProperty?.GetValue(task);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extract, validate, and invoke a pre-resolved command with JSON parameters synchronously on
|
||
/// the current main thread, returning its unwrapped result. This is the exact parameter
|
||
/// extraction, required-parameter validation, reflection invoke, and Task unwrap that the
|
||
/// <c>/api/exec</c> path (<see cref="ExecuteCommandByName(CommandInfo, JObject, int)"/>) uses,
|
||
/// exposed for composite commands (e.g. <c>batch</c>) that re-dispatch other registered
|
||
/// commands from inside their own main-thread execution — so each sub-operation behaves, and
|
||
/// its result is shaped, identically to a standalone call.
|
||
///
|
||
/// MUST be called on the main thread. A composite command is itself
|
||
/// <c>MainThreadRequired</c>, so it already runs there; calling off the main thread throws.
|
||
/// A command that throws surfaces exactly as it would through <c>/api/exec</c>
|
||
/// (<see cref="ArgumentException"/> preserved, other exceptions wrapped in
|
||
/// <see cref="InvalidOperationException"/>) so the caller can record a per-operation failure.
|
||
/// </summary>
|
||
internal object DispatchCommandOnMainThread(CommandInfo command, JObject parametersJson)
|
||
{
|
||
if (command == null)
|
||
throw new ArgumentNullException(nameof(command));
|
||
if (!m_Dispatcher.IsMainThread())
|
||
throw new InvalidOperationException(
|
||
"DispatchCommandOnMainThread must be called on the main thread.");
|
||
|
||
var parameters = ExtractCommandParameters(command, parametersJson, out var conversionError);
|
||
|
||
// Same contract as ExecuteCommandByName: an argument that could not be converted is a
|
||
// rejected request, not a silent fallback to the parameter's default.
|
||
if (!string.IsNullOrEmpty(conversionError))
|
||
throw new ArgumentException(conversionError);
|
||
|
||
var validationError = ValidateCommandParameters(command, parameters);
|
||
if (!string.IsNullOrEmpty(validationError))
|
||
throw new ArgumentException(validationError);
|
||
|
||
var raw = ExecuteCommandDirect(command, parameters);
|
||
// Unwrap a Task result inline. WARNING: this GetResult() blocks the main thread, so it is
|
||
// only safe for results that are already complete (synchronous commands, or async methods
|
||
// that happened to complete synchronously). An async command driven to completion by
|
||
// EditorApplication.update callbacks (run_tests/list_tests) could NEVER finish here —
|
||
// update cannot pump while this call blocks — and the Editor would freeze permanently.
|
||
// That is why composite callers (batch) must reject commands with Task return types
|
||
// before dispatching them through this path.
|
||
return UnwrapResult(raw).GetAwaiter().GetResult();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Direct command execution using reflection. Sets <see cref="CurrentServer"/> for the
|
||
/// duration so CliProgress/PipelineCancellation calls inside the command body resolve to
|
||
/// this server, regardless of which thread this ends up running on.
|
||
/// </summary>
|
||
private object ExecuteCommandDirect(CommandInfo command, object[] parameters)
|
||
{
|
||
var previousServer = m_CurrentServer;
|
||
m_CurrentServer = this;
|
||
try
|
||
{
|
||
// Target is null for every attribute-declared command (they are static) and carries
|
||
// the receiver for a dynamically registered instance-method handler.
|
||
return command.Method.Invoke(command.Target, parameters);
|
||
}
|
||
catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null)
|
||
{
|
||
// Reflection wraps the command's own exception in a TargetInvocationException; surface
|
||
// the inner message so callers (and agents) get an actionable error instead of the
|
||
// generic "Exception has been thrown by the target of an invocation."
|
||
var inner = tie.InnerException;
|
||
Debug.LogError($"Command '{command.Name}' failed: {inner.Message}");
|
||
|
||
// Preserve validation-oriented exceptions so HandleExecRequest's dedicated
|
||
// catch (ArgumentException) classifies them as "Parameter Validation Failed" rather than
|
||
// collapsing them into "Command Execution Failed". Rethrow the original with its stack.
|
||
if (inner is ArgumentException)
|
||
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(inner).Throw();
|
||
|
||
throw new InvalidOperationException($"Command '{command.Name}' failed: {inner.Message}", inner);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"Command execution failed: {ex.Message}");
|
||
throw new InvalidOperationException($"Command '{command.Name}' failed: {ex.Message}", ex);
|
||
}
|
||
finally
|
||
{
|
||
m_CurrentServer = previousServer;
|
||
}
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// Get the port range for this server type.
|
||
/// Editor servers use 7800-7899, Runtime servers use 7900-7999.
|
||
/// </summary>
|
||
/// <returns>The inclusive port range to try when binding the listener.</returns>
|
||
protected virtual (int basePort, int maxPort) GetPortRange()
|
||
{
|
||
return (7800, 7849); // Editor production (test editor servers use 7850-7899)
|
||
}
|
||
|
||
/// <summary>
|
||
/// Find an available port in the pipeline server range.
|
||
/// </summary>
|
||
private int FindAvailablePort()
|
||
{
|
||
var (basePort, maxPort) = GetPortRange();
|
||
|
||
for (int port = basePort; port <= maxPort; port++)
|
||
{
|
||
if (IsPortAvailable(port))
|
||
{
|
||
return port;
|
||
}
|
||
}
|
||
|
||
throw new InvalidOperationException($"No available ports in range {basePort}-{maxPort}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Check if a specific port is available for binding.
|
||
/// </summary>
|
||
private bool IsPortAvailable(int port)
|
||
{
|
||
try
|
||
{
|
||
using (var listener = new HttpListener())
|
||
{
|
||
AddLoopbackPrefixes(listener, port);
|
||
listener.Start();
|
||
listener.Stop();
|
||
return true;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
}
|