File: common\Tracing\RollingFileTraceListener.cs
Web Access
Project: src\src\vstest\src\Microsoft.TestPlatform.PlatformAbstractions\Microsoft.TestPlatform.PlatformAbstractions.csproj (Microsoft.TestPlatform.PlatformAbstractions)
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Diagnostics;
using System.IO;
using System.Text;

using Microsoft.TestPlatform.PlatformAbstractions;

namespace Microsoft.VisualStudio.TestPlatform.ObjectModel;

/// <summary>
/// Performs logging to a file and rolls the output file when either time or size thresholds are
/// exceeded.
/// </summary>
public class RollingFileTraceListener : TextWriterTraceListener
{
    private readonly int _rollSizeInBytes;
    private bool _isDisposed;

    /// <summary>
    /// Initializes a new instance of the <see cref="RollingFileTraceListener"/> class.
    /// </summary>
    /// <param name="fileName">The filename where the entries will be logged.</param>
    /// <param name="name">Name of the trace listener.</param>
    /// <param name="rollSizeKB">The maximum file size (KB) before rolling.</param>
    public RollingFileTraceListener(
        string fileName,
        string name,
        int rollSizeKB)
        : base(OpenTextWriter(fileName), name)
    {
        if (fileName.IsNullOrWhiteSpace())
        {
            throw new ArgumentException("fileName was null or whitespace", nameof(fileName));
        }

        if (rollSizeKB <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(rollSizeKB));
        }

