using System; using System.IO; using System.Diagnostics; using System.Collections; using System.Runtime.CompilerServices; using System.Collections.Generic; using System.Collections.Immutable; using System.Reflection; using System.Collections.Concurrent; using System.Linq; using System.Threading; using lib.Net; using System.Text; //using System.Threading.Tasks; #nullable enable /* T O D O : x) Hook the C# prints from glue. x) Fix x) Refactor various logs in order to do automagic structured logging ref: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/interpolated-string-handler D O N E: N O T  D O I N G : */ public record struct Value( T _val, string _exp = "" ) { public static T Default = default!; public static implicit operator T( Value v ) { return v._val; } static public Value Get( U v, [CallerArgumentExpression("v")] string dbgExp = "" ) { return new( v, dbgExp ); } } public struct SourceLoc { readonly string _reason = ""; readonly string _dbgName = ""; readonly string _dbgPath = ""; readonly string _dbgFile = ""; readonly int _dbgLine = -1; public SourceLoc( string reason, string dbgName, string dbgPath, int dbgLine ) { _reason = reason; _dbgName = dbgName; _dbgPath = dbgPath; _dbgLine = dbgLine; _dbgFile = log.whatFile( dbgPath ); } public string ToLogString() => $"{_dbgFile}.{_dbgName}"; public string Log => ToLogString(); static public SourceLoc Record( string reason = "", [CallerMemberName] string dbgName = "", [CallerFilePath] string dbgPath = "", [CallerLineNumber] int dbgLine = 0 ) { return new SourceLoc( reason, dbgName, dbgPath, dbgLine ); } } static public class log { //static #region CLR Logging static log() { log.high( $"Starting tracers" ); /* { var start = new ThreadStart( StartGCWatcher ); var thread = new Thread( start ); thread.Priority = ThreadPriority.BelowNormal; thread.Name = $"Logging"; thread.Start(); } // */ //log.info( $"Tracing.Program.CreateTracingSession" ); //var task = log.call( Tracing.Program.CreateTracingSession( false, true, 64 ) ); /* log.info( $"Tracing.MemoryPipe" ); var memoryPipe = log.call( () => new Tracing.MemoryPipe() ); //var pipeTask = log.call( () => memoryPipe.StartAsync( true ) ); //*/ /* { var start = new ThreadStart( StartTracing ); var thread = new Thread( start ); thread.Priority = ThreadPriority.BelowNormal; thread.Name = $"Logging"; thread.Start(); } // */ } static void StartGCWatcher() { // This wait is no longer needed with BlockingCollection, but kept for this component's logic // It would be better to use a ManualResetEvent here if waiting is truly needed. Thread.Sleep( 500 ); // Simple wait var processId = Process.GetCurrentProcess().Id; LogGC.PrintRuntimeGCEvents( processId ); } static void StartTracing() { // See above comment Thread.Sleep( 500 ); Tracing.TraceLogMonitor.Run(); } #endregion // CLR Logging static public Value Value( T val, [CallerArgumentExpression("val")] string dbgExp = "" ) { return new( val, dbgExp ); } [Flags] public enum LogType { Invalid = 0, Trace = 1, Debug = 2, Info = 3, High = 4, Warn = 5, Error = 6, Fatal = 7, Time = 64, Raw = 65, } [Flags] public enum Endpoints { None = 0, File = 1 << 0, Console = 1 << 1, All = File | Console, } public struct LogEvent { public DateTime Time; public LogType LogType; public string Msg; public string Path; public int Line; public string Member; public string Exp; public string Cat; public object? Obj; static ImmutableDictionary s_shortname = ImmutableDictionary.Empty; public LogEvent( LogType logType, string msg, string path, int line, string member, string cat, string exp, object? obj ) { //Cache the automatic category names // R A R E and S L O W and S A F E if( string.IsNullOrEmpty( cat ) ) { var pathHash = path.GetHashCode(); if( s_shortname.TryGetValue( pathHash, out var autoCat ) ) { cat = autoCat; } else { var pathPieces = path.Split( '\\' ); if( pathPieces.Length < 2 ) { pathPieces = path.Split( '/' ); } var lastDir = pathPieces[pathPieces.Length - 2]; ImmutableInterlocked.AddOrUpdate( ref s_shortname, pathHash, lastDir, ( key, value ) => { return lastDir; } ); cat = lastDir; } } Time = DateTime.Now; LogType = logType; Msg = msg; Path = path; Line = line; Member = member; Cat = cat; Exp = exp; Obj = obj; } } static LogEvent CreateLogEvent( LogType logType, string msg, string cat, object? obj, [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", string exp = "" ) { var logEvent = new LogEvent( logType, msg, path, line, member, cat, exp, obj ); return logEvent; } public delegate void Log_delegate( LogEvent evt ); static ImmutableDictionary s_logEPforCat = ImmutableDictionary.Empty; static public void endpointForCat( string cat, Endpoints ep ) { ImmutableInterlocked.AddOrUpdate( ref s_logEPforCat, cat, ep, ( k, v ) => ep ); } static public void shutdown() { string msg = "==============================================================================\nLogfile shutdown at " + DateTime.Now.ToString(); var evt = CreateLogEvent( LogType.Info, msg, "System", null ); s_events.Add( evt ); // Use Add instead of Enqueue stop(); } // MODIFIED: Replaced ConcurrentQueue with BlockingCollection for a more robust producer-consumer pattern. static readonly BlockingCollection s_events = new BlockingCollection( new ConcurrentQueue() ); static private Thread? s_thread; static string s_cwd = Directory.GetCurrentDirectory(); static int s_cwdLength = s_cwd.Length; static ImmutableDictionary s_files = ImmutableDictionary.Empty; #region Util static public SourceLoc loc( [CallerMemberName] string dbgName = "", [CallerFilePath] string dbgPath = "", [CallerLineNumber] int dbgLine = -1 ) => new SourceLoc( "", dbgName, dbgPath, dbgLine ); static public string whatFile( string path ) { var file = ""; var pathHash = path.GetHashCode(); if( s_files.TryGetValue( pathHash, out var autoCat ) ) { file = autoCat; } else { var pathPieces = path.Split( '\\' ); if( pathPieces.Length < 2 ) { pathPieces = path.Split( '/' ); } var lastDir = pathPieces[pathPieces.Length - 1]; ImmutableInterlocked.AddOrUpdate( ref s_files, pathHash, lastDir, ( key, value ) => { return lastDir; } ); file = lastDir; } return file; } static public string relativePath( string fullPath ) { if( fullPath.Length < s_cwdLength ) return fullPath; var rel = fullPath.Substring( s_cwdLength + 1 ); return rel; } static public string thisFilePath( [CallerFilePath] string path = "" ) { return relativePath( path ); } #endregion // Util #region Forwards static public T call( Func func, [CallerMemberName] string dbgName = "", [CallerFilePath] string dbgPath = "", [CallerLineNumber] int dbgLine = -1, [CallerArgumentExpression( "func" )] string dbgExp = "" ) { log.info( $"Fn {dbgExp}", "", null, dbgPath, dbgLine, dbgName, dbgExp ); var val = func(); log.info( $"| Got {val}", "", null, dbgPath, dbgLine, dbgName, dbgExp ); return val; } /* static public void info( string msg, string cat = "", object? obj = null, [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", [CallerArgumentExpression( "msg" )] string dbgExp = "" ) */ static public T var( T val, [CallerMemberName] string dbgName = "", [CallerFilePath] string dbgPath = "", [CallerLineNumber] int dbgLine = -1, [CallerArgumentExpression( "val" )] string dbgExp = "" ) { log.info( $"{dbgExp} = {val}", "", null, dbgPath, dbgLine, dbgName, dbgExp ); return val; } static public void call( Action func, [CallerMemberName] string dbgName = "", [CallerFilePath] string dbgPath = "", [CallerLineNumber] int dbgLine = -1, [CallerArgumentExpression( "func" )] string dbgExp = "" ) { log.info( $"{dbgExp}", "", null, dbgPath, dbgLine, dbgName, dbgExp ); func(); log.info( $"| Done", "", null, dbgPath, dbgLine, dbgName, dbgExp ); } static public void fatal( string msg, string cat = "", object? obj = null, [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", [CallerArgumentExpression( "msg" )] string dbgExp = "" ) { logBase( msg, LogType.Fatal, path, line, member, cat, dbgExp, obj ); } [StackTraceHidden] static public void error( string msg, string cat = "", object? obj = null, [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", [CallerArgumentExpression( "msg" )] string dbgExp = "" ) { logBase( msg, LogType.Error, path, line, member, cat, dbgExp, obj ); } [StackTraceHidden] static public void warn( string msg, string cat = "", object? obj = null, [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", [CallerArgumentExpression( "msg" )] string dbgExp = "" ) { logBase( msg, LogType.Warn, path, line, member, cat, dbgExp, obj ); } static public void high( string msg, string cat = "", object? obj = null, [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", [CallerArgumentExpression( "msg" )] string dbgExp = "" ) { logBase( msg, LogType.High, path, line, member, cat, dbgExp, obj ); } static public void info( string msg, string cat = "", object? obj = null, [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", [CallerArgumentExpression( "msg" )] string dbgExp = "" ) { logBase( msg, LogType.Info, path, line, member, cat, dbgExp, obj ); } static public void debug( string msg, string cat = "", object? obj = null, [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", [CallerArgumentExpression( "msg" )] string dbgExp = "" ) { logBase( msg, LogType.Debug, path, line, member, cat, dbgExp, obj ); } static public void trace( string msg, string cat = "", object? obj = null, [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", [CallerArgumentExpression( "msg" )] string dbgExp = "" ) { logBase( msg, LogType.Trace, path, line, member, cat, dbgExp, obj ); } #endregion #region Helpers static public void logProps( object obj, string header, LogType type = LogType.Debug, string cat = "", string prefix = "", [CallerFilePath] string path = "", [CallerLineNumber] int line = -1, [CallerMemberName] string member = "", [CallerArgumentExpression( "obj" )] string dbgExpObj = "" ) { var list = refl.GetAllProperties( obj.GetType() ); lock( s_lock ) { var evt = new LogEvent( type, header, path, line, member, cat, dbgExpObj, obj ); { // Use Add instead of Enqueue s_events.Add( evt ); foreach( var pi in list ) { try { var v = pi.GetValue( obj ); logBase( $"{prefix}{pi.Name} = {v}", type, path, line, member, dbgExpObj, cat ); } catch( Exception ex ) { logBase( $"Exception processing {pi.Name} {ex.Message}", LogType.Error, path, line, member, cat, dbgExpObj, obj ); } } } } } //This might seem a little odd, but the intent is that usually you wont need to set notExpectedValue. static public void expected( T value, string falseString, string trueString = "", T? notExpectedValue = default( T ) ) { if( !object.Equals( value, notExpectedValue ) ) { log.debug( $"Properly got {value}{trueString}" ); } else { log.warn( $"Got {notExpectedValue} instead of {value}{falseString}" ); } } #endregion static object s_lock = new object(); static object s_lockTypeCallback = new object(); static ImmutableDictionary> s_callbacks = ImmutableDictionary>.Empty; [StackTraceHidden] static public void addDirectCallback( LogType logType, Action callback ) { //ImmutableInterlocked.Add( ref s_callbacks, logType, callback ); var added = II.TryAdd( ref s_callbacks, logType, callback ); if( !added ) { log.warn( $"Failed to add callback for {logType}" ); } } static public LogEvent logCreateEvent( string msg, LogType type = LogType.Debug, string path = "", int line = -1, string member = "", string cat = "unk", string exp = "", object? obj = null ) { LogEvent evt = new LogEvent( type, msg, path, line, member, cat, exp, obj ); return evt; } [StackTraceHidden] static public void logBase( string msg, LogType type = LogType.Debug, string path = "", int line = -1, string member = "", string cat = "unk", string exp = "", object? obj = null ) { var evt = logCreateEvent( msg, type, path, line, member, cat, exp ); s_callbacks.TryGetValue( type, out var callback ); if( callback != null ) { lock( s_lockTypeCallback ) { callback( evt ); } } else { // MODIFIED: Use Add instead of Enqueue for the BlockingCollection. s_events.Add( evt ); } } static Endpoints s_endpoints = Endpoints.Console; static int s_catWidth = 14; static public bool IsLogging => s_thread != null && s_thread.IsAlive; static public void startup( string filename, Endpoints endpoints ) { if( string.IsNullOrWhiteSpace( filename ) ) { return; } lock( s_lock ) { //We're already running if the thread is alive if( s_thread != null && s_thread.IsAlive ) { log.info( $"Already running, so this is a NOP" ); return; } s_startTime = DateTime.Now; s_cwd = Directory.GetCurrentDirectory(); s_cwdLength = s_cwd.Length; s_endpoints = endpoints; var dir = Path.GetDirectoryName( filename ); if( dir?.Length > 0 ) { Directory.CreateDirectory( dir ); } s_stream = new FileStream( filename, FileMode.Append, FileAccess.Write ); s_writer = new StreamWriter( s_stream, Encoding.UTF8, 128, true ); //s_errorStream = new FileStream( filename + ".error", FileMode.Append, FileAccess.Write ); //s_errorWriter = new StreamWriter( s_errorStream ); { var time = DateTime.Now; // Header for this run var blankLine = new LogEvent( LogType.Raw, $"", "", 0, "", "lib.time", "", null ); var beginLine = new LogEvent( LogType.Raw, $"Begin B E G I N ******************************************************************************************************************", "", 0, "", "lib.time", "", null ); var timeLine = new LogEvent( LogType.Raw, $"D A T E {time.Year}/{time.Month.ToString( "00" )}/{time.Day.ToString( "00" )} T I M E {time.Hour.ToString( "00" )}:{time.Minute.ToString( "00" )}:{time.Second.ToString( "00" )}.{time.Millisecond.ToString( "000" )}{time.Microsecond.ToString( "000" )}", "", 0, "", "lib.time", "", null ); // MODIFIED: All writes are now safely enqueued to be processed by the logger thread. // This prevents the StreamWriter buffer corruption that caused null bytes. s_events.Add( blankLine ); s_events.Add( blankLine ); s_events.Add( beginLine ); s_events.Add( blankLine ); s_events.Add( timeLine ); s_events.Add( blankLine ); } LogEvent msgStartupBegin = new LogEvent( LogType.Info, $"startup BEGIN", "", 0, "", "log.startup", "", null ); s_events.Add( msgStartupBegin ); LogEvent msgFilename = new LogEvent( LogType.Info, $"Logging in {filename}", "", 0, "", "log.startup", "", null ); s_events.Add( msgFilename ); var optionsLine = new LogEvent( LogType.Info, $"Endpoints: {endpoints}", "", 0, "", "log.startup", "", null ); s_events.Add( optionsLine ); StartThread(); LogGC.RegisterObjectId( s_lock ); info( $"startup END", cat: "log.startup" ); } } private static void StartThread() { var start = new ThreadStart( threadLoop ); s_thread = new Thread( start ); s_thread.Priority = ThreadPriority.BelowNormal; s_thread.Name = $"Logging"; s_thread.IsBackground = true; // Mark as background thread s_thread.Start(); } // REMOVED: ThreadState enum, StopThread, pauseThread, and unpauseThread methods. // They are replaced by the simpler, safer lifecycle management of BlockingCollection. // MODIFIED: Complete rewrite of the thread loop. // This is now an efficient, blocking loop that consumes zero CPU while waiting for messages. // It is also free of race conditions. static void threadLoop() { Console.WriteLine( $"**********************************************************\n" ); Console.WriteLine( $"Logger thread started" ); try { // This loop will block when the collection is empty and will // automatically finish when s_events.CompleteAdding() is called from another thread. foreach( var evt in s_events.GetConsumingEnumerable() ) { writeToAll( evt ); } } catch( Exception ex ) { Console.WriteLine( $"[CRITICAL] Logger thread crashed: {ex}" ); } Console.WriteLine( $"Logger thread has finished processing and is shutting down." ); } public static void stop() { log.info( "Logger shutdown requested.", "log.system" ); // MODIFIED: This safely signals the consumer thread to finish. s_events.CompleteAdding(); while( s_events.Count > 0 ) { // Wait for the queue to be fully processed. Thread.Sleep( 10 ); } // Wait for the thread to finish processing all remaining messages in the queue. s_thread?.Join( 1000 ); s_thread = null; s_writer?.Close(); s_stream?.Close(); //s_errorWriter?.Close(); //s_errorStream?.Close(); } static public void addDelegate( Log_delegate cb ) { s_delegates.Add( cb ); } public static char getSymbol( LogType type ) { switch( type ) { case LogType.Trace: return ' '; case LogType.Debug: return ' '; case LogType.Info: return ' '; case LogType.High: return '+'; case LogType.Warn: return '+'; case LogType.Error: return '*'; case LogType.Fatal: return '*'; default: return '?'; } } private static void setConsoleColor( log.LogEvent evt ) { switch( evt.LogType ) { case log.LogType.Trace: Console.ForegroundColor = ConsoleColor.DarkGray; break; case log.LogType.Debug: Console.ForegroundColor = ConsoleColor.Gray; break; case log.LogType.Info: Console.ForegroundColor = ConsoleColor.DarkGreen; break; case log.LogType.High: Console.ForegroundColor = ConsoleColor.Cyan; break; case log.LogType.Warn: Console.ForegroundColor = ConsoleColor.Yellow; break; case log.LogType.Error: Console.ForegroundColor = ConsoleColor.DarkRed; Console.BackgroundColor = ConsoleColor.DarkGray; break; case log.LogType.Fatal: Console.ForegroundColor = ConsoleColor.Red; Console.BackgroundColor = ConsoleColor.DarkGray; break; case log.LogType.Invalid: case log.LogType.Time: case log.LogType.Raw: Console.ForegroundColor = ConsoleColor.Red; break; } } static private DateTime s_startTime = DateTime.MinValue; static private int s_lastDisplaySeconds = -1; static private int s_lastSecond = -1; static private string s_timeHeader = " "; static public string headerPrint( LogEvent evt ) { if( evt.LogType != LogType.Raw ) { var span = evt.Time - s_startTime; char sym = getSymbol( evt.LogType ); var truncatedCat = evt.Cat.Substring( 0, Math.Min( s_catWidth, evt.Cat.Length ) ); var timeHdr = $"{s_timeHeader}{( (int)span.TotalMinutes ).ToString( "000" )}:{span.Seconds.ToString( "D2" )}.{span.Milliseconds.ToString( "000" )}"; var msgHdr = string.Format( $"{timeHdr} | {{0,-{s_catWidth}}}{{1}}| ", truncatedCat, sym ); return msgHdr; } else { return ""; } } static public string headerFile( LogEvent evt ) { if( evt.LogType != LogType.Raw ) { try { var span = evt.Time - s_startTime; char sym = getSymbol( evt.LogType ); var truncatedCat = evt.Cat.Substring( 0, Math.Min( s_catWidth, evt.Cat.Length ) ); if( string.IsNullOrWhiteSpace( truncatedCat ) ) truncatedCat = $"B R O K E N truncatedCat"; //Dont really need the year-month-day frankly. //var timeHdr = $"{evt.Time.Year}-{evt.Time.Month.ToString("00")}-{evt.Time.Day.ToString("00")} {evt.Time.Hour.ToString("00")}:{evt.Time.Minute.ToString("00")}:{evt.Time.Second.ToString("00")}.{evt.Time.Millisecond.ToString("000")}{evt.Time.Microsecond.ToString("000")}"; var timeHdr = $"{evt.Time.Hour.ToString( "00" )}:{evt.Time.Minute.ToString( "00" )}:{evt.Time.Second.ToString( "00" )}.{evt.Time.Millisecond.ToString( "000" )}{evt.Time.Microsecond.ToString( "000" )}"; if( string.IsNullOrWhiteSpace( timeHdr ) ) timeHdr = $"B R O K E N timeHdr"; var msgHdr = string.Format( $"{timeHdr} | {{0,-{s_catWidth}}}{{1}}| ", truncatedCat, sym ); if( string.IsNullOrWhiteSpace( msgHdr ) ) msgHdr = $"B R O K E N msgHdr"; return msgHdr; } catch( Exception ex ) { return $"Ex {ex.Message} processing msg"; } } else { // Empty for RAW return ""; } } static public string msgPrint( LogEvent evt ) { var msgHdr = headerPrint( evt ); string finalLine = $"{msgHdr}{evt.Msg}"; return finalLine; } static public string msgFile( LogEvent evt ) { var msgHdr = headerFile( evt ); var msg = evt.Msg; if( ( string.IsNullOrWhiteSpace( msg ) && evt.LogType != LogType.Raw ) || msg.Contains( (char)0 ) ) { msg = "B R O K E N msg"; } string finalLine = $"{msgHdr}{msg}"; return finalLine; } static private void writeSpecialEvent( LogEvent evt ) { if( ( s_endpoints & Endpoints.Console ) == Endpoints.Console ) { //setConsoleColor( evt ); //Console.WriteLine( $"{evt.Time.ToShortTimeString()}" ); //Console.ResetColor(); } foreach( Log_delegate cb in s_delegates ) { cb( evt ); } } static private void writeToAll( LogEvent evt ) { try { #region Time var span = evt.Time - s_startTime; var curSeconds = (int)span.TotalSeconds; if( curSeconds - s_lastDisplaySeconds > 10 ) { s_lastDisplaySeconds = curSeconds; var minuteEvt = new LogEvent( LogType.Raw, $"T I M E ==> {evt.Time.Hour.ToString( "00" )}:{evt.Time.Minute.ToString( "00" )}:{evt.Time.Second.ToString( "00" )}.{evt.Time.Millisecond.ToString( "000" )} : {evt.Time.ToShortDateString()}", "", 0, "", "lib.time", "", null ); minuteEvt.Time = evt.Time; writeSpecialEvent( minuteEvt ); } if( evt.LogType != LogType.Raw ) { var curSecond = (int)span.TotalSeconds; if( s_lastSecond == curSecond ) { s_timeHeader = " "; } else { s_timeHeader = "*"; s_lastSecond = curSecond; } } #endregion #region Write var finalEndpoints = s_logEPforCat.TryGetValue( evt.Cat, out var ep ) ? ep & s_endpoints : s_endpoints; //Console.WriteLine( $"Final endpoints for {evt.Cat} = {finalEndpoints}" ); if( ( finalEndpoints & Endpoints.File ) == Endpoints.File ) { var line = msgFile( evt ); if( !string.IsNullOrWhiteSpace( line ) ) { var trimmedLine = line.Trim( (char)0 ); s_writer?.WriteLine( trimmedLine ); } } if( ( finalEndpoints & Endpoints.Console ) == Endpoints.Console ) { var line = msgPrint( evt ); if( !string.IsNullOrWhiteSpace( line ) ) { setConsoleColor( evt ); Console.WriteLine( line ); Console.ResetColor(); } } //Debug.WriteLine( finalLine ); s_writer?.Flush(); foreach( Log_delegate cb in s_delegates ) { cb( evt ); } #endregion } catch( Exception ex ) { #region Catch Console.WriteLine( "EXCEPTION DURING LOGGING" ); Console.WriteLine( "EXCEPTION DURING LOGGING" ); Console.WriteLine( "EXCEPTION DURING LOGGING" ); Console.WriteLine( "EXCEPTION DURING LOGGING" ); Console.WriteLine( "EXCEPTION DURING LOGGING" ); Console.WriteLine( $"Exception {ex}" ); Debug.WriteLine( "EXCEPTION DURING LOGGING" ); Debug.WriteLine( "EXCEPTION DURING LOGGING" ); Debug.WriteLine( "EXCEPTION DURING LOGGING" ); Debug.WriteLine( "EXCEPTION DURING LOGGING" ); Debug.WriteLine( "EXCEPTION DURING LOGGING" ); Debug.WriteLine( $"Exception {ex}" ); #endregion } } public static void WriteToConsole( LogEvent evt ) { char sym = getSymbol( evt.LogType ); var truncatedCat = evt.Cat.Substring( 0, Math.Min( 8, evt.Cat.Length ) ); string finalLine = string.Format( "{0,-8}{1}| {2}", truncatedCat, sym, evt.Msg ); Console.WriteLine( finalLine ); } private static Stream? s_stream; private static StreamWriter? s_writer; //private static Stream? s_errorStream; //private static StreamWriter? s_errorWriter; private static ArrayList s_delegates = new ArrayList(); }