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 { /// /// HTTP server that enables CLI tools to execute commands in Unity Editor. /// Represents an Instance that can serve as a Pipeline Element for automation. /// public abstract class BasePipelineServer { private const long DefaultMaxRequestBodyBytes = 1 * 1024 * 1024; /// /// 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. /// public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes; /// /// 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 /// to cover clients only slightly over the cap, while still /// bounding the work a single abusive request can force (a larger body simply resets). /// private const long MaxDrainBytes = 8 * 1024 * 1024; /// /// Maximum accepted body size for /api/job/cancel (4 KiB). The body is just an /// {"id": "..."} object, so it needs far less headroom than . /// private const long MaxCancelBodyBytes = 4 * 1024; /// /// 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. /// private const int UnboundedJobDispatcherTimeoutMs = int.MaxValue; private bool m_IsRunning; private int m_Port; /// /// 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. /// private readonly SemaphoreSlim m_ExecGate = new SemaphoreSlim(1, 1); private HttpListener m_HttpListener; private readonly Dispatcher m_Dispatcher = new Dispatcher(); /// This server's own progress-reporting state (see CliProgress). internal CliProgressState Progress { get; } = new CliProgressState(); /// This server's own modal-dialog state (see EditorDialogStateMirror, Task 7). internal DialogStateTracker Dialogs { get; } = new DialogStateTracker(); /// This server's own detached-job registry (see PipelineCancellation, /api/job). internal PipelineJobRegistry JobRegistry { get; } = new PipelineJobRegistry(); /// /// The server instance currently executing a command on this thread, if any. Set only /// around the actual command invocation (see ) 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). /// [ThreadStatic] private static BasePipelineServer m_CurrentServer; internal static BasePipelineServer CurrentServer => m_CurrentServer; private bool m_WatchdogEnabled; private bool m_WatchdogArmed; private DateTime m_LastWatchdogCheck; /// /// 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). /// public Dispatcher Dispatcher => m_Dispatcher; /// /// 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. /// public bool IsRunning => m_IsRunning && m_HttpListener != null && m_HttpListener.IsListening; /// /// When enabled, the server periodically checks its own HTTP listener and re-opens it in /// place if it died without going through (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 . 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. /// public bool WatchdogEnabled { get => m_WatchdogEnabled; set { if (m_WatchdogEnabled == value) return; m_WatchdogEnabled = value; if (!m_IsRunning) return; if (value) ArmWatchdog(); else DisarmWatchdog(); } } /// /// How often the watchdog checks the listener (seconds). Default 5. /// public double WatchdogIntervalSeconds { get; set; } = 5.0; /// /// 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). /// public int Port => m_Port; /// UTC time this server instance started listening. public abstract DateTime StartedAt { get; } /// /// 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. /// protected virtual bool WritesDescriptor => true; /// /// 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. /// protected virtual bool IncludeRuntimeOnlyCommands => true; /// /// 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. /// 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; /// The only Origin a sandboxed frame reports; a real page sends its own origin. private const string SandboxedOrigin = "null"; /// Write the shared instance descriptor file so clients can discover this server. protected abstract void CreateInstanceDescriptor(); /// Remove the shared instance descriptor file on shutdown. protected abstract void DeleteInstanceDescriptor(); /// Refresh the descriptor's heartbeat timestamp so discovery can detect a live server. protected abstract void UpdateHeartBeat(); /// Build the host-specific payload for /api/status (Editor vs. Player fields differ). /// The status payload. protected abstract object GetServerStatus(); /// The bearer token clients must present to authenticate requests. /// The current token. protected abstract string GetToken(); /// /// 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. /// internal static readonly string[] Capabilities = { "exec.argv", "exec.commandLine" }; /// Hook invoked once the listener is accepting requests. No-op by default. protected virtual void ServerStarted() { } /// /// Busy probe for the /api/exec gate: return a non-null, human-readable reason when the /// host cannot service 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. /// /// The command about to be dispatched. /// A human-readable busy reason, or null to let the command run. protected virtual string GetBusyReason(CommandInfo command) => null; /// /// 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). /// internal virtual object TryGetDialogBlockedFallback(CommandInfo command, DialogInfo blockingDialog) => null; /// Hook invoked once the listener has stopped accepting requests. No-op by default. protected virtual void ServerStopped() { } /// /// This server's current auth token. Exposed to the test assembly (via InternalsVisibleTo) /// so the test client can authenticate without re-deriving the token. /// internal string Token => GetToken(); /// /// Whether 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. /// internal bool PortAutoAssigned { get; private set; } /// /// Start the HTTP server on the specified port or auto-assign from range. /// /// Port to bind to, or 0 for auto-assignment from server-specific range 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; } } /// /// Create the HTTP listener, bind it, and start the request-handling loop. Extracted from /// 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. /// private void OpenListener() { m_HttpListener = new HttpListener(); AddLoopbackPrefixes(m_HttpListener, m_Port); m_HttpListener.Start(); // Start request handling _ = Task.Run(HandleRequests); } /// /// 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 literally 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 /// (plus bearer-token auth). /// private static void AddLoopbackPrefixes(HttpListener listener, int port) { listener.Prefixes.Add($"http://+:{port}/"); } /// /// Stop the HTTP server and clean up resources. /// 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 } /// /// 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 /// . /// 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 } /// /// Watchdog heartbeat: throttled to , 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. /// 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}"); } } /// /// Handle incoming HTTP requests with routing to appropriate endpoints. /// 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; } /// /// Run ProcessRequest without the accept loop awaiting it; a per-request failure is /// logged and must never tear down the listener loop. /// private async Task ProcessRequestDetached(HttpListenerContext context) { try { await ProcessRequest(context); } catch (Exception ex) { Debug.LogError(ex); } } /// /// Process individual HTTP request and route to appropriate handler. /// /// The incoming HTTP request/response context. /// A task that completes once the response has been written. 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 { } } } /// /// Validate the request's bearer token against this server's token. /// 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()); } /// /// Send a structured JSON error response with an explicit HTTP status code. /// 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 { } } } /// /// Parse the omit_nulls query parameter (snake_case like the other query /// parameters, e.g. group_by) — the GET-endpoint equivalent of exec's omitNulls /// 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. /// omit_nulls=1) is NOT silently coerced to false: it yields false plus a /// 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.) /// 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; } /// /// Serialize 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 from /// the endpoint's omit_nulls query parameter (see ) /// to opt out (AUTHAPI-21). /// Shares '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. /// 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 { } } } /// /// Handle /api/status endpoint - returns basic server health information. /// No Editor API access required, always fast response. /// 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}"); } } /// /// Handle /api/editor_status endpoint - returns detailed Editor state via command execution. /// Executes the "editor_status" command to get rich Editor information. /// 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}"); } } /// /// Update StatusResponse with server-specific information. /// Used by /api/editor_status to add server metadata to command result. /// private void UpdateStatusWithServerInfo(StatusResponse editorStatus) { UpdateHeartBeat(); // Ensure heartbeat is current editorStatus.LastHeartbeat = DateTime.UtcNow; } /// /// 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. /// 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 window = matching.Skip(offset); if (limit.HasValue) window = window.Take(limit.Value); var page = window.ToList(); Func project = c => fullDetail ? BuildFullCommandResponse(c) : BuildCompactCommandResponse(c); var responseData = new Dictionary(); 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(); } } /// /// Build the lightweight index entry for a single command (detail=compact). /// private static object BuildCompactCommandResponse(CommandInfo command) { return new { name = command.Name, description = command.Description, tags = command.Tags, package = command.Package }; } /// /// Build the complete JSON response object for a single command (detail=full). /// private object BuildFullCommandResponse(CommandInfo command) { var entry = BuildCommandEntry(command); entry["schema"] = JsonSchemaGenerator.GenerateCommandSchema(command); return entry; } /// /// One command's catalog entry: everything /api/commands serves for it except the /// generated JSON schema. /// /// 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. /// 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() }); } /// /// 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. /// private static CommandExecutionResponse WithBoundParameters( CommandExecutionResponse response, CommandExecutionRequest request) { if (request.IsRawForm) response.BoundParameters = request.Parameters ?? new JObject(); return response; } /// /// Renders argument problems as English prose for errorDetails. /// /// This is the fallback, not the primary channel: a client that understands /// argProblems renders and localizes them itself. This text serves curl, MCP, and /// any client that meets a kind it does not recognize, so it must stay useful on /// its own. /// private static string DescribeArgProblems(CommandInfo command, List problems) { if (problems == null || problems.Count == 0) return "Invalid arguments"; var parts = new List(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); } /// Every declared parameter of the command, flag-spelled, or "(none)". private static string DescribeParameterNames(CommandInfo command) { if (command.Parameters.Count == 0) return "(none)"; return string.Join(", ", command.Parameters.Select(p => "--" + p.Name)); } /// /// 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. /// 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; } /// /// Order the filtered command list: by name, or by originating package with name as /// tiebreak. Both keys follow the requested direction. /// private static IEnumerable SortCommands(IEnumerable 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); } /// /// Case-insensitive substring match on the command's name, description, or any tag. /// A null/empty query matches everything. /// 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); } /// /// Segment-aware tag subtree match: 'assets' matches tags 'assets' and 'assets/import' /// but not 'assetsx'. A null/empty tag matches everything. /// 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)); } /// /// Group a page of commands by originating package, sorted by package name. /// private static List BuildPackageGroups(List commands, Func 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(); } /// /// 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. /// private class TagTreeNode { public readonly List Commands = new List(); public readonly SortedDictionary Children = new SortedDictionary(StringComparer.Ordinal); } /// /// 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). /// private static List BuildTagTree(List commands, Func 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 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); } /// /// Handle 404 Not Found responses. /// 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(); } /// /// Handle 405 Method Not Allowed responses. /// private async Task HandleMethodNotAllowed(HttpListenerResponse response, string allowedMethod) { await SendErrorResponse(response, "Method Not Allowed", $"This endpoint only supports {allowedMethod} requests", statusCode: 405); } /// /// Handle /api/test-status endpoint - returns status of async test execution. /// 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}"); } } /// /// Runs 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 so /// the two copies of this scaffold can't drift apart. /// /// Id CliProgress.Report calls during this execution are attributed to. /// The gated work to run once the exec gate is acquired. /// Runs after the gate is acquired but before /// CliProgress.BeginExecution; returning false skips entirely /// (used by the job path to honor a cancellation requested while still queued). private async Task ExecuteGated(string executionId, Func body, Func 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(); } } /// /// 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). /// 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)); } /// /// Project a progress snapshot to the {title,info,current,total,pct} wire shape /// shared by /api/progress and a running job's progress field — kept as one helper /// so the two can't drift out of sync with each other or the documented contract. /// 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 }; } /// Serialize one job record to the wire shape shared by the job endpoints. 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 } }; } } /// /// 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. /// 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}"); } } /// /// 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). /// 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}"); } } /// /// 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 /// {"active":false,"progress":null}): /// {"active":true,"progress":{"title":"…","info":"…","current":42,"total":100,"pct":0.42}} /// 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}"); } } /// /// 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. /// 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}"); } } /// /// Shared wire projection of a DialogInfo, used by /api/dialog, dialogsDuringExecution, the /// busy-gate, and (internal, via InternalsVisibleTo) EditorPipelineServer's /api/status. /// 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 }; /// 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. 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}"; } /// /// Handle /api/exec endpoint - execute CLI commands with parameters. /// 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(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 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); } } /// /// Read and discard the remaining request body, up to 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. /// 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. } } /// /// Send error response with structured JSON format. /// 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(); } /// /// Send an /api/exec response and report the finished interaction to /// . 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. /// 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)); } /// /// 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. /// /// The finished interaction: its transaction, its execution, or both. protected virtual void OnCommandDone(in CommandExecutionInfo info) { } /// /// 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. /// 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; } } /// /// Convert a single JSON parameter token to the command's parameter type. /// /// Agents and the CLI frequently pass structured parameters (e.g. float[] position, /// JObject properties) as a JSON-ENCODED STRING — e.g. position "[0,0,0]" or /// properties "{\"m_Mass\":0.17}". Newtonsoft's JValue(string).ToObject(float[]) /// (and likewise for JObject) 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 token.ToObject, so the class-level /// 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). /// /// /// Internal rather than private so 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. /// 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(); 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); } /// /// Extract command parameters from JSON request and convert to appropriate types. /// 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 limit returns more than was /// asked, an ignored timeout waits longer than allowed. The slot still gets the default /// so the array stays well-formed, but the callers reject the request. /// 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; } /// /// 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 — ObjectRefConverter.FromString /// returns null for it, and set_parent advertises "Omit (or empty) to move the object /// to the scene root", so {"parent":""} must keep detaching rather than 400. /// private static bool IsIntentionalNullToken(JToken token) { if (token == null || token.Type == JTokenType.Null) return true; return token.Type == JTokenType.String && string.IsNullOrWhiteSpace(token.Value()); } /// Appends one parameter-conversion failure to the accumulated report. 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}"); } /// /// 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. /// private static string MissingRequiredParameterMessage(string parameterName) { return $"Required parameter '{parameterName}' is missing or empty"; } /// /// Validates required parameters against a BOUND parameter object, before anything runs. /// /// 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 , so the raw and /// structured paths cannot drift on what counts as missing. /// 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; } /// /// 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. /// private static bool IsMissingRequiredValue(object value) { return value == null || (value is string text && string.IsNullOrEmpty(text)); } /// /// A bound JSON value as the CLR value 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. /// private static object AsRequiredValue(JToken value) { if (value == null || value.Type == JTokenType.Null) return null; return value.Type == JTokenType.String ? value.Value() : (object)value; } /// /// Validate that all required command parameters are provided. /// 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 } /// /// Resolve a command name to its , 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. /// 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; } /// /// 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. /// private async Task ExecuteCommandByName(string commandName, JObject parametersJson, int dispatcherTimeoutMs = 60000) { return await ExecuteCommandByName(ResolveCommand(commandName), parametersJson, dispatcherTimeoutMs); } /// /// Execute a pre-resolved command with JSON parameters (parameter extraction, required /// validation, threading). /// private async Task 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); } /// /// Execute the command method with provided parameters. /// Handles main thread execution if required. /// private async Task 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); } /// /// 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. /// private static readonly HashSet CommandsWithMillisecondTimeoutParameter = new HashSet { "eval", "eval_file", "run_script" }; /// /// For commands in , 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. /// 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; } /// /// 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. /// private static async Task 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; } /// /// 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 /// /api/exec path () uses, /// exposed for composite commands (e.g. batch) 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 /// MainThreadRequired, so it already runs there; calling off the main thread throws. /// A command that throws surfaces exactly as it would through /api/exec /// ( preserved, other exceptions wrapped in /// ) so the caller can record a per-operation failure. /// 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(); } /// /// Direct command execution using reflection. Sets for the /// duration so CliProgress/PipelineCancellation calls inside the command body resolve to /// this server, regardless of which thread this ends up running on. /// 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; } } /// /// Get the port range for this server type. /// Editor servers use 7800-7899, Runtime servers use 7900-7999. /// /// The inclusive port range to try when binding the listener. protected virtual (int basePort, int maxPort) GetPortRange() { return (7800, 7849); // Editor production (test editor servers use 7850-7899) } /// /// Find an available port in the pipeline server range. /// 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}"); } /// /// Check if a specific port is available for binding. /// private bool IsPortAvailable(int port) { try { using (var listener = new HttpListener()) { AddLoopbackPrefixes(listener, port); listener.Start(); listener.Stop(); return true; } } catch { return false; } } } }