diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index afc21cf445ab..d87eaa88bf81 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Components.WebAssembly.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting; @@ -38,6 +39,7 @@ public sealed class WebAssemblyHost : IAsyncDisposable private bool _disposed; private bool _started; private WebAssemblyRenderer? _renderer; + private IEnumerable? _hostedServices; internal WebAssemblyHost( WebAssemblyHostBuilder builder, @@ -78,7 +80,29 @@ public async ValueTask DisposeAsync() _disposed = true; - if (_renderer != null) + // Stop hosted services first + if (_hostedServices is not null) + { + try + { + await StopHostedServicesAsync(_hostedServices, CancellationToken.None); + } + catch (Exception ex) + { + // Log the exception but don't fail disposal + try + { + var logger = Services.GetService>(); + logger?.LogError(ex, "An error occurred stopping hosted services during disposal."); + } + catch + { + // Ignore logging errors during disposal + } + } + } + + if (_renderer is not null) { await _renderer.DisposeAsync(); } @@ -137,6 +161,10 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl manager.SetPlatformRenderMode(RenderMode.InteractiveWebAssembly); await manager.RestoreStateAsync(store, RestoreContext.InitialValue); + // Start hosted services + _hostedServices = Services.GetServices(); + await StartHostedServicesAsync(_hostedServices, cancellationToken); + var tcs = new TaskCompletionSource(); using (cancellationToken.Register(() => tcs.TrySetResult())) { @@ -225,4 +253,36 @@ private static void AddWebRootComponents(WebAssemblyRenderer renderer, RootCompo renderer.NotifyEndUpdateRootComponents(operationBatch.BatchId); } + + private static async Task StartHostedServicesAsync(IEnumerable hostedServices, CancellationToken cancellationToken) + { + foreach (var service in hostedServices) + { + await service.StartAsync(cancellationToken); + } + } + + private static async Task StopHostedServicesAsync(IEnumerable hostedServices, CancellationToken cancellationToken) + { + List? exceptions = null; + + foreach (var service in hostedServices) + { + try + { + await service.StopAsync(cancellationToken); + } + catch (Exception ex) + { + exceptions ??= []; + exceptions.Add(ex); + } + } + + // Throw an aggregate exception if there were any exceptions + if (exceptions is not null) + { + throw new AggregateException(exceptions); + } + } } diff --git a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj index ba479f5352f7..291249ed6766 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj +++ b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyHostTest.cs b/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyHostTest.cs index 2083c8230b1b..fafb1a9cfecb 100644 --- a/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyHostTest.cs +++ b/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyHostTest.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.JSInterop; using Moq; @@ -83,6 +84,172 @@ public async Task DisposeAsync_CanDisposeAfterCallingRunAsync() Assert.Equal(1, disposable.DisposeCount); } + [Fact] + public async Task RunAsync_StartsHostedServices() + { + // Arrange + var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods()); + builder.Services.AddSingleton(Mock.Of()); + + var testHostedService = new TestHostedService(); + builder.Services.AddSingleton(testHostedService); + + var host = builder.Build(); + var cultureProvider = new TestSatelliteResourcesLoader(); + + var cts = new CancellationTokenSource(); + + // Act + var task = host.RunAsyncCore(cts.Token, cultureProvider); + + // Give hosted services time to start + await Task.Delay(100); + cts.Cancel(); + await task.TimeoutAfter(TimeSpan.FromSeconds(3)); + + // Assert + Assert.True(testHostedService.StartCalled); + Assert.Equal(cts.Token, testHostedService.StartToken); + } + + [Fact] + public async Task DisposeAsync_StopsHostedServices() + { + // Arrange + var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods()); + builder.Services.AddSingleton(Mock.Of()); + + var testHostedService1 = new TestHostedService(); + var testHostedService2 = new TestHostedService(); + builder.Services.AddSingleton(testHostedService1); + builder.Services.AddSingleton(testHostedService2); + + var host = builder.Build(); + var cultureProvider = new TestSatelliteResourcesLoader(); + + var cts = new CancellationTokenSource(); + + // Start the host to initialize hosted services + var runTask = host.RunAsyncCore(cts.Token, cultureProvider); + await Task.Delay(100); + + // Act - dispose the host + await host.DisposeAsync(); + cts.Cancel(); + await runTask.TimeoutAfter(TimeSpan.FromSeconds(3)); + + // Assert + Assert.True(testHostedService1.StartCalled); + Assert.True(testHostedService1.StopCalled); + Assert.True(testHostedService2.StartCalled); + Assert.True(testHostedService2.StopCalled); + } + + [Fact] + public async Task DisposeAsync_HandlesHostedServiceStopErrors() + { + // Arrange + var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods()); + builder.Services.AddSingleton(Mock.Of()); + + var goodService = new TestHostedService(); + var faultyService = new FaultyHostedService(); + builder.Services.AddSingleton(goodService); + builder.Services.AddSingleton(faultyService); + + var host = builder.Build(); + var cultureProvider = new TestSatelliteResourcesLoader(); + + var cts = new CancellationTokenSource(); + + // Start the host to initialize hosted services + var runTask = host.RunAsyncCore(cts.Token, cultureProvider); + await Task.Delay(100); + + // Act & Assert - dispose should not throw even if hosted service fails + await host.DisposeAsync(); + cts.Cancel(); + await runTask.TimeoutAfter(TimeSpan.FromSeconds(3)); + + Assert.True(goodService.StartCalled); + Assert.True(goodService.StopCalled); + Assert.True(faultyService.StartCalled); + Assert.True(faultyService.StopCalled); + } + + [Fact] + public async Task RunAsync_SupportsAddHostedServiceExtension() + { + // Arrange + var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods()); + builder.Services.AddSingleton(Mock.Of()); + + // Test manual hosted service registration (equivalent to AddHostedService) + builder.Services.AddSingleton(); + builder.Services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + + var host = builder.Build(); + var cultureProvider = new TestSatelliteResourcesLoader(); + + var cts = new CancellationTokenSource(); + + // Act + var task = host.RunAsyncCore(cts.Token, cultureProvider); + + // Give hosted services time to start + await Task.Delay(100); + cts.Cancel(); + await task.TimeoutAfter(TimeSpan.FromSeconds(3)); + + // Assert - verify the hosted service was started via service collection + var hostedServices = host.Services.GetServices(); + Assert.Single(hostedServices); + + var testService = hostedServices.First(); + Assert.IsType(testService); + Assert.True(((TestHostedService)testService).StartCalled); + } + + private class TestHostedService : IHostedService + { + public bool StartCalled { get; private set; } + public bool StopCalled { get; private set; } + public CancellationToken StartToken { get; private set; } + public CancellationToken StopToken { get; private set; } + + public Task StartAsync(CancellationToken cancellationToken) + { + StartCalled = true; + StartToken = cancellationToken; + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + StopCalled = true; + StopToken = cancellationToken; + return Task.CompletedTask; + } + } + + private class FaultyHostedService : IHostedService + { + public bool StartCalled { get; private set; } + public bool StopCalled { get; private set; } + + public Task StartAsync(CancellationToken cancellationToken) + { + StartCalled = true; + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + StopCalled = true; + throw new InvalidOperationException("Simulated hosted service stop error"); + } + } + private class DisposableService : IAsyncDisposable { public int DisposeCount { get; private set; } diff --git a/src/Components/WebAssembly/WebAssembly/test/Microsoft.AspNetCore.Components.WebAssembly.Tests.csproj b/src/Components/WebAssembly/WebAssembly/test/Microsoft.AspNetCore.Components.WebAssembly.Tests.csproj index 2f9ac20c7c09..e1d4e8394e38 100644 --- a/src/Components/WebAssembly/WebAssembly/test/Microsoft.AspNetCore.Components.WebAssembly.Tests.csproj +++ b/src/Components/WebAssembly/WebAssembly/test/Microsoft.AspNetCore.Components.WebAssembly.Tests.csproj @@ -7,6 +7,7 @@ +