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):
- @foreach (var ex in _unobservedExceptions)
+ @foreach (var exceptionDetail in _unobservedExceptions)
{
- - @ex.ToString()
+ -
+ Observed at: @exceptionDetail.ObservedAt.ToString("yyyy-MM-dd HH:mm:ss.fff") UTC
+ Thread ID: @exceptionDetail.ObservedThreadId
+ From Finalizer Thread: @exceptionDetail.IsFromFinalizerThread
+ Exception: @exceptionDetail.Exception.ToString()
+
+ Detailed Exception Information
+ @exceptionDetail.DetailedExceptionInfo
+
+
+ Call Stack When Observed
+ @exceptionDetail.ObservedCallStack
+
+
}
}
@@ -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()
{