Files
AIGame/AIClient/Packages/com.unity.pipeline/Editor/Commands/PushReloadCommand.cs
T

510 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Compilation;
using Unity.Pipeline.CodeReload;
using UnityEditor;
using UnityEditor.Networking.PlayerConnection;
using UnityEngine;
using UnityEngine.Networking.PlayerConnection;
namespace Unity.Pipeline.Editor.Commands
{
/// <summary>
/// Compile a source file's [CodeReload] override(s) in the editor and push the IL to a connected
/// player over PlayerConnection — the Profiler/Console channel (tunnels over USB, no open port or
/// token). The player applies it through the IlInterpreter interpreter (IL2CPP-safe: no Assembly.Load,
/// no Roslyn on device).
///
/// <c>filename</c> may be a single .cs file or a folder: a folder is walked recursively and every
/// .cs that opts into code reload is compiled and pushed once. For a continuous watch, use the Hot
/// Reload Interpreter Watch section of the Pipeline Settings inspector (<see cref="EditorCodeReloadWatcher"/>).
///
/// Requires a Development Build connected to the editor (Build Settings → Autoconnect Profiler,
/// or attach via the Profiler/Console) with the runtime Pipeline enabled. The player's
/// ack is asynchronous and logged to the editor console.
/// </summary>
static class PushReloadCommand
{
private static bool s_Registered;
// Pending-ack watchdog. A push that reaches no receiver (e.g. the runtime Pipeline isn't
// enabled in the player) is otherwise indistinguishable from success: the editor logs
// "pushed … (ack async)" and then nothing, forever. One entry per expected ack; entries are
// matched to acks oldest-first (acks carry no correlation id). Editor-session static: a
// domain reload drops pending entries together with the ack handler itself.
private const double AckTimeoutSeconds = 5.0;
private static readonly List<(double deadline, string desc)> s_PendingAcks = new List<(double, string)>();
private static bool s_AckPumpHooked;
static PushReloadCommand()
{
AssemblyReloadEvents.beforeAssemblyReload += Unregister;
}
[CliCommand("reload_file_player_interpreter",
"Compile a file's (or folder's) [CodeReload] method(s) and push the IL to a connected player (IL2CPP-safe) over PlayerConnection. Folders are walked recursively and pushed once.",
MainThreadRequired = true, Tags = new[] { "scripts/codereload" })]
public static object PushReload(
[CliArg("filename", "Source .cs file or a folder containing [CodeReload] methods (e.g. Assets/pong.cs or Assets/Scripts)", Required = true)] string filename,
[CliArg("player", "Target connected player id; -1 = broadcast to all connected players")] int player = -1)
{
if (string.IsNullOrWhiteSpace(filename))
return new { success = false, error = "filename is required" };
if (!TryResolvePath(filename, out var fullPath, out var isFolder))
return new { success = false, error = $"Could not locate source file or folder: {filename}" };
if (isFolder)
return PushFolderOnce(fullPath, player);
var ok = DoCompileAndSend(fullPath, player, out var summary, out var diagnostics);
return new { success = ok, message = summary, diagnostics };
}
/// <summary>Compile and push every reloadable .cs under a folder once. Files that don't opt into
/// code reload are skipped (not errors).</summary>
private static object PushFolderOnce(string folder, int player)
{
List<string> files;
try
{
files = Directory.EnumerateFiles(folder, "*.cs", SearchOption.AllDirectories)
.Where(FileMentionsCodeReload)
.ToList();
}
catch (Exception ex)
{
return new { success = false, error = $"Could not enumerate {folder}: {ex.Message}" };
}
if (files.Count == 0)
return new { success = false, error = $"No .cs files mentioning [CodeReload] found under {folder}" };
var pushed = new List<string>();
var failed = new List<object>();
foreach (var f in files)
{
if (DoCompileAndSend(f, player, out var summary, out var diagnostics))
pushed.Add(Path.GetFileName(f));
else
failed.Add(new { file = Path.GetFileName(f), error = summary, diagnostics });
}
return new
{
success = failed.Count == 0,
message = $"Pushed {pushed.Count}/{files.Count} file(s) from {DisplayName(folder)}" +
(pushed.Count > 0 ? $": {string.Join(", ", pushed)}" : ""),
pushed,
failed
};
}
/// <summary>Watcher-save push: like <see cref="PushReload"/> for a single file, but diffed
/// against the watch-start <see cref="CodeReloadBaseline"/> — methods still matching it are
/// left running compiled on the device, and a fully up-to-date file skips the compile and
/// send entirely. The explicit CLI command stays unfiltered as the escape hatch when the
/// connected player was NOT built from the watched sources (the baseline can't see a
/// device's build state).</summary>
internal static void PushChanged(string fullPath, int player)
{
DoCompileAndSend(fullPath, player, out _, out _, useBaseline: true);
}
/// <summary>Re-push the watched file's/folder's code-reload overrides to one player, compiled
/// from current source. Called by <see cref="EditorCodeReloadWatcher"/> when a player
/// (re)connects while a Player-target watch is active: a restarted player boots the original
/// AOT code, so everything pushed during its previous run is gone until re-pushed.</summary>
internal static void RepushAll(string fullPath, bool isFolder, int player)
{
if (!isFolder)
{
DoCompileAndSend(fullPath, player, out _, out _, useBaseline: true); // logs success and failure itself
return;
}
List<string> files;
try
{
files = Directory.EnumerateFiles(fullPath, "*.cs", SearchOption.AllDirectories)
.Where(FileMentionsCodeReload)
.ToList();
}
catch (Exception ex)
{
Debug.LogError($"[PushReload] Re-push to player {player} failed — could not enumerate {fullPath}: {ex.Message}");
return;
}
int pushed = 0;
foreach (var f in files)
if (DoCompileAndSend(f, player, out _, out _, useBaseline: true))
pushed++;
Debug.Log($"[PushReload] Re-pushed {pushed}/{files.Count} reloadable file(s) to player {player}.");
}
/// <summary>Preprocessor symbols the target player's copy of this file was compiled with:
/// the defines of the player-variant assembly that owns the file, falling back to any
/// player assembly's defines, then null (= editor defines downstream) if the compilation
/// pipeline can't answer. Cheap relative to the compile that follows.</summary>
internal static string[] PlayerDefinesFor(string fullPath)
{
try
{
var assemblies = UnityEditor.Compilation.CompilationPipeline
.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player);
var norm = Path.GetFullPath(fullPath).Replace('\\', '/');
foreach (var a in assemblies)
foreach (var f in a.sourceFiles)
if (Path.GetFullPath(f).Replace('\\', '/')
.Equals(norm, StringComparison.OrdinalIgnoreCase))
return a.defines;
return assemblies.Length > 0 ? assemblies[0].defines : null;
}
catch (Exception)
{
return null;
}
}
/// <summary>Compile the file's overrides and send them to the target player(s). Never throws.
/// Failures are logged to the editor console as well as returned: the code-reload watcher
/// (Player target) discards the return value, so the console is the only surface where a
/// failing watch-mode push is visible at all.</summary>
private static bool DoCompileAndSend(string fullPath, int player, out string summary, out List<string> diagnostics,
bool useBaseline = false)
{
diagnostics = null;
try
{
EnsureRegistered();
// Compile with the PLAYER's preprocessor symbols, not the editor's: the pushed body
// must resolve #if UNITY_EDITOR / platform regions the way the player's own
// assemblies did, or the override diverges from the code it replaces.
var playerDefines = PlayerDefinesFor(fullPath);
// Methods already overridden on a device must reload even when they match the
// baseline again: there is no remote unregister, so the only way to clear a stale
// override is to replace it with one compiled from the (reverted) source.
var mustReload = useBaseline ? GetPushedMethods(fullPath) : null;
// Up-to-date pre-check BEFORE the pending notice: announcing a compile and then
// sending nothing would leave the device overlay stuck on "compiling…".
if (useBaseline && FileIsUpToDate(fullPath, playerDefines, mustReload))
{
summary = "up to date — every [CodeReload] method matches the watch-start baseline; nothing pushed";
Debug.Log($"[PushReload] {Path.GetFileName(fullPath)}: {summary}");
return true;
}
// Compilation happens here in the editor — announce it so the player's status overlay
// can show "compiling…" during the gap before the payload (or failure notice) arrives.
TrySendNotice(PipelineCodeReloadConnect.ReloadPendingMsg, Path.GetFileName(fullPath), player);
var compiled = InPlaceReloadProcessor.CompileOverrideForPush(fullPath, playerDefines,
useBaseline ? mustReload : null, useBaseline);
diagnostics = compiled.Diagnostics;
// The pre-check should have caught this; a save landing between the two reads can
// still get here. Nothing to push either way.
if (compiled.AllUpToDate)
{
summary = "up to date — every [CodeReload] method matches the watch-start baseline; nothing pushed";
Debug.Log($"[PushReload] {Path.GetFileName(fullPath)}: {summary}");
return true;
}
if (!compiled.Success)
{
summary = "compile failed: " + (compiled.Error ?? "unknown");
var details = diagnostics != null && diagnostics.Count > 0
? ":\n- " + string.Join("\n- ", diagnostics)
: "";
Debug.LogError($"[PushReload] {Path.GetFileName(fullPath)}: {summary}{details}");
TrySendNotice(PipelineCodeReloadConnect.ReloadFailedMsg,
$"{Path.GetFileName(fullPath)}: {summary} (details in the editor console)", player);
return false;
}
// Run the same interpreter load pass the player runs on receipt, so binding gaps come
// back in THIS response — the player's async ack carries only a warning count (the
// member list goes to the device log). Never blocks the push: an unbound member is a
// stub that throws only if executed.
var unbound = InterpreterCodeReloadExecutor.ValidateBindingSurface(
compiled.IlBytes, compiled.TypeName, compiled.MethodNames, out var validationNote);
if (unbound.Count > 0)
{
diagnostics ??= new List<string>();
foreach (var member in unbound)
diagnostics.Add($"binding: host member '{member}' is not registered and will " +
"throw if the reloaded code reaches it");
Debug.LogWarning($"[PushReload] {Path.GetFileName(fullPath)}: {unbound.Count} host " +
$"member(s) referenced by the override are not in the interpreter binding and will " +
$"throw if reached: {string.Join(", ", unbound)}. Types outside the standard " +
"surface and demand-time auto-bind are not callable from pushed overrides; BCL " +
"members outside the standard surface need an explicit AllowBcl shim.");
}
if (validationNote != null)
Debug.LogWarning($"[PushReload] {Path.GetFileName(fullPath)}: binding validation note: {validationNote}");
var players = EditorConnection.instance.ConnectedPlayers;
if (players == null || players.Count == 0)
{
summary = "no player connected (run a Development Build with Autoconnect Profiler)";
Debug.LogWarning($"[PushReload] {Path.GetFileName(fullPath)}: {summary}");
return false;
}
var payload = PipelineCodeReloadConnect.Encode(compiled.TypeName, compiled.MethodNames, compiled.IlBytes);
var desc = $"{compiled.TypeName} ({Path.GetFileName(fullPath)})";
// Every player gets its own signed envelope (session nonces differ), so "broadcast"
// is a per-player loop rather than one connection-wide Send. PushSigner tracks the
// expected ack per actual send, so no TrackExpectedAcks call here.
int sent = 0, deferred = 0, failedSends = 0, matched = 0;
foreach (var target in players)
{
if (player >= 0 && target.playerId != player) continue;
matched++;
switch (PushSigner.PushOrDefer(target.playerId, payload, desc))
{
case PushSendStatus.Sent: sent++; break;
case PushSendStatus.Deferred: deferred++; break;
default: failedSends++; break;
}
}
if (matched == 0)
{
summary = $"player {player} is not connected";
Debug.LogWarning($"[PushReload] {Path.GetFileName(fullPath)}: {summary}");
return false;
}
// A device can't unregister an override, so record every method sent or deferred — a
// later baseline-filtered push must re-include it or a reverted edit would strand the
// device on the stale override.
RecordPushedMethods(fullPath, compiled.MethodNames);
summary = $"pushed {compiled.TypeName} ({compiled.MethodNames.Count} method(s), {compiled.IlBytes.Length} B) " +
$"signed to {sent} player(s)" +
(deferred > 0 ? $"; {deferred} deferred awaiting player handshake" : "") +
(failedSends > 0 ? $"; {failedSends} failed (see console)" : "") +
(compiled.UpToDateMethods.Count > 0 ? $"; {compiled.UpToDateMethods.Count} method(s) up to date, left compiled" : "") +
(unbound.Count > 0 ? $"; {unbound.Count} binding warning(s) — see diagnostics" : "") +
" (ack async)";
Debug.Log($"[PushReload] {summary}");
return failedSends == 0;
}
catch (Exception ex)
{
summary = $"push error: {ex.Message}";
Debug.LogError($"[PushReload] {Path.GetFileName(fullPath)}: {summary}");
TrySendNotice(PipelineCodeReloadConnect.ReloadFailedMsg,
$"{Path.GetFileName(fullPath)}: {summary}", player);
return false;
}
}
/// <summary>Best-effort overlay notice to the target player(s); a notice failing must never
/// fail the push itself.</summary>
private static void TrySendNotice(Guid msg, string text, int player)
{
try
{
var payload = PipelineCodeReloadConnect.EncodeText(text);
if (player >= 0)
EditorConnection.instance.Send(msg, payload, player);
else
EditorConnection.instance.Send(msg, payload);
}
catch { /* overlay notices are cosmetic */ }
}
private static void EnsureRegistered()
{
EditorConnection.instance.Initialize();
if (s_Registered) return;
EditorConnection.instance.Register(PipelineCodeReloadConnect.ResultMsg, OnResult);
s_Registered = true;
}
private static void Unregister()
{
s_PendingAcks.Clear();
if (s_AckPumpHooked)
{
EditorApplication.update -= PumpPendingAcks;
s_AckPumpHooked = false;
}
if (!s_Registered) return;
try { EditorConnection.instance.Unregister(PipelineCodeReloadConnect.ResultMsg, OnResult); }
catch { /* connection may already be gone during teardown */ }
s_Registered = false;
}
// EditorConnection callbacks are dispatched on the main thread, same as the update pump —
// no locking needed around s_PendingAcks.
private static void OnResult(MessageEventArgs args)
{
if (s_PendingAcks.Count > 0)
s_PendingAcks.RemoveAt(0);
if (PipelineCodeReloadConnect.TryDecodeResult(args.data, out var ok, out var message))
{
// A pre-signing player build cannot parse a signed envelope and acks it as malformed
// — name the actual fix instead of leaving a mystery.
var hint = !ok && message != null && message.Contains("malformed code-reload push")
? " (if this player build predates push signing, rebuild it with the current com.unity.pipeline package)"
: "";
Debug.Log($"[PushReload] Player ack: {(ok ? "OK" : "FAILED")} — {message}{hint}");
}
else
Debug.LogWarning("[PushReload] Received a player ack that could not be decoded.");
}
/// <summary>One expected-ack slot for a push <see cref="PushSigner"/> just sent.</summary>
internal static void TrackAck(string desc)
{
EnsureRegistered();
TrackExpectedAcks(1, desc);
}
private static void TrackExpectedAcks(int count, string desc)
{
double deadline = EditorApplication.timeSinceStartup + AckTimeoutSeconds;
for (int i = 0; i < count; i++)
s_PendingAcks.Add((deadline, desc));
if (!s_AckPumpHooked)
{
EditorApplication.update += PumpPendingAcks;
s_AckPumpHooked = true;
}
}
private static void PumpPendingAcks()
{
double now = EditorApplication.timeSinceStartup;
for (int i = s_PendingAcks.Count - 1; i >= 0; i--)
{
if (now < s_PendingAcks[i].deadline) continue;
Debug.LogWarning($"[PushReload] No player ack for {s_PendingAcks[i].desc} within {AckTimeoutSeconds:0}s — " +
"is the player still connected, and does it have the runtime Pipeline enabled?");
s_PendingAcks.RemoveAt(i);
}
if (s_PendingAcks.Count == 0)
{
EditorApplication.update -= PumpPendingAcks;
s_AckPumpHooked = false;
}
}
/// <summary>Resolve <paramref name="input"/> to an existing folder or .cs file. Folders are checked
/// first (so a bare directory name isn't turned into "&lt;dir&gt;.cs"); files get a .cs suffix if missing.</summary>
private static bool TryResolvePath(string input, out string fullPath, out bool isFolder)
{
fullPath = null;
isFolder = false;
foreach (var d in new[] { input, Path.Combine("Assets", input) })
{
if (Directory.Exists(d))
{
fullPath = Path.GetFullPath(d);
isFolder = true;
return true;
}
}
var file = input.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) ? input : input + ".cs";
fullPath = ResolveSourceFilePath(file);
return fullPath != null;
}
private static string ResolveSourceFilePath(string filename)
{
if (Path.IsPathRooted(filename) && File.Exists(filename))
return filename;
foreach (var p in new[] { Path.Combine("Assets", filename), Path.Combine("Assets", "Scripts", filename), filename })
if (File.Exists(p))
return Path.GetFullPath(p);
return null;
}
/// <summary>Every [CodeReload] method in the file still matches the watch-start baseline
/// (player defines) and none of them was ever pushed. Read failure = not up to date.</summary>
private static bool FileIsUpToDate(string fullPath, string[] playerDefines, HashSet<string> mustReload)
{
try
{
var source = File.ReadAllText(fullPath);
return CodeReloadBaseline.IsFileUpToDate(fullPath, source, playerDefines, mustReload);
}
catch
{
return false;
}
}
// Method names pushed to any player this editor session, per file. A device can't
// unregister an override, so once a method is pushed, every later baseline-filtered push
// must include it again — otherwise reverting an edit would strand the device on the stale
// override while the editor reports "up to date". SessionState-backed so the play-mode
// domain reload doesn't forget what connected devices still hold.
private const string SkPushedMethods = "Pipeline.PushReload.PushedMethods";
private static HashSet<string> GetPushedMethods(string fullPath)
{
var set = new HashSet<string>(StringComparer.Ordinal);
var raw = SessionState.GetString(SkPushedMethods, "");
if (string.IsNullOrEmpty(raw)) return set;
var key = Path.GetFullPath(fullPath);
foreach (var line in raw.Split('\n'))
{
int sep = line.IndexOf('\u001f');
if (sep <= 0 || !string.Equals(line.Substring(0, sep), key, StringComparison.OrdinalIgnoreCase))
continue;
foreach (var m in line.Substring(sep + 1).Split(';'))
if (m.Length > 0)
set.Add(m);
}
return set;
}
private static void RecordPushedMethods(string fullPath, List<string> methods)
{
if (methods == null || methods.Count == 0) return;
var set = GetPushedMethods(fullPath);
int before = set.Count;
foreach (var m in methods)
set.Add(m);
if (set.Count == before) return;
var key = Path.GetFullPath(fullPath);
var lines = new List<string>();
var raw = SessionState.GetString(SkPushedMethods, "");
if (!string.IsNullOrEmpty(raw))
foreach (var line in raw.Split('\n'))
{
int sep = line.IndexOf('\u001f');
if (sep > 0 && !string.Equals(line.Substring(0, sep), key, StringComparison.OrdinalIgnoreCase))
lines.Add(line);
}
lines.Add(key + '\u001f' + string.Join(";", set));
SessionState.SetString(SkPushedMethods, string.Join("\n", lines));
}
// Cheap opt-in filter shared with EditorCodeReloadWatcher: a file that never mentions
// "CodeReload" cannot carry the attribute, so folder scans skip it without compiling.
internal static bool FileMentionsCodeReload(string path)
{
try { return File.ReadAllText(path).Contains("CodeReload"); }
catch { return false; }
}
private static string DisplayName(string path) =>
string.IsNullOrEmpty(path) ? "?" : Path.GetFileName(path.TrimEnd('/', '\\'));
}
}