Files

665 lines
33 KiB
C#

using System;
using System.Collections.Generic;
using System.Reflection;
using Unity.Pipeline.Config;
using Unity.Pipeline.Commands;
using Unity.Pipeline.CodeReload;
using Unity.Pipeline.Runtime.Telemetry;
using UnityEngine;
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
using UnityEngine.Networking.PlayerConnection;
#endif
namespace Unity.Pipeline
{
/// <summary>
/// Drives the runtime Pipeline server. Created exactly once, in code, by
/// <see cref="RuntimePipelineBootstrap"/> — never authored in a scene. Hidden from Add
/// Component (nothing to configure via the Inspector; see <see cref="Initialize"/> instead)
/// and guarded against stacking multiple instances on one GameObject, in case it's ever added
/// manually or via a script despite that.
/// </summary>
[AddComponentMenu("")]
[DisallowMultipleComponent]
public class RuntimePipelineDriver : MonoBehaviour
{
// Not yet exposed via RuntimePipelineConfig/the runtime settings page — always on. Wire up
// through m_Config alongside the other Runtime Behavior settings if a project needs to
// disable the overlay.
private const bool ShowReloadOverlay = true;
private RuntimePipelineServer m_Server;
private RuntimePipelineConfig m_Config;
private IReadOnlyList<string> m_AllowedReloadRoots = Array.Empty<string>();
// Public half of the project's push-signing key. Baked at build time into the
// RuntimePipelineBuildInfo asset (from Library/Pipeline/signing.key in the editor) and
// supplied via Initialize — a player build rejects every code-reload push that is not signed
// by the matching private key; empty means "reject all pushes" (strict policy, no fallback).
private string m_PushPublicKey = "";
private bool m_OwnsSampler;
// Restored by StopServer so Play Mode exit doesn't leave the user's setting overridden.
private bool m_PreviousRunInBackground;
// True only between something overriding Application.runInBackground (StartServer, or
// DrainPendingReloads reacting to a code-reload push that arrived with no HTTP server
// running) and StopServer restoring it. Both writers check this before saving
// m_PreviousRunInBackground, so whichever gets there first "owns" the saved value — the
// other must not clobber it with the already-overridden "true". Gating the restore on this
// (rather than m_Server.IsRunning) also means it still happens even if the listener already
// died on its own before StopServer runs — otherwise runInBackground would stay stuck on,
// reproducing a narrower version of the bug this ownership move fixed in the first place.
private bool m_RunInBackgroundOverridden;
/// <summary>Get the runtime server instance if it's running.</summary>
public RuntimePipelineServer Server => m_Server;
/// <summary>Whether the server is currently running.</summary>
public bool IsServerRunning => m_Server != null && m_Server.IsRunning;
/// <summary>Get the actual port the server is running on.</summary>
public int ActualPort => m_Server?.Port ?? 0;
/// <summary>The configuration this driver was initialized with.</summary>
public RuntimePipelineConfig Config => m_Config;
/// <summary>Absolute roots this build may code reload source files from. Baked at build time.</summary>
public IReadOnlyList<string> AllowedReloadRoots => m_AllowedReloadRoots;
/// <summary>
/// Supply configuration and baked code-reload roots. Must be called once, immediately after
/// AddComponent (before the end of the current frame, i.e. before Start runs).
/// </summary>
/// <param name="config">The configuration to run with.</param>
/// <param name="allowedReloadRoots">Absolute roots this build may code reload source files from.</param>
/// <param name="pushPublicKey">Public half of the push-signing key pushes must be signed with; empty rejects all pushes.</param>
public void Initialize(RuntimePipelineConfig config, IReadOnlyList<string> allowedReloadRoots,
string pushPublicKey = "")
{
m_Config = config;
m_AllowedReloadRoots = allowedReloadRoots ?? Array.Empty<string>();
m_PushPublicKey = pushPublicKey ?? "";
}
/// <summary>Public half of the project push-signing key. Baked at build time.</summary>
public string PushPublicKey => m_PushPublicKey;
void Awake()
{
// Persistent across scene loads; exactly one of these ever exists (created by
// RuntimePipelineBootstrap), so there is no duplicate-instance case to defend against.
// DontDestroyOnLoad only functions (and is only legal to call) in Play Mode; invoking it
// from an Edit Mode context (e.g. an EditMode test, or this driver created via eval)
// throws, which would otherwise abort the rest of Awake below.
if (Application.isPlaying)
DontDestroyOnLoad(gameObject);
// Set up command discovery (the server owns its own dispatcher, initialized on Start).
CommandRegistry.SetDiscovery(null); // Triggers reflection-based discovery
// Auto-discover and register reloadable methods. Code reload requires this driver to
// be present (it owns the communication workflow), so discovery lives here rather than in
// each gameplay script's Awake.
RegisterDiscoveredCodeReloadMethods();
// Own the process-wide frame-stats sampler. A Player has no profiler window or
// EditorApplication.update to drive sampling, so the driver feeds it from Update (below) and
// the runtime_status telemetry command reads FrameStatsSampler.Shared. Created here (rather than
// on server start) so fps history is already warm by the time an agent connects. Only claim
// ownership if nothing already created one — a second, transient driver (e.g. a test creating
// its own driver alongside one RuntimeInitializeOnLoadMethod already created) must not steal or
// later tear down a sibling driver's sampler.
if (FrameStatsSampler.Shared == null)
{
FrameStatsSampler.Shared = new FrameStatsSampler();
m_OwnsSampler = true;
}
}
void Start()
{
if (m_Config != null && m_Config.autoStart && m_Config.enableInBuilds)
{
StartServer();
}
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
// Independent of the HTTP server: code-reload IL pushed from the editor arrives over
// PlayerConnection (the Profiler's channel — no open port needed, tunnels over USB).
RegisterReloadReceiver();
if (ShowReloadOverlay && GetComponent<CodeReloadStatusOverlay>() == null)
gameObject.AddComponent<CodeReloadStatusOverlay>();
#endif
}
/// <summary>
/// Scan loaded user assemblies for methods tagged [CodeReload] and register them as reload targets.
/// </summary>
/// <summary>
/// Scan loaded user assemblies for [CodeReload] methods and register them as reload
/// targets. Runs from Awake; RuntimePipelineBootstrap also calls it in the Editor when the
/// runtime server is disabled and no driver is created.
/// </summary>
internal static void RegisterDiscoveredCodeReloadMethods()
{
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic
| BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
int registered = 0;
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
{
if (!ShouldScanForCodeReload(assembly))
continue;
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types; // best effort: keep the types that did load
}
foreach (var type in types)
{
if (type == null)
continue;
foreach (var method in type.GetMethods(flags))
{
var reloadable = method.GetCustomAttribute<CodeReloadAttribute>();
if (reloadable == null)
continue;
// The weaver keys dispatch on TypeName.MethodName, so register with the
// default id (ignore any custom Id) to match.
CodeReloadRegistry.RegisterReloadableMethod(
method, new CodeReloadAttribute { RequireMainThread = reloadable.RequireMainThread });
registered++;
}
}
}
// The count only matters once someone tries to reload, so it joins the first-reload
// summary instead of the startup noise (this runs on every Play Mode entry). That holds
// for zero too: the summary prints on the first reload ATTEMPT, so a project that never
// reloads stays quiet, and one that does sees the stripping/attribute hint right next to
// the "target not registered" reasons.
CodeReloadRegistry.NoteForFirstReload($"{registered} reloadable method(s) registered" +
(registered == 0
? " (if methods were expected: are the [CodeReload] attributes in this build, and did the owning assembly survive stripping?)"
: ""));
}
/// <summary>Skip engine/framework assemblies that cannot contain user code-reload methods.</summary>
private static bool ShouldScanForCodeReload(Assembly assembly)
{
if (assembly.IsDynamic)
return false;
var name = assembly.GetName().Name;
if (string.IsNullOrEmpty(name))
return false;
foreach (var prefix in s_CodeReloadSkipPrefixes)
{
if (name.StartsWith(prefix, StringComparison.Ordinal))
return false;
}
return true;
}
private static readonly string[] s_CodeReloadSkipPrefixes =
{
"System", "mscorlib", "netstandard", "Microsoft", "Mono.", "nunit",
"UnityEngine", "UnityEditor", "Unity.Collections", "Unity.Burst",
"Unity.Mathematics", "Newtonsoft", "log4net", "ICSharpCode",
};
/// <summary>
/// The maxWorkItemsPerFrame value actually governing the dispatcher right now — live from
/// the settings file in the Editor, frozen to the build-time value in a Player. Exposed
/// (rather than reading m_Config.maxWorkItemsPerFrame directly) so status reporting and
/// Update() can never disagree about which value is actually in effect.
/// </summary>
public int CurrentMaxWorkItemsPerFrame
{
get
{
var fallback = m_Config != null ? m_Config.maxWorkItemsPerFrame : 10;
#if UNITY_EDITOR
return RuntimePipelineConfig.GetLiveMaxWorkItemsPerFrame(fallback);
#else
return fallback;
#endif
}
}
void Update()
{
// Feed the frame-stats sampler once per frame on the main thread. Uses unscaled delta so
// reported fps reflects real frame pacing regardless of Time.timeScale.
FrameStatsSampler.Shared?.Sample(Time.unscaledDeltaTime);
// Pump this server's own dispatcher for main-thread operations (player builds have no
// EditorApplication.update to auto-pump it).
m_Server?.Dispatcher.ProcessWorkQueue(CurrentMaxWorkItemsPerFrame);
// Drive the watchdog (player builds have no EditorApplication.update). No-op unless the
// server's watchdog is enabled and armed.
m_Server?.WatchdogTick();
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
DrainPendingReloads();
#endif
}
void OnApplicationQuit()
{
StopServer();
}
void OnDestroy()
{
StopServer();
if (RuntimePipelineBootstrap.Instance == this)
RuntimePipelineBootstrap.Instance = null;
// Only the driver that actually created the shared sampler tears it down — see Awake().
if (m_OwnsSampler)
{
FrameStatsSampler.Shared?.Dispose();
FrameStatsSampler.Shared = null;
}
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
UnregisterReloadReceiver();
#endif
}
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
// ---- PlayerConnection code-reload receiver (editor pushes compiled override IL to this player) ----
// Pushes arrive on the connection message pump (not guaranteed main thread); enqueue here and
// apply in Update() where the interpreter + Unity APIs are safe to touch.
private readonly List<byte[]> m_PendingReloads = new List<byte[]>();
private bool m_ReloadReceiverRegistered;
// Push-signing session state. The nonce is fresh per receiver lifetime and announced to the
// editor via HandshakeMsg; every accepted push must be signed over it (see PushEnvelope).
private byte[] m_PushSessionNonce;
private PushVerifier m_PushVerifier;
private void RegisterReloadReceiver()
{
try
{
PlayerConnection.instance.Register(PipelineCodeReloadConnect.ApplyReloadMsg, OnApplyReloadMessage);
PlayerConnection.instance.Register(PipelineCodeReloadConnect.ReloadPendingMsg, OnReloadPendingMessage);
PlayerConnection.instance.Register(PipelineCodeReloadConnect.ReloadFailedMsg, OnReloadFailedMessage);
PlayerConnection.instance.Register(PipelineCodeReloadConnect.RequestHandshakeMsg, OnRequestHandshakeMessage);
PlayerConnection.instance.RegisterConnection(OnPushPeerConnected);
m_ReloadReceiverRegistered = true;
m_PushSessionNonce = PushEnvelope.CreateNonce();
#if UNITY_EDITOR
// Editor play mode is not a network surface for pushes (they originate in this same
// process, and remote access goes through the token-authenticated HTTP server), so
// signing is not enforced here — see DrainPendingReloads.
CodeReloadRegistry.NoteForFirstReload("PlayerConnection receiver ready (editor play mode, push signing not enforced)");
#else
if (!string.IsNullOrEmpty(m_PushPublicKey))
{
m_PushVerifier = new PushVerifier(m_PushPublicKey, m_PushSessionNonce);
var fingerprint = PushEnvelope.Fingerprint(m_PushPublicKey);
if (PushEnvelope.VerifySelfTest(m_PushPublicKey, out var selfTestError))
Debug.Log($"Pipeline: PlayerConnection code-reload receiver ready — signed pushes only (key {fingerprint}, verify self-test OK). Push IL from the editor via 'reload_file_player_interpreter'.");
else
Debug.LogError($"Pipeline: push-signature verify self-test FAILED on this platform ({selfTestError}) — every push will be rejected as CryptoUnavailable.");
}
else
{
// Strict policy: a receiver without a baked key accepts nothing. The only way to
// get here is a build whose RuntimePipelineBuildInfo carries no key (e.g. an older
// build, or one produced without the build processor running).
Debug.LogWarning("Pipeline: code-reload push receiver has NO baked signing key — all pushes will be rejected. " +
"Rebuild with the Pipeline build processor enabled (it generates the key and bakes the public half into RuntimePipelineBuildInfo).");
}
#endif
SendPushHandshake(); // the editor may already be connected (e.g. autoconnect raced us)
}
catch (Exception ex)
{
Debug.LogWarning($"Pipeline: PlayerConnection register failed: {ex.Message}");
}
}
private void UnregisterReloadReceiver()
{
if (!m_ReloadReceiverRegistered) return;
try
{
PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.ApplyReloadMsg, OnApplyReloadMessage);
PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.ReloadPendingMsg, OnReloadPendingMessage);
PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.ReloadFailedMsg, OnReloadFailedMessage);
PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.RequestHandshakeMsg, OnRequestHandshakeMessage);
PlayerConnection.instance.UnregisterConnection(OnPushPeerConnected);
}
catch { }
m_ReloadReceiverRegistered = false;
}
/// <summary>Announce this session (protocol version, expected key fingerprint, session nonce)
/// to the editor. Sent on receiver start, on every new connection, and on RequestHandshakeMsg.</summary>
private void SendPushHandshake()
{
try
{
var fingerprint = string.IsNullOrEmpty(m_PushPublicKey) ? "" : PushEnvelope.Fingerprint(m_PushPublicKey);
PlayerConnection.instance.Send(PipelineCodeReloadConnect.HandshakeMsg,
PipelineCodeReloadConnect.EncodeHandshake(PushEnvelope.ProtocolVersion, fingerprint, m_PushSessionNonce));
}
catch { /* no connection yet — the connect callback will re-announce */ }
}
// Both may fire on the connection thread; SendPushHandshake only touches immutable session state.
private void OnRequestHandshakeMessage(MessageEventArgs args) => SendPushHandshake();
private void OnPushPeerConnected(int playerId) => SendPushHandshake();
private void OnApplyReloadMessage(MessageEventArgs args)
{
if (args?.data == null) return;
lock (m_PendingReloads) m_PendingReloads.Add(args.data);
}
// Connection-thread handlers: CodeReloadActivity is thread-safe, no marshalling needed.
private void OnReloadPendingMessage(MessageEventArgs args) =>
CodeReloadActivity.ReportCompileStarted(PipelineCodeReloadConnect.DecodeText(args?.data));
private void OnReloadFailedMessage(MessageEventArgs args) =>
CodeReloadActivity.ReportFailed(PipelineCodeReloadConnect.DecodeText(args?.data));
private void DrainPendingReloads()
{
byte[][] batch;
lock (m_PendingReloads)
{
if (m_PendingReloads.Count == 0) return;
batch = m_PendingReloads.ToArray();
m_PendingReloads.Clear();
}
// A player that received a code-reload push must keep ticking while unfocused, otherwise
// Update() (which drains + applies these pushes, and runs the reloaded code) pauses the
// moment the window loses focus. Force it on once, on first receipt — reusing StartServer/
// StopServer's own save/restore bookkeeping (rather than writing Application.runInBackground
// directly) so OnDestroy's StopServer() still restores the user's original value even when
// the HTTP server itself was never started (PlayerConnection needs no open port).
if (!m_RunInBackgroundOverridden)
{
m_PreviousRunInBackground = Application.runInBackground;
Application.runInBackground = true;
m_RunInBackgroundOverridden = true;
Debug.Log("Pipeline: forced Application.runInBackground = true (code-reload push received)");
}
foreach (var data in batch)
{
bool ok = false;
string summary;
try
{
// ---- authentication gate (before anything touches the bytes) ----
byte[] payload = null;
string rejectAck = null; // generic wire ack; the device log carries the detail
#if UNITY_EDITOR
// Editor play mode: strip a signed envelope without verification (no key is baked
// in play mode), accept legacy raw payloads as-is. Not a security gate — pushes to
// the editor originate from the editor itself.
if (PushEnvelope.LooksLikeEnvelope(data))
{
if (!PushEnvelope.TryParse(data, out _, out _, out payload))
rejectAck = "malformed code-reload push";
}
else
{
payload = data;
}
#else
if (m_PushVerifier == null)
{
LogPushReject("no signing key baked in this build — rebuild with the Pipeline build processor enabled");
rejectAck = "push rejected (authentication)";
}
else
{
var status = m_PushVerifier.Verify(data, out payload);
if (status != PushVerifyStatus.Ok)
{
// Q10 diagnostics: a legacy (pre-signing) editor sends a raw payload, which
// is the one failure the sender can actually fix without touching the build.
if (status == PushVerifyStatus.Malformed && !PushEnvelope.LooksLikeEnvelope(data))
{
LogPushReject("unsigned legacy push — the sending editor's com.unity.pipeline package predates push signing");
rejectAck = "push rejected: unsigned legacy push — update the com.unity.pipeline package in the editor";
}
else
{
LogPushReject($"{status} (session nonce/counter and signature details are in this device log only)");
rejectAck = "push rejected (authentication)";
}
}
}
#endif
if (rejectAck != null)
{
summary = rejectAck;
}
else if (!PipelineCodeReloadConnect.TryDecode(payload, out var typeName, out var methodNames, out var il))
{
summary = "malformed code-reload push";
}
else
{
var registered = Unity.Pipeline.Compilation.InterpreterCodeReloadExecutor.Register(
il, typeName, methodNames, out var skipped, out var warnings);
CodeReloadRegistry.InvokeReloadCallbacks(registered);
// Full warning text (unbound host members) goes to the device log; the ack
// summary the editor shows carries just the count so it stays one line.
foreach (var w in warnings)
Debug.LogWarning($"Pipeline: code-reload {typeName} — {w}");
// Same for per-method skip reasons: the summary carries only the count, but a
// method that didn't apply (e.g. SetupUI referencing a member the running
// build stripped) is only diagnosable if the reason reaches the device log —
// "skipped 1" alone gives the user nothing to act on.
foreach (var s in skipped)
Debug.LogWarning($"Pipeline: code-reload {typeName} — skipped {s}");
ok = registered.Count > 0;
summary = ok
? $"applied {registered.Count} method(s) on {typeName}" +
(skipped.Count > 0 ? $"; skipped {skipped.Count}" : "") +
(warnings.Count > 0 ? $"; {warnings.Count} binding warning(s)" : "")
: $"no methods applied on {typeName}" +
(skipped.Count > 0 ? $": {string.Join("; ", skipped)}" : "");
}
}
catch (Exception ex)
{
// The interpreter binds via reflection, so the real cause (e.g.
// ExecutionEngineException from a generic instantiation IL2CPP never
// AOT-compiled) arrives wrapped in TargetInvocationException, whose Message is
// just "Exception has been thrown by the target of an invocation." Unwrap for
// the ack the editor shows; keep the full chain + stack in the device log.
var root = ex;
while (root is System.Reflection.TargetInvocationException && root.InnerException != null)
root = root.InnerException;
Debug.LogException(ex);
summary = $"apply failed: {root.GetType().Name}: {root.Message}";
}
Debug.Log($"Pipeline: code-reload push — {summary}");
if (ok) CodeReloadActivity.ReportApplied(summary);
else CodeReloadActivity.ReportFailed(summary);
try
{
PlayerConnection.instance.Send(
PipelineCodeReloadConnect.ResultMsg, PipelineCodeReloadConnect.EncodeResult(ok, summary));
}
catch { }
}
}
// Reject-log rate limiting: full detail for the first few failures, then one summary line per
// minute — an attacker probing the port must not be able to spam the device log into
// uselessness, but "signature failures are happening" stays visible (it IS the intrusion signal).
private const int RejectLogBurst = 10;
private const float RejectWindowSeconds = 60f;
private int m_RejectsInWindow;
private float m_RejectWindowStart = float.NegativeInfinity;
private int m_SuppressedRejects;
private float m_LastRejectSummaryTime = float.NegativeInfinity;
private void LogPushReject(string detail)
{
float now = Time.realtimeSinceStartup;
if (now - m_RejectWindowStart > RejectWindowSeconds)
{
m_RejectWindowStart = now;
m_RejectsInWindow = 0;
}
m_RejectsInWindow++;
if (m_RejectsInWindow <= RejectLogBurst)
{
Debug.LogWarning($"Pipeline: code-reload push rejected — {detail}");
return;
}
m_SuppressedRejects++;
if (now - m_LastRejectSummaryTime >= RejectWindowSeconds)
{
Debug.LogWarning($"Pipeline: {m_SuppressedRejects} further code-reload push(es) rejected in the last minute " +
$"(possible probing on this network); most recent: {detail}");
m_LastRejectSummaryTime = now;
m_SuppressedRejects = 0;
}
}
#endif
/// <summary>Manually start the Pipeline server.</summary>
public void StartServer()
{
if (m_Server != null && m_Server.IsRunning)
{
Debug.LogWarning("Pipeline: Server already started or initialization attempted.");
return;
}
try
{
System.Console.WriteLine("Pipeline: Starting runtime server from RuntimePipelineDriver...");
if (m_Config == null || !m_Config.enableInBuilds)
{
Debug.LogWarning("Pipeline: Runtime server disabled in configuration (enableInBuilds = false)");
return;
}
var validation = m_Config.Validate();
if (!validation.IsValid)
{
Debug.LogError($"Pipeline: Invalid runtime configuration: {validation.Message}");
return;
}
if (validation.Level == "warning")
{
Debug.LogWarning($"Pipeline: Runtime configuration warning: {validation.Message}");
}
m_Server = new RuntimePipelineServer(m_Config);
m_Server.Start(m_Config.port);
if (m_Server.IsRunning)
{
// Keeps Update -> Dispatcher.ProcessWorkQueue/WatchdogTick running while unfocused.
// Guarded on m_RunInBackgroundOverridden (rather than unconditional) because
// DrainPendingReloads can already have overridden it — a code-reload push over
// PlayerConnection needs no HTTP server, so it can arrive and force this on before
// StartServer ever runs. Saving unconditionally here would clobber the real
// original value with the already-overridden "true", and StopServer would then
// restore to "true" instead of the user's setting.
if (!m_RunInBackgroundOverridden)
{
m_PreviousRunInBackground = Application.runInBackground;
Application.runInBackground = true;
m_RunInBackgroundOverridden = true;
}
// The code-reload registry marshals main-thread overrides through a dispatcher
// when reloaded code is invoked from a background thread at runtime. Inject
// this server's dispatcher here (a player has exactly one driver), so editor and
// per-test servers never clobber the global registry's dispatcher.
CodeReloadRegistry.Dispatcher = m_Server.Dispatcher;
// Publish the build-baked allowed roots so reload commands can validate that
// incoming files are inside the project before compiling and injecting them.
CodeReloadRegistry.AllowedReloadRoots = m_AllowedReloadRoots;
System.Console.WriteLine($"Pipeline: Runtime server started successfully on port {m_Server.Port}");
}
else
{
Debug.LogError("Pipeline: Failed to start runtime server");
}
}
catch (Exception ex)
{
Debug.LogError($"Pipeline: Runtime initialization failed: {ex.Message}");
Debug.LogError($"Pipeline: Stack trace: {ex.StackTrace}");
}
}
/// <summary>Manually stop the Pipeline server.</summary>
public void StopServer()
{
try
{
if (m_Server != null && m_Server.IsRunning)
{
System.Console.WriteLine("Pipeline: Stopping runtime server...");
// Drop the registry's reference to this server's (about to be shut down) dispatcher.
if (CodeReloadRegistry.Dispatcher == m_Server.Dispatcher)
CodeReloadRegistry.Dispatcher = null;
m_Server.Stop(); // Stop() shuts down the server's own dispatcher.
}
}
catch (Exception ex)
{
Debug.LogError($"Pipeline: Runtime shutdown error: {ex.Message}");
}
// Independent of whether the listener was still alive above: if we overrode
// runInBackground, restore it regardless, so a listener that already died on its own
// (e.g. the watchdog never got to it) can't leave the setting stuck on.
if (m_RunInBackgroundOverridden)
{
Application.runInBackground = m_PreviousRunInBackground;
m_RunInBackgroundOverridden = false;
}
}
/// <summary>Validate the current configuration.</summary>
/// <returns>The validation result.</returns>
public ValidationResult ValidateConfiguration()
{
return m_Config != null ? m_Config.Validate() : ValidationResult.Error("No RuntimePipelineConfig assigned");
}
}
}