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 { /// /// Drives the runtime Pipeline server. Created exactly once, in code, by /// — never authored in a scene. Hidden from Add /// Component (nothing to configure via the Inspector; see instead) /// and guarded against stacking multiple instances on one GameObject, in case it's ever added /// manually or via a script despite that. /// [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 m_AllowedReloadRoots = Array.Empty(); // 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; /// Get the runtime server instance if it's running. public RuntimePipelineServer Server => m_Server; /// Whether the server is currently running. public bool IsServerRunning => m_Server != null && m_Server.IsRunning; /// Get the actual port the server is running on. public int ActualPort => m_Server?.Port ?? 0; /// The configuration this driver was initialized with. public RuntimePipelineConfig Config => m_Config; /// Absolute roots this build may code reload source files from. Baked at build time. public IReadOnlyList AllowedReloadRoots => m_AllowedReloadRoots; /// /// 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). /// /// The configuration to run with. /// Absolute roots this build may code reload source files from. /// Public half of the push-signing key pushes must be signed with; empty rejects all pushes. public void Initialize(RuntimePipelineConfig config, IReadOnlyList allowedReloadRoots, string pushPublicKey = "") { m_Config = config; m_AllowedReloadRoots = allowedReloadRoots ?? Array.Empty(); m_PushPublicKey = pushPublicKey ?? ""; } /// Public half of the project push-signing key. Baked at build time. 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() == null) gameObject.AddComponent(); #endif } /// /// Scan loaded user assemblies for methods tagged [CodeReload] and register them as reload targets. /// /// /// 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. /// 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(); 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?)" : "")); } /// Skip engine/framework assemblies that cannot contain user code-reload methods. 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", }; /// /// 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. /// 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 m_PendingReloads = new List(); 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; } /// Announce this session (protocol version, expected key fingerprint, session nonce) /// to the editor. Sent on receiver start, on every new connection, and on RequestHandshakeMsg. 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 /// Manually start the Pipeline server. 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}"); } } /// Manually stop the Pipeline server. 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; } } /// Validate the current configuration. /// The validation result. public ValidationResult ValidateConfiguration() { return m_Config != null ? m_Config.Validate() : ValidationResult.Error("No RuntimePipelineConfig assigned"); } } }