        TraceFileName = fileName;
        _rollSizeInBytes = rollSizeKB * 1024;
        RollingHelper = new StreamWriterRollingHelper(this);
    }

    /// <summary>
    /// Gets name of the Trace file.
    /// </summary>
    internal string TraceFileName
    {
        get;
        private set;
    }

    /// <summary>
    /// Gets the <see cref="StreamWriterRollingHelper"/> for the flat file.
    /// </summary>
    /// <value>
    /// The <see cref="StreamWriterRollingHelper"/> for the flat file.
    /// </value>
    internal StreamWriterRollingHelper RollingHelper { get; }

    /// <summary>
    /// Writes the trace messages to the file.
    /// </summary>
    /// <param name="message">Trace message string</param>
    public override void WriteLine(string? message)
    {
        RollingHelper.RollIfNecessary();
        base.WriteLine(message);
    }

    /// <summary>
    /// Opens specified file and returns text writer.
    /// </summary>
    /// <param name="fileName">The file to open.</param>
    /// <returns>A <see cref="TallyKeepingFileStreamWriter"/> instance.</returns>
    internal static TallyKeepingFileStreamWriter OpenTextWriter(string fileName)
    {
        return new TallyKeepingFileStreamWriter(
                                File.Open(fileName, FileMode.Append, FileAccess.Write, FileShare.ReadWrite),
                                GetEncodingWithFallback());
    }

    /// <inheritdoc/>
    protected override void Dispose(bool disposing)
    {
        if (_isDisposed)
            return;

        if (disposing)
        {
            RollingHelper.Dispose();
        }

        base.Dispose(disposing);

        _isDisposed = true;
    }

    private static Encoding GetEncodingWithFallback()
    {
        Encoding encoding = (Encoding)new UTF8Encoding(false).Clone();
#if TODO
            encoding.EncoderFallback = EncoderFallback.ReplacementFallback;
            encoding.DecoderFallback = DecoderFallback.ReplacementFallback;
#endif
        return encoding;
    }

    /// <summary>
    /// Encapsulates the logic to perform rolls.
    /// </summary>
    /// <remarks>
    /// If no rolling behavior has been configured no further processing will be performed.
    /// </remarks>
    internal sealed class StreamWriterRollingHelper : IDisposable
    {
        /// <summary>
        /// Synchronization lock.
        /// </summary>
        private readonly object _synclock = new();

        /// <summary>
        /// Whether the object is disposed or not.
        /// </summary>
        private bool _disposed;

        /// <summary>
        /// A tally keeping writer used when file size rolling is configured.<para/>
        /// The original stream writer from the base trace listener will be replaced with
        /// this listener.
        /// </summary>
        private TallyKeepingFileStreamWriter? _managedWriter;
        private bool _lastRollFailed;
        private readonly Stopwatch _sinceLastRoll = Stopwatch.StartNew();
        private readonly long _failedRollTimeout = TimeSpan.FromMinutes(1).Ticks;

        /// <summary>
        /// The trace listener for which rolling is being managed.
        /// </summary>
        private readonly RollingFileTraceListener _owner;

        /// <summary>
        /// Initializes a new instance of the <see cref="StreamWriterRollingHelper"/> class.
        /// </summary>
        /// <param name="owner">
        /// The <see cref="RollingFileTraceListener"/> to use.
        /// </param>
        public StreamWriterRollingHelper(RollingFileTraceListener owner)
        {
            _owner = owner;
            _managedWriter = owner.Writer as TallyKeepingFileStreamWriter;
        }

        /// <summary>
        /// Checks whether rolling should be performed, and returns the date to use when performing the roll.
        /// </summary>
        /// <returns>The date roll to use if performing a roll, or <see langword="null"/> if no rolling should occur.</returns>
        /// <remarks>
        /// Defer request for the roll date until it is necessary to avoid overhead.<para/>
        /// Information used for rolling checks should be set by now.
        /// </remarks>
        public DateTime? CheckIsRollNecessary()
        {
            // check for size roll, if enabled.
            if (_managedWriter != null && _managedWriter.Tally > _owner._rollSizeInBytes)
            {
                return DateTime.Now;
            }

            // no roll is necessary, return a null roll date
            return null;
        }

        /// <summary>
        /// Perform the roll for the next date.
        /// </summary>
        /// <param name="rollDateTime">The roll date.</param>
        public void PerformRoll(DateTime rollDateTime)
        {
            string actualFileName = ((FileStream)((StreamWriter)_owner.Writer!).BaseStream).Name;

            // calculate archive name
            string directory = Path.GetDirectoryName(actualFileName)!;
            string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(actualFileName);
            string extension = Path.GetExtension(actualFileName);

            StringBuilder fileNameBuilder = new(fileNameWithoutExtension);
            fileNameBuilder.Append('.');
            fileNameBuilder.Append("bak");
            fileNameBuilder.Append(extension);

            string archiveFileName = Path.Combine(directory, fileNameBuilder.ToString());
#if TODO

// close file
                owner.Writer.Close();
#endif

            // move file
            SafeMove(actualFileName, archiveFileName, rollDateTime);

            // update writer - let TWTL open the file as needed to keep consistency
            _owner.Writer = null;
            _managedWriter = null;
            UpdateRollingInformationIfNecessary();
        }

        /// <summary>
        /// Rolls the file if necessary.
        /// </summary>
        public void RollIfNecessary()
        {
            if (!UpdateRollingInformationIfNecessary())
            {
                // an error was detected while handling roll information - avoid further processing
                return;
            }

            DateTime? rollDateTime;
            if ((rollDateTime = CheckIsRollNecessary()) != null)
            {
                lock (_synclock)
                {
                    // double check if the roll is still required and do it...
                    if ((rollDateTime = CheckIsRollNecessary()) != null)
                    {
                        PerformRoll(rollDateTime.Value);
                    }
                }
            }
        }

        /// <summary>
        /// Updates book keeping information necessary for rolling, as required by the specified
        /// rolling configuration.
        /// </summary>
        /// <returns>true if update was successful, false if an error occurred.</returns>
        public bool UpdateRollingInformationIfNecessary()
        {
            // if we fail to move the file, don't retry to move it for a long time
            // it is most likely locked, and we end up trying to move it twice
            // on each message write.
            if (_lastRollFailed && _sinceLastRoll.ElapsedTicks < _failedRollTimeout)
            {
                return false;
            }

            // replace writer with the tally keeping version if necessary for size rolling
            if (_managedWriter == null)
            {
                try
                {
                    _managedWriter = OpenTextWriter(_owner.TraceFileName);
                }
                catch (Exception)
                {
                    // there's a slight chance of error here - abort if this occurs and just let TWTL handle it without attempting to roll
                    return false;
                }

                _owner.Writer = _managedWriter;
            }

            return true;
        }

        /// <summary>
        /// Disposes this instance
        /// </summary>
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        private void SafeMove(string actualFileName, string archiveFileName, DateTime currentDateTime)
        {
            try
            {
                if (File.Exists(archiveFileName))
                {
                    File.Delete(archiveFileName);
                }

                // take care of tunneling issues http://support.microsoft.com/kb/172190
                File.SetCreationTime(actualFileName, currentDateTime);
                File.Move(actualFileName, archiveFileName);

                _lastRollFailed = false;
                _sinceLastRoll.Restart();
            }
            catch (IOException)
            {
                _lastRollFailed = true;

                // catch errors and attempt move to a new file with a GUID
                archiveFileName += Guid.NewGuid().ToString();

                try
                {
                    File.Move(actualFileName, archiveFileName);

                    _lastRollFailed = false;
                    _sinceLastRoll.Restart();
                }
                catch (IOException)
                {
                    _lastRollFailed = true;
                }
            }
        }

        private void Dispose(bool disposing)
        {
            if (_disposed)
            {
                return;
            }

            if (disposing && _managedWriter != null)
            {
#if TODO
                     managedWriter.Close();
#endif
            }

            _disposed = true;
        }
    }

    /// <summary>
    /// Represents a file stream writer that keeps a tally of the length of the file.
    /// </summary>
    internal sealed class TallyKeepingFileStreamWriter : StreamWriter
    {

        /// <summary>
        /// Initializes a new instance of the <see cref="TallyKeepingFileStreamWriter"/> class.
        /// </summary>
        /// <param name="stream">
        /// The <see cref="FileStream"/> to write to.
        /// </param>
        public TallyKeepingFileStreamWriter(FileStream stream)
            : base(stream)
        {
            Tally = stream.Length;
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="TallyKeepingFileStreamWriter"/> class.
        /// </summary>
        /// <param name="stream">
        /// The <see cref="FileStream"/> to write to.
        /// </param>
        /// <param name="encoding">
        /// The <see cref="Encoding"/> to use.
        /// </param>
        public TallyKeepingFileStreamWriter(FileStream stream, Encoding encoding)
            : base(stream, encoding)
        {
            Tally = stream.Length;
        }

        /// <summary>
        /// Gets the tally of the length of the string.
        /// </summary>
        /// <value>
        /// The tally of the length of the string.
        /// </value>
        public long Tally { get; private set; }

        /// <summary>
        /// Writes a character to the stream.
        /// </summary>
        /// <param name="value">
        /// The character to write to the text stream.
        /// </param>
        /// <exception cref="T:System.ObjectDisposedException">
        /// <see cref="P:System.IO.StreamWriter.AutoFlush"></see>is true or the<see cref="T:System.IO.StreamWriter"></see>buffer is full, and current writer is closed.
        /// </exception>
        /// <exception cref="T:System.NotSupportedException">
        /// <see cref="P:System.IO.StreamWriter.AutoFlush"></see>is true or the<see cref="T:System.IO.StreamWriter"></see>buffer is full, and the contents of the buffer cannot be written to the underlying fixed size stream because the<see cref="T:System.IO.StreamWriter"></see>is at the end the stream.
        /// </exception>
        /// <exception cref="T:System.IO.IOException">
        /// An I/O error occurs.
        /// </exception>
        /// <filterpriority>1</filterpriority>
        public override void Write(char value)
        {
            base.Write(value);
            Tally += Encoding.GetByteCount(new[] { value });
        }

        /// <summary>
        /// Writes a character array to the stream.
        /// </summary>
        /// <param name="buffer">
        /// A character array containing the data to write. If buffer is null, nothing is written.
        /// </param>
        /// <exception cref="T:System.ObjectDisposedException">
        /// <see cref="P:System.IO.StreamWriter.AutoFlush"></see>is true or the<see cref="T:System.IO.StreamWriter"></see>buffer is full, and current writer is closed.
        /// </exception>
        /// <exception cref="T:System.NotSupportedException">
        /// <see cref="P:System.IO.StreamWriter.AutoFlush"></see>is true or the<see cref="T:System.IO.StreamWriter"></see>buffer is full, and the contents of the buffer cannot be written to the underlying fixed size stream because the<see cref="T:System.IO.StreamWriter"></see>is at the end the stream.
        /// </exception>
        /// <exception cref="T:System.IO.IOException">
        /// An I/O error occurs.
        /// </exception>
        /// <filterpriority>1</filterpriority>
        public override void Write(char[]? buffer)
        {
            base.Write(buffer);
            Tally += (buffer is not null) ? Encoding.GetByteCount(buffer) : 0;
        }

        /// <summary>
        /// Writes an array of characters to the stream.
        /// </summary>
        /// <param name="buffer">
        /// A character array containing the data to write.
        /// </param>
        /// <param name="index">
        /// The index into buffer at which to begin writing.
        /// </param>
        /// <param name="count">
        /// The number of characters to read from buffer.
        /// </param>
        /// <exception cref="T:System.IO.IOException">
        /// An I/O error occurs.
        /// </exception>
        /// <exception cref="T:System.ObjectDisposedException">
        /// <see cref="P:System.IO.StreamWriter.AutoFlush"></see>is true or the<see cref="T:System.IO.StreamWriter"></see>buffer is full, and current writer is closed.
        /// </exception>
        /// <exception cref="T:System.NotSupportedException">
        /// <see cref="P:System.IO.StreamWriter.AutoFlush"></see>is true or the<see cref="T:System.IO.StreamWriter"></see>buffer is full, and the contents of the buffer cannot be written to the underlying fixed size stream because the<see cref="T:System.IO.StreamWriter"></see>is at the end the stream.
        /// </exception>
        /// <exception cref="T:System.ArgumentOutOfRangeException">
        /// index or count is negative.
        /// </exception>
        /// <exception cref="T:System.ArgumentException">
        /// The buffer length minus index is less than count.
        /// </exception>
        /// <exception cref="T:System.ArgumentNullException">
        /// buffer is null.
        /// </exception>
        /// <filterpriority>1</filterpriority>
        public override void Write(char[] buffer, int index, int count)
        {
            base.Write(buffer, index, count);
            Tally += Encoding.GetByteCount(buffer, index, count);
        }

        /// <summary>
        /// Writes a string to the stream.
        /// </summary>
        /// <param name="value">
        /// The string to write to the stream. If value is null, nothing is written.
        /// </param>
        /// <exception cref="T:System.ObjectDisposedException">
        /// <see cref="P:System.IO.StreamWriter.AutoFlush"></see>is true or the<see cref="T:System.IO.StreamWriter"></see>buffer is full, and current writer is closed.
        /// </exception>
        /// <exception cref="T:System.NotSupportedException">
        /// <see cref="P:System.IO.StreamWriter.AutoFlush"></see>is true or the<see cref="T:System.IO.StreamWriter"></see>buffer is full, and the contents of the buffer cannot be written to the underlying fixed size stream because the<see cref="T:System.IO.StreamWriter"></see>is at the end the stream.
        /// </exception>
        /// <exception cref="T:System.IO.IOException">
        /// An I/O error occurs.
        /// </exception>
        /// <filterpriority>1</filterpriority>
        public override void Write(string? value)
        {
            base.Write(value);
            Tally += (value is not null) ? Encoding.GetByteCount(value) : 0;
        }
    }
}