Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,6 +39,7 @@ public sealed class WebAssemblyHost : IAsyncDisposable
private bool _disposed;
private bool _started;
private WebAssemblyRenderer? _renderer;
private IEnumerable<IHostedService>? _hostedServices;

internal WebAssemblyHost(
WebAssemblyHostBuilder builder,
Expand Down Expand Up @@ -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<ILogger<WebAssemblyHost>>();
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();
}
Expand Down Expand Up @@ -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<IHostedService>();
await StartHostedServicesAsync(_hostedServices, cancellationToken);

var tcs = new TaskCompletionSource();
using (cancellationToken.Register(() => tcs.TrySetResult()))
{
Expand Down Expand Up @@ -225,4 +253,36 @@ private static void AddWebRootComponents(WebAssemblyRenderer renderer, RootCompo

renderer.NotifyEndUpdateRootComponents(operationBatch.BatchId);
}

private static async Task StartHostedServicesAsync(IEnumerable<IHostedService> hostedServices, CancellationToken cancellationToken)
{
foreach (var service in hostedServices)
{
await service.StartAsync(cancellationToken);
}
}

private static async Task StopHostedServicesAsync(IEnumerable<IHostedService> hostedServices, CancellationToken cancellationToken)
{
List<Exception>? 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<Reference Include="Microsoft.AspNetCore.Components.Web" />
<Reference Include="Microsoft.Extensions.Configuration.Json" />
<Reference Include="Microsoft.Extensions.Configuration.Binder" />
<Reference Include="Microsoft.Extensions.Hosting.Abstractions" />
<Reference Include="Microsoft.Extensions.Logging" />
<Reference Include="Microsoft.JSInterop.WebAssembly" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<IJSRuntime>());

var testHostedService = new TestHostedService();
builder.Services.AddSingleton<IHostedService>(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<IJSRuntime>());

var testHostedService1 = new TestHostedService();
var testHostedService2 = new TestHostedService();
builder.Services.AddSingleton<IHostedService>(testHostedService1);
builder.Services.AddSingleton<IHostedService>(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<IJSRuntime>());

var goodService = new TestHostedService();
var faultyService = new FaultyHostedService();
builder.Services.AddSingleton<IHostedService>(goodService);
builder.Services.AddSingleton<IHostedService>(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<IJSRuntime>());

// Test manual hosted service registration (equivalent to AddHostedService)
builder.Services.AddSingleton<TestHostedService>();
builder.Services.AddSingleton<IHostedService>(serviceProvider => serviceProvider.GetRequiredService<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 - verify the hosted service was started via service collection
var hostedServices = host.Services.GetServices<IHostedService>();
Assert.Single(hostedServices);

var testService = hostedServices.First();
Assert.IsType<TestHostedService>(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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Components.WebAssembly" />
<Reference Include="Microsoft.CodeAnalysis.CSharp" />
<Reference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>

</Project>
Loading