using System; using System.Collections.Generic; using System.Reflection; using Unity.Pipeline.Console; using Unity.Pipeline.Models; using UnityEditor; using UnityEngine; namespace Unity.Pipeline.Editor.Console { /// /// Samples what the Editor knows about the console — the compile-failure flag and the console's /// own entry counts — and publishes it for the console and recompile_status /// commands. Those commands are deliberately not main-thread commands, because clients poll /// them while the Editor is blocked compiling or importing, so they cannot read this state /// themselves; sampling it here and handing over an immutable snapshot is what lets them report /// it at all. /// /// The same pass does two more jobs that need the same main-thread tick: /// - Backfills the buffer from the console's own store once per session, so sticky compile /// errors logged before capture started are visible (see ). /// - Watches for the console gaining entries while the buffer's cursor stands still, which is /// the only observable symptom of the log-callback subscription having been dropped, and /// re-arms capture when it sees it. /// /// Sampling runs on rather than /// , because that event is raised from /// ConsoleWindow.LogChanged, which returns early when no Console window exists — precisely the /// case this targets. The event is subscribed only to shorten the wait when a window is open. /// [InitializeOnLoad] static class EditorConsoleGroundTruth { const string SeededKey = "pipeline.console.seeded"; const double SampleIntervalSeconds = 0.25; const double ReseedCooldownSeconds = 5.0; static double s_NextSampleTime; static double s_NextReseedTime; static int s_LastConsoleTotal = -1; static long s_LastBufferSeq = -1; static bool s_GapPending; static long s_GapSeq; static bool s_Seeded; static EditorConsoleGroundTruth() { s_Seeded = SessionState.GetBool(SeededKey, false); EditorApplication.update -= Sample; EditorApplication.update += Sample; #if UNITY_6000_0_OR_NEWER // consoleLogsChanged is Unity 6+; on older editors sampling via update alone is enough. ConsoleWindowUtility.consoleLogsChanged -= OnConsoleLogsChanged; ConsoleWindowUtility.consoleLogsChanged += OnConsoleLogsChanged; #endif AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } /// /// Console entry counts. Unity 6 exposes ConsoleWindowUtility.GetConsoleLogCounts; /// Unity 2022 falls back to internal LogEntries.GetCountsByType via reflection. /// static void GetConsoleLogCounts(out int errors, out int warnings, out int logs) { #if UNITY_6000_0_OR_NEWER ConsoleWindowUtility.GetConsoleLogCounts(out errors, out warnings, out logs); #else errors = warnings = logs = 0; try { var logEntries = Type.GetType("UnityEditor.LogEntries,UnityEditor"); var method = logEntries?.GetMethod( "GetCountsByType", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); if (method == null) return; object[] args = { 0, 0, 0 }; method.Invoke(null, args); errors = (int)args[0]; warnings = (int)args[1]; logs = (int)args[2]; } catch { errors = warnings = logs = 0; } #endif } static void Sample() { if (EditorApplication.timeSinceStartup < s_NextSampleTime) return; s_NextSampleTime = EditorApplication.timeSinceStartup + SampleIntervalSeconds; // Seed before publishing, so the first snapshot a client sees already reports whether // the backfill happened. if (!s_Seeded) TrySeed(); GetConsoleLogCounts(out var errors, out var warnings, out var logs); var total = errors + warnings + logs; var seq = ConsoleLogCapture.Buffer.LastSeq; // The console gained entries while the buffer's cursor stood still. Unity unregisters // the native log callback on domain unload and on play-mode exit, while the managed // event keeps its subscriber list, so capture can be dead with nothing to test directly. // // A gap is confirmed by the cursor still sitting where it was one sample later, not by // the console count growing again: a dead subscription that missed a single burst — a // failed compile, say — would otherwise be spotted once and then forgotten. Waiting for // the second sample keeps a console entry that legitimately never reaches the managed // callback from re-arming on its own. A Clear lowers the counts, so it can never be // mistaken for a gap. if (s_GapPending && seq == s_GapSeq) { if (EditorApplication.timeSinceStartup >= s_NextReseedTime) { ConsoleLogCapture.EnsureCapturing(); Reseed(); seq = ConsoleLogCapture.Buffer.LastSeq; s_NextReseedTime = EditorApplication.timeSinceStartup + ReseedCooldownSeconds; s_GapPending = false; } } else if (s_LastConsoleTotal >= 0 && total > s_LastConsoleTotal && seq == s_LastBufferSeq) { s_GapSeq = seq; s_GapPending = true; } else { // The buffer caught up on its own. s_GapPending = false; } s_LastConsoleTotal = total; s_LastBufferSeq = seq; ConsoleLogCapture.GroundTruth = new ConsoleGroundTruth { SampledUtc = DateTime.UtcNow, CompilationFailed = EditorUtility.scriptCompilationFailed, Compiling = EditorApplication.isCompiling, ConsoleErrors = errors, ConsoleWarnings = warnings, ConsoleLogs = logs, Seeded = s_Seeded }; } // Only shortens the wait for the next sample; never a correctness dependency, since it is // not raised while the Console window is closed. #if UNITY_6000_0_OR_NEWER static void OnConsoleLogsChanged() => s_NextSampleTime = 0; #endif static void OnAfterAssemblyReload() { ConsoleLogCapture.EnsureCapturing(); // The snapshot describes the domain that just went away, and on 6000.5+ it survives the // reload with the rest of the statics. Drop it so nothing reads it as current until the // next sample replaces it. ConsoleLogCapture.GroundTruth = null; // Let the next sample establish a fresh baseline instead of comparing against counts // and a cursor from before the reload, which would look like a gap. s_LastConsoleTotal = -1; s_LastBufferSeq = -1; s_GapPending = false; s_GapSeq = 0; } static void TrySeed() { if (!Reseed()) return; s_Seeded = true; SessionState.SetBool(SeededKey, true); } /// /// Copy console entries the buffer does not already hold into it. Entries are matched on type /// and message, the only fields both sides record identically. A log type never contains "|", /// so the first one always terminates it and the key is unambiguous. /// /// Matching counts occurrences rather than presence, because the console holds repeated /// messages by design. Treating a key as seen-or-not lets a row the buffer already has cancel /// out an identical row it missed, and that row is then never recovered. /// static bool Reseed() { var entries = new List(); if (!EditorConsoleEntries.TryReadRecent(ConsoleLogBuffer.Capacity, entries)) return false; var held = new Dictionary(); foreach (var entry in ConsoleLogCapture.Buffer.Query(-1, 0, ConsoleLogBuffer.SeverityLog).Entries) { var key = Key(entry.LogType, entry.Message); held.TryGetValue(key, out var count); held[key] = count + 1; } var now = DateTime.UtcNow; foreach (var entry in entries) { var key = Key(entry.Type.ToString(), entry.Message); // Consume one occurrence the buffer already holds; whatever the console has beyond // that is what capture missed. if (held.TryGetValue(key, out var remaining) && remaining > 0) { held[key] = remaining - 1; continue; } ConsoleLogCapture.Buffer.Add(entry.Type, entry.Message, entry.StackTrace, now, seeded: true); } return true; } static string Key(string logType, string message) => logType + "|" + message; /// Sample immediately, ignoring the throttle. For tests. internal static void SampleForTests() { s_NextSampleTime = 0; s_NextReseedTime = 0; Sample(); } /// Forget the baselines and the seeded flag, so the next sample starts over. For tests. internal static void ResetForTests() { s_NextSampleTime = 0; s_NextReseedTime = 0; s_LastConsoleTotal = -1; s_LastBufferSeq = -1; s_GapPending = false; s_GapSeq = 0; s_Seeded = false; SessionState.EraseBool(SeededKey); } } }