Files
AIC-Project/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Runtime/Common/BasePipelineServer.cs
T

1178 lines
49 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Unity.Pipeline.Models;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Security;
using Unity.Pipeline.Threading;
using UnityEngine;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Unity.Pipeline
{
/// <summary>
/// HTTP server that enables CLI tools to execute commands in Unity Editor.
/// Represents an Instance that can serve as a Pipeline Element for automation.
/// </summary>
public abstract class BasePipelineServer
{
/// <summary>
/// Maximum accepted request body size (1 MiB). Requests larger than this are rejected with
/// 413 to bound the memory a single remote request can force the server to buffer.
/// </summary>
private const long MaxRequestBodyBytes = 1 * 1024 * 1024;
/// <summary>
/// Upper bound on how many bytes we read-and-discard from an over-limit request body before
/// sending the 413. HttpListener resets the TCP connection if we close the response while the
/// client is still uploading, and the client then sees a connection error instead of the 413.
/// Draining lets the client finish so it can read the response. Kept comfortably above
/// <see cref="MaxRequestBodyBytes"/> to cover clients only slightly over the cap, while still
/// bounding the work a single abusive request can force (a larger body simply resets).
/// </summary>
private const long MaxDrainBytes = 8 * 1024 * 1024;
private bool m_IsRunning;
private int m_Port;
private HttpListener m_HttpListener;
private readonly Dispatcher m_Dispatcher = new Dispatcher();
private bool m_WatchdogEnabled;
private bool m_WatchdogArmed;
private DateTime m_LastWatchdogCheck;
/// <summary>
/// This server's own main-thread dispatcher. Each server instance owns one (no global
/// singleton), so tests that start their own server never affect any other server's
/// dispatch. Pump it via ProcessWorkQueue from the main thread (auto-pumped on
/// EditorApplication.update in the editor; pumped by RuntimePipelineManager.Update in a
/// player; pumped explicitly by tests).
/// </summary>
public Dispatcher Dispatcher => m_Dispatcher;
/// <summary>
/// Whether the server is running AND its HTTP listener is actually listening. More accurate
/// than the internal running flag alone: returns false if the listener was stopped/disposed
/// (e.g. by a domain reload) even when the flag is stale-true. Cheap and non-blocking.
///
/// A self-HTTP probe was considered for "does it actually respond" but rejected: the server
/// processes requests strictly one-at-a-time (HandleRequests awaits ProcessRequest), so a
/// self-probe deadlocks if issued from within a handler and false-negatives whenever a
/// request is in flight — unsafe for a watchdog. IsListening reliably catches the realistic
/// failure (listener stopped by a domain reload), which is what we need.
/// </summary>
public bool IsRunning => m_IsRunning && m_HttpListener != null && m_HttpListener.IsListening;
/// <summary>
/// When enabled, the server periodically checks its own HTTP listener and re-opens it in
/// place if it died without going through <see cref="Stop"/> (e.g. an unexpected listener
/// fault). The server instance survives such failures — only the listener dies — so it can
/// self-heal without an external restart. Default OFF: transient/test servers must not
/// watchdog. The editor owner enables it for the live server.
///
/// Setting this while the server is running arms/disarms the watchdog immediately; otherwise
/// it takes effect on the next <see cref="Start"/>. Domain reloads are NOT handled here (the
/// instance is torn down and recreated by [InitializeOnLoad]); the watchdog only revives a
/// listener that died while the instance is still alive.
/// </summary>
public bool WatchdogEnabled
{
get => m_WatchdogEnabled;
set
{
if (m_WatchdogEnabled == value)
return;
m_WatchdogEnabled = value;
if (!m_IsRunning)
return;
if (value)
ArmWatchdog();
else
DisarmWatchdog();
}
}
/// <summary>
/// How often the watchdog checks the listener (seconds). Default 5.
/// </summary>
public double WatchdogIntervalSeconds { get; set; } = 5.0;
/// <summary>
/// Port number the server is listening on. 0 if not running.
/// Range: 7800-7899 for Editor, 7900-7999 for Runtime (avoids unity-tools port range 37800-37899).
/// </summary>
public int Port => m_Port;
public abstract DateTime StartedAt { get; }
/// <summary>
/// Whether this server writes/deletes the shared instance descriptor (.unity-pipeline-port).
/// Test servers override this to false so they never clobber the live server's descriptor —
/// the test already knows its port, so no discovery file is needed.
/// </summary>
protected virtual bool WritesDescriptor => true;
/// <summary>
/// Whether this server advertises commands marked [CliCommand(RuntimeOnly = true)] in
/// its /api/commands listing. Runtime servers list them; Editor servers hide them so a
/// client connected to an Editor only sees the Editor command surface.
/// </summary>
protected virtual bool IncludeRuntimeOnlyCommands => true;
protected abstract void CreateInstanceDescriptor();
protected abstract void DeleteInstanceDescriptor();
protected abstract void UpdateHeartBeat();
protected abstract object GetServerStatus();
protected abstract string GetToken();
protected virtual void ServerStarted()
{
}
protected virtual void ServerStopped()
{
}
/// <summary>
/// This server's current auth token. Exposed to the test assembly (via InternalsVisibleTo)
/// so the test client can authenticate without re-deriving the token.
/// </summary>
internal string Token => GetToken();
/// <summary>
/// Start the HTTP server on the specified port or auto-assign from range.
/// </summary>
/// <param name="port">Port to bind to, or 0 for auto-assignment from server-specific range</param>
public void Start(int port = 0)
{
if (m_IsRunning)
return;
m_Port = port == 0 ? FindAvailablePort() : port;
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();
// Keep the Editor and player loop (and thus Update -> Dispatcher.ProcessWorkQueue) running
// while the window is unfocused or minimized this is similar to auto_tick (especially for the Player).
Application.runInBackground = true;
ServerStarted();
}
catch (Exception)
{
m_IsRunning = false;
m_HttpListener?.Stop();
m_HttpListener = null;
throw;
}
}
/// <summary>
/// Create the HTTP listener, bind it, and start the request-handling loop. Extracted from
/// <see cref="Start"/> so the watchdog can re-open a dead listener in place without
/// re-running the rest of startup (dispatcher init, descriptor). Caller sets m_IsRunning.
/// </summary>
private void OpenListener()
{
m_HttpListener = new HttpListener();
AddLoopbackPrefixes(m_HttpListener, m_Port);
m_HttpListener.Start();
// Start request handling
_ = Task.Run(HandleRequests);
}
/// <summary>
/// Bind a wildcard-host prefix so the listener accepts any Host header ("127.0.0.1",
/// "localhost", "::1") no matter how a client — or its DNS resolver — resolves the name.
/// This is required because Mono's HttpListener binds a hostname prefix to a single resolved
/// address family and then matches the request's Host <em>literally</em> per prefix: an
/// explicit "http://127.0.0.1/" prefix rejects "Host: localhost" (and vice-versa) with a
/// 400. A wildcard sidesteps that. The wildcard binds all interfaces at the socket level, so
/// access is confined to the local machine by the loopback check in
/// <see cref="ProcessRequest"/> (plus bearer-token auth).
/// </summary>
private static void AddLoopbackPrefixes(HttpListener listener, int port)
{
listener.Prefixes.Add($"http://+:{port}/");
}
/// <summary>
/// Stop the HTTP server and clean up resources.
/// </summary>
public void Stop()
{
if (!m_IsRunning)
return;
m_IsRunning = false;
DisarmWatchdog();
System.Console.WriteLine("Pipeline Server stopped");
try
{
if (WritesDescriptor)
DeleteInstanceDescriptor();
m_HttpListener?.Stop();
m_HttpListener?.Close();
ServerStopped();
}
catch
{
// Ignore cleanup errors
}
finally
{
m_HttpListener = null;
m_Dispatcher.Shutdown();
}
// Note: Keep port value for diagnostic purposes
}
/// <summary>
/// Arm the watchdog (no-op if disabled or already armed). In the editor the tick rides
/// EditorApplication.update; in a player it is driven by RuntimePipelineManager.Update via
/// <see cref="WatchdogTick"/>.
/// </summary>
private void ArmWatchdog()
{
if (!m_WatchdogEnabled || m_WatchdogArmed)
return;
m_WatchdogArmed = true;
m_LastWatchdogCheck = DateTime.UtcNow;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update += WatchdogTick;
#endif
}
private void DisarmWatchdog()
{
if (!m_WatchdogArmed)
return;
m_WatchdogArmed = false;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update -= WatchdogTick;
#endif
}
/// <summary>
/// Watchdog heartbeat: throttled to <see cref="WatchdogIntervalSeconds"/>, re-opens the HTTP
/// listener in place if it died while the server is still meant to be running. Safe to call
/// every frame. In the editor it is subscribed to EditorApplication.update by ArmWatchdog;
/// in a player RuntimePipelineManager.Update calls it.
/// </summary>
public void WatchdogTick()
{
if (!m_WatchdogArmed)
return;
#if UNITY_EDITOR
// Don't fight domain reloads / asset updates — the listener is expected to be torn down
// then, and [InitializeOnLoad] recreates the server afterwards.
if (UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isUpdating)
return;
#endif
var now = DateTime.UtcNow;
if ((now - m_LastWatchdogCheck).TotalSeconds < WatchdogIntervalSeconds)
return;
m_LastWatchdogCheck = now;
if (m_HttpListener != null && m_HttpListener.IsListening)
return; // healthy
try
{
// Dispose the dead listener so it releases the port binding before we re-bind a new
// one on the same port (a stopped-but-not-closed listener can keep the port held).
try { m_HttpListener?.Close(); } catch { }
m_HttpListener = null;
// The instance is alive and the watchdog is armed → the server is meant to be
// listening. Re-open the listener in place.
m_IsRunning = true;
OpenListener();
Debug.Log($"Pipeline watchdog re-opened HTTP listener on port {m_Port}");
}
catch (Exception ex)
{
Debug.LogWarning($"Pipeline watchdog failed to re-open listener on port {m_Port}: {ex.Message}");
}
}
/// <summary>
/// Handle incoming HTTP requests with routing to appropriate endpoints.
/// </summary>
private async Task HandleRequests()
{
while (m_IsRunning && m_HttpListener != null && m_HttpListener.IsListening)
{
try
{
var context = await m_HttpListener.GetContextAsync();
await ProcessRequest(context);
}
catch (ObjectDisposedException)
{
// Listener was stopped, exit gracefully
break;
}
catch (HttpListenerException)
{
// Listener was stopped, exit gracefully
break;
}
catch (Exception ex)
{
Debug.LogError(ex);
}
}
m_IsRunning = false;
}
/// <summary>
/// Process individual HTTP request and route to appropriate handler.
/// </summary>
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).
if (!string.IsNullOrEmpty(request.Headers["Origin"]))
{
await SendStatusResponse(response, 403, "Forbidden", "Cross-origin requests are not allowed");
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(response);
break;
case "/api/exec":
if (request.HttpMethod == "POST")
await HandleExecRequest(request, response);
else
await HandleMethodNotAllowed(response);
break;
case "/api/test-status":
await HandleTestStatusRequest(response);
break;
default:
await HandleNotFound(response);
break;
}
}
catch (Exception)
{
// Ensure response is always closed
try
{
if (response.OutputStream.CanWrite)
{
response.StatusCode = 500;
response.OutputStream.Close();
}
}
catch { }
}
}
/// <summary>
/// Validate the request's bearer token against this server's token.
/// </summary>
private bool IsAuthorized(HttpListenerRequest request)
{
var header = request.Headers["Authorization"];
if (string.IsNullOrEmpty(header))
return false;
const string prefix = "Bearer ";
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return false;
var token = header.Substring(prefix.Length).Trim();
return SecurityTokenManager.ConstantTimeEquals(token, GetToken());
}
/// <summary>
/// Send a structured JSON error response with an explicit HTTP status code.
/// </summary>
private async Task SendStatusResponse(HttpListenerResponse response, int statusCode, string error, string details)
{
try
{
response.StatusCode = statusCode;
response.ContentType = "application/json";
var json = JsonConvert.SerializeObject(BaseResponse.Failure(error, details), Formatting.Indented);
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
catch
{
try { response.OutputStream.Close(); } catch { }
}
}
/// <summary>
/// Handle /api/status endpoint - returns basic server health information.
/// No Editor API access required, always fast response.
/// </summary>
private async Task HandleStatusRequest(HttpListenerResponse response)
{
try
{
var basicStatus = GetServerStatus();
var json = JsonConvert.SerializeObject(basicStatus, Formatting.Indented);
response.StatusCode = 200;
response.ContentType = "application/json";
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
catch (Exception ex)
{
Debug.LogError($"HandleStatusRequest failed: {ex.Message}");
await SendErrorResponse(response, "Status Error", $"Failed to get status: {ex.Message}");
}
}
/// <summary>
/// Handle /api/editor_status endpoint - returns detailed Editor state via command execution.
/// Executes the "editor_status" command to get rich Editor information.
/// </summary>
private async Task HandleEditorStatusRequest(HttpListenerResponse response)
{
try
{
// TODO: should this be implemented as a command??
// Execute editor_status command to get rich Editor information
var result = await ExecuteCommandByName("editor_status", new JObject());
// The editor_status command returns a StatusResponse directly
var editorStatus = result as StatusResponse;
if (editorStatus != null)
{
// Update with server-specific information
UpdateStatusWithServerInfo(editorStatus);
var json = JsonConvert.SerializeObject(editorStatus, Formatting.Indented);
response.StatusCode = 200;
response.ContentType = "application/json";
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
else
{
Debug.LogError($"HandleEditorStatusRequest: editor_status command returned wrong type: {result?.GetType().FullName ?? "null"}");
await SendErrorResponse(response, "Editor Status Error", "editor_status command did not return valid StatusResponse");
}
}
catch (Exception ex)
{
var errorMessage = !string.IsNullOrEmpty(ex.Message) ? ex.Message : "[EMPTY EXCEPTION MESSAGE]";
Debug.LogError($"HandleEditorStatusRequest failed:");
Debug.LogError($" Exception Type: {ex.GetType().FullName}");
Debug.LogError($" Message: '{errorMessage}'");
Debug.LogError($" Full Exception: {ex}");
await SendErrorResponse(response, "Editor Status Error", $"Failed to get editor status: {errorMessage}");
}
}
/// <summary>
/// Update StatusResponse with server-specific information.
/// Used by /api/editor_status to add server metadata to command result.
/// </summary>
private void UpdateStatusWithServerInfo(StatusResponse editorStatus)
{
UpdateHeartBeat();
// Ensure heartbeat is current
editorStatus.LastHeartbeat = DateTime.UtcNow;
}
/// <summary>
/// Handle /api/commands endpoint - returns available CLI commands with schemas.
/// </summary>
private async Task HandleCommandsRequest(HttpListenerResponse response)
{
try
{
var commands = CommandRegistry.DiscoverCommands()
.Where(c => IncludeRuntimeOnlyCommands || !c.RuntimeOnly)
.ToList();
var commandsJson = commands.Select(BuildCommandResponse).ToList();
var responseData = new
{
commands = commandsJson,
count = commands.Count,
server = new
{
version = "0.0.1", // TODO: Get from package.json
port = m_Port,
startTime = StartedAt
}
};
var json = JsonConvert.SerializeObject(responseData, Formatting.Indented);
response.StatusCode = 200;
response.ContentType = "application/json";
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
catch (Exception ex)
{
Debug.LogError($"Failed to handle /api/commands request: {ex.Message}");
// Return error response
response.StatusCode = 500;
response.ContentType = "application/json";
var errorResponse = new { error = "Internal server error", message = ex.Message };
var errorJson = JsonConvert.SerializeObject(errorResponse);
var errorBuffer = Encoding.UTF8.GetBytes(errorJson);
response.ContentLength64 = errorBuffer.Length;
await response.OutputStream.WriteAsync(errorBuffer, 0, errorBuffer.Length);
response.OutputStream.Close();
}
}
/// <summary>
/// Build JSON response object for a single command.
/// </summary>
private object BuildCommandResponse(CommandInfo command)
{
return new
{
name = command.Name,
description = command.Description,
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(),
schema = JsonSchemaGenerator.GenerateCommandSchema(command)
};
}
/// <summary>
/// Handle 404 Not Found responses.
/// </summary>
private async Task HandleNotFound(HttpListenerResponse response)
{
response.StatusCode = 404;
response.ContentType = "text/plain";
var responseText = "Not Found";
var buffer = Encoding.UTF8.GetBytes(responseText);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
/// <summary>
/// Handle 405 Method Not Allowed responses.
/// </summary>
private async Task HandleMethodNotAllowed(HttpListenerResponse response)
{
await SendErrorResponse(response, "Method Not Allowed", "This endpoint only supports POST requests");
}
/// <summary>
/// Handle /api/test-status endpoint - returns status of async test execution.
/// </summary>
private async Task HandleTestStatusRequest(HttpListenerResponse response)
{
try
{
// Execute test_status command to get current test status
var result = await ExecuteCommandByName("test_status", new JObject());
string jsonResponse;
if (result is string statusString)
{
// test_status command returns JSON string directly
jsonResponse = statusString;
}
else
{
// Fallback - serialize whatever was returned
jsonResponse = JsonConvert.SerializeObject(result ?? new { status = "no_tests", message = "No test run in progress" });
}
response.StatusCode = 200;
response.ContentType = "application/json";
var buffer = Encoding.UTF8.GetBytes(jsonResponse);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
catch (Exception ex)
{
Debug.LogError($"HandleTestStatusRequest failed: {ex.Message}");
await SendErrorResponse(response, "Test Status Error", $"Failed to get test status: {ex.Message}");
}
}
/// <summary>
/// Handle /api/exec endpoint - execute CLI commands with parameters.
/// </summary>
private async Task HandleExecRequest(HttpListenerRequest request, HttpListenerResponse response)
{
var cmd = "";
string requestBody = null;
try
{
// Reject oversized bodies up front via Content-Length (cheap, before reading anything).
if (request.ContentLength64 > MaxRequestBodyBytes)
{
// Drain the body first so the client can read the 413 (see DrainRequestBody).
await DrainRequestBody(request, MaxDrainBytes);
await SendExecResponse(response, 413,
BaseResponse.Failure("Payload Too Large",
$"Request body exceeds the maximum allowed size of {MaxRequestBodyBytes} bytes"), null);
return;
}
// Read request body, enforcing the same cap while reading in case Content-Length is
// absent or untruthful (e.g. chunked transfer-encoding).
try
{
// leaveOpen: on an over-limit read we still need the raw InputStream open to drain
// the unsent remainder before responding, so it must outlive the reader/wrapper.
using (var limited = new MaxLengthStream(request.InputStream, MaxRequestBodyBytes, leaveOpen: true))
using (var reader = new StreamReader(limited))
{
requestBody = await reader.ReadToEndAsync();
}
}
catch (RequestTooLargeException ex)
{
// Drain the body first so the client can read the 413 (see DrainRequestBody).
await DrainRequestBody(request, MaxDrainBytes);
await SendExecResponse(response, 413,
BaseResponse.Failure("Payload Too Large", ex.Message), null);
return;
}
finally
{
// MaxLengthStream left the InputStream open; dispose it now that any read/drain is done.
request.InputStream.Dispose();
}
if (string.IsNullOrEmpty(requestBody))
{
await SendExecResponse(response, 400,
BaseResponse.Failure("Bad Request", "Request body is required"), requestBody);
return;
}
// Parse command request
CommandExecutionRequest commandRequest;
try
{
commandRequest = JsonConvert.DeserializeObject<CommandExecutionRequest>(requestBody);
}
catch (JsonException ex)
{
await SendExecResponse(response, 400,
BaseResponse.Failure("Invalid JSON", $"Failed to parse request body: {ex.Message}"), requestBody);
return;
}
// Validate request structure
var requestValidationError = commandRequest?.Validate();
if (!string.IsNullOrEmpty(requestValidationError))
{
await SendExecResponse(response, 400,
BaseResponse.Failure("Invalid Request", requestValidationError), requestBody);
return;
}
cmd = commandRequest.Command;
// Execute command using shared execution logic
var result = await ExecuteCommandByName(commandRequest.Command, commandRequest.Parameters);
// Send success response
await SendExecResponse(response, 200,
CommandExecutionResponse.CmdSuccess(commandRequest.Command, result), requestBody);
}
catch (ArgumentException ex)
{
// Parameter validation errors
await SendExecResponse(response, 400,
CommandExecutionResponse.CmdFailure(cmd, "Parameter Validation Failed", ex.Message), requestBody);
}
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);
}
catch (InvalidOperationException ex)
{
// Command execution errors
await SendExecResponse(response, 400,
CommandExecutionResponse.CmdFailure(cmd, "Command Execution Failed", ex.Message), requestBody);
}
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);
}
}
/// <summary>
/// Read and discard the remaining request body, up to <paramref name="maxDrainBytes"/> bytes.
/// When we reject a request early (e.g. 413) the client may still be uploading; if we close the
/// response while unread data is in flight, HttpListener resets the TCP connection and the client
/// sees a connection error instead of our HTTP response. Draining lets the upload complete so the
/// response is delivered. Best-effort and bounded: a body larger than the budget stops draining
/// (and may reset), which is acceptable for an abusive request.
/// </summary>
private static async Task DrainRequestBody(HttpListenerRequest request, long maxDrainBytes)
{
try
{
var input = request.InputStream;
var buffer = new byte[16 * 1024];
long drained = 0;
while (drained < maxDrainBytes)
{
var read = await input.ReadAsync(buffer, 0, buffer.Length);
if (read <= 0)
break;
drained += read;
}
}
catch
{
// Best-effort: if the client already closed the connection or the stream is gone,
// there is nothing left to drain and the response send below will handle the rest.
}
}
/// <summary>
/// Send error response with structured JSON format.
/// </summary>
private async Task SendErrorResponse(HttpListenerResponse response, string error, string details = null)
{
try
{
var errorResponse = BaseResponse.Failure(error, details);
await SendResponse(response, 400, errorResponse);
}
catch
{
// Ignore errors in error handling
}
}
private async Task SendResponse(HttpListenerResponse response, int statusCode, BaseResponse pipelineResponse)
{
response.StatusCode = 400;
response.ContentType = "application/json";
var json = JsonConvert.SerializeObject(pipelineResponse, Formatting.Indented);
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
/// <summary>
/// Send an /api/exec response and notify the transaction hook with the raw request and
/// response JSON. Single send point for the exec handler so every branch (success and
/// error) is captured uniformly.
/// </summary>
private async Task SendExecResponse(HttpListenerResponse response, int statusCode,
BaseResponse body, string requestJson)
{
response.StatusCode = statusCode;
response.ContentType = "application/json";
var json = JsonConvert.SerializeObject(body, Formatting.Indented);
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
OnTransactionProcessed(requestJson, json);
}
/// <summary>
/// Hook invoked after an /api/exec transaction is sent, with the raw request and response
/// JSON. No-op by default (the Player never logs); the Editor server overrides this to
/// write the transaction log.
/// </summary>
protected virtual void OnTransactionProcessed(string requestJson, string responseJson) { }
/// <summary>
/// Convert a single JSON parameter token to the command's parameter type.
///
/// Agents and the CLI frequently pass structured parameters (e.g. <c>float[]</c> position,
/// <c>JObject</c> properties) as a JSON-ENCODED STRING — e.g. position <c>"[0,0,0]"</c> or
/// properties <c>"{\"m_Mass\":0.17}"</c>. Newtonsoft's <c>JValue(string).ToObject(float[])</c>
/// (and likewise for <c>JObject</c>) returns null, so the parameter silently drops out:
/// set_transform applies nothing and set_component_properties fails Required-parameter
/// validation (CLI-219 / CLI-220). To fix this generally — without special-casing any command —
/// when the token is a string but the target type is NOT string AND the trimmed string starts
/// with '{' or '[', we re-parse it as a JSON document before converting.
///
/// The '{'/'[' guard is deliberate and narrow: ordinary string params (and ObjectRef string
/// handles like "/Player", "Assets/Foo.prefab", "guid:..." — none of which start with '{'/'[')
/// fall straight through to <c>token.ToObject</c>, so the class-level
/// <see cref="Unity.Pipeline.Models.ObjectRefConverter"/> and plain-string parameters keep
/// working unchanged. A re-parse that fails falls through to the normal conversion path (the
/// caller's try/catch then handles any remaining failure).
/// </summary>
private static object ConvertParameterToken(Newtonsoft.Json.Linq.JToken token, System.Type targetType)
{
if (token == null)
return null;
if (token.Type == Newtonsoft.Json.Linq.JTokenType.String
&& targetType != typeof(string)
&& !targetType.IsPrimitive
&& !targetType.IsEnum)
{
var s = token.Value<string>();
if (!string.IsNullOrWhiteSpace(s))
{
var t = s.TrimStart();
if (t.Length > 0 && (t[0] == '{' || t[0] == '['))
{
try { return Newtonsoft.Json.Linq.JToken.Parse(s).ToObject(targetType); }
catch (Newtonsoft.Json.JsonException) { /* fall through to the direct conversion */ }
}
}
}
return token.ToObject(targetType);
}
/// <summary>
/// Extract command parameters from JSON request and convert to appropriate types.
/// </summary>
private object[] ExtractCommandParameters(CommandInfo command, Newtonsoft.Json.Linq.JObject parametersJson)
{
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))
{
try
{
jsonValue = ConvertParameterToken(parametersJson[paramName], paramInfo.ParameterType);
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to convert parameter '{paramName}' to {paramInfo.ParameterType.Name}: {ex.Message}");
// Conversion failed, will use default value
}
}
// 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;
}
}
return parameterValues;
}
/// <summary>
/// Validate that all required command parameters are provided.
/// </summary>
private string ValidateCommandParameters(CommandInfo command, object[] parameters)
{
for (int i = 0; i < command.Parameters.Count; i++)
{
var paramInfo = command.Parameters[i];
var value = parameters[i];
if (paramInfo.Required && (value == null ||
(value is string str && string.IsNullOrEmpty(str))))
{
return $"Required parameter '{paramInfo.Name}' is missing or empty";
}
}
return null; // No validation errors
}
/// <summary>
/// Execute a command by name with JSON parameters.
/// Handles command lookup, parameter validation, and execution.
/// Shared logic for both /api/exec and /api/editor_status endpoints.
/// </summary>
private async Task<object> ExecuteCommandByName(string commandName, JObject parametersJson)
{
// Find command
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);
}
// Extract parameters
var parameters = ExtractCommandParameters(command, parametersJson);
// 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);
}
/// <summary>
/// Execute the command method with provided parameters.
/// Handles main thread execution if required.
/// </summary>
private async Task<object> ExecuteCommand(CommandInfo command, object[] parameters)
{
object raw;
if (command.MainThreadRequired)
{
// Execute on the main thread using THIS server's dispatcher.
if (m_Dispatcher.IsMainThread())
{
raw = ExecuteCommandDirect(command, parameters);
}
else
{
raw = await Task.Run(() => m_Dispatcher.Invoke(() => ExecuteCommandDirect(command, parameters)));
}
}
else
{
// Execute on background thread
raw = await Task.Run(() => ExecuteCommandDirect(command, parameters));
}
return await UnwrapResult(raw);
}
/// <summary>
/// Commands declared as `async Task`/`Task&lt;T&gt;` return their Task from reflection Invoke.
/// Await it here (on the calling background thread, leaving the main thread free to pump the
/// dispatcher) so the actual value is serialized rather than the Task itself, and so a faulted
/// command surfaces its real exception to the request handler instead of being masked when
/// Newtonsoft reads Task.Result during serialization.
/// </summary>
private static async Task<object> UnwrapResult(object result)
{
if (result is Task task)
{
await task.ConfigureAwait(false);
// Task&lt;T&gt; exposes a Result property; non-generic Task does not.
var resultProperty = task.GetType().GetProperty("Result");
return resultProperty?.GetValue(task);
}
return result;
}
/// <summary>
/// Direct command execution using reflection.
/// </summary>
private object ExecuteCommandDirect(CommandInfo command, object[] parameters)
{
try
{
return command.Method.Invoke(null, 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);
}
}
/// <summary>
/// Get the port range for this server type.
/// Editor servers use 7800-7899, Runtime servers use 7900-7999.
/// </summary>
protected virtual (int basePort, int maxPort) GetPortRange()
{
return (7800, 7849); // Editor production (test editor servers use 7850-7899)
}
/// <summary>
/// Find an available port in the pipeline server range.
/// </summary>
private int FindAvailablePort()
{
var (basePort, maxPort) = GetPortRange();
for (int port = basePort; port <= maxPort; port++)
{
if (IsPortAvailable(port))
{
return port;
}
}
throw new InvalidOperationException($"No available ports in range {basePort}-{maxPort}");
}
/// <summary>
/// Check if a specific port is available for binding.
/// </summary>
private bool IsPortAvailable(int port)
{
try
{
using (var listener = new HttpListener())
{
AddLoopbackPrefixes(listener, port);
listener.Start();
listener.Stop();
return true;
}
}
catch
{
return false;
}
}
}
}