mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
ASP.Net
This commit is contained in:
@@ -0,0 +1,849 @@
|
||||
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<OptionsValidationException>(
|
||||
() => 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<OptionsValidationException>(
|
||||
() => 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<PasswordAuthenticationProvider>()
|
||||
.UseAuthentication(_ => authenticationProvider)
|
||||
.UseEncryption(encryptionProvider)
|
||||
.UseEncryption<AesEncryptionProvider>()
|
||||
.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<Warehouse>();
|
||||
var server = application.Services.GetRequiredService<EpServer>();
|
||||
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<OptionsValidationException>(
|
||||
() => 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<OptionsValidationException>(
|
||||
() => 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<IOptions<EsiurOptions>>()
|
||||
.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<FrameworkWebSocket>(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<InvalidOperationException>(
|
||||
() => 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<Warehouse>();
|
||||
var provider = Assert.IsAssignableFrom<PasswordAuthenticationProvider>(
|
||||
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<InvalidOperationException>(() =>
|
||||
provider.GetHostedAccountCredential("bad-hash", "example"));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
provider.GetHostedAccountCredential("bad-salt", "example"));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
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<LifecycleTestResource>(
|
||||
"sys/failure",
|
||||
_ => throw new InvalidOperationException("factory failed"))
|
||||
.UseAuthentication(authenticationProvider));
|
||||
var warehouse = application.Services.GetRequiredService<Warehouse>();
|
||||
var server = application.Services.GetRequiredService<EpServer>();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => 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<OperationCanceledException>(
|
||||
() => 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<OperationCanceledException>(
|
||||
() => stop);
|
||||
|
||||
Assert.False(host.Server.IsRunning);
|
||||
Assert.Empty(host.Server.Connections);
|
||||
Assert.Null(stalledResource.Instance);
|
||||
Assert.False(host.Application.Services.GetRequiredService<Warehouse>().IsOpen);
|
||||
}
|
||||
|
||||
private static WebApplication BuildApplication(
|
||||
Action<EsiurBuilder>? configureEsiur = null,
|
||||
Action<EpServer>? configureServer = null,
|
||||
Action<WarehouseConfiguration>? 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<TestApplication> StartApplicationAsync(
|
||||
Action<EpServer>? configureServer = null,
|
||||
Action<WarehouseConfiguration>? configureWarehouse = null,
|
||||
Action<EsiurBuilder>? 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<IServer>()
|
||||
.Features
|
||||
.Get<IServerAddressesFeature>()
|
||||
?.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<bool> 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<EpServer>();
|
||||
}
|
||||
|
||||
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<bool> 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<bool> Handle(
|
||||
ResourceOperation operation,
|
||||
IResourceContext? context = null)
|
||||
{
|
||||
if (operation == ResourceOperation.Terminate)
|
||||
{
|
||||
Interlocked.Increment(ref terminationCount);
|
||||
if (stallTermination)
|
||||
return termination;
|
||||
}
|
||||
|
||||
return new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
public void Destroy() => OnDestroy?.Invoke(this);
|
||||
}
|
||||
|
||||
private sealed class BlockingStartupResource : IResource
|
||||
{
|
||||
private readonly AsyncReply<bool> 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<bool> 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<bool>(true);
|
||||
}
|
||||
|
||||
public void Destroy() => OnDestroy?.Invoke(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Net;
|
||||
using Esiur.Core;
|
||||
using Esiur.Net;
|
||||
using Esiur.Net.Sockets;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public sealed class EpConnectionReconnectTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InitialAutoReconnect_CreatesAFreshSocketAfterConnectFailure()
|
||||
{
|
||||
var failedSocket = new ConnectTestSocket(connects: false);
|
||||
var connectedSocket = new ConnectTestSocket(connects: true);
|
||||
var sockets = new Queue<ConnectTestSocket>(
|
||||
new[] { failedSocket, connectedSocket });
|
||||
var connection = new EpConnection
|
||||
{
|
||||
AutoReconnect = true,
|
||||
ReconnectInterval = 0,
|
||||
ClientSocketFactory = () => sockets.Dequeue(),
|
||||
};
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("client", connection);
|
||||
|
||||
var open = connection.Connect(
|
||||
hostname: "localhost",
|
||||
port: 10518,
|
||||
domain: "test");
|
||||
|
||||
var completed = await Task.WhenAny(
|
||||
connectedSocket.Began,
|
||||
Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.True(
|
||||
ReferenceEquals(completed, connectedSocket.Began),
|
||||
$"Fresh socket was not started (remaining={sockets.Count}, " +
|
||||
$"failed-connects={failedSocket.ConnectCount}, " +
|
||||
$"replacement-connects={connectedSocket.ConnectCount}, " +
|
||||
$"open-error={open.Exception?.Message}).");
|
||||
|
||||
Assert.Equal(1, failedSocket.ConnectCount);
|
||||
Assert.Equal(1, connectedSocket.ConnectCount);
|
||||
Assert.Equal(1, connectedSocket.BeginCount);
|
||||
Assert.Same(connectedSocket, connection.Socket);
|
||||
Assert.Empty(sockets);
|
||||
|
||||
connection.Destroy();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DestroyDuringPendingConnect_DoesNotAttachLateSocket()
|
||||
{
|
||||
var delayedSocket = new ConnectTestSocket(connects: null);
|
||||
var connection = new EpConnection
|
||||
{
|
||||
ClientSocketFactory = () => delayedSocket,
|
||||
};
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("client", connection);
|
||||
|
||||
var open = connection.Connect(
|
||||
hostname: "localhost",
|
||||
port: 10518,
|
||||
domain: "test");
|
||||
|
||||
await delayedSocket.ConnectInvoked.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
connection.Destroy();
|
||||
delayedSocket.CompleteConnect(true);
|
||||
|
||||
Assert.Null(connection.Socket);
|
||||
Assert.Equal(0, delayedSocket.BeginCount);
|
||||
Assert.Equal(SocketState.Closed, delayedSocket.State);
|
||||
Assert.NotNull(open.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisablingAutoReconnect_CompletesDelayedOpenAndAllowsAnotherConnect()
|
||||
{
|
||||
var failedSocket = new ConnectTestSocket(connects: false);
|
||||
var factoryCalls = 0;
|
||||
var connection = new EpConnection
|
||||
{
|
||||
AutoReconnect = true,
|
||||
ReconnectInterval = 30,
|
||||
ClientSocketFactory = () =>
|
||||
{
|
||||
factoryCalls++;
|
||||
return failedSocket;
|
||||
},
|
||||
};
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("client", connection);
|
||||
|
||||
var open = connection.Connect(
|
||||
hostname: "localhost",
|
||||
port: 10518,
|
||||
domain: "test");
|
||||
|
||||
connection.AutoReconnect = false;
|
||||
|
||||
Assert.NotNull(open.Exception);
|
||||
Assert.Equal(EpConnectionStatus.Closed, connection.Status);
|
||||
Assert.Equal(1, factoryCalls);
|
||||
|
||||
var replacement = new ConnectTestSocket(connects: true);
|
||||
var secondOpen = connection.Connect(replacement);
|
||||
Assert.Null(secondOpen.Exception);
|
||||
Assert.Same(replacement, connection.Socket);
|
||||
|
||||
connection.Destroy();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisablingAutoReconnectDuringDisconnectDelay_PreventsReconnect()
|
||||
{
|
||||
var initialSocket = new ConnectTestSocket(connects: true);
|
||||
var factoryCalls = 0;
|
||||
var connection = new EpConnection
|
||||
{
|
||||
AutoReconnect = true,
|
||||
ReconnectInterval = 1,
|
||||
ClientSocketFactory = () =>
|
||||
{
|
||||
factoryCalls++;
|
||||
return new ConnectTestSocket(connects: true);
|
||||
},
|
||||
};
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("client", connection);
|
||||
|
||||
_ = connection.Connect(
|
||||
initialSocket,
|
||||
hostname: "localhost",
|
||||
port: 10518,
|
||||
domain: "test");
|
||||
initialSocket.Disconnect();
|
||||
connection.AutoReconnect = false;
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1_200));
|
||||
|
||||
Assert.Equal(0, factoryCalls);
|
||||
connection.Destroy();
|
||||
}
|
||||
|
||||
private sealed class ConnectTestSocket : ISocket
|
||||
{
|
||||
private readonly bool? connects;
|
||||
private readonly TaskCompletionSource began = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource connectInvoked = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private AsyncReply<bool>? pendingConnect;
|
||||
private SocketState state = SocketState.Initial;
|
||||
|
||||
public ConnectTestSocket(bool? connects) => this.connects = connects;
|
||||
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
public SocketState State => state;
|
||||
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
|
||||
public IPEndPoint RemoteEndPoint { get; } =
|
||||
new(IPAddress.Loopback, 10518);
|
||||
public IPEndPoint LocalEndPoint { get; } =
|
||||
new(IPAddress.Loopback, 50000);
|
||||
public int ConnectCount { get; private set; }
|
||||
public int BeginCount { get; private set; }
|
||||
public Task Began => began.Task;
|
||||
public Task ConnectInvoked => connectInvoked.Task;
|
||||
|
||||
public AsyncReply<bool> Connect(string hostname, ushort port)
|
||||
{
|
||||
ConnectCount++;
|
||||
connectInvoked.TrySetResult();
|
||||
if (connects is null)
|
||||
{
|
||||
pendingConnect = new AsyncReply<bool>();
|
||||
return pendingConnect;
|
||||
}
|
||||
|
||||
if (connects.Value)
|
||||
{
|
||||
state = SocketState.Established;
|
||||
return new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
state = SocketState.Closed;
|
||||
var reply = new AsyncReply<bool>();
|
||||
reply.TriggerError(new InvalidOperationException("connect failed"));
|
||||
return reply;
|
||||
}
|
||||
|
||||
public void CompleteConnect(bool connected)
|
||||
{
|
||||
var reply = pendingConnect
|
||||
?? throw new InvalidOperationException("No connection is pending.");
|
||||
pendingConnect = null;
|
||||
state = connected ? SocketState.Established : SocketState.Closed;
|
||||
reply.Trigger(connected);
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
state = SocketState.Closed;
|
||||
Receiver.NetworkClose(this);
|
||||
}
|
||||
|
||||
public bool Begin()
|
||||
{
|
||||
BeginCount++;
|
||||
began.TrySetResult();
|
||||
return state == SocketState.Established;
|
||||
}
|
||||
|
||||
public AsyncReply<bool> BeginAsync() => new(Begin());
|
||||
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length) =>
|
||||
new(state == SocketState.Established);
|
||||
public void Send(byte[] message) { }
|
||||
public void Send(byte[] message, int offset, int length) { }
|
||||
public void Hold() { }
|
||||
public void Unhold() { }
|
||||
public void Close() => state = SocketState.Closed;
|
||||
public void Destroy()
|
||||
{
|
||||
state = SocketState.Closed;
|
||||
OnDestroy?.Invoke(this);
|
||||
}
|
||||
|
||||
public AsyncReply<ISocket> AcceptAsync() =>
|
||||
throw new NotSupportedException();
|
||||
public ISocket Accept() => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
@@ -22,6 +26,7 @@
|
||||
mirroring Tests/Features/Functional so [Resource]/[Export] test types get generated code. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" OutputItemType="Analyzer" />
|
||||
<ProjectReference Include="..\..\Integrations\Esiur.AspNetCore\Esiur.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
using Esiur.Net;
|
||||
using Esiur.Net.Sockets;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class FrameworkWebSocketTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AcceptedSocket_ReceivesBinaryDataAndCompletesOnceOnPeerClose()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
platformSocket.QueueBinary(new byte[] { 1, 2, 3 });
|
||||
platformSocket.QueueClose();
|
||||
|
||||
var local = new IPEndPoint(IPAddress.Loopback, 443);
|
||||
var remote = new IPEndPoint(IPAddress.Parse("192.0.2.10"), 55123);
|
||||
var socket = new FrameworkWebSocket(platformSocket, local, remote);
|
||||
var receiver = new RecordingReceiver();
|
||||
socket.Receiver = receiver;
|
||||
|
||||
Assert.True(socket.Begin());
|
||||
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(local, socket.LocalEndPoint);
|
||||
Assert.Equal(remote, socket.RemoteEndPoint);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, Assert.Single(receiver.Messages));
|
||||
Assert.Equal(1, receiver.CloseCount);
|
||||
Assert.Equal(1, platformSocket.CloseOutputCount);
|
||||
|
||||
socket.Close();
|
||||
socket.Destroy();
|
||||
Assert.Equal(1, receiver.CloseCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("ep")]
|
||||
[InlineData("not-ep")]
|
||||
public void AcceptedSocket_RequiresExactEpSubprotocol(string? subProtocol)
|
||||
{
|
||||
var platformSocket = new TestWebSocket(subProtocol);
|
||||
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Parse("192.0.2.10"), 55123)));
|
||||
|
||||
Assert.Contains("'EP' subprotocol", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OwnerCancellation_EndsReceiveLoopWithoutFaultOrDuplicateClose()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000),
|
||||
cancellation.Token);
|
||||
var receiver = new RecordingReceiver();
|
||||
socket.Receiver = receiver;
|
||||
|
||||
Assert.True(socket.Begin());
|
||||
cancellation.Cancel();
|
||||
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
socket.Close();
|
||||
socket.Destroy();
|
||||
socket.Destroy();
|
||||
|
||||
Assert.Equal(1, receiver.CloseCount);
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LocalClose_WaitsForPeerAcknowledgementBeforeCompleting()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000));
|
||||
var receiver = new RecordingReceiver();
|
||||
socket.Receiver = receiver;
|
||||
|
||||
Assert.True(socket.Begin());
|
||||
var completion = socket.CloseAsync();
|
||||
|
||||
await Task.Delay(25);
|
||||
Assert.False(completion.IsCompleted);
|
||||
Assert.Equal(1, platformSocket.CloseOutputCount);
|
||||
|
||||
platformSocket.QueueClose();
|
||||
await completion.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(1, receiver.CloseCount);
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimultaneousPeerClose_AcknowledgesAfterAnInFlightSend()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
platformSocket.BlockSends();
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000));
|
||||
socket.Receiver = new RecordingReceiver();
|
||||
|
||||
Assert.True(socket.Begin());
|
||||
var send = socket.SendAsync(new byte[] { 1 }, 0, 1);
|
||||
await platformSocket.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var completion = socket.CloseAsync();
|
||||
platformSocket.QueueClose();
|
||||
platformSocket.ReleaseSends();
|
||||
|
||||
await completion.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.True(await send);
|
||||
Assert.Equal(1, platformSocket.CloseOutputCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendQueue_OverflowAbortsOnceAndSettlesEveryReply()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
platformSocket.BlockSends();
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000))
|
||||
{
|
||||
MaximumPendingSendBytes = 4,
|
||||
};
|
||||
var receiver = new RecordingReceiver();
|
||||
socket.Receiver = receiver;
|
||||
|
||||
var first = socket.SendAsync(new byte[] { 1, 2 }, 0, 2);
|
||||
await platformSocket.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var second = socket.SendAsync(new byte[] { 3, 4 }, 0, 2);
|
||||
var overflow = socket.SendAsync(new byte[] { 5 }, 0, 1);
|
||||
|
||||
Assert.True(overflow.Failed);
|
||||
Assert.Contains("send queue exceeded", overflow.Exception?.Message);
|
||||
Assert.True(second.Failed);
|
||||
Assert.Contains("send queue exceeded", second.Exception?.Message);
|
||||
Assert.False(await first);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => socket.Completion);
|
||||
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Equal(0, socket.PendingSendBytes);
|
||||
Assert.Equal(1, platformSocket.AbortCount);
|
||||
Assert.Equal(1, receiver.CloseCount);
|
||||
Assert.Empty(platformSocket.SentMessages);
|
||||
|
||||
var later = socket.SendAsync(new byte[] { 6 }, 0, 1);
|
||||
Assert.True(later.Ready);
|
||||
Assert.False(await later);
|
||||
|
||||
socket.Destroy();
|
||||
Assert.Equal(1, platformSocket.AbortCount);
|
||||
Assert.Equal(1, receiver.CloseCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SynchronousSend_OverflowAbortsBeforeThrowing()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000))
|
||||
{
|
||||
MaximumPendingSendBytes = 1,
|
||||
};
|
||||
var receiver = new RecordingReceiver();
|
||||
socket.Receiver = receiver;
|
||||
socket.Hold();
|
||||
|
||||
socket.Send(new byte[] { 1 }, 0, 1);
|
||||
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||
socket.Send(new byte[] { 2 }, 0, 1));
|
||||
|
||||
Assert.Contains("send queue exceeded", error.Message);
|
||||
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Equal(0, socket.PendingSendBytes);
|
||||
Assert.Equal(1, platformSocket.AbortCount);
|
||||
Assert.Equal(1, receiver.CloseCount);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => socket.Completion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SharedSendBudget_BoundsAggregateQueuedBytesAcrossSockets()
|
||||
{
|
||||
var budget = new TestPendingSendBudget(3);
|
||||
var firstPlatform = new TestWebSocket();
|
||||
firstPlatform.BlockSends();
|
||||
var firstSocket = CreateAcceptedSocket(firstPlatform, budget);
|
||||
var secondSocket = CreateAcceptedSocket(new TestWebSocket(), budget);
|
||||
|
||||
var firstSend = firstSocket.SendAsync(new byte[] { 1, 2 }, 0, 2);
|
||||
await firstPlatform.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var overflow = secondSocket.SendAsync(new byte[] { 3, 4 }, 0, 2);
|
||||
|
||||
Assert.True(overflow.Failed);
|
||||
Assert.Contains("host-wide", overflow.Exception?.Message);
|
||||
Assert.Equal(SocketState.Closed, secondSocket.State);
|
||||
Assert.Equal(2, budget.ReservedBytes);
|
||||
|
||||
firstSocket.Destroy();
|
||||
Assert.False(await firstSend);
|
||||
await firstSocket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(0, budget.ReservedBytes);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => secondSocket.Completion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completion_DoesNotWaitForAnInProgressSendReplyCallback()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
platformSocket.BlockSends();
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000));
|
||||
using var callbackStarted = new ManualResetEventSlim();
|
||||
using var releaseCallback = new ManualResetEventSlim();
|
||||
|
||||
try
|
||||
{
|
||||
var send = socket.SendAsync(new byte[] { 1 }, 0, 1);
|
||||
_ = send.Then(_ =>
|
||||
{
|
||||
callbackStarted.Set();
|
||||
releaseCallback.Wait();
|
||||
});
|
||||
await platformSocket.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
platformSocket.ReleaseSends();
|
||||
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
socket.Destroy();
|
||||
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(releaseCallback.IsSet);
|
||||
Assert.True(send.Ready);
|
||||
Assert.True(await send);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseCallback.Set();
|
||||
socket.Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Destroy_DoesNotWaitForABlockedQueuedSendReplyCallback()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000));
|
||||
using var callbackStarted = new ManualResetEventSlim();
|
||||
using var releaseCallback = new ManualResetEventSlim();
|
||||
|
||||
try
|
||||
{
|
||||
socket.Hold();
|
||||
var send = socket.SendAsync(new byte[] { 1 }, 0, 1);
|
||||
_ = send.Then(_ =>
|
||||
{
|
||||
callbackStarted.Set();
|
||||
releaseCallback.Wait();
|
||||
});
|
||||
|
||||
socket.Destroy();
|
||||
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(send.Ready);
|
||||
Assert.False(await send);
|
||||
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
Assert.False(releaseCallback.IsSet);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseCallback.Set();
|
||||
socket.Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TextMessage_FaultsCompletionAndNotifiesCloseOnce()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
platformSocket.QueueText("not EP");
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000));
|
||||
var receiver = new RecordingReceiver();
|
||||
socket.Receiver = receiver;
|
||||
|
||||
Assert.True(socket.Begin());
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () =>
|
||||
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
socket.Close();
|
||||
socket.Destroy();
|
||||
Assert.Equal(1, receiver.CloseCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completion_DoesNotWaitForABlockedReceiverCloseCallback()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000));
|
||||
using var callbackStarted = new ManualResetEventSlim();
|
||||
using var releaseCallback = new ManualResetEventSlim();
|
||||
socket.Receiver = new BlockingCloseReceiver(callbackStarted, releaseCallback);
|
||||
|
||||
try
|
||||
{
|
||||
socket.Destroy();
|
||||
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
Assert.False(socket.CloseNotification.IsCompleted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseCallback.Set();
|
||||
}
|
||||
|
||||
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThrowingSendCallback_DoesNotCorruptQueueOrCloseTransport()
|
||||
{
|
||||
var platformSocket = new TestWebSocket();
|
||||
var socket = new FrameworkWebSocket(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000));
|
||||
|
||||
socket.Hold();
|
||||
var reply = socket.SendAsync(new byte[] { 42 }, 0, 1);
|
||||
_ = reply.Then(_ => throw new InvalidOperationException("consumer callback failed"));
|
||||
socket.Unhold();
|
||||
|
||||
Assert.True(await reply);
|
||||
Assert.Equal(SocketState.Established, socket.State);
|
||||
Assert.Equal(0, socket.PendingSendBytes);
|
||||
Assert.Equal(new byte[] { 42 }, Assert.Single(platformSocket.SentMessages));
|
||||
|
||||
socket.Destroy();
|
||||
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboundUri_RequiresWebSocketScheme()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new FrameworkWebSocket(new Uri("https://example.test/esiur")));
|
||||
|
||||
_ = new FrameworkWebSocket(new Uri("wss://example.test/esiur?tenant=one"));
|
||||
Assert.Equal("EP", FrameworkWebSocket.SubProtocol);
|
||||
}
|
||||
|
||||
private sealed class RecordingReceiver : INetworkReceiver<ISocket>
|
||||
{
|
||||
public List<byte[]> Messages { get; } = new List<byte[]>();
|
||||
public int CloseCount;
|
||||
|
||||
public void NetworkClose(ISocket sender) => Interlocked.Increment(ref CloseCount);
|
||||
|
||||
public void NetworkConnect(ISocket sender)
|
||||
{
|
||||
}
|
||||
|
||||
public void NetworkReceive(ISocket sender, NetworkBuffer buffer)
|
||||
{
|
||||
var message = buffer.Read();
|
||||
if (message != null)
|
||||
Messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingCloseReceiver : INetworkReceiver<ISocket>
|
||||
{
|
||||
private readonly ManualResetEventSlim callbackStarted;
|
||||
private readonly ManualResetEventSlim releaseCallback;
|
||||
|
||||
public BlockingCloseReceiver(
|
||||
ManualResetEventSlim callbackStarted,
|
||||
ManualResetEventSlim releaseCallback)
|
||||
{
|
||||
this.callbackStarted = callbackStarted;
|
||||
this.releaseCallback = releaseCallback;
|
||||
}
|
||||
|
||||
public void NetworkClose(ISocket sender)
|
||||
{
|
||||
callbackStarted.Set();
|
||||
releaseCallback.Wait();
|
||||
}
|
||||
|
||||
public void NetworkConnect(ISocket sender)
|
||||
{
|
||||
}
|
||||
|
||||
public void NetworkReceive(ISocket sender, NetworkBuffer buffer)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static FrameworkWebSocket CreateAcceptedSocket(
|
||||
TestWebSocket platformSocket,
|
||||
IPendingSendBudget budget)
|
||||
=> new(
|
||||
platformSocket,
|
||||
new IPEndPoint(IPAddress.Loopback, 443),
|
||||
new IPEndPoint(IPAddress.Loopback, 50000))
|
||||
{
|
||||
PendingSendBudget = budget,
|
||||
};
|
||||
|
||||
private sealed class TestPendingSendBudget : IPendingSendBudget
|
||||
{
|
||||
private readonly long limit;
|
||||
private long reservedBytes;
|
||||
|
||||
public TestPendingSendBudget(long limit) => this.limit = limit;
|
||||
|
||||
public long ReservedBytes => Interlocked.Read(ref reservedBytes);
|
||||
|
||||
public bool TryReserve(int byteCount)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = ReservedBytes;
|
||||
if (byteCount > limit - current)
|
||||
return false;
|
||||
if (Interlocked.CompareExchange(
|
||||
ref reservedBytes,
|
||||
current + byteCount,
|
||||
current) == current)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Release(int byteCount) =>
|
||||
Interlocked.Add(ref reservedBytes, -byteCount);
|
||||
}
|
||||
|
||||
private sealed class TestWebSocket : WebSocket
|
||||
{
|
||||
private readonly ConcurrentQueue<ReceiveFrame> receiveFrames = new();
|
||||
private readonly SemaphoreSlim receiveSignal = new(0);
|
||||
private WebSocketState state = WebSocketState.Open;
|
||||
private WebSocketCloseStatus? closeStatus;
|
||||
private string? closeStatusDescription;
|
||||
private readonly string? subProtocol;
|
||||
private TaskCompletionSource? sendStarted;
|
||||
private TaskCompletionSource? sendRelease;
|
||||
|
||||
public TestWebSocket(string? subProtocol = FrameworkWebSocket.SubProtocol)
|
||||
=> this.subProtocol = subProtocol;
|
||||
|
||||
public List<byte[]> SentMessages { get; } = new List<byte[]>();
|
||||
public int AbortCount;
|
||||
public int CloseOutputCount;
|
||||
public Task SendStarted => sendStarted?.Task ?? Task.CompletedTask;
|
||||
|
||||
public void BlockSends()
|
||||
{
|
||||
sendStarted = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
sendRelease = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
public void ReleaseSends() => sendRelease?.TrySetResult();
|
||||
|
||||
public override WebSocketCloseStatus? CloseStatus => closeStatus;
|
||||
public override string? CloseStatusDescription => closeStatusDescription;
|
||||
public override WebSocketState State => state;
|
||||
public override string? SubProtocol => subProtocol;
|
||||
|
||||
public void QueueBinary(byte[] data) =>
|
||||
Queue(new ReceiveFrame(data, WebSocketMessageType.Binary, true));
|
||||
|
||||
public void QueueText(string data) =>
|
||||
Queue(new ReceiveFrame(
|
||||
System.Text.Encoding.UTF8.GetBytes(data),
|
||||
WebSocketMessageType.Text,
|
||||
true));
|
||||
|
||||
public void QueueClose() =>
|
||||
Queue(new ReceiveFrame(Array.Empty<byte>(), WebSocketMessageType.Close, true));
|
||||
|
||||
public override void Abort()
|
||||
{
|
||||
Interlocked.Increment(ref AbortCount);
|
||||
state = WebSocketState.Aborted;
|
||||
}
|
||||
|
||||
public override Task CloseAsync(
|
||||
WebSocketCloseStatus closeStatus,
|
||||
string? statusDescription,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this.closeStatus = closeStatus;
|
||||
closeStatusDescription = statusDescription;
|
||||
state = WebSocketState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task CloseOutputAsync(
|
||||
WebSocketCloseStatus closeStatus,
|
||||
string? statusDescription,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref CloseOutputCount);
|
||||
this.closeStatus = closeStatus;
|
||||
closeStatusDescription = statusDescription;
|
||||
state = WebSocketState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override void Dispose() => state = WebSocketState.Closed;
|
||||
|
||||
public override async Task<WebSocketReceiveResult> ReceiveAsync(
|
||||
ArraySegment<byte> buffer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await receiveSignal.WaitAsync(cancellationToken);
|
||||
if (!receiveFrames.TryDequeue(out var frame))
|
||||
throw new InvalidOperationException("The receive signal had no frame.");
|
||||
|
||||
if (frame.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
state = WebSocketState.CloseReceived;
|
||||
closeStatus = WebSocketCloseStatus.NormalClosure;
|
||||
}
|
||||
|
||||
Buffer.BlockCopy(frame.Data, 0, buffer.Array!, buffer.Offset, frame.Data.Length);
|
||||
return new WebSocketReceiveResult(
|
||||
frame.Data.Length,
|
||||
frame.MessageType,
|
||||
frame.EndOfMessage,
|
||||
frame.MessageType == WebSocketMessageType.Close
|
||||
? WebSocketCloseStatus.NormalClosure
|
||||
: null,
|
||||
null);
|
||||
}
|
||||
|
||||
public override async Task SendAsync(
|
||||
ArraySegment<byte> buffer,
|
||||
WebSocketMessageType messageType,
|
||||
bool endOfMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
sendStarted?.TrySetResult();
|
||||
if (sendRelease is not null)
|
||||
await sendRelease.Task.WaitAsync(cancellationToken);
|
||||
|
||||
var copy = new byte[buffer.Count];
|
||||
Buffer.BlockCopy(buffer.Array!, buffer.Offset, copy, 0, buffer.Count);
|
||||
SentMessages.Add(copy);
|
||||
}
|
||||
|
||||
private void Queue(ReceiveFrame frame)
|
||||
{
|
||||
receiveFrames.Enqueue(frame);
|
||||
receiveSignal.Release();
|
||||
}
|
||||
|
||||
private readonly record struct ReceiveFrame(
|
||||
byte[] Data,
|
||||
WebSocketMessageType MessageType,
|
||||
bool EndOfMessage);
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
? encryptionMode
|
||||
: EncryptionMode.None,
|
||||
EncryptionProviders = new[] { AesEncryptionProvider.Name },
|
||||
UseWebSocket = useWebSocket,
|
||||
WebSocketUri = useWebSocket
|
||||
? new Uri($"ws://127.0.0.1:{port}")
|
||||
: null,
|
||||
});
|
||||
|
||||
return cluster;
|
||||
|
||||
@@ -5,6 +5,7 @@ using Esiur.Net.Sockets;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
using Esiur.Security.Cryptography;
|
||||
using System.Net;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class SessionHeadersIntegrationTests
|
||||
@@ -248,16 +249,17 @@ public class SessionHeadersIntegrationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddressBoundEncryption_RejectsWebSocketWithoutConcreteEndpoints()
|
||||
public async Task AddressBoundEncryption_WorksAcrossWebSocketWithConcreteEndpoints()
|
||||
{
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await IntegrationCluster.StartAsync(
|
||||
_ => Task.CompletedTask,
|
||||
encrypted: true,
|
||||
encryptionMode: EncryptionMode.EncryptWithSessionKeyAndAddress,
|
||||
useWebSocket: true)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
await using var cluster = await IntegrationCluster.StartAsync(
|
||||
_ => Task.CompletedTask,
|
||||
encrypted: true,
|
||||
encryptionMode: EncryptionMode.EncryptWithSessionKeyAndAddress,
|
||||
useWebSocket: true)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.IsNotType<TimeoutException>(exception);
|
||||
Assert.True(cluster.Connection.IsEncrypted);
|
||||
Assert.True(Assert.Single(cluster.Server.Connections).IsEncrypted);
|
||||
Assert.NotEqual(IPAddress.Any, cluster.Connection.RemoteEndPoint.Address);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Esiur.Security.Authority.Providers;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public sealed class PasswordAuthenticationProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProtocolName_HasNoLegacyPublicAlias()
|
||||
{
|
||||
var publicProtocolConstants = typeof(PasswordAuthenticationProvider)
|
||||
.GetFields(System.Reflection.BindingFlags.Public
|
||||
| System.Reflection.BindingFlags.Static)
|
||||
.Where(field => field.IsLiteral && field.FieldType == typeof(string))
|
||||
.Select(field => Assert.IsType<string>(field.GetRawConstantValue()))
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(new[] { PasswordAuthenticationProvider.ProtocolName },
|
||||
publicProtocolConstants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateCredential_GeneratesFreshSaltAndExpectedProtocolHash()
|
||||
{
|
||||
var password = new byte[] { 1, 2, 3, 4, 5 };
|
||||
|
||||
var first = PasswordAuthenticationProvider.CreateCredential(password);
|
||||
var second = PasswordAuthenticationProvider.CreateCredential(password);
|
||||
|
||||
Assert.Equal(32, first.Salt.Length);
|
||||
Assert.Equal(32, first.Hash.Length);
|
||||
Assert.Equal(
|
||||
PasswordAuthenticationHandler.ComputeSha3(
|
||||
password.Concat(first.Salt).ToArray()),
|
||||
first.Hash);
|
||||
Assert.False(first.Salt.SequenceEqual(second.Salt));
|
||||
Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateCredential_RejectsMissingOrEmptyPassword()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
PasswordAuthenticationProvider.CreateCredential(null!));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
PasswordAuthenticationProvider.CreateCredential(Array.Empty<byte>()));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,45 @@ namespace Esiur.Tests.Unit;
|
||||
|
||||
public class PeerConnectionLimitTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Server_CanTrackExternallyHostedConnectionsWithoutOpeningTcpListener()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
var server = new EpServer
|
||||
{
|
||||
EnableTcpListener = false,
|
||||
};
|
||||
server.Instance = new Instance(warehouse, 1, "server", server, null);
|
||||
|
||||
Assert.True(await server.Handle(ResourceOperation.Initialize));
|
||||
Assert.False(server.IsRunning);
|
||||
Assert.Empty(server.Connections);
|
||||
|
||||
var socket = new TestSocket(IPAddress.Parse("192.0.2.5"), 10000);
|
||||
var connection = new EpConnection();
|
||||
connection.Assign(socket);
|
||||
|
||||
Assert.True(server.TryAdd(connection));
|
||||
Assert.Single(server.Connections);
|
||||
|
||||
socket.Close();
|
||||
Assert.Empty(server.Connections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_RequiresSocketAssignmentBeforeAdmission()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
var server = new EpServer();
|
||||
server.Instance = new Instance(warehouse, 1, "server", server, null);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(
|
||||
() => server.TryAdd(new EpConnection()));
|
||||
|
||||
Assert.Contains("Assign a socket", error.Message, StringComparison.Ordinal);
|
||||
Assert.Empty(server.Connections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_RejectsConnectionsAbovePerIpLimit_AndReleasesClosedSlot()
|
||||
{
|
||||
@@ -24,12 +63,12 @@ public class PeerConnectionLimitTests
|
||||
var firstSocket = new TestSocket(address, 10001);
|
||||
var first = new EpConnection();
|
||||
first.Assign(firstSocket);
|
||||
server.Add(first);
|
||||
Assert.True(server.TryAdd(first));
|
||||
|
||||
var rejectedSocket = new TestSocket(address, 10002);
|
||||
var rejected = new EpConnection();
|
||||
rejected.Assign(rejectedSocket);
|
||||
server.Add(rejected);
|
||||
Assert.False(server.TryAdd(rejected));
|
||||
|
||||
Assert.Single(server.Connections);
|
||||
Assert.Equal(SocketState.Closed, rejectedSocket.State);
|
||||
@@ -41,7 +80,7 @@ public class PeerConnectionLimitTests
|
||||
var replacementSocket = new TestSocket(address, 10003);
|
||||
var replacement = new EpConnection();
|
||||
replacement.Assign(replacementSocket);
|
||||
server.Add(replacement);
|
||||
Assert.True(server.TryAdd(replacement));
|
||||
|
||||
Assert.Equal(SocketState.Established, replacementSocket.State);
|
||||
Assert.Equal(1, server.GetConnectionCount(address));
|
||||
@@ -49,6 +88,49 @@ public class PeerConnectionLimitTests
|
||||
replacementSocket.Close();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_RejectsConnectionsAboveGlobalLimitAcrossDifferentAddresses_AndReleasesSlot()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
warehouse.Configuration.Connections.MaximumConnections = 2;
|
||||
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 0;
|
||||
warehouse.Configuration.Connections.MaximumConnectionAttempts = 0;
|
||||
warehouse.Configuration.Connections.MaximumConnectionAttemptsPerIpAddress = 0;
|
||||
|
||||
var server = new EpServer();
|
||||
server.Instance = new Instance(warehouse, 1, "server", server, null);
|
||||
|
||||
var firstSocket = new TestSocket(IPAddress.Parse("192.0.2.10"), 10101);
|
||||
var first = new EpConnection();
|
||||
first.Assign(firstSocket);
|
||||
Assert.True(server.TryAdd(first));
|
||||
|
||||
var secondSocket = new TestSocket(IPAddress.Parse("192.0.2.11"), 10102);
|
||||
var second = new EpConnection();
|
||||
second.Assign(secondSocket);
|
||||
Assert.True(server.TryAdd(second));
|
||||
|
||||
var rejectedSocket = new TestSocket(IPAddress.Parse("192.0.2.12"), 10103);
|
||||
var rejected = new EpConnection();
|
||||
rejected.Assign(rejectedSocket);
|
||||
Assert.False(server.TryAdd(rejected));
|
||||
|
||||
Assert.Equal(2, server.Connections.Count);
|
||||
Assert.Equal(SocketState.Closed, rejectedSocket.State);
|
||||
|
||||
firstSocket.Close();
|
||||
|
||||
var replacementSocket = new TestSocket(IPAddress.Parse("192.0.2.13"), 10104);
|
||||
var replacement = new EpConnection();
|
||||
replacement.Assign(replacementSocket);
|
||||
Assert.True(server.TryAdd(replacement));
|
||||
Assert.Equal(2, server.Connections.Count);
|
||||
|
||||
secondSocket.Close();
|
||||
replacementSocket.Close();
|
||||
Assert.Empty(server.Connections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_RejectsRepeatedConnectionsAbovePerIpAttemptRate()
|
||||
{
|
||||
@@ -88,6 +170,40 @@ public class PeerConnectionLimitTests
|
||||
otherSocket.Close();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_RejectsRepeatedConnectionsAboveGlobalAttemptRateAcrossDifferentAddresses()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
warehouse.Configuration.Connections.MaximumConnections = 0;
|
||||
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 0;
|
||||
warehouse.Configuration.Connections.MaximumConnectionAttempts = 2;
|
||||
warehouse.Configuration.Connections.MaximumConnectionAttemptsPerIpAddress = 0;
|
||||
warehouse.Configuration.Connections.ConnectionAttemptWindow = TimeSpan.FromMinutes(1);
|
||||
|
||||
var server = new EpServer();
|
||||
server.Instance = new Instance(warehouse, 1, "server", server, null);
|
||||
|
||||
for (var index = 0; index < 2; index++)
|
||||
{
|
||||
var admittedSocket = new TestSocket(
|
||||
IPAddress.Parse($"192.0.2.{20 + index}"),
|
||||
11100 + index);
|
||||
var admitted = new EpConnection();
|
||||
admitted.Assign(admittedSocket);
|
||||
|
||||
Assert.True(server.TryAdd(admitted));
|
||||
admittedSocket.Close();
|
||||
}
|
||||
|
||||
var rejectedSocket = new TestSocket(IPAddress.Parse("192.0.2.22"), 11102);
|
||||
var rejected = new EpConnection();
|
||||
rejected.Assign(rejectedSocket);
|
||||
|
||||
Assert.False(server.TryAdd(rejected));
|
||||
Assert.Equal(SocketState.Closed, rejectedSocket.State);
|
||||
Assert.Empty(server.Connections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_ClosesStalledAuthenticationAtDeadlineAndReleasesSlot()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using Esiur.Core;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Stores;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public sealed class WarehouseLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Close_AllowsWarehouseToBeOpenedAgain()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("sys", new MemoryStore());
|
||||
|
||||
Assert.False(warehouse.IsOpen);
|
||||
Assert.True(await warehouse.Open());
|
||||
Assert.True(warehouse.IsOpen);
|
||||
Assert.False(await warehouse.Open());
|
||||
|
||||
Assert.True(await warehouse.Close());
|
||||
Assert.False(warehouse.IsOpen);
|
||||
|
||||
Assert.True(await warehouse.Open());
|
||||
Assert.True(warehouse.IsOpen);
|
||||
Assert.True(await warehouse.Close());
|
||||
Assert.False(warehouse.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_WaitsForAnInProgressOpenBeforeTerminatingResources()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("sys", new MemoryStore());
|
||||
var resource = await warehouse.Put("sys/blocking", new BlockingResource());
|
||||
|
||||
var open = warehouse.Open();
|
||||
await resource.InitializeStarted.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var close = warehouse.Close();
|
||||
await Task.Delay(25);
|
||||
Assert.False(resource.TerminateStarted.IsCompleted);
|
||||
|
||||
resource.ReleaseInitialize();
|
||||
|
||||
Assert.True(await open);
|
||||
Assert.True(await close);
|
||||
Assert.True(resource.TerminateStarted.IsCompletedSuccessfully);
|
||||
Assert.False(warehouse.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_WaitsForEachShutdownPhaseBeforeAdvancingOrReopening()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("sys", new MemoryStore());
|
||||
|
||||
var terminateReply = new AsyncReply<bool>();
|
||||
var systemTerminatedReply = new AsyncReply<bool>();
|
||||
var terminateBlocker = await warehouse.Put(
|
||||
"sys/terminate-blocker",
|
||||
new ControlledLifecycleResource(terminateReply: terminateReply));
|
||||
var systemTerminatedBlocker = await warehouse.Put(
|
||||
"sys/system-terminated-blocker",
|
||||
new ControlledLifecycleResource(systemTerminatedReply: systemTerminatedReply));
|
||||
|
||||
Assert.True(await warehouse.Open());
|
||||
|
||||
var close = Observe(warehouse.Close());
|
||||
await Task.WhenAll(
|
||||
terminateBlocker.TerminateStarted,
|
||||
systemTerminatedBlocker.TerminateStarted)
|
||||
.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(terminateBlocker.SystemTerminatedStarted.IsCompleted);
|
||||
Assert.False(systemTerminatedBlocker.SystemTerminatedStarted.IsCompleted);
|
||||
|
||||
terminateReply.Trigger(true);
|
||||
|
||||
await Task.WhenAll(
|
||||
terminateBlocker.SystemTerminatedStarted,
|
||||
systemTerminatedBlocker.SystemTerminatedStarted)
|
||||
.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.False(close.IsCompleted);
|
||||
|
||||
var reopen = Observe(warehouse.Open());
|
||||
await Task.Delay(25);
|
||||
Assert.False(reopen.IsCompleted);
|
||||
|
||||
systemTerminatedReply.Trigger(true);
|
||||
|
||||
Assert.True(await close);
|
||||
Assert.True(await reopen);
|
||||
Assert.True(await warehouse.Close());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_SynchronousThrowDoesNotSkipOtherResourcesOrSecondPhase()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("sys", new MemoryStore());
|
||||
|
||||
var expected = new InvalidOperationException("terminate failed synchronously");
|
||||
var throwing = await warehouse.Put(
|
||||
"sys/throwing",
|
||||
new ControlledLifecycleResource(terminateException: expected));
|
||||
var other = await warehouse.Put(
|
||||
"sys/other",
|
||||
new ControlledLifecycleResource());
|
||||
|
||||
Assert.True(await warehouse.Open());
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Observe(warehouse.Close()));
|
||||
|
||||
Assert.Same(expected, exception);
|
||||
Assert.True(other.TerminateStarted.IsCompletedSuccessfully);
|
||||
Assert.True(throwing.SystemTerminatedStarted.IsCompletedSuccessfully);
|
||||
Assert.True(other.SystemTerminatedStarted.IsCompletedSuccessfully);
|
||||
Assert.False(warehouse.IsOpen);
|
||||
Assert.False(await warehouse.Close());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_AsyncErrorWaitsForEveryOtherReplyToSettle()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("sys", new MemoryStore());
|
||||
|
||||
var failingReply = new AsyncReply<bool>();
|
||||
var blockingReply = new AsyncReply<bool>();
|
||||
var failing = await warehouse.Put(
|
||||
"sys/failing",
|
||||
new ControlledLifecycleResource(systemTerminatedReply: failingReply));
|
||||
var blocking = await warehouse.Put(
|
||||
"sys/blocking",
|
||||
new ControlledLifecycleResource(systemTerminatedReply: blockingReply));
|
||||
|
||||
Assert.True(await warehouse.Open());
|
||||
|
||||
var close = Observe(warehouse.Close());
|
||||
await Task.WhenAll(failing.SystemTerminatedStarted, blocking.SystemTerminatedStarted)
|
||||
.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var expected = new InvalidOperationException("system termination failed asynchronously");
|
||||
failingReply.TriggerError(expected);
|
||||
|
||||
Assert.False(close.IsCompleted);
|
||||
|
||||
blockingReply.Trigger(true);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<AsyncException>(() => close);
|
||||
Assert.Same(expected, exception.InnerException);
|
||||
Assert.True(failing.TerminateStarted.IsCompletedSuccessfully);
|
||||
Assert.True(blocking.TerminateStarted.IsCompletedSuccessfully);
|
||||
Assert.False(warehouse.IsOpen);
|
||||
Assert.False(await warehouse.Close());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_ReturnsFalseAfterBothPhasesWhenAResourceReturnsFalse()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
await warehouse.Put("sys", new MemoryStore());
|
||||
|
||||
var resource = await warehouse.Put(
|
||||
"sys/false-result",
|
||||
new ControlledLifecycleResource(
|
||||
terminateReply: new AsyncReply<bool>(false)));
|
||||
|
||||
Assert.True(await warehouse.Open());
|
||||
|
||||
Assert.False(await warehouse.Close());
|
||||
Assert.True(resource.SystemTerminatedStarted.IsCompletedSuccessfully);
|
||||
Assert.False(warehouse.IsOpen);
|
||||
}
|
||||
|
||||
private static async Task<bool> Observe(AsyncReply<bool> reply) => await reply;
|
||||
|
||||
private sealed class ControlledLifecycleResource : IResource
|
||||
{
|
||||
private readonly AsyncReply<bool>? terminateReply;
|
||||
private readonly AsyncReply<bool>? systemTerminatedReply;
|
||||
private readonly Exception? terminateException;
|
||||
private readonly TaskCompletionSource terminateStarted = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource systemTerminatedStarted = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public ControlledLifecycleResource(
|
||||
AsyncReply<bool>? terminateReply = null,
|
||||
AsyncReply<bool>? systemTerminatedReply = null,
|
||||
Exception? terminateException = null)
|
||||
{
|
||||
this.terminateReply = terminateReply;
|
||||
this.systemTerminatedReply = systemTerminatedReply;
|
||||
this.terminateException = terminateException;
|
||||
}
|
||||
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
public Instance? Instance { get; set; }
|
||||
public Task TerminateStarted => terminateStarted.Task;
|
||||
public Task SystemTerminatedStarted => systemTerminatedStarted.Task;
|
||||
|
||||
public AsyncReply<bool> Handle(
|
||||
ResourceOperation operation,
|
||||
IResourceContext? context = null)
|
||||
{
|
||||
if (operation == ResourceOperation.Terminate)
|
||||
{
|
||||
terminateStarted.TrySetResult();
|
||||
if (terminateException != null)
|
||||
throw terminateException;
|
||||
|
||||
return terminateReply ?? new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
if (operation == ResourceOperation.SystemTerminated)
|
||||
{
|
||||
systemTerminatedStarted.TrySetResult();
|
||||
return systemTerminatedReply ?? new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
return new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
public void Destroy() => OnDestroy?.Invoke(this);
|
||||
}
|
||||
|
||||
private sealed class BlockingResource : IResource
|
||||
{
|
||||
private readonly AsyncReply<bool> initialize = new();
|
||||
private readonly TaskCompletionSource initializeStarted = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource terminateStarted = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
public Instance? Instance { get; set; }
|
||||
public Task InitializeStarted => initializeStarted.Task;
|
||||
public Task TerminateStarted => terminateStarted.Task;
|
||||
|
||||
public void ReleaseInitialize() => initialize.Trigger(true);
|
||||
|
||||
public AsyncReply<bool> Handle(
|
||||
ResourceOperation operation,
|
||||
IResourceContext? context = null)
|
||||
{
|
||||
if (operation == ResourceOperation.Initialize)
|
||||
{
|
||||
initializeStarted.TrySetResult();
|
||||
return initialize;
|
||||
}
|
||||
|
||||
if (operation == ResourceOperation.Terminate)
|
||||
terminateStarted.TrySetResult();
|
||||
|
||||
return new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
public void Destroy() => OnDestroy?.Invoke(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user