From 3758697a469b6d0aee3100e3beb0bf789d4f3fb6 Mon Sep 17 00:00:00 2001 From: calledude <22471295+calledude@users.noreply.github.com> Date: Sat, 30 Mar 2024 19:52:21 +0100 Subject: [PATCH] Fix deadlock in DiscordWebhookSink whenever an error occurs in a context where a SynchronizationContext exists (e.g. Blazor) --- .../Utilities/DiscordWebhookSink.cs | 177 ++++++++++-------- 1 file changed, 100 insertions(+), 77 deletions(-) diff --git a/src/Modix.Services/Utilities/DiscordWebhookSink.cs b/src/Modix.Services/Utilities/DiscordWebhookSink.cs index 4aeaf9e4c..f283e05e2 100644 --- a/src/Modix.Services/Utilities/DiscordWebhookSink.cs +++ b/src/Modix.Services/Utilities/DiscordWebhookSink.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; using Discord; using Discord.Webhook; using Modix.Services.CodePaste; @@ -8,104 +11,124 @@ using Serilog.Core; using Serilog.Events; -namespace Modix.Services.Utilities +namespace Modix.Services.Utilities; + +public sealed class DiscordWebhookSink : ILogEventSink, IAsyncDisposable { - public sealed class DiscordWebhookSink - : ILogEventSink, - IDisposable + private readonly Lazy _codePasteService; + private readonly DiscordWebhookClient _discordWebhookClient; + private readonly IFormatProvider _formatProvider; + private readonly JsonSerializerSettings _jsonSerializerSettings; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly Task _logEventProcessorTask; + private readonly BlockingCollection _logEventQueue; + + public DiscordWebhookSink( + ulong webhookId, + string webhookToken, + IFormatProvider formatProvider, + Lazy codePasteService) { - private readonly Lazy _codePasteService; - private readonly DiscordWebhookClient _discordWebhookClient; - private readonly IFormatProvider _formatProvider; - private readonly JsonSerializerSettings _jsonSerializerSettings; - public DiscordWebhookSink( - ulong webhookId, - string webhookToken, - IFormatProvider formatProvider, - Lazy codePasteService) - { - _codePasteService = codePasteService; - _discordWebhookClient = new DiscordWebhookClient(webhookId, webhookToken); - _formatProvider = formatProvider; + _codePasteService = codePasteService; + _discordWebhookClient = new DiscordWebhookClient(webhookId, webhookToken); + _formatProvider = formatProvider; - _jsonSerializerSettings = new JsonSerializerSettings - { - Formatting = Formatting.Indented, - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - ContractResolver = new ExceptionContractResolver() - }; - } - public void Emit(LogEvent logEvent) + _jsonSerializerSettings = new JsonSerializerSettings { - const int DiscordStringTruncateLength = 1000; - - var formattedMessage = logEvent.RenderMessage(_formatProvider); + Formatting = Formatting.Indented, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + ContractResolver = new ExceptionContractResolver() + }; + + _cancellationTokenSource = new CancellationTokenSource(); + _logEventQueue = []; + _logEventProcessorTask = Task.Run(ProcessLogEventItemsAsync, _cancellationTokenSource.Token); + } - var message = new EmbedBuilder() - .WithAuthor("DiscordLogger") - .WithTitle("Modix") - .WithTimestamp(DateTimeOffset.UtcNow) - .WithColor(Color.Red); + public void Emit(LogEvent logEvent) + => _logEventQueue.Add(logEvent); + public async Task ProcessLogEventItemsAsync() + { + foreach (var logEvent in _logEventQueue.GetConsumingEnumerable(_cancellationTokenSource.Token)) + { try { - var messagePayload = $"{formattedMessage}\n{logEvent.Exception?.Message}"; + const int DiscordStringTruncateLength = 1000; - message.AddField(new EmbedFieldBuilder() - .WithIsInline(false) - .WithName($"LogLevel: {logEvent.Level}") - .WithValue(Format.Code(messagePayload.TruncateTo(DiscordStringTruncateLength)))); + var formattedMessage = logEvent.RenderMessage(_formatProvider); - var eventAsJson = JsonConvert.SerializeObject(logEvent, _jsonSerializerSettings); + var message = new EmbedBuilder() + .WithAuthor("DiscordLogger") + .WithTitle("Modix") + .WithTimestamp(DateTimeOffset.UtcNow) + .WithColor(Color.Red); - var url = _codePasteService.Value.UploadCodeAsync(eventAsJson).GetAwaiter().GetResult(); + try + { + var messagePayload = $"{formattedMessage}\n{logEvent.Exception?.Message}"; - message.AddField(new EmbedFieldBuilder() - .WithIsInline(false) - .WithName("Full Log Event") - .WithValue($"[view on paste.mod.gg]({url})")); - } - catch (Exception ex) - { - Console.WriteLine($"Unable to upload log event. {ex}"); + message.AddField(new EmbedFieldBuilder() + .WithIsInline(false) + .WithName($"LogLevel: {logEvent.Level}") + .WithValue(Format.Code(messagePayload.TruncateTo(DiscordStringTruncateLength)))); + + var eventAsJson = JsonConvert.SerializeObject(logEvent, _jsonSerializerSettings); + + var url = await _codePasteService.Value.UploadCodeAsync(eventAsJson); - var stackTracePayload = $"{formattedMessage}\n{logEvent.Exception?.ToString().TruncateTo(DiscordStringTruncateLength)}".TruncateTo(DiscordStringTruncateLength); + message.AddField(new EmbedFieldBuilder() + .WithIsInline(false) + .WithName("Full Log Event") + .WithValue($"[view on paste.mod.gg]({url})")); + } + catch (Exception ex) + { + Console.WriteLine($"Unable to upload log event. {ex}"); - message.AddField(new EmbedFieldBuilder() - .WithIsInline(false) - .WithName("Stack Trace") - .WithValue(Format.Code(stackTracePayload))); + var stackTracePayload = $"{formattedMessage}\n{logEvent.Exception?.ToString().TruncateTo(DiscordStringTruncateLength)}".TruncateTo(DiscordStringTruncateLength); - message.AddField(new EmbedFieldBuilder() - .WithIsInline(false) - .WithName("Upload Failure Exception") - .WithValue(Format.Code($"{ex.ToString().TruncateTo(DiscordStringTruncateLength)}"))); + message.AddField(new EmbedFieldBuilder() + .WithIsInline(false) + .WithName("Stack Trace") + .WithValue(Format.Code(stackTracePayload))); + + message.AddField(new EmbedFieldBuilder() + .WithIsInline(false) + .WithName("Upload Failure Exception") + .WithValue(Format.Code($"{ex.ToString().TruncateTo(DiscordStringTruncateLength)}"))); + } + + await _discordWebhookClient.SendMessageAsync(string.Empty, embeds: [message.Build()], username: "Modix Logger"); + } + catch + { + // Catching all exceptions as to not crash the processor thread + // Wait an arbitrary amount of time before trying to process the next item. + await Task.Delay(10000); } - _discordWebhookClient.SendMessageAsync(string.Empty, embeds: new[] { message.Build() }, username: "Modix Logger"); } - - public void Dispose() - => _discordWebhookClient.Dispose(); } - public static class DiscordWebhookSinkExtensions + public async ValueTask DisposeAsync() { - public static LoggerConfiguration DiscordWebhookSink(this LoggerSinkConfiguration config, ulong id, string token, LogEventLevel minLevel, Lazy codePasteService) - { - return config.Sink(new DiscordWebhookSink(id, token, null, codePasteService), minLevel); - } + _discordWebhookClient.Dispose(); + await _cancellationTokenSource.CancelAsync(); + await _logEventProcessorTask; + _cancellationTokenSource.Dispose(); } +} - public static class LoggingExtensions - { - public static string TruncateTo(this string str, int length) - { - if (str.Length < length) - { - return str; - } +public static class DiscordWebhookSinkExtensions +{ + public static LoggerConfiguration DiscordWebhookSink(this LoggerSinkConfiguration config, ulong id, string token, LogEventLevel minLevel, Lazy codePasteService) + => config.Sink(new DiscordWebhookSink(id, token, null, codePasteService), minLevel); +} - return str.Substring(0, length); - } - } +public static class LoggingExtensions +{ + public static string TruncateTo(this string str, int length) + => str.Length < length + ? str + : str[..length]; }