Skip to content

Commit c3fa2bb

Browse files
authored
Fix for Unobserved NavigationException in Blazor SSR (#62554)
* Add test that reproduces the issue. * Make sure we always run this test with navigation exception flow. * @campersau's feedback: unify the catching approach.
1 parent 2edb8c8 commit c3fa2bb

File tree

5 files changed

+125
-5
lines changed

5 files changed

+125
-5
lines changed

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,16 @@ async Task Execute()
229229
// Clear all pending work.
230230
_nonStreamingPendingTasks.Clear();
231231

232-
// new work might be added before we check again as a result of waiting for all
233-
// the child components to finish executing SetParametersAsync
234-
await pendingWork;
232+
try
233+
{
234+
// new work might be added before we check again as a result of waiting for all
235+
// the child components to finish executing SetParametersAsync
236+
await pendingWork;
237+
}
238+
catch (NavigationException navigationException)
239+
{
240+
await HandleNavigationException(_httpContext, navigationException);
241+
}
235242
}
236243
}
237244
}

src/Components/test/E2ETest/ServerRenderingTests/RedirectionTest.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,18 @@ public void RedirectEnhancedGetToInternalWithErrorBoundary()
221221
Assert.EndsWith("/subdir/redirect", Browser.Url);
222222
}
223223

224+
[Fact]
225+
public void NavigationException_InAsyncContext_DoesNotBecomeUnobservedTaskException()
226+
{
227+
AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.DisableThrowNavigationException", false);
228+
229+
// Navigate to the page that triggers the circular redirect.
230+
Navigate($"{ServerPathBase}/redirect/circular");
231+
232+
// The component will stop redirecting after 3 attempts and render the exception count.
233+
Browser.Equal("0", () => Browser.FindElement(By.Id("unobserved-exceptions-count")).Text);
234+
}
235+
224236
private void AssertElementRemoved(IWebElement element)
225237
{
226238
Browser.True(() =>

src/Components/test/testassets/Components.TestServer/Program.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,16 @@ private static string[] CreateAdditionalArgs(string[] args) =>
8181

8282
public static IHost BuildWebHost(string[] args) => BuildWebHost<Startup>(args);
8383

84-
public static IHost BuildWebHost<TStartup>(string[] args) where TStartup : class =>
85-
Host.CreateDefaultBuilder(args)
84+
public static IHost BuildWebHost<TStartup>(string[] args) where TStartup : class
85+
{
86+
var unobservedTaskExceptionObserver = new UnobservedTaskExceptionObserver();
87+
TaskScheduler.UnobservedTaskException += unobservedTaskExceptionObserver.OnUnobservedTaskException;
88+
89+
return Host.CreateDefaultBuilder(args)
90+
.ConfigureServices(services =>
91+
{
92+
services.AddSingleton(unobservedTaskExceptionObserver);
93+
})
8694
.ConfigureLogging((ctx, lb) =>
8795
{
8896
TestSink sink = new TestSink();
@@ -98,6 +106,7 @@ public static IHost BuildWebHost<TStartup>(string[] args) where TStartup : class
98106
webHostBuilder.UseStaticWebAssets();
99107
})
100108
.Build();
109+
}
101110

102111
private static int GetNextChildAppPortNumber()
103112
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
@page "/redirect/circular"
2+
@using System.Collections.Concurrent
3+
@inject NavigationManager Nav
4+
@inject UnobservedTaskExceptionObserver Observer
5+
6+
<h1>Hello, world!</h1>
7+
8+
@if (_shouldStopRedirecting)
9+
{
10+
<p id="unobserved-exceptions-count">@_unobservedExceptions.Count</p>
11+
12+
@if (_unobservedExceptions.Any())
13+
{
14+
<h2>Unobserved Exceptions (for debugging):</h2>
15+
<ul>
16+
@foreach (var ex in _unobservedExceptions)
17+
{
18+
<li>@ex.ToString()</li>
19+
}
20+
</ul>
21+
}
22+
}
23+
24+
@code {
25+
private bool _shouldStopRedirecting;
26+
private IReadOnlyCollection<Exception> _unobservedExceptions = Array.Empty<Exception>();
27+
28+
protected override async Task OnInitializedAsync()
29+
{
30+
int visits = Observer.GetCircularRedirectCount();
31+
if (visits == 0)
32+
{
33+
// make sure we start with clean logs
34+
Observer.Clear();
35+
}
36+
37+
// Force GC collection to trigger finalizers - this is what causes the issue
38+
GC.Collect();
39+
GC.WaitForPendingFinalizers();
40+
GC.Collect();
41+
await Task.Yield();
42+
43+
if (Observer.GetAndIncrementCircularRedirectCount() < 3)
44+
{
45+
Nav.NavigateTo("redirect/circular");
46+
}
47+
else
48+
{
49+
_shouldStopRedirecting = true;
50+
_unobservedExceptions = Observer.GetExceptions();
51+
}
52+
}
53+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Concurrent;
5+
using System.Threading;
6+
7+
namespace TestServer;
8+
9+
public class UnobservedTaskExceptionObserver
10+
{
11+
private readonly ConcurrentQueue<Exception> _exceptions = new();
12+
private int _circularRedirectCount;
13+
14+
public void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
15+
{
16+
_exceptions.Enqueue(e.Exception);
17+
e.SetObserved(); // Mark as observed to prevent the process from crashing during tests
18+
}
19+
20+
public bool HasExceptions => !_exceptions.IsEmpty;
21+
22+
public IReadOnlyCollection<Exception> GetExceptions() => _exceptions.ToArray();
23+
24+
public void Clear()
25+
{
26+
_exceptions.Clear();
27+
_circularRedirectCount = 0;
28+
}
29+
30+
public int GetCircularRedirectCount()
31+
{
32+
return _circularRedirectCount;
33+
}
34+
35+
public int GetAndIncrementCircularRedirectCount()
36+
{
37+
return Interlocked.Increment(ref _circularRedirectCount) - 1;
38+
}
39+
}

0 commit comments

Comments
 (0)