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.
///
public 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;
struct StoredEntry
{
public int Severity;
public ConsoleLogEntry Entry;
}
///
/// 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.
///
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
}
});
}
}
///
/// 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).
public ConsoleLogResponse Query(long since, int tail, int minSeverity)
{
lock (m_Lock)
{
var matches = new List();
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)
};
}
}
///
/// 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; } }
}
#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).
///
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}");
}
}
///
/// Restore a buffer previously written by . Missing or unreadable files
/// leave the buffer empty. Returns true if a snapshot was 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)
{
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
/// Map a Unity to an ordered severity.
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.
///
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;
}
}
/// The canonical level name for a severity.
public static string LevelName(int severity)
{
switch (severity)
{
case SeverityError:
return LevelError;
case SeverityWarn:
return LevelWarn;
default:
return LevelLog;
}
}
#endregion
}
}