using System; using System.Collections.Generic; using System.IO; using Newtonsoft.Json; using Unity.Pipeline.Models; using UnityEngine; namespace Unity.Pipeline.Console { /// /// Bounded, thread-safe ring buffer of captured Unity console entries that backs the /// console command. /// /// Why thread-safe: entries arrive via , /// 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 that never repeats and only /// increases — including across domain reloads, because the buffer (and the seq counter) is /// persisted via /. The seq is the cursor a "--follow" /// client uses to fetch only newer entries. /// class ConsoleLogBuffer { /// Maximum number of entries retained. Older entries are evicted first. 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 m_Entries = new Queue(); long m_LastSeq; string m_Session = Guid.NewGuid().ToString("N"); struct StoredEntry { public int Severity; public ConsoleLogEntry Entry; } /// /// Identifies the capture session that values belong to. /// Sequence numbers restart from zero with the process, so a cursor only names the same /// entries within one session. In the Editor this is adopted from SessionState, which has /// exactly the lifetime of one editor launch; in a player the constructor's value stands. /// public string Session { get { lock (m_Lock) { return m_Session; } } } /// Adopt an externally owned session identifier. See the Editor bootstrap. /// The identifier to report as . public void SetSession(string session) { if (string.IsNullOrEmpty(session)) return; lock (m_Lock) m_Session = session; } /// /// 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. /// /// Unity log type; mapped to a severity via . /// The log message. /// The stack trace, if any. /// UTC time the entry was captured. /// /// True when the entry was read from the Editor console's own store rather than captured /// live, which makes the read time. /// public void Add(LogType type, string message, string stackTrace, DateTime timestampUtc, bool seeded = false) { 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), LogType = type.ToString(), Message = message ?? string.Empty, StackTrace = stackTrace ?? string.Empty, Seeded = seeded } }); } } /// /// Query the buffer for the console command. /// /// /// Cursor: return only entries with Seq > since. Pass a negative value (e.g. -1) /// for a snapshot of the most recent entries with no cursor filtering. /// /// /// Maximum number of entries to return (the most recent matches). Values <= 0 mean "no /// limit" (up to ). /// /// Minimum severity to include (see the Severity* constants). /// /// The that issued . When it names a different /// session the cursor is refused; omit it and the cursor is taken at face value. /// /// The matching entries plus cursor/drop metadata for the console command. public ConsoleLogResponse Query(long since, int tail, int minSeverity, string sinceSession = null) { lock (m_Lock) { // A cursor issued in another session, or one past the counter, cannot be honored: // sequence numbers restart from zero with the process, so such a cursor filters out // every entry held and reads as "nothing new". Ignore it, return the tail, and say so. var reset = since > m_LastSeq || (!string.IsNullOrEmpty(sinceSession) && sinceSession != m_Session); if (reset) since = -1; var counts = new ConsoleLevelCounts(); var matches = new List(); foreach (var stored in m_Entries) { // Counted before the level filter and the tail trim, so the counts describe // what the buffer holds rather than what this call returned. Tally(counts, stored.Severity); 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, Session = m_Session, Returned = matches.Count, Dropped = reset || ComputeDropped(since), Reset = reset, Counts = counts }; } } /// /// The buffer's counters without its entries: retained counts per severity, the cursor, and /// the session that issued it. Backs console_status, which clients poll in a loop, so /// it deliberately avoids materializing the entry list. /// /// A response with an empty . public ConsoleLogResponse Stats() { lock (m_Lock) { var counts = new ConsoleLevelCounts(); foreach (var stored in m_Entries) Tally(counts, stored.Severity); return new ConsoleLogResponse { Entries = Array.Empty(), Cursor = m_LastSeq, Session = m_Session, Counts = counts }; } } /// /// True when refers to entries that have already been evicted, so /// the caller missed some output. Caller must hold . /// 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; } /// Remove all entries. Does NOT reset the sequence counter (cursors stay monotonic). public void Clear() { lock (m_Lock) { m_Entries.Clear(); } } /// Current number of retained entries. For diagnostics/tests. public int Count { get { lock (m_Lock) { return m_Entries.Count; } } } /// The highest sequence number assigned so far (the live cursor). public long LastSeq { get { lock (m_Lock) { return m_LastSeq; } } } /// /// Raise the sequence counter to if it is behind. Never lowers it, so /// a cursor already handed to a client can never be issued again for different entries. /// /// The floor the counter must be at or above. public void RaiseLastSeq(long seq) { lock (m_Lock) { if (seq > m_LastSeq) m_LastSeq = seq; } } #region Persistence [Serializable] class Snapshot { [JsonProperty("lastSeq")] public long LastSeq; [JsonProperty("entries")] public ConsoleLogEntry[] Entries; } /// /// Persist the buffer (entries + sequence counter) to so it survives /// a domain reload. Failures are swallowed (logging is best-effort, never fatal). /// /// File to write the snapshot to. 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}"); } } /// /// Merge a buffer previously written by into this one. Missing or /// unreadable files leave the buffer untouched. Returns true if a snapshot was loaded. /// /// File previously written by . /// True if a snapshot was found and loaded. public bool Load(string path) { try { if (!File.Exists(path)) return false; var snapshot = JsonConvert.DeserializeObject(File.ReadAllText(path)); if (snapshot == null) return false; lock (m_Lock) { // Merge rather than replace. Where [NoAutoStaticsCleanup] keeps this buffer alive // across a domain reload, entries logged since the snapshot are already here, and // a snapshot entry whose seq the counter has already issued is one of those. // Anything with a higher seq was never seen in memory, so it is newer than // everything held and appends in order. if (snapshot.Entries != null) { foreach (var entry in snapshot.Entries) { if (entry == null || entry.Seq <= m_LastSeq) continue; if (m_Entries.Count >= Capacity) m_Entries.Dequeue(); m_Entries.Enqueue(new StoredEntry { Severity = SeverityFromLevelName(entry.Level), Entry = entry }); m_LastSeq = entry.Seq; } } // Keep the counter ahead of the snapshot even when it held no entries, and never // lower it: a cursor already handed out must not be issued a second time. if (snapshot.LastSeq > m_LastSeq) m_LastSeq = snapshot.LastSeq; } return true; } catch (Exception ex) { Debug.LogWarning($"[Pipeline] Failed to restore console log buffer: {ex.Message}"); return false; } } #endregion #region Severity mapping /// Map a Unity to an ordered severity. /// The Unity log type. /// One of the Severity* constants. 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; } } /// /// Parse a "--level" value ("log"/"warn"/"error", plus common aliases) into a minimum /// severity. Unknown values fall back to (everything), matching the /// lenient behavior of the existing log command. /// /// Level name to parse (case-insensitive). /// One of the Severity* constants. 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; } } /// Add one entry of to . /// The tally to add to. /// One of the Severity* constants. static void Tally(ConsoleLevelCounts counts, int severity) { switch (severity) { case SeverityError: counts.Error++; break; case SeverityWarn: counts.Warn++; break; default: counts.Log++; break; } } /// The canonical level name for a severity. /// One of the Severity* constants. /// The matching Level* constant. public static string LevelName(int severity) { switch (severity) { case SeverityError: return LevelError; case SeverityWarn: return LevelWarn; default: return LevelLog; } } #endregion } }