Skip to content

Commit 49a3054

Browse files
authored
[Blazor] Support hosted services in WebAssemblyHost (#63814)
1 parent 52230ff commit 49a3054

File tree

6 files changed

+255
-1
lines changed

6 files changed

+255
-1
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 Microsoft.Extensions.Hosting;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting;
8+
9+
internal sealed partial class HostedServiceExecutor
10+
{
11+
private readonly IEnumerable<IHostedService> _services;
12+
private readonly ILogger<HostedServiceExecutor> _logger;
13+
14+
public HostedServiceExecutor(IEnumerable<IHostedService> services, ILogger<HostedServiceExecutor> logger)
15+
{
16+
_services = services;
17+
_logger = logger;
18+
}
19+
20+
public async Task StartAsync(CancellationToken token)
21+
{
22+
foreach (var service in _services)
23+
{
24+
await service.StartAsync(token);
25+
}
26+
}
27+
28+
public async Task StopAsync(CancellationToken token)
29+
{
30+
List<Exception>? exceptions = null;
31+
32+
foreach (var service in _services)
33+
{
34+
try
35+
{
36+
await service.StopAsync(token);
37+
}
38+
catch (Exception ex)
39+
{
40+
exceptions ??= [];
41+
exceptions.Add(ex);
42+
}
43+
}
44+
45+
// Throw an aggregate exception if there were any exceptions
46+
if (exceptions is not null)
47+
{
48+
var aggregateException = new AggregateException(exceptions);
49+
try
50+
{
51+
Log.ErrorStoppingHostedServices(_logger, aggregateException);
52+
}
53+
catch
54+
{
55+
// Ignore logging errors
56+
}
57+
throw aggregateException;
58+
}
59+
}
60+
61+
private static partial class Log
62+
{
63+
[LoggerMessage(1, LogLevel.Error, "An error occurred stopping hosted services.", EventName = "ErrorStoppingHostedServices")]
64+
public static partial void ErrorStoppingHostedServices(ILogger logger, Exception exception);
65+
}
66+
}

src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public sealed class WebAssemblyHost : IAsyncDisposable
3838
private bool _disposed;
3939
private bool _started;
4040
private WebAssemblyRenderer? _renderer;
41+
private HostedServiceExecutor? _hostedServiceExecutor;
4142

4243
internal WebAssemblyHost(
4344
WebAssemblyHostBuilder builder,
@@ -78,7 +79,20 @@ public async ValueTask DisposeAsync()
7879

7980
_disposed = true;
8081

81-
if (_renderer != null)
82+
// Stop hosted services first
83+
if (_hostedServiceExecutor is not null)
84+
{
85+
try
86+
{
87+
await _hostedServiceExecutor.StopAsync(CancellationToken.None);
88+
}
89+
catch
90+
{
91+
// Ignore errors when stopping hosted services during disposal
92+
}
93+
}
94+
95+
if (_renderer is not null)
8296
{
8397
await _renderer.DisposeAsync();
8498
}
@@ -137,6 +151,10 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl
137151
manager.SetPlatformRenderMode(RenderMode.InteractiveWebAssembly);
138152
await manager.RestoreStateAsync(store, RestoreContext.InitialValue);
139153

154+
// Start hosted services
155+
_hostedServiceExecutor = Services.GetRequiredService<HostedServiceExecutor>();
156+
await _hostedServiceExecutor.StartAsync(cancellationToken);
157+
140158
var tcs = new TaskCompletionSource();
141159
using (cancellationToken.Register(() => tcs.TrySetResult()))
142160
{

src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,5 +342,6 @@ internal void InitializeDefaultServices()
342342
Services.AddSingleton<AntiforgeryStateProvider, DefaultAntiforgeryStateProvider>();
343343
RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<AntiforgeryStateProvider>(Services, RenderMode.InteractiveWebAssembly);
344344
Services.AddSupplyValueFromQueryProvider();
345+
Services.AddSingleton<HostedServiceExecutor>();
345346
}
346347
}

src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<Reference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
2121
<Reference Include="Microsoft.Extensions.Configuration.Json" />
2222
<Reference Include="Microsoft.Extensions.Configuration.Binder" />
23+
<Reference Include="Microsoft.Extensions.Hosting.Abstractions" />
2324
<Reference Include="Microsoft.Extensions.Logging" />
2425
<Reference Include="Microsoft.JSInterop.WebAssembly" />
2526
</ItemGroup>

src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyHostTest.cs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Text.Json;
66
using Microsoft.AspNetCore.InternalTesting;
77
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Hosting;
89
using Microsoft.JSInterop;
910
using Moq;
1011

@@ -83,6 +84,172 @@ public async Task DisposeAsync_CanDisposeAfterCallingRunAsync()
8384
Assert.Equal(1, disposable.DisposeCount);
8485
}
8586

