diff --git a/EonaCat.Logger/EonaCat.Logger.csproj b/EonaCat.Logger/EonaCat.Logger.csproj index 5a608bd..95f005c 100644 --- a/EonaCat.Logger/EonaCat.Logger.csproj +++ b/EonaCat.Logger/EonaCat.Logger.csproj @@ -13,8 +13,8 @@ EonaCat (Jeroen Saey) EonaCat;Logger;EonaCatLogger;Log;Writer;Jeroen;Saey - 1.7.2 - 1.7.2 + 1.7.3 + 1.7.3 README.md True LICENSE @@ -25,7 +25,7 @@ - 1.7.2+{chash:10}.{c:ymd} + 1.7.3+{chash:10}.{c:ymd} true true v[0-9]* diff --git a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs index 116cc52..9bed913 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs @@ -31,26 +31,43 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable private const int MaxQueueSize = 50_000; private const int MaxCategories = 256; + private const int MaxStringBuilderPoolSize = 32; // Limit pool size + private const int MaxCompressionQueueSize = 100; // Limit compression queue public bool IsEncryptionEnabled => _encryptionKey != null && _encryptionIV != null; - private bool _disposed; + private int _disposed; private int _isFlushing; public static TimeSpan FaultCooldown = TimeSpan.FromSeconds(60); - private readonly ConcurrentQueue _compressionQueue = new(); - private readonly SemaphoreSlim _compressionSemaphore = new(1, 1); - private readonly Task _compressionWorker; + private readonly ConcurrentQueue _compressionQueue = new ConcurrentQueue(); + private readonly SemaphoreSlim _compressionSemaphore = new SemaphoreSlim(1, 1); + private Task _compressionWorker; + private readonly CancellationTokenSource _compressionCts = new CancellationTokenSource(); - private readonly LoggerScopedContext _context = new(); - private readonly ConcurrentDictionary _files = new(); + private readonly LoggerScopedContext _context = new LoggerScopedContext(); + private readonly ConcurrentDictionary _files = new ConcurrentDictionary(); private sealed class MessageQueue { - public readonly ConcurrentQueue Queue = new(); + public readonly ConcurrentQueue Queue = new ConcurrentQueue(); public int Count; + public bool TryEnqueue(LogMessage msg, int maxSize) + { + // Check before incrementing + int currentCount = Volatile.Read(ref Count); + if (currentCount >= maxSize) + { + return false; + } + + Queue.Enqueue(msg); + Interlocked.Increment(ref Count); + return true; + } + public bool TryDequeue(out LogMessage msg) { if (Queue.TryDequeue(out msg)) @@ -59,17 +76,23 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable return true; } - msg = default; + msg = default(LogMessage); return false; } + + public void Clear() + { + while (Queue.TryDequeue(out _)) + { + Interlocked.Decrement(ref Count); + } + } } - - private readonly ConcurrentDictionary _messageQueues = new(); + private readonly ConcurrentDictionary _messageQueues = new ConcurrentDictionary(); private const int BufferSize = 256 * 1024; // 256 KB private static readonly Encoding Utf8 = new UTF8Encoding(false); - private readonly SemaphoreSlim _queueSignal = new(0); public bool IncludeCorrelationId { get; } public bool EnableCategoryRouting { get; } @@ -81,10 +104,14 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable public event EventHandler OnError; public event EventHandler OnRollOver; - private readonly Timer _flushTimer; + private Timer _flushTimer; private readonly TimeSpan _flushInterval = TimeSpan.FromMilliseconds(100); private readonly string _fallbackPath; - private readonly Aes _aes; + private Aes _aes; + + // Bounded StringBuilder pool with size limit + private readonly ConcurrentBag _stringBuilderPool = new ConcurrentBag(); + private int _stringBuilderPoolCount; private sealed class FileState : IDisposable { @@ -92,17 +119,23 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable public long Size; public DateTime Date; - public byte[] Buffer = ArrayPool.Shared.Rent(BufferSize); + public byte[] Buffer; public int BufferPosition; public FileStream Stream; - public SemaphoreSlim WriteLock = new(1, 1); + public SemaphoreSlim WriteLock = new SemaphoreSlim(1, 1); public bool IsFaulted; public DateTime LastFailureUtc; + private int _disposed; public void Dispose() { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) + { + return; // Already disposed + } + try { if (Buffer != null) @@ -111,8 +144,22 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable Buffer = null; } - Stream?.Dispose(); - WriteLock?.Dispose(); + if (Stream != null) + { + try + { + Stream.Flush(); + } + catch { } + Stream.Dispose(); + Stream = null; + } + + if (WriteLock != null) + { + WriteLock.Dispose(); + WriteLock = null; + } } catch { } } @@ -149,7 +196,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable _flushTimer = new Timer(FlushTimerCallback, null, _flushInterval, _flushInterval); - _compressionWorker = Task.Run(CompressionWorkerAsync); + _compressionWorker = Task.Run(() => CompressionWorkerAsync(_compressionCts.Token)); } private string EnsureWritableDirectory(string path) @@ -175,23 +222,48 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable private void FlushTimerCallback(object state) { - if (_disposed) + if (_disposed == 1) { return; } - if (Interlocked.Exchange(ref _isFlushing, 1) == 1) + // Non-blocking check - skip if already flushing + if (Interlocked.CompareExchange(ref _isFlushing, 1, 0) != 0) { return; } - _ = PeriodicFlushAsync().ContinueWith(_ => Interlocked.Exchange(ref _isFlushing, 0)); + // Fire-and-forget with proper error handling + _ = Task.Run(async () => + { + try + { + await PeriodicFlushAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + OnError?.Invoke(this, new ErrorMessage + { + Exception = ex, + Message = "Error during periodic flush" + }); + } + finally + { + Interlocked.Exchange(ref _isFlushing, 0); + } + }); } internal override Task WriteMessagesAsync( - IReadOnlyList messages, - CancellationToken token) + IReadOnlyList messages, + CancellationToken token) { + if (_disposed == 1) + { + return Task.CompletedTask; + } + foreach (var msg in messages) { if (msg.Level < MinimumLogLevel) @@ -203,33 +275,45 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable ? SanitizeCategory(msg.Category) : string.Empty; - var mq = _messageQueues.GetOrAdd(key, _ => new MessageQueue()); - - if (Interlocked.Increment(ref mq.Count) > MaxQueueSize) + // Limit total categories to prevent unbounded growth + if (_messageQueues.Count >= MaxCategories && !_messageQueues.ContainsKey(key)) { - if (mq.Queue.TryDequeue(out _)) - { - Interlocked.Decrement(ref mq.Count); - } + // Use default queue if we've hit category limit + key = string.Empty; } - mq.Queue.Enqueue(msg); + var mq = _messageQueues.GetOrAdd(key, _ => new MessageQueue()); + + // Use atomic enqueue with size check + if (!mq.TryEnqueue(msg, MaxQueueSize)) + { + // Queue full - drop oldest message + if (mq.TryDequeue(out _)) + { + mq.TryEnqueue(msg, MaxQueueSize); + } + } } return Task.CompletedTask; } - - private async Task PeriodicFlushAsync() { - if (_disposed) + if (_disposed == 1) { return; } + var keysToRemove = new List(); + foreach (var kv in _messageQueues) { + if (_disposed == 1) + { + break; + } + var key = kv.Key; var mq = kv.Value; @@ -241,11 +325,8 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable if (!TryRecover(state)) { - while (mq.Queue.TryDequeue(out _)) - { - Interlocked.Decrement(ref mq.Count); - } - + // Drain queue on unrecoverable failure + mq.Clear(); continue; } @@ -254,7 +335,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable continue; } - // Non-blocking lock attempt + // Non-blocking lock attempt with immediate bailout if (!state.WriteLock.Wait(0)) { continue; @@ -262,40 +343,50 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable try { - await FlushMessagesBatchAsync(state, mq) - .ConfigureAwait(false); + await FlushMessagesBatchAsync(state, mq).ConfigureAwait(false); if (state.BufferPosition > 0) { - await FlushBufferAsync(state) - .ConfigureAwait(false); + await FlushBufferAsync(state).ConfigureAwait(false); } + + // Mark for cleanup if empty + if (mq.Count == 0 && !string.IsNullOrEmpty(key)) + { + keysToRemove.Add(key); + } + } + catch (Exception ex) + { + OnError?.Invoke(this, new ErrorMessage + { + Exception = ex, + Message = $"Error flushing messages for category: {key}" + }); } finally { state.WriteLock.Release(); } + } - // Cleanup empty categories - if (mq.Count == 0) + // Cleanup empty categories outside the iteration + foreach (var key in keysToRemove) + { + if (_messageQueues.TryRemove(key, out var removed)) { - if (_messageQueues.TryRemove(key, out var removed)) - { - while (removed.Queue.TryDequeue(out _)) { } - } + removed.Clear(); + } - if (_files.TryRemove(key, out var removedState)) - { - removedState.Dispose(); - } + if (_files.TryRemove(key, out var removedState)) + { + removedState.Dispose(); } } QueueOldFilesForCompression(); } - - private async Task FlushMessagesBatchAsync(FileState state, MessageQueue queue) { const int MaxBatch = 5000; @@ -315,6 +406,11 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable // Rotate if date changed if (state.Date != msgDate) { + if (pos > 0) + { + await WriteBufferedData(state, combined, pos).ConfigureAwait(false); + pos = 0; + } await FlushBufferAsync(state).ConfigureAwait(false); RotateByDate(state, msgDate, string.Empty); } @@ -325,53 +421,29 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable // Grow buffer if needed if (pos + requiredLength > combined.Length) { - int newSize = Math.Max(combined.Length * 2, pos + requiredLength); - byte[] newBuffer = ArrayPool.Shared.Rent(newSize); - Array.Copy(combined, 0, newBuffer, 0, pos); - ArrayPool.Shared.Return(combined, clearArray: true); - combined = newBuffer; + // Write what we have first + if (pos > 0) + { + await WriteBufferedData(state, combined, pos).ConfigureAwait(false); + pos = 0; + } + + // If single message is huge, rent bigger buffer + if (requiredLength > combined.Length) + { + ArrayPool.Shared.Return(combined, clearArray: true); + combined = ArrayPool.Shared.Rent(requiredLength + 1024); + } } pos += Utf8.GetBytes(messageString, 0, messageString.Length, combined, pos); batchCount++; } - if (pos == 0) + if (pos > 0) { - return; // nothing to write + await WriteBufferedData(state, combined, pos).ConfigureAwait(false); } - - byte[] dataToWrite; - if (_isEncryptionEnabled) - { - // Encrypt and clear combined buffer immediately - dataToWrite = Encrypt(combined.AsSpan(0, pos).ToArray()); - } - else - { - dataToWrite = combined; - } - - // Flush buffer if needed - if (state.BufferPosition + pos > BufferSize) - { - await FlushBufferAsync(state).ConfigureAwait(false); - } - - // Rollover - if (_maxFileSize > 0 && state.Size + pos > _maxFileSize) - { - await FlushBufferAsync(state).ConfigureAwait(false); - RollOverAndCompressOldest(state, string.Empty); - } - - // Copy to file buffer - Array.Copy(dataToWrite, 0, state.Buffer, state.BufferPosition, pos); - state.BufferPosition += pos; - state.Size += pos; - - // Clear sensitive data - Array.Clear(dataToWrite, 0, dataToWrite.Length); } finally { @@ -379,8 +451,57 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable } } + private async Task WriteBufferedData(FileState state, byte[] data, int length) + { + byte[] dataToWrite = data; + int dataLength = length; + byte[] encryptedData = null; - private async Task FlushBufferAsync(FileState state, CancellationToken token = default) + try + { + if (_isEncryptionEnabled) + { + // Encrypt - this creates a new array + encryptedData = Encrypt(data, length); + dataToWrite = encryptedData; + dataLength = encryptedData.Length; + } + + // Flush buffer if needed + if (state.BufferPosition + dataLength > BufferSize) + { + await FlushBufferAsync(state).ConfigureAwait(false); + } + + // Rollover check + if (_maxFileSize > 0 && state.Size + dataLength > _maxFileSize) + { + await FlushBufferAsync(state).ConfigureAwait(false); + RollOverAndCompressOldest(state, string.Empty); + } + + // Ensure buffer exists + if (state.Buffer == null) + { + state.Buffer = ArrayPool.Shared.Rent(BufferSize); + } + + // Copy to file buffer + Array.Copy(dataToWrite, 0, state.Buffer, state.BufferPosition, dataLength); + state.BufferPosition += dataLength; + state.Size += dataLength; + } + finally + { + // Clear and return encrypted data + if (encryptedData != null) + { + Array.Clear(encryptedData, 0, encryptedData.Length); + } + } + } + + private async Task FlushBufferAsync(FileState state, CancellationToken token = default(CancellationToken)) { if (state.IsFaulted || state.BufferPosition == 0 || state.Stream == null) { @@ -399,66 +520,93 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable finally { // Clear buffer to prevent sensitive data leak - Array.Clear(state.Buffer, 0, state.BufferPosition); + if (state.Buffer != null) + { + Array.Clear(state.Buffer, 0, state.BufferPosition); + } state.BufferPosition = 0; } } private void QueueOldFilesForCompression() { - if (_maxRetainedFiles <= 0) + if (_maxRetainedFiles <= 0 || _disposed == 1) { return; } - var files = new DirectoryInfo(_path) - .GetFiles($"{_fileNamePrefix}*") - .OrderByDescending(f => f.LastWriteTimeUtc) - .Skip(_maxRetainedFiles); - - foreach (var f in files) + try { - EnqueueCompression(f.FullName); + var dirInfo = new DirectoryInfo(_path); + if (!dirInfo.Exists) + { + return; + } + + var files = dirInfo.GetFiles($"{_fileNamePrefix}*") + .Where(f => !f.Name.EndsWith(".gz", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(f => f.LastWriteTimeUtc) + .Skip(_maxRetainedFiles) + .ToList(); + + foreach (var f in files) + { + EnqueueCompression(f.FullName); + } + } + catch + { + // Ignore errors in cleanup } } private void EnqueueCompression(string file) { + // Limit compression queue size to prevent unbounded growth + if (_compressionQueue.Count >= MaxCompressionQueueSize) + { + return; + } + _compressionQueue.Enqueue(file); - _queueSignal.Release(); } - private async Task CompressionWorkerAsync() + private async Task CompressionWorkerAsync(CancellationToken cancellationToken) { try { - while (!_disposed) + while (!cancellationToken.IsCancellationRequested) { try { - // Use a timeout to avoid hanging if disposed - if (!await _queueSignal.WaitAsync(TimeSpan.FromSeconds(1))) - { - if (_disposed) - { - break; - } - - continue; - } + // Wait for work with cancellation + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); } - catch + catch (OperationCanceledException) { - // Handle cancellation or disposal race condition break; } - if (_compressionQueue.TryDequeue(out var filePath)) + // Process all queued files + while (_compressionQueue.TryDequeue(out var filePath)) { - await _compressionSemaphore.WaitAsync(); + if (cancellationToken.IsCancellationRequested) + { + break; + } + + await _compressionSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await CompressOldLogFileAsync(filePath); + await CompressOldLogFileAsync(filePath).ConfigureAwait(false); + } + catch (Exception ex) + { + OnError?.Invoke(this, new ErrorMessage + { + Exception = ex, + Message = $"Compression worker error: {filePath}" + }); } finally { @@ -473,7 +621,6 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable } } - private async Task CompressOldLogFileAsync(string filePath, int retryCount = 3) { if (!File.Exists(filePath) || filePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase)) @@ -482,8 +629,8 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable } var dir = Path.GetDirectoryName(filePath); - var name = Path.GetFileNameWithoutExtension(filePath); // "app_20260212_1" - var ext = Path.GetExtension(filePath); // ".log" + var name = Path.GetFileNameWithoutExtension(filePath); + var ext = Path.GetExtension(filePath); // Determine the next _N.log.gz name int suffix = 1; @@ -492,26 +639,37 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable { compressedFile = Path.Combine(dir, $"{name}_{suffix}{ext}.gz"); suffix++; - } while (File.Exists(compressedFile)); + } while (File.Exists(compressedFile) && suffix < 1000); // Prevent infinite loop for (int attempt = 0; attempt < retryCount; attempt++) { + FileStream original = null; + FileStream compressed = null; + GZipStream gzip = null; + try { - using var original = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - using var compressed = new FileStream(compressedFile, FileMode.CreateNew, FileAccess.Write); - using var gzip = new GZipStream(compressed, CompressionLevel.Optimal); + original = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + compressed = new FileStream(compressedFile, FileMode.CreateNew, FileAccess.Write); + gzip = new GZipStream(compressed, CompressionLevel.Optimal); await original.CopyToAsync(gzip).ConfigureAwait(false); await gzip.FlushAsync().ConfigureAwait(false); + gzip.Dispose(); + gzip = null; + compressed.Dispose(); + compressed = null; + original.Dispose(); + original = null; + File.Delete(filePath); return; // success } catch (IOException) { // File busy? Wait a bit and retry - await Task.Delay(200); + await Task.Delay(200).ConfigureAwait(false); } catch (Exception ex) { @@ -522,25 +680,28 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable }); return; } + finally + { + if (gzip != null) gzip.Dispose(); + if (compressed != null) compressed.Dispose(); + if (original != null) original.Dispose(); + } } } - private byte[] Encrypt(byte[] plainBytes) + private byte[] Encrypt(byte[] plainBytes, int length) { - if (plainBytes == null || plainBytes.Length == 0) + if (plainBytes == null || length == 0) { - return plainBytes; + return Array.Empty(); } - using var encryptor = _aes.CreateEncryptor(); - var encrypted = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); - - // Clear plaintext - Array.Clear(plainBytes, 0, plainBytes.Length); - return encrypted; + using (var encryptor = _aes.CreateEncryptor()) + { + return encryptor.TransformFinalBlock(plainBytes, 0, length); + } } - public byte[] Decrypt(byte[] encryptedData) { if (!IsEncryptionEnabled || encryptedData == null || encryptedData.Length == 0) @@ -548,18 +709,40 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable return encryptedData; } - using var decryptor = _aes.CreateDecryptor(); - using var ms = new MemoryStream(encryptedData); - using var cryptoStream = new System.Security.Cryptography.CryptoStream(ms, decryptor, System.Security.Cryptography.CryptoStreamMode.Read); - using var resultStream = new MemoryStream(); - cryptoStream.CopyTo(resultStream); + using (var decryptor = _aes.CreateDecryptor()) + using (var ms = new MemoryStream(encryptedData)) + using (var cryptoStream = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) + using (var resultStream = new MemoryStream()) + { + cryptoStream.CopyTo(resultStream); + return resultStream.ToArray(); + } + } - var result = resultStream.ToArray(); + private StringBuilder RentStringBuilder() + { + if (_stringBuilderPool.TryTake(out var sb)) + { + sb.Clear(); + return sb; + } + return new StringBuilder(512); + } - // Clear sensitive memory - Array.Clear(encryptedData, 0, encryptedData.Length); - - return result; + private void ReturnStringBuilder(StringBuilder sb) + { + // Limit pool size and builder capacity to prevent unbounded growth + if (sb.Capacity < 8192 && _stringBuilderPoolCount < MaxStringBuilderPoolSize) + { + if (Interlocked.Increment(ref _stringBuilderPoolCount) <= MaxStringBuilderPoolSize) + { + _stringBuilderPool.Add(sb); + } + else + { + Interlocked.Decrement(ref _stringBuilderPoolCount); + } + } } private string BuildMessage(LogMessage msg) @@ -570,34 +753,41 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable } var ctx = _context.GetAll(); - if (ctx.Count == 0) + if (ctx.Count == 0 && (msg.Tags == null || !msg.Tags.Any())) { return msg.Message + Environment.NewLine; } - var sb = new StringBuilder(msg.Message.Length + 64); - sb.Append(msg.Message).Append(" ["); - - foreach (var (key, value) in ctx.Select(kv => (kv.Key, kv.Value))) + var sb = RentStringBuilder(); + try { - sb.Append(key).Append('=').Append(value).Append(' '); - } + sb.Append(msg.Message).Append(" ["); - if (msg.Tags != null) - { - foreach (var tag in msg.Tags) + foreach (var kv in ctx) { - sb.Append("tag=").Append(tag).Append(' '); + sb.Append(kv.Key).Append('=').Append(kv.Value).Append(' '); } - } - if (sb[sb.Length - 1] == ' ') + if (msg.Tags != null) + { + foreach (var tag in msg.Tags) + { + sb.Append("tag=").Append(tag).Append(' '); + } + } + + if (sb.Length > 0 && sb[sb.Length - 1] == ' ') + { + sb.Length--; // remove trailing space + } + + sb.Append(']').AppendLine(); + return sb.ToString(); + } + finally { - sb.Length--; // remove trailing space + ReturnStringBuilder(sb); } - - sb.Append(']').AppendLine(); - return sb.ToString(); } private void HandleWriteFailure(FileState state, Exception ex) @@ -605,8 +795,15 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable state.IsFaulted = true; state.LastFailureUtc = DateTime.UtcNow; - state.Stream?.Dispose(); - state.Stream = null; + if (state.Stream != null) + { + try + { + state.Stream.Dispose(); + } + catch { } + state.Stream = null; + } string originalDir = Path.GetDirectoryName(state.FilePath); string fallbackDir = Directory.Exists(_fallbackPath) ? _fallbackPath : EnsureWritableDirectory(originalDir); @@ -667,7 +864,6 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable catch { // Attempt fallback path if recovery fails - try { string fallbackFile = Path.Combine(_fallbackPath, Path.GetFileName(state.FilePath)); @@ -694,20 +890,18 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable private FileState CreateFileState(DateTime date, string category) { - // Get the intended log file path var intendedPath = GetFullName(date, category); - - // Ensure directory is writable (falls back automatically if needed) var writableDir = EnsureWritableDirectory(Path.GetDirectoryName(intendedPath)); var path = Path.Combine(writableDir, Path.GetFileName(intendedPath)); try { - return new FileState + var state = new FileState { FilePath = path, Date = date, Size = GetFileSize(path), + Buffer = ArrayPool.Shared.Rent(BufferSize), Stream = new FileStream( path, FileMode.Append, @@ -715,6 +909,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable FileShare.ReadWrite | FileShare.Delete ) }; + return state; } catch (Exception ex) { @@ -728,57 +923,116 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable { FilePath = path, Date = date, - IsFaulted = true + IsFaulted = true, + Buffer = ArrayPool.Shared.Rent(BufferSize) }; } } private void RotateByDate(FileState state, DateTime newDate, string category) { - state.Stream?.Dispose(); + if (state.Stream != null) + { + try + { + state.Stream.Flush(); + } + catch { } + state.Stream.Dispose(); + state.Stream = null; + } + state.Date = newDate; state.FilePath = GetFullName(newDate, category); state.Size = GetFileSize(state.FilePath); - state.Stream = new FileStream(state.FilePath, FileMode.Append, - FileAccess.Write, FileShare.ReadWrite | FileShare.Delete); + + try + { + state.Stream = new FileStream(state.FilePath, FileMode.Append, + FileAccess.Write, FileShare.ReadWrite | FileShare.Delete); + } + catch (Exception ex) + { + state.IsFaulted = true; + OnError?.Invoke(this, new ErrorMessage + { + Exception = ex, + Message = $"Failed to rotate log file: {state.FilePath}" + }); + } } private void RollOverAndCompressOldest(FileState state, string category) { - state.Stream?.Dispose(); + if (state.Stream != null) + { + try + { + state.Stream.Flush(); + } + catch { } + state.Stream.Dispose(); + state.Stream = null; + } var dir = Path.GetDirectoryName(state.FilePath); var name = Path.GetFileNameWithoutExtension(state.FilePath); - var ext = Path.GetExtension(state.FilePath); // ".log" + var ext = Path.GetExtension(state.FilePath); // Shift existing rolled files up for (int i = _maxRolloverFiles - 1; i >= 1; i--) { var src = Path.Combine(dir, $"{name}_{i}{ext}"); var dst = Path.Combine(dir, $"{name}_{i + 1}{ext}"); - if (File.Exists(dst)) + try { - File.Delete(dst); - } + if (File.Exists(dst)) + { + File.Delete(dst); + } - if (File.Exists(src)) + if (File.Exists(src)) + { + File.Move(src, dst); + } + } + catch { - File.Move(src, dst); + // Continue on error } } // Move current file to _1 var rolledFile = Path.Combine(dir, $"{name}_1{ext}"); - if (File.Exists(state.FilePath)) + try { - File.Move(state.FilePath, rolledFile); - } + if (File.Exists(state.FilePath)) + { + File.Move(state.FilePath, rolledFile); + } - OnRollOver?.Invoke(this, rolledFile); + OnRollOver?.Invoke(this, rolledFile); + } + catch + { + // Continue on error + } // Create new active log file state.Size = 0; - state.Stream = new FileStream(state.FilePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete); + try + { + state.Stream = new FileStream(state.FilePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete); + } + catch (Exception ex) + { + state.IsFaulted = true; + OnError?.Invoke(this, new ErrorMessage + { + Exception = ex, + Message = $"Failed to create new log file after rollover: {state.FilePath}" + }); + } // Compress the oldest file safely var oldestFile = Path.Combine(dir, $"{name}_{_maxRolloverFiles}{ext}"); @@ -807,66 +1061,117 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable private static string SanitizeCategory(string category) { - foreach (var c in Path.GetInvalidFileNameChars()) + if (string.IsNullOrEmpty(category)) { - category = category.Replace(c, '_'); + return category; } - return category.Replace('.', '_'); + var chars = category.ToCharArray(); + var invalidChars = Path.GetInvalidFileNameChars(); + + for (int i = 0; i < chars.Length; i++) + { + if (Array.IndexOf(invalidChars, chars[i]) >= 0 || chars[i] == '.') + { + chars[i] = '_'; + } + } + + return new string(chars); } protected override async Task OnShutdownFlushAsync() { - _disposed = true; - _flushTimer?.Dispose(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - try + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) { - await PeriodicFlushAsync().ConfigureAwait(false); - } - catch - { - // Do nothing + return; // Already disposed } - foreach (var state in _files.Values) + // Stop timer + if (_flushTimer != null) + { + _flushTimer.Dispose(); + _flushTimer = null; + } + + // Stop compression worker + _compressionCts.Cancel(); + + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) { try { - await FlushBufferAsync(state, cts.Token).ConfigureAwait(false); + await PeriodicFlushAsync().ConfigureAwait(false); } catch { - // Do nothing + // Ignore errors during shutdown } - state.Dispose(); - } - - _files.Clear(); - _messageQueues.Clear(); - - // Wait for compression worker to finish remaining tasks with timeout - try - { - if (_compressionWorker != null && !_compressionWorker.IsCompleted) + foreach (var state in _files.Values) { - await Task.WhenAny(_compressionWorker, Task.Delay(TimeSpan.FromSeconds(5))); + try + { + await FlushBufferAsync(state, cts.Token).ConfigureAwait(false); + } + catch + { + // Ignore errors during shutdown + } + + state.Dispose(); + } + + _files.Clear(); + + // Clear all message queues + foreach (var mq in _messageQueues.Values) + { + mq.Clear(); + } + _messageQueues.Clear(); + + // Wait for compression worker to finish with timeout + try + { + if (_compressionWorker != null && !_compressionWorker.IsCompleted) + { + await Task.WhenAny(_compressionWorker, Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false); + } + } + catch + { + // Ignore errors + } + + // Dispose resources + if (_compressionSemaphore != null) + { + _compressionSemaphore.Dispose(); + } + + if (_compressionCts != null) + { + _compressionCts.Dispose(); + } + + if (_aes != null) + { + _aes.Dispose(); + _aes = null; + } + + // Clear StringBuilder pool + while (_stringBuilderPool.TryTake(out _)) + { + Interlocked.Decrement(ref _stringBuilderPoolCount); } } - catch - { - // Do nothing - } - - _queueSignal.Release(); - - // Signal compression worker to stop and wait for it to finish - _compressionSemaphore?.Dispose(); - _queueSignal?.Dispose(); - - _aes?.Dispose(); } -} + + public new void Dispose() + { + OnShutdownFlushAsync().GetAwaiter().GetResult(); + base.Dispose(); + } +} \ No newline at end of file