diff --git a/src/Components/Server/src/Circuits/CircuitRegistry.cs b/src/Components/Server/src/Circuits/CircuitRegistry.cs index 6b068e56eae1..33d0713415e9 100644 --- a/src/Components/Server/src/Circuits/CircuitRegistry.cs +++ b/src/Components/Server/src/Circuits/CircuitRegistry.cs @@ -309,9 +309,20 @@ private Task PauseAndDisposeCircuitEntry(DisconnectedCircuitEntry entry) private async Task PauseAndDisposeCircuitHost(CircuitHost circuitHost, bool saveStateToClient) { - await _circuitPersistenceManager.PauseCircuitAsync(circuitHost, saveStateToClient); - circuitHost.UnhandledException -= CircuitHost_UnhandledException; - await circuitHost.DisposeAsync(); + try + { + await _circuitPersistenceManager.PauseCircuitAsync(circuitHost, saveStateToClient); + } + catch (ObjectDisposedException ex) + { + // Expected when service provider is disposed during circuit cleanup e.g. by forced GC + CircuitHost_UnhandledException(circuitHost, new UnhandledExceptionEventArgs(ex, isTerminating: true)); + } + finally + { + circuitHost.UnhandledException -= CircuitHost_UnhandledException; + await circuitHost.DisposeAsync(); + } } internal async Task PauseCircuitAsync( diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Redirections/CircularRedirection.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Redirections/CircularRedirection.razor index dd4ec0b413e0..e5e2bfe39a65 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Redirections/CircularRedirection.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Redirections/CircularRedirection.razor @@ -13,9 +13,22 @@ {

Unobserved Exceptions (for debugging):

} @@ -23,16 +36,16 @@ @code { private bool _shouldStopRedirecting; - private IReadOnlyCollection _unobservedExceptions = Array.Empty(); + private IReadOnlyCollection _unobservedExceptions = Array.Empty(); protected override async Task OnInitializedAsync() { - int visits = Observer.GetCircularRedirectCount(); - if (visits == 0) - { - // make sure we start with clean logs - Observer.Clear(); - } + int visits = Observer.GetCircularRedirectCount(); + if (visits == 0) + { + // make sure we start with clean logs + Observer.Clear(); + } // Force GC collection to trigger finalizers - this is what causes the issue GC.Collect(); @@ -47,7 +60,7 @@ else { _shouldStopRedirecting = true; - _unobservedExceptions = Observer.GetExceptions(); + _unobservedExceptions = Observer.GetExceptionDetails(); } } } diff --git a/src/Components/test/testassets/Components.TestServer/UnobservedTaskExceptionObserver.cs b/src/Components/test/testassets/Components.TestServer/UnobservedTaskExceptionObserver.cs index af61be6cebd5..0fca588d2b35 100644 --- a/src/Components/test/testassets/Components.TestServer/UnobservedTaskExceptionObserver.cs +++ b/src/Components/test/testassets/Components.TestServer/UnobservedTaskExceptionObserver.cs @@ -2,24 +2,134 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Text; using System.Threading; +using System.Linq; namespace TestServer; +/// +/// Represents detailed information about an unobserved task exception, including the original call stack. +/// +public class UnobservedExceptionDetails +{ + /// + /// The original exception that was unobserved. + /// + public Exception Exception { get; init; } + + /// + /// The timestamp when the exception was observed. + /// + public DateTime ObservedAt { get; init; } + + /// + /// The current call stack when the exception was observed (may show finalizer thread). + /// + public string ObservedCallStack { get; init; } + + /// + /// Detailed breakdown of inner exceptions and their stack traces. + /// + public string DetailedExceptionInfo { get; init; } + + /// + /// The managed thread ID where the exception was observed. + /// + public int ObservedThreadId { get; init; } + + /// + /// Whether this exception was observed on the finalizer thread. + /// + public bool IsFromFinalizerThread { get; init; } + + public UnobservedExceptionDetails(Exception exception) + { + Exception = exception; + ObservedAt = DateTime.UtcNow; + ObservedCallStack = Environment.StackTrace; + DetailedExceptionInfo = BuildDetailedExceptionInfo(exception); + ObservedThreadId = Environment.CurrentManagedThreadId; + IsFromFinalizerThread = Thread.CurrentThread.IsThreadPoolThread && Thread.CurrentThread.IsBackground; + } + + private static string BuildDetailedExceptionInfo(Exception exception) + { + var sb = new StringBuilder(); + var currentException = exception; + var depth = 0; + + while (currentException is not null) + { + var indent = new string(' ', depth * 2); + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}Exception Type: {currentException.GetType().FullName}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}Message: {currentException.Message}"); + + if (currentException.Data.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}Data:"); + foreach (var key in currentException.Data.Keys) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent} {key}: {currentException.Data[key]}"); + } + } + + if (!string.IsNullOrEmpty(currentException.StackTrace)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}Stack Trace:"); + sb.AppendLine(currentException.StackTrace); + } + + // Handle AggregateException specially to extract all inner exceptions + if (currentException is AggregateException aggregateException) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}Aggregate Exception contains {aggregateException.InnerExceptions.Count} inner exceptions:"); + for (int i = 0; i < aggregateException.InnerExceptions.Count; i++) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent} Inner Exception {i + 1}:"); + sb.AppendLine(BuildDetailedExceptionInfo(aggregateException.InnerExceptions[i])); + } + break; // Don't process InnerException for AggregateException as we've handled all inner exceptions + } + + currentException = currentException.InnerException; + depth++; + + if (currentException is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}--- Inner Exception ---"); + } + } + + return sb.ToString(); + } +} + public class UnobservedTaskExceptionObserver { - private readonly ConcurrentQueue _exceptions = new(); + private readonly ConcurrentQueue _exceptions = new(); private int _circularRedirectCount; public void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) { - _exceptions.Enqueue(e.Exception); + var details = new UnobservedExceptionDetails(e.Exception); + _exceptions.Enqueue(details); e.SetObserved(); // Mark as observed to prevent the process from crashing during tests } public bool HasExceptions => !_exceptions.IsEmpty; - public IReadOnlyCollection GetExceptions() => _exceptions.ToArray(); + /// + /// Gets the detailed exception information including original call stacks. + /// + public IReadOnlyCollection GetExceptionDetails() => _exceptions.ToArray(); + + /// + /// Gets the raw exceptions for backward compatibility. + /// + public IReadOnlyCollection GetExceptions() => _exceptions.ToArray().Select(d => d.Exception).ToList(); public void Clear() {