87+
[Fact]
88+
public async Task RunAsync_StartsHostedServices()
89+
{
90+
// Arrange
91+
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
92+
builder.Services.AddSingleton(Mock.Of<IJSRuntime>());
93+
94+
var testHostedService = new TestHostedService();
95+
builder.Services.AddSingleton<IHostedService>(testHostedService);
96+
97+
var host = builder.Build();
98+
var cultureProvider = new TestSatelliteResourcesLoader();
99+
100+
var cts = new CancellationTokenSource();
101+
102+
// Act
103+
var task = host.RunAsyncCore(cts.Token, cultureProvider);
104+
105+
// Give hosted services time to start
106+
await Task.Delay(100);
107+
cts.Cancel();
108+
await task.TimeoutAfter(TimeSpan.FromSeconds(3));
109+
110+
// Assert
111+
Assert.True(testHostedService.StartCalled);
112+
Assert.Equal(cts.Token, testHostedService.StartToken);
113+
}
114+
115+
[Fact]
116+
public async Task DisposeAsync_StopsHostedServices()
117+
{
118+
// Arrange
119+
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
120+
builder.Services.AddSingleton(Mock.Of<IJSRuntime>());
121+
122+
var testHostedService1 = new TestHostedService();
123+
var testHostedService2 = new TestHostedService();
124+
builder.Services.AddSingleton<IHostedService>(testHostedService1);
125+
builder.Services.AddSingleton<IHostedService>(testHostedService2);
126+
127+
var host = builder.Build();
128+
var cultureProvider = new TestSatelliteResourcesLoader();
129+
130+
var cts = new CancellationTokenSource();
131+
132+
// Start the host to initialize hosted services
133+
var runTask = host.RunAsyncCore(cts.Token, cultureProvider);
134+
await Task.Delay(100);
135+
136+
// Act - dispose the host
137+
await host.DisposeAsync();
138+
cts.Cancel();
139+
await runTask.TimeoutAfter(TimeSpan.FromSeconds(3));
140+
141+
// Assert
142+
Assert.True(testHostedService1.StartCalled);
143+
Assert.True(testHostedService1.StopCalled);
144+
Assert.True(testHostedService2.StartCalled);
145+
Assert.True(testHostedService2.StopCalled);
146+
}
147+
148+
[Fact]
149+
public async Task DisposeAsync_HandlesHostedServiceStopErrors()
150+
{
151+
// Arrange
152+
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
153+
builder.Services.AddSingleton(Mock.Of<IJSRuntime>());
154+
155+
var goodService = new TestHostedService();
156+
var faultyService = new FaultyHostedService();
157+
builder.Services.AddSingleton<IHostedService>(goodService);
158+
builder.Services.AddSingleton<IHostedService>(faultyService);
159+
160+
var host = builder.Build();
161+
var cultureProvider = new TestSatelliteResourcesLoader();
162+
163+
var cts = new CancellationTokenSource();
164+
165+
// Start the host to initialize hosted services
166+
var runTask = host.RunAsyncCore(cts.Token, cultureProvider);
167+
await Task.Delay(100);
168+
169+
// Act & Assert - dispose should not throw even if hosted service fails
170+
await host.DisposeAsync();
171+
cts.Cancel();
172+
await runTask.TimeoutAfter(TimeSpan.FromSeconds(3));
173+
174+
Assert.True(goodService.StartCalled);
175+
Assert.True(goodService.StopCalled);
176+
Assert.True(faultyService.StartCalled);
177+
Assert.True(faultyService.StopCalled);
178+
}
179+
180+
[Fact]
181+
public async Task RunAsync_SupportsAddHostedServiceExtension()
182+
{
183+
// Arrange
184+
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
185+
builder.Services.AddSingleton(Mock.Of<IJSRuntime>());
186+
187+
// Test manual hosted service registration (equivalent to AddHostedService)
188+
builder.Services.AddSingleton<TestHostedService>();
189+
builder.Services.AddSingleton<IHostedService>(serviceProvider => serviceProvider.GetRequiredService<TestHostedService>());
190+
191+
var host = builder.Build();
192+
var cultureProvider = new TestSatelliteResourcesLoader();
193+
194+
var cts = new CancellationTokenSource();
195+
196+
// Act
197+
var task = host.RunAsyncCore(cts.Token, cultureProvider);
198+
199+
// Give hosted services time to start
200+
await Task.Delay(100);
201+
cts.Cancel();
202+
await task.TimeoutAfter(TimeSpan.FromSeconds(3));
203+
204+
// Assert - verify the hosted service was started via service collection
205+
var hostedServices = host.Services.GetServices<IHostedService>();
206+
Assert.Single(hostedServices);
207+
208+
var testService = hostedServices.First();
209+
Assert.IsType<TestHostedService>(testService);
210+
Assert.True(((TestHostedService)testService).StartCalled);
211+
}
212+
213+
private class TestHostedService : IHostedService
214+
{
215+
public bool StartCalled { get; private set; }
216+
public bool StopCalled { get; private set; }
217+
public CancellationToken StartToken { get; private set; }
218+
public CancellationToken StopToken { get; private set; }
219+
220+
public Task StartAsync(CancellationToken cancellationToken)
221+
{
222+
StartCalled = true;
223+
StartToken = cancellationToken;
224+
return Task.CompletedTask;
225+
}
226+
227+
public Task StopAsync(CancellationToken cancellationToken)
228+
{
229+
StopCalled = true;
230+
StopToken = cancellationToken;
231+
return Task.CompletedTask;
232+
}
233+
}
234+
235+
private class FaultyHostedService : IHostedService
236+
{
237+
public bool StartCalled { get; private set; }
238+
public bool StopCalled { get; private set; }
239+
240+
public Task StartAsync(CancellationToken cancellationToken)
241+
{
242+
StartCalled = true;
243+
return Task.CompletedTask;
244+
}
245+
246+
public Task StopAsync(CancellationToken cancellationToken)
247+
{
248+
StopCalled = true;
249+
throw new InvalidOperationException("Simulated hosted service stop error");
250+
}
251+
}
252+
86253
private class DisposableService : IAsyncDisposable
87254
{
88255
public int DisposeCount { get; private set; }

src/Components/WebAssembly/WebAssembly/test/Microsoft.AspNetCore.Components.WebAssembly.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<ItemGroup>
88
<Reference Include="Microsoft.AspNetCore.Components.WebAssembly" />
99
<Reference Include="Microsoft.CodeAnalysis.CSharp" />
10+
<Reference Include="Microsoft.Extensions.Hosting.Abstractions" />
1011
</ItemGroup>
1112

1213
</Project>

0 commit comments

Comments
 (0)