using System.Net; using System.Net.Sockets; using System.Net.WebSockets; using Esiur.AspNetCore; using Esiur.Core; using Esiur.Net.Sockets; using Esiur.Protocol; using Esiur.Resource; using Esiur.Security.Authority.Providers; using Esiur.Security.Cryptography; using Esiur.Stores; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; namespace Esiur.Tests.Unit; public sealed class AspNetCoreIntegrationTests { private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); [Fact] public async Task AddEsiur_ValidatesConfigurationWhenHostStarts() { await using var application = BuildApplication(builder => builder.AddMemoryStore("sys")); var exception = await Assert.ThrowsAsync( () => application.StartAsync().WaitAsync(TestTimeout)); Assert.Contains( "Authentication is required", exception.Message, StringComparison.Ordinal); } [Fact] public async Task AddEsiur_RejectsAnonymousOnlyRequiredEncryption() { await using var application = BuildApplication(builder => builder .AddMemoryStore("sys") .AllowAnonymous() .UseEncryption(new AesEncryptionProvider()) .RequireEncryption()); var exception = await Assert.ThrowsAsync( () => application.StartAsync().WaitAsync(TestTimeout)); Assert.Contains( "Encrypted EP sessions require authentication", exception.Message, StringComparison.Ordinal); } [Fact] public void ProviderFacade_ExposesInstancesTypesAndFactoriesWithoutProtocolAliases() { var authenticationMethods = typeof(EsiurBuilder).GetMethods() .Where(method => method.Name == nameof(EsiurBuilder.UseAuthentication)) .ToArray(); var encryptionMethods = typeof(EsiurBuilder).GetMethods() .Where(method => method.Name == nameof(EsiurBuilder.UseEncryption)) .ToArray(); Assert.Equal(3, authenticationMethods.Length); Assert.Equal(3, encryptionMethods.Length); Assert.All( authenticationMethods.Concat(encryptionMethods), method => Assert.DoesNotContain( method.GetParameters(), parameter => parameter.ParameterType == typeof(string))); var services = new ServiceCollection(); var esiur = services.AddEsiur(); var authenticationProvider = new PasswordAuthenticationProvider(); var encryptionProvider = new AesEncryptionProvider(); esiur .UseAuthentication(authenticationProvider) .UseAuthentication() .UseAuthentication(_ => authenticationProvider) .UseEncryption(encryptionProvider) .UseEncryption() .UseEncryption(_ => encryptionProvider); } [Fact] public async Task ProviderFacade_RegistersAndAllowsOnlyProviderDefaultNames() { var authenticationProvider = new PasswordAuthenticationProvider(); var encryptionProvider = new AesEncryptionProvider(); await using var application = BuildApplication(builder => builder .AddMemoryStore("sys") .UseAuthentication(authenticationProvider) .UseEncryption(encryptionProvider) .RequireEncryption()); using var cancellation = new CancellationTokenSource(TestTimeout); await application.StartAsync(cancellation.Token); var warehouse = application.Services.GetRequiredService(); var server = application.Services.GetRequiredService(); Assert.Same( authenticationProvider, warehouse.TryGetAuthenticationProvider(authenticationProvider.DefaultName)); Assert.Same( encryptionProvider, warehouse.TryGetEncryptionProvider(encryptionProvider.DefaultName)); Assert.Equal( new[] { authenticationProvider.DefaultName }, server.AllowedAuthenticationProviders); Assert.Equal( new[] { encryptionProvider.DefaultName }, server.AllowedEncryptionProviders); } [Theory] [InlineData(false, "Warehouse.Configuration.Connections is required")] [InlineData(true, "MaximumConnections cannot be negative")] public async Task AddEsiur_RejectsNullOrNegativeConnectionConfiguration( bool useNegativeLimit, string expectedFailure) { await using var application = BuildApplication( builder => builder.AddMemoryStore("sys").AllowAnonymous(), configureWarehouse: configuration => { if (useNegativeLimit) configuration.Connections.MaximumConnections = -1; else configuration.Connections = null!; }); var exception = await Assert.ThrowsAsync( () => application.StartAsync().WaitAsync(TestTimeout)); Assert.Contains(expectedFailure, exception.Message, StringComparison.Ordinal); } [Fact] public async Task AddEsiur_RejectsPerConnectionSendLimitAboveHostBudget() { await using var application = BuildApplication(builder => builder .AddMemoryStore("sys") .AllowAnonymous() .LimitPendingWebSocketSendBytes(2) .LimitTotalPendingWebSocketSendBytes(1)); var exception = await Assert.ThrowsAsync( () => application.StartAsync().WaitAsync(TestTimeout)); Assert.Contains( "cannot exceed the host-wide send limit", exception.Message, StringComparison.Ordinal); } [Theory] [InlineData(false)] [InlineData(true)] public async Task AddEsiur_UsesCodeOnlyExceptionsUnlessMessagesAreExplicitlyIncluded( bool includeMessages) { await using var application = BuildApplication(builder => { builder.AddMemoryStore("sys").AllowAnonymous(); if (includeMessages) builder.IncludeExceptionMessages(); }); var options = application.Services .GetRequiredService>() .Value; var expected = ExceptionLevel.Code; if (includeMessages) expected |= ExceptionLevel.Message; Assert.Equal(expected, options.Server.ExceptionLevel); Assert.Equal(includeMessages, options.Server.ExceptionLevel.HasFlag(ExceptionLevel.Message)); Assert.False(options.Server.ExceptionLevel.HasFlag(ExceptionLevel.Source)); Assert.False(options.Server.ExceptionLevel.HasFlag(ExceptionLevel.Trace)); } [Fact] public async Task MappedEndpoint_DoesNotAffectOrdinaryHttp_AndRequiresWebSocketUpgrade() { await using var host = await StartApplicationAsync(); using var client = new HttpClient { BaseAddress = host.HttpAddress }; using var healthResponse = await client.GetAsync("/health"); Assert.Equal(HttpStatusCode.OK, healthResponse.StatusCode); Assert.Equal("healthy", await healthResponse.Content.ReadAsStringAsync()); using var endpointResponse = await client.GetAsync("/esiur"); Assert.Equal(HttpStatusCode.UpgradeRequired, endpointResponse.StatusCode); Assert.Equal("websocket", endpointResponse.Headers.GetValues("Upgrade").Single()); } [Theory] [InlineData(null)] [InlineData("not-ep")] public async Task MappedEndpoint_RejectsWebSocketHandshakeWithoutEpSubProtocol( string? protocol) { await using var host = await StartApplicationAsync(); using var client = new HttpClient { BaseAddress = host.HttpAddress }; using var request = CreateWebSocketUpgradeRequest("/esiur", protocol); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Contains( $"'{FrameworkWebSocket.SubProtocol}' WebSocket subprotocol is required", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); } [Fact] public async Task MappedEndpoint_RejectsCrossOriginBrowserHandshakeByDefault() { await using var host = await StartApplicationAsync(); using var client = new HttpClient { BaseAddress = host.HttpAddress }; using var request = CreateWebSocketUpgradeRequest( "/esiur", FrameworkWebSocket.SubProtocol); request.Headers.TryAddWithoutValidation("Origin", "https://untrusted.example"); using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task MappedEndpoint_ExplicitOriginAllowlist_ReplacesImplicitSameOriginPolicy() { const string trustedOrigin = "https://trusted.example"; await using var host = await StartApplicationAsync( configureEsiur: builder => builder.AllowWebSocketOrigins(trustedOrigin)); using var cancellation = new CancellationTokenSource(TestTimeout); using var client = new HttpClient { BaseAddress = host.HttpAddress }; using var sameOriginRequest = CreateWebSocketUpgradeRequest( "/esiur", FrameworkWebSocket.SubProtocol); sameOriginRequest.Headers.TryAddWithoutValidation( "Origin", host.HttpAddress.GetLeftPart(UriPartial.Authority)); using var sameOriginResponse = await client.SendAsync( sameOriginRequest, cancellation.Token); Assert.Equal(HttpStatusCode.Forbidden, sameOriginResponse.StatusCode); using var trustedSocket = new ClientWebSocket(); trustedSocket.Options.AddSubProtocol(FrameworkWebSocket.SubProtocol); trustedSocket.Options.SetRequestHeader("Origin", trustedOrigin); await trustedSocket.ConnectAsync(host.WebSocketAddress, cancellation.Token); Assert.Equal(WebSocketState.Open, trustedSocket.State); Assert.Equal(FrameworkWebSocket.SubProtocol, trustedSocket.SubProtocol); await WaitUntilAsync( () => host.Server.Connections.Count == 1, cancellation.Token); trustedSocket.Abort(); await WaitUntilAsync( () => host.Server.Connections.Count == 0, cancellation.Token); } [Fact] public async Task MappedEndpoint_AppliesConfiguredPendingSendLimitToAcceptedSocket() { const long pendingSendLimit = 4 * 1024; await using var host = await StartApplicationAsync( configureEsiur: builder => builder.LimitPendingWebSocketSendBytes(pendingSendLimit)); using var cancellation = new CancellationTokenSource(TestTimeout); var clientSocket = new FrameworkWebSocket(host.WebSocketAddress); try { Assert.True(await clientSocket.Connect(host.WebSocketAddress, cancellation.Token)); Assert.True(clientSocket.Begin()); await WaitUntilAsync( () => host.Server.Connections.Count == 1, cancellation.Token); var connection = Assert.Single(host.Server.Connections); var acceptedSocket = Assert.IsType(connection.Socket); Assert.Equal(pendingSendLimit, acceptedSocket.MaximumPendingSendBytes); Assert.NotNull(acceptedSocket.PendingSendBudget); } finally { clientSocket.Destroy(); } await WaitUntilAsync( () => host.Server.Connections.Count == 0, cancellation.Token); } [Fact] public async Task FrameworkWebSocket_ConnectsThroughKestrel_WithRealPeerAdmission() { await using var host = await StartApplicationAsync( configureWarehouse: configuration => configuration.Connections.MaximumConnectionsPerIpAddress = 1); using var cancellation = new CancellationTokenSource(TestTimeout); var socket = new FrameworkWebSocket(host.WebSocketAddress); try { Assert.True(await socket.Connect(host.WebSocketAddress, cancellation.Token)); Assert.Equal(SocketState.Established, socket.State); Assert.True(socket.Begin()); await WaitUntilAsync( () => host.Server.Connections.Count == 1, cancellation.Token); var connection = Assert.Single(host.Server.Connections); Assert.Equal(IPAddress.Loopback, connection.RemoteEndPoint.Address); Assert.Equal(1, host.Server.GetConnectionCount(IPAddress.Loopback)); var rejectedSocket = new FrameworkWebSocket(host.WebSocketAddress); try { // The HTTP upgrade can complete before the EP admission decision closes the // second transport. What matters is that it is never added to the server. if (await rejectedSocket.Connect(host.WebSocketAddress, cancellation.Token)) rejectedSocket.Begin(); await WaitUntilAsync( () => rejectedSocket.State == SocketState.Closed, cancellation.Token); Assert.Single(host.Server.Connections); Assert.Equal(1, host.Server.GetConnectionCount(IPAddress.Loopback)); } finally { rejectedSocket.Destroy(); } } finally { socket.Destroy(); } using var cleanupCancellation = new CancellationTokenSource(TestTimeout); await WaitUntilAsync( () => host.Server.Connections.Count == 0 && host.Server.GetConnectionCount(IPAddress.Loopback) == 0, cleanupCancellation.Token); } [Fact] public async Task HostShutdown_CancelsWebSocketAndCleansUpAdmission() { await using var host = await StartApplicationAsync(); using var cancellation = new CancellationTokenSource(TestTimeout); var socket = new FrameworkWebSocket(host.WebSocketAddress); try { Assert.True(await socket.Connect(host.WebSocketAddress, cancellation.Token)); Assert.True(socket.Begin()); await WaitUntilAsync( () => host.Server.Connections.Count == 1, cancellation.Token); await host.Application.StopAsync(cancellation.Token); Assert.True(socket.Completion.IsCompleted); Assert.Equal(SocketState.Closed, socket.State); Assert.Empty(host.Server.Connections); Assert.Equal(0, host.Server.GetConnectionCount(IPAddress.Loopback)); } finally { socket.Destroy(); } } [Fact] public async Task AspNetHosting_DoesNotOpenTheNativeEpTcpListener() { var nativePort = GetUnusedTcpPort(); await using var host = await StartApplicationAsync( configureServer: server => server.Port = checked((ushort)nativePort)); Assert.False(host.Server.EnableTcpListener); Assert.False(host.Server.IsRunning); var probe = new TcpListener(IPAddress.Loopback, nativePort); try { probe.Start(); } finally { probe.Stop(); } } [Fact] public async Task ExternalWarehouse_MustBeOpenBeforeEsiurStarts() { var warehouse = new Warehouse(); await warehouse.Put("sys", new MemoryStore()); var server = await warehouse.Put("sys/server", new EpServer { EnableTcpListener = false, AuthenticationTimeout = TestTimeout, }); var builder = WebApplication.CreateBuilder(); builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); builder.Services .AddEsiur(warehouse, server, manageWarehouseLifecycle: false) .UsePasswordAuthentication((_, _) => null); await using var application = builder.Build(); application.UseWebSockets(); application.MapEsiur(); var exception = await Assert.ThrowsAsync( () => application.StartAsync().WaitAsync(TestTimeout)); Assert.Contains( "externally managed Warehouse must be open", exception.Message, StringComparison.Ordinal); Assert.Null(warehouse.TryGetAuthenticationProvider( PasswordAuthenticationProvider.ProtocolName)); Assert.Empty(server.AllowedAuthenticationProviders); } [Fact] public async Task ExternalWarehouse_DoesNotRetainFacadeAuthenticationOnShutdown() { var warehouse = new Warehouse(); await warehouse.Put("sys", new MemoryStore()); var server = await warehouse.Put("sys/server", new EpServer { EnableTcpListener = false, AuthenticationTimeout = TestTimeout, }); await warehouse.Open(); var builder = WebApplication.CreateBuilder(); builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); builder.Services .AddEsiur(warehouse, server, manageWarehouseLifecycle: false) .UsePasswordAuthentication((_, _) => null); await using var application = builder.Build(); application.UseWebSockets(); application.MapEsiur(); try { using var cancellation = new CancellationTokenSource(TestTimeout); await application.StartAsync(cancellation.Token); Assert.NotNull(warehouse.TryGetAuthenticationProvider( PasswordAuthenticationProvider.ProtocolName)); Assert.Equal( new[] { PasswordAuthenticationProvider.ProtocolName }, server.AllowedAuthenticationProviders); await application.StopAsync(cancellation.Token); Assert.Null(warehouse.TryGetAuthenticationProvider( PasswordAuthenticationProvider.ProtocolName)); Assert.Empty(server.AllowedAuthenticationProviders); Assert.True(warehouse.IsOpen); } finally { if (warehouse.IsOpen) await warehouse.Close(); } } [Fact] public async Task PasswordShortcut_UsesValidatedNonAliasedCredentialsAndUnlinkableDummies() { var knownHash = Enumerable.Range(1, 32).Select(value => (byte)value).ToArray(); var knownSalt = Enumerable.Range(33, 32).Select(value => (byte)value).ToArray(); await using var application = BuildApplication(builder => builder .AddMemoryStore("sys") .UsePasswordAuthentication((identity, _) => identity switch { "known" => new PasswordHash(knownHash, knownSalt), "bad-hash" => new PasswordHash(new byte[31], new byte[32]), "bad-salt" => new PasswordHash(new byte[32], new byte[31]), "partial" => new PasswordHash(new byte[32], null!), _ => null, })); using var cancellation = new CancellationTokenSource(TestTimeout); await application.StartAsync(cancellation.Token); var warehouse = application.Services.GetRequiredService(); var provider = Assert.IsAssignableFrom( warehouse.TryGetAuthenticationProvider( PasswordAuthenticationProvider.ProtocolName)); var firstUnknown = provider.GetHostedAccountCredential("missing", "example"); var secondUnknown = provider.GetHostedAccountCredential("missing", "example"); var otherUnknown = provider.GetHostedAccountCredential("other", "example"); var originalDummyHash = (byte[])firstUnknown.Hash.Clone(); var originalDummySalt = (byte[])firstUnknown.Salt.Clone(); Assert.Equal(32, firstUnknown.Hash.Length); Assert.Equal(32, firstUnknown.Salt.Length); Assert.Equal(originalDummyHash, secondUnknown.Hash); Assert.Equal(originalDummySalt, secondUnknown.Salt); Assert.NotSame(firstUnknown.Hash, secondUnknown.Hash); Assert.NotSame(firstUnknown.Salt, secondUnknown.Salt); Assert.NotEqual(originalDummyHash, otherUnknown.Hash); Assert.NotEqual(originalDummySalt, otherUnknown.Salt); Assert.NotEqual(knownHash, originalDummyHash); Assert.NotEqual(knownSalt, originalDummySalt); firstUnknown.Hash[0] ^= 0xff; firstUnknown.Salt[0] ^= 0xff; var thirdUnknown = provider.GetHostedAccountCredential("missing", "example"); Assert.Equal(originalDummyHash, thirdUnknown.Hash); Assert.Equal(originalDummySalt, thirdUnknown.Salt); var firstKnown = provider.GetHostedAccountCredential("known", "example"); Assert.Equal(knownHash, firstKnown.Hash); Assert.Equal(knownSalt, firstKnown.Salt); Assert.NotSame(knownHash, firstKnown.Hash); Assert.NotSame(knownSalt, firstKnown.Salt); firstKnown.Hash[0] ^= 0xff; firstKnown.Salt[0] ^= 0xff; var secondKnown = provider.GetHostedAccountCredential("known", "example"); Assert.Equal(knownHash, secondKnown.Hash); Assert.Equal(knownSalt, secondKnown.Salt); Assert.Throws(() => provider.GetHostedAccountCredential("bad-hash", "example")); Assert.Throws(() => provider.GetHostedAccountCredential("bad-salt", "example")); Assert.Throws(() => provider.GetHostedAccountCredential("partial", "example")); Assert.Null(provider.GetHostedAccountCredential( new string('i', 513), "example").Hash); Assert.Null(provider.GetHostedAccountCredential( "missing", new string('d', 513)).Hash); } [Fact] public async Task StartupFailure_RollsBackResourcesOwnedByTheFacade() { var attachedBeforeFailure = new LifecycleTestResource(); var authenticationProvider = new PasswordAuthenticationProvider(); await using var application = BuildApplication(builder => builder .AddMemoryStore("sys") .AddResource("sys/first", attachedBeforeFailure) .AddResource( "sys/failure", _ => throw new InvalidOperationException("factory failed")) .UseAuthentication(authenticationProvider)); var warehouse = application.Services.GetRequiredService(); var server = application.Services.GetRequiredService(); var exception = await Assert.ThrowsAsync( () => application.StartAsync().WaitAsync(TestTimeout)); Assert.Contains("factory failed", exception.Message, StringComparison.Ordinal); Assert.Null(attachedBeforeFailure.Instance); Assert.Null(warehouse.GetStore("sys")); Assert.Null(server.Instance); Assert.False(warehouse.IsOpen); Assert.Equal(0, attachedBeforeFailure.TerminationCount); Assert.True(server.EnableTcpListener); Assert.Empty(server.AllowedAuthenticationProviders); Assert.Null(warehouse.TryGetAuthenticationProvider( PasswordAuthenticationProvider.ProtocolName)); } [Fact] public async Task StartupCancellationDuringWarehouseOpen_CompletesRollback() { var resource = new BlockingStartupResource(); await using var application = BuildApplication(builder => builder .AddMemoryStore("sys") .AddResource("sys/blocking", resource) .AllowAnonymous()); using var cancellation = new CancellationTokenSource(); var start = application.StartAsync(cancellation.Token); await resource.InitializeStarted.WaitAsync(TestTimeout); cancellation.Cancel(); resource.ReleaseInitialize(); await Assert.ThrowsAnyAsync( () => start.WaitAsync(TestTimeout)); Assert.Equal(1, resource.TerminationCount); Assert.Null(resource.Instance); } [Fact] public async Task HostShutdown_ObservesCancellationWhenResourceTerminationStalls() { var stalledResource = new LifecycleTestResource(stallTermination: true); await using var host = await StartApplicationAsync( configureEsiur: builder => builder.AddResource("sys/stalled", stalledResource)); using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); var stop = host.Application.StopAsync(cancellation.Token); await Task.Delay(TimeSpan.FromMilliseconds(200)); stalledResource.ReleaseTermination(); await Assert.ThrowsAnyAsync( () => stop); Assert.False(host.Server.IsRunning); Assert.Empty(host.Server.Connections); Assert.Null(stalledResource.Instance); Assert.False(host.Application.Services.GetRequiredService().IsOpen); } private static WebApplication BuildApplication( Action? configureEsiur = null, Action? configureServer = null, Action? configureWarehouse = null) { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Development, }); builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); var warehouse = new Warehouse(); var server = new EpServer { AuthenticationTimeout = TestTimeout, }; var esiur = builder.Services.AddEsiur(warehouse, server); configureEsiur?.Invoke(esiur); if (configureServer is not null) esiur.ConfigureServer(configureServer); if (configureWarehouse is not null) esiur.ConfigureWarehouse(configureWarehouse); var application = builder.Build(); application.UseWebSockets(); application.MapGet("/health", () => Results.Text("healthy")); application.MapEsiur("/esiur"); return application; } private static async Task StartApplicationAsync( Action? configureServer = null, Action? configureWarehouse = null, Action? configureEsiur = null) { var application = BuildApplication( esiur => { esiur.AddMemoryStore("sys").AllowAnonymous(); configureEsiur?.Invoke(esiur); }, configureServer, configureWarehouse); using var cancellation = new CancellationTokenSource(TestTimeout); await application.StartAsync(cancellation.Token); var addresses = application.Services .GetRequiredService() .Features .Get() ?.Addresses; var address = Assert.Single(addresses!); var httpAddress = new Uri(address); var webSocketAddress = new UriBuilder(httpAddress) { Scheme = "ws", Path = "/esiur", }.Uri; return new TestApplication(application, httpAddress, webSocketAddress); } private static HttpRequestMessage CreateWebSocketUpgradeRequest( string path, string? protocol) { var request = new HttpRequestMessage(HttpMethod.Get, path) { Version = HttpVersion.Version11, VersionPolicy = HttpVersionPolicy.RequestVersionExact, }; request.Headers.TryAddWithoutValidation("Connection", "Upgrade"); request.Headers.TryAddWithoutValidation("Upgrade", "websocket"); request.Headers.TryAddWithoutValidation("Sec-WebSocket-Version", "13"); request.Headers.TryAddWithoutValidation( "Sec-WebSocket-Key", Convert.ToBase64String(Guid.NewGuid().ToByteArray())); if (protocol is not null) request.Headers.TryAddWithoutValidation("Sec-WebSocket-Protocol", protocol); return request; } private static async Task WaitUntilAsync( Func condition, CancellationToken cancellationToken) { while (!condition()) await Task.Delay(10, cancellationToken); } private static int GetUnusedTcpPort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); try { return ((IPEndPoint)listener.LocalEndpoint).Port; } finally { listener.Stop(); } } private sealed class TestApplication : IAsyncDisposable { public TestApplication( WebApplication application, Uri httpAddress, Uri webSocketAddress) { Application = application; HttpAddress = httpAddress; WebSocketAddress = webSocketAddress; Server = application.Services.GetRequiredService(); } public WebApplication Application { get; } public Uri HttpAddress { get; } public Uri WebSocketAddress { get; } public EpServer Server { get; } public async ValueTask DisposeAsync() { try { using var cancellation = new CancellationTokenSource(TestTimeout); await Application.StopAsync(cancellation.Token); } finally { await Application.DisposeAsync(); } } } private sealed class LifecycleTestResource : IResource { private readonly bool stallTermination; private readonly AsyncReply termination = new(); private int terminationCount; public LifecycleTestResource(bool stallTermination = false) { this.stallTermination = stallTermination; } public event DestroyedEvent? OnDestroy; public Instance? Instance { get; set; } public int TerminationCount => Volatile.Read(ref terminationCount); public void ReleaseTermination() => termination.Trigger(true); public AsyncReply Handle( ResourceOperation operation, IResourceContext? context = null) { if (operation == ResourceOperation.Terminate) { Interlocked.Increment(ref terminationCount); if (stallTermination) return termination; } return new AsyncReply(true); } public void Destroy() => OnDestroy?.Invoke(this); } private sealed class BlockingStartupResource : IResource { private readonly AsyncReply initialize = new(); private readonly TaskCompletionSource initializeStarted = new( TaskCreationOptions.RunContinuationsAsynchronously); private int terminationCount; public event DestroyedEvent? OnDestroy; public Instance? Instance { get; set; } public Task InitializeStarted => initializeStarted.Task; public int TerminationCount => Volatile.Read(ref terminationCount); public void ReleaseInitialize() => initialize.Trigger(true); public AsyncReply Handle( ResourceOperation operation, IResourceContext? context = null) { if (operation == ResourceOperation.Initialize) { initializeStarted.TrySetResult(); return initialize; } if (operation == ResourceOperation.Terminate) Interlocked.Increment(ref terminationCount); return new AsyncReply(true); } public void Destroy() => OnDestroy?.Invoke(this); } }