From f2f40f3f728a146e4be6cb448b0df8bc64f8fbc2 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 3 Jul 2025 11:29:26 +0200 Subject: [PATCH 1/4] Add test that reproduces the issue. --- .../ServerRenderingTests/RedirectionTest.cs | 10 ++++ .../Components.TestServer/Program.cs | 13 ++++- .../Redirections/CircularRedirection.razor | 53 +++++++++++++++++++ .../UnobservedTaskExceptionObserver.cs | 39 ++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Redirections/CircularRedirection.razor create mode 100644 src/Components/test/testassets/Components.TestServer/UnobservedTaskExceptionObserver.cs diff --git a/src/Components/test/E2ETest/ServerRenderingTests/RedirectionTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/RedirectionTest.cs index f3b70bc87509..3bed2b0aae53 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/RedirectionTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/RedirectionTest.cs @@ -221,6 +221,16 @@ public void RedirectEnhancedGetToInternalWithErrorBoundary() Assert.EndsWith("/subdir/redirect", Browser.Url); } + [Fact] + public void NavigationException_InAsyncContext_DoesNotBecomeUnobservedTaskException() + { + // Navigate to the page that triggers the circular redirect. + Navigate($"{ServerPathBase}/redirect/circular"); + + // The component will stop redirecting after 3 attempts and render the exception count. + Browser.Equal("0", () => Browser.FindElement(By.Id("unobserved-exceptions-count")).Text); + } + private void AssertElementRemoved(IWebElement element) { Browser.True(() => diff --git a/src/Components/test/testassets/Components.TestServer/Program.cs b/src/Components/test/testassets/Components.TestServer/Program.cs index 800ad27ddce4..568f809454a7 100644 --- a/src/Components/test/testassets/Components.TestServer/Program.cs +++ b/src/Components/test/testassets/Components.TestServer/Program.cs @@ -81,8 +81,16 @@ private static string[] CreateAdditionalArgs(string[] args) => public static IHost BuildWebHost(string[] args) => BuildWebHost(args); - public static IHost BuildWebHost(string[] args) where TStartup : class => - Host.CreateDefaultBuilder(args) + public static IHost BuildWebHost(string[] args) where TStartup : class + { + var unobservedTaskExceptionObserver = new UnobservedTaskExceptionObserver(); + TaskScheduler.UnobservedTaskException += unobservedTaskExceptionObserver.OnUnobservedTaskException; + + return Host.CreateDefaultBuilder(args) + .ConfigureServices(services => + { + services.AddSingleton(unobservedTaskExceptionObserver); + }) .ConfigureLogging((ctx, lb) => { TestSink sink = new TestSink(); @@ -98,6 +106,7 @@ public static IHost BuildWebHost(string[] args) where TStartup : class webHostBuilder.UseStaticWebAssets(); }) .Build(); + } private static int GetNextChildAppPortNumber() { 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 new file mode 100644 index 000000000000..dd4ec0b413e0 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Redirections/CircularRedirection.razor @@ -0,0 +1,53 @@ +@page "/redirect/circular" +@using System.Collections.Concurrent +@inject NavigationManager Nav +@inject UnobservedTaskExceptionObserver Observer + +

Hello, world!

+ +@if (_shouldStopRedirecting) +{ +

@_unobservedExceptions.Count

+ + @if (_unobservedExceptions.Any()) + { +

Unobserved Exceptions (for debugging):

+
    + @foreach (var ex in _unobservedExceptions) + { +
  • @ex.ToString()
  • + } +
+ } +} + +@code { + private bool _shouldStopRedirecting; + 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(); + } + + // Force GC collection to trigger finalizers - this is what causes the issue + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + await Task.Yield(); + + if (Observer.GetAndIncrementCircularRedirectCount() < 3) + { + Nav.NavigateTo("redirect/circular"); + } + else + { + _shouldStopRedirecting = true; + _unobservedExceptions = Observer.GetExceptions(); + } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/UnobservedTaskExceptionObserver.cs b/src/Components/test/testassets/Components.TestServer/UnobservedTaskExceptionObserver.cs new file mode 100644 index 000000000000..af61be6cebd5 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/UnobservedTaskExceptionObserver.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Threading; + +namespace TestServer; + +public class UnobservedTaskExceptionObserver +{ + private readonly ConcurrentQueue _exceptions = new(); + private int _circularRedirectCount; + + public void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) + { + _exceptions.Enqueue(e.Exception); + e.SetObserved(); // Mark as observed to prevent the process from crashing during tests + } + + public bool HasExceptions => !_exceptions.IsEmpty; + + public IReadOnlyCollection GetExceptions() => _exceptions.ToArray(); + + public void Clear() + { + _exceptions.Clear(); + _circularRedirectCount = 0; + } + + public int GetCircularRedirectCount() + { + return _circularRedirectCount; + } + + public int GetAndIncrementCircularRedirectCount() + { + return Interlocked.Increment(ref _circularRedirectCount) - 1; + } +} From 2287842abcac8714fa849d719d0ffbf12349d4c7 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 3 Jul 2025 13:19:12 +0200 Subject: [PATCH 2/4] Fix. --- .../EndpointHtmlRenderer.Prerendering.cs | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 16c44c92f641..70f04ecac1da 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -229,9 +229,29 @@ async Task Execute() // Clear all pending work. _nonStreamingPendingTasks.Clear(); - // new work might be added before we check again as a result of waiting for all - // the child components to finish executing SetParametersAsync - await pendingWork; + try + { + // new work might be added before we check again as a result of waiting for all + // the child components to finish executing SetParametersAsync + await pendingWork; + } + catch (Exception ex) + { + // We need to handle NavigationException specially to ensure it gets properly + // processed through HandleNavigationException rather than being rethrown + if (ex is NavigationException navigationEx) + { + if (_httpContext is not null) + { + await HandleNavigationException(_httpContext, navigationEx); + } + return; + } + else + { + throw; + } + } } } } From 719484805c5c1007b561e5ea7937ffc939ef154e Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 3 Jul 2025 13:21:23 +0200 Subject: [PATCH 3/4] Make sure we always run this test with navigation exception flow. --- .../test/E2ETest/ServerRenderingTests/RedirectionTest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Components/test/E2ETest/ServerRenderingTests/RedirectionTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/RedirectionTest.cs index 3bed2b0aae53..e7830517b3ef 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/RedirectionTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/RedirectionTest.cs @@ -224,6 +224,8 @@ public void RedirectEnhancedGetToInternalWithErrorBoundary() [Fact] public void NavigationException_InAsyncContext_DoesNotBecomeUnobservedTaskException() { + AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.DisableThrowNavigationException", false); + // Navigate to the page that triggers the circular redirect. Navigate($"{ServerPathBase}/redirect/circular"); From 8553ccff81862a7984b04fdc9b92b9060baeddc9 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 3 Jul 2025 14:02:13 +0200 Subject: [PATCH 4/4] @campersau's feedback: unify the catching approach. --- .../EndpointHtmlRenderer.Prerendering.cs | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 70f04ecac1da..abc9441d54fa 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -235,22 +235,9 @@ async Task Execute() // the child components to finish executing SetParametersAsync await pendingWork; } - catch (Exception ex) + catch (NavigationException navigationException) { - // We need to handle NavigationException specially to ensure it gets properly - // processed through HandleNavigationException rather than being rethrown - if (ex is NavigationException navigationEx) - { - if (_httpContext is not null) - { - await HandleNavigationException(_httpContext, navigationEx); - } - return; - } - else - { - throw; - } + await HandleNavigationException(_httpContext, navigationException); } } }