Add Unity Pipeline package support

This commit is contained in:
ud18010
2026-07-31 17:34:04 +08:00
parent d1a526094e
commit 786b7ffff9
590 changed files with 51995 additions and 2 deletions
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Unity.Pipeline.Models;
using UnityEngine;
namespace Unity.Pipeline.Console
{
/// <summary>
/// Bounded, thread-safe ring buffer of captured Unity console entries that backs the
/// <c>console</c> command.
///
/// Why thread-safe: entries arrive via <see cref="Application.logMessageReceivedThreaded"/>,
/// which can fire from background threads, while the command reads the buffer from an HTTP
/// request thread. All access is guarded by a single lock.
///
/// Each entry gets a monotonic <see cref="ConsoleLogEntry.Seq"/> that never repeats and only
/// increases — including across domain reloads, because the buffer (and the seq counter) is
/// persisted via <see cref="Save"/>/<see cref="Load"/>. The seq is the cursor a "--follow"
/// client uses to fetch only newer entries.
/// </summary>
public class ConsoleLogBuffer
{
/// <summary>Maximum number of entries retained. Older entries are evicted first.</summary>
public const int Capacity = 2000;
// Severity levels, ordered so a "minimum severity" filter is a simple >= comparison.
public const int SeverityLog = 0;
public const int SeverityWarn = 1;
public const int SeverityError = 2;
public const string LevelLog = "log";
public const string LevelWarn = "warn";
public const string LevelError = "error";
readonly object m_Lock = new object();
readonly Queue<StoredEntry> m_Entries = new Queue<StoredEntry>();
long m_LastSeq;
struct StoredEntry
{
public int Severity;
public ConsoleLogEntry Entry;
}
/// <summary>
/// Capture a console entry. Assigns the next sequence number and evicts the oldest entry if
/// the buffer is full. Safe to call from any thread.
/// </summary>
public void Add(LogType type, string message, string stackTrace, DateTime timestampUtc)
{
var severity = SeverityFromLogType(type);
lock (m_Lock)
{
m_LastSeq++;
if (m_Entries.Count >= Capacity)
m_Entries.Dequeue();
m_Entries.Enqueue(new StoredEntry
{
Severity = severity,
Entry = new ConsoleLogEntry
{
Seq = m_LastSeq,
TimestampUtc = timestampUtc,
Level = LevelName(severity),
Message = message ?? string.Empty,
StackTrace = stackTrace ?? string.Empty
}
});
}
}
/// <summary>
/// Query the buffer for the <c>console</c> command.
/// </summary>
/// <param name="since">
/// Cursor: return only entries with <c>Seq &gt; since</c>. Pass a negative value (e.g. -1)
/// for a snapshot of the most recent entries with no cursor filtering.
/// </param>
/// <param name="tail">
/// Maximum number of entries to return (the most recent matches). Values &lt;= 0 mean "no
/// limit" (up to <see cref="Capacity"/>).
/// </param>
/// <param name="minSeverity">Minimum severity to include (see the Severity* constants).</param>
public ConsoleLogResponse Query(long since, int tail, int minSeverity)
{
lock (m_Lock)
{
var matches = new List<ConsoleLogEntry>();
foreach (var stored in m_Entries)
{
if (stored.Severity < minSeverity)
continue;
if (since >= 0 && stored.Entry.Seq <= since)
continue;
matches.Add(stored.Entry);
}
// Keep only the most recent `tail` matches.
if (tail > 0 && matches.Count > tail)
matches.RemoveRange(0, matches.Count - tail);
return new ConsoleLogResponse
{
Entries = matches.ToArray(),
Cursor = m_LastSeq,
Returned = matches.Count,
Dropped = ComputeDropped(since)
};
}
}
/// <summary>
/// True when <paramref name="since"/> refers to entries that have already been evicted, so
/// the caller missed some output. Caller must hold <see cref="m_Lock"/>.
/// </summary>
bool ComputeDropped(long since)
{
if (since < 0)
return false;
if (since >= m_LastSeq)
return false;
// Everything after `since` was evicted (or never retained): buffer empty but seq moved on.
if (m_Entries.Count == 0)
return m_LastSeq > since;
// The next entry the caller expects is since+1; if the oldest entry we still hold is
// newer than that, the gap in between was evicted.
var oldestSeq = m_Entries.Peek().Entry.Seq;
return oldestSeq > since + 1;
}
/// <summary>Remove all entries. Does NOT reset the sequence counter (cursors stay monotonic).</summary>
public void Clear()
{
lock (m_Lock)
{
m_Entries.Clear();
}
}
/// <summary>Current number of retained entries. For diagnostics/tests.</summary>
public int Count
{
get { lock (m_Lock) { return m_Entries.Count; } }
}
/// <summary>The highest sequence number assigned so far (the live cursor).</summary>
public long LastSeq
{
get { lock (m_Lock) { return m_LastSeq; } }
}
#region Persistence
[Serializable]
class Snapshot
{
[JsonProperty("lastSeq")] public long LastSeq;
[JsonProperty("entries")] public ConsoleLogEntry[] Entries;
}
/// <summary>
/// Persist the buffer (entries + sequence counter) to <paramref name="path"/> so it survives
/// a domain reload. Failures are swallowed (logging is best-effort, never fatal).
/// </summary>
public void Save(string path)
{
try
{
Snapshot snapshot;
lock (m_Lock)
{
var entries = new ConsoleLogEntry[m_Entries.Count];
var i = 0;
foreach (var stored in m_Entries)
entries[i++] = stored.Entry;
snapshot = new Snapshot { LastSeq = m_LastSeq, Entries = entries };
}
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(path, JsonConvert.SerializeObject(snapshot));
}
catch (Exception ex)
{
Debug.LogWarning($"[Pipeline] Failed to persist console log buffer: {ex.Message}");
}
}
/// <summary>
/// Restore a buffer previously written by <see cref="Save"/>. Missing or unreadable files
/// leave the buffer empty. Returns true if a snapshot was loaded.
/// </summary>
public bool Load(string path)
{
try
{
if (!File.Exists(path))
return false;
var snapshot = JsonConvert.DeserializeObject<Snapshot>(File.ReadAllText(path));
if (snapshot == null)
return false;
lock (m_Lock)
{
m_Entries.Clear();
long lastRestoredSeq = 0;
if (snapshot.Entries != null)
{
foreach (var entry in snapshot.Entries)
{
if (entry == null)
continue;
if (m_Entries.Count >= Capacity)
m_Entries.Dequeue();
m_Entries.Enqueue(new StoredEntry
{
Severity = SeverityFromLevelName(entry.Level),
Entry = entry
});
lastRestoredSeq = entry.Seq;
}
}
// Keep the counter ahead of any restored entry so seq stays monotonic.
m_LastSeq = snapshot.LastSeq;
if (m_Entries.Count > 0)
m_LastSeq = Math.Max(m_LastSeq, lastRestoredSeq);
}
return true;
}
catch (Exception ex)
{
Debug.LogWarning($"[Pipeline] Failed to restore console log buffer: {ex.Message}");
return false;
}
}
#endregion
#region Severity mapping
/// <summary>Map a Unity <see cref="LogType"/> to an ordered severity.</summary>
public static int SeverityFromLogType(LogType type)
{
switch (type)
{
case LogType.Warning:
return SeverityWarn;
case LogType.Error:
case LogType.Exception:
case LogType.Assert:
return SeverityError;
default:
return SeverityLog;
}
}
/// <summary>
/// Parse a "--level" value ("log"/"warn"/"error", plus common aliases) into a minimum
/// severity. Unknown values fall back to <see cref="SeverityLog"/> (everything), matching the
/// lenient behavior of the existing log command.
/// </summary>
public static int SeverityFromLevelName(string level)
{
switch ((level ?? string.Empty).Trim().ToLowerInvariant())
{
case "error":
case "err":
case "exception":
return SeverityError;
case "warn":
case "warning":
return SeverityWarn;
default:
return SeverityLog;
}
}
/// <summary>The canonical level name for a severity.</summary>
public static string LevelName(int severity)
{
switch (severity)
{
case SeverityError:
return LevelError;
case SeverityWarn:
return LevelWarn;
default:
return LevelLog;
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1e014974635df5fe6a6787c4ba983f80
@@ -0,0 +1,67 @@
using System;
using UnityEngine;
namespace Unity.Pipeline.Console
{
/// <summary>
/// Captures Unity console output into a process-wide <see cref="ConsoleLogBuffer"/> so the
/// <c>console</c> command can serve it to CLI clients. This type lives in the runtime
/// assembly, so it ships in player builds as well as the Editor.
///
/// Lifecycle:
/// - In a Player, the <c>[RuntimeInitializeOnLoadMethod]</c> hook starts capture as the app boots.
/// - In the Editor, the editor-only <c>EditorConsoleCaptureBootstrap</c> starts capture on every
/// domain reload and layers on persistence so entries survive reloads (e.g. after a recompile).
/// Both paths funnel through <see cref="EnsureCapturing"/>, which is idempotent — entering Play
/// mode in the Editor fires the runtime hook too, and the guard makes that a no-op.
/// - <see cref="Application.logMessageReceivedThreaded"/> is used (not the non-threaded variant)
/// so logs emitted from background threads are captured too. The buffer is thread-safe.
///
/// Capture starts when this type first initializes. Console entries produced before that — or
/// before the package's first import — are not retroactively captured; this reads from the public
/// log callback, not Unity's internal console store.
/// </summary>
public static class ConsoleLogCapture
{
static readonly ConsoleLogBuffer s_Buffer = new ConsoleLogBuffer();
static readonly object s_SubscriptionLock = new object();
static bool s_Subscribed;
/// <summary>The shared buffer holding captured console entries.</summary>
public static ConsoleLogBuffer Buffer => s_Buffer;
/// <summary>
/// Player entry point. Runs as the application boots so console output is captured from the
/// start. In the Editor this also fires when entering Play mode, but <see cref="EnsureCapturing"/>
/// is idempotent so it does not double-subscribe.
/// </summary>
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void RuntimeBootstrap()
{
EnsureCapturing();
}
/// <summary>
/// Subscribe to Unity's log callback if not already subscribed. Safe to call repeatedly and
/// from either the runtime bootstrap or the Editor bootstrap.
/// </summary>
public static void EnsureCapturing()
{
lock (s_SubscriptionLock)
{
if (s_Subscribed)
return;
// Defensive: remove before adding in case a prior subscription leaked across a reload.
Application.logMessageReceivedThreaded -= OnLogMessage;
Application.logMessageReceivedThreaded += OnLogMessage;
s_Subscribed = true;
}
}
static void OnLogMessage(string condition, string stackTrace, LogType type)
{
s_Buffer.Add(type, condition, stackTrace, DateTime.UtcNow);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3d9b6e1a4f2c40a78c5e0b1d2a6f9c84