This commit is contained in:
2026-07-17 02:19:05 +03:00
parent 4cd9f928ff
commit dbd5715578
42 changed files with 6000 additions and 866 deletions
+85 -5
View File
@@ -22,6 +22,7 @@ SOFTWARE.
*/
using Esiur.Misc;
using Esiur.Resource;
using System;
using System.Collections.Generic;
@@ -217,7 +218,17 @@ public class AsyncReply
return this;
}
public void Trigger(object result)
public void Trigger(object result) => TrySetResult(result, dispatchCallbacksAsynchronously: false);
/// <summary>
/// Completes the reply immediately while dispatching callbacks independently.
/// This is reserved for teardown paths where consumer code must not hold a
/// transport or other infrastructure resource open.
/// </summary>
internal void TriggerDetached(object result) =>
TrySetResult(result, dispatchCallbacksAsynchronously: true);
private bool TrySetResult(object result, bool dispatchCallbacksAsynchronously)
{
Action<object> singleCallback = null;
Action<object>[] registeredCallbacks = null;
@@ -227,7 +238,7 @@ public class AsyncReply
lock (asyncLock)
{
if (exception != null || resultReady)
return;
return false;
ReadyTime = DateTime.Now;
this.result = result;
@@ -250,11 +261,24 @@ public class AsyncReply
waiter?.Set();
if (callbackCount == 1)
if (dispatchCallbacksAsynchronously)
{
if (callbackCount == 1)
QueueDetachedCallbacks(singleCallback, null, result);
else if (registeredCallbacks != null)
QueueDetachedCallbacks(null, registeredCallbacks, result);
}
else if (callbackCount == 1)
{
singleCallback(result);
}
else if (registeredCallbacks != null)
{
foreach (var callback in registeredCallbacks)
callback(result);
}
return true;
}
public virtual void TriggerError(Exception exception)
@@ -262,6 +286,19 @@ public class AsyncReply
TrySetException(exception);
}
/// <summary>
/// Faults the reply immediately while dispatching callbacks independently.
/// This is reserved for teardown paths where consumer code must not hold a
/// transport or other infrastructure resource open.
/// </summary>
internal void TriggerErrorDetached(Exception exception)
{
TrySetException(
exception,
preserveSourceForAwait: false,
dispatchCallbacksAsynchronously: true);
}
internal void TriggerErrorFromBuilder(Exception exception, bool preserveSourceForAwait)
{
TrySetException(exception, preserveSourceForAwait);
@@ -347,7 +384,10 @@ public class AsyncReply
}
}
private bool TrySetException(Exception sourceException, bool preserveSourceForAwait = false)
private bool TrySetException(
Exception sourceException,
bool preserveSourceForAwait = false,
bool dispatchCallbacksAsynchronously = false)
{
AsyncException asyncException;
Action<AsyncException> singleCallback = null;
@@ -378,12 +418,52 @@ public class AsyncReply
waiter?.Set();
if (callbackCount == 1)
if (dispatchCallbacksAsynchronously)
{
if (callbackCount == 1)
QueueDetachedCallbacks(singleCallback, null, asyncException);
else if (registeredCallbacks != null)
QueueDetachedCallbacks(null, registeredCallbacks, asyncException);
}
else if (callbackCount == 1)
{
singleCallback(asyncException);
}
else if (registeredCallbacks != null)
{
foreach (var callback in registeredCallbacks)
callback(asyncException);
}
return true;
}
private static void QueueDetachedCallbacks<T>(
Action<T> singleCallback,
Action<T>[] registeredCallbacks,
T value)
{
ThreadPool.QueueUserWorkItem(_ =>
{
try
{
if (singleCallback != null)
{
singleCallback(value);
}
else
{
foreach (var callback in registeredCallbacks)
{
try { callback(value); }
catch (Exception exception) { Global.Log(exception); }
}
}
}
catch (Exception exception)
{
Global.Log(exception);
}
});
}
}
+8 -1
View File
@@ -16,6 +16,9 @@
<PackageId>Esiur</PackageId>
<Product>Esiur</Product>
<LangVersion>latest</LangVersion>
<!-- Keep CallerFilePath/PDB entries deterministic and avoid embedding the
build machine's absolute source directory in the package assembly. -->
<PathMap>$(MSBuildProjectDirectory)=/_/Esiur</PathMap>
<!-- Enable the nullable *annotation* context project-wide so '?' reference-type
annotations are legal everywhere, without turning on nullable flow warnings
(files that opt in via '#nullable enable' still get full checking). -->
@@ -39,6 +42,8 @@
<ItemGroup>
<!-- Allow the unit test project to exercise internal helpers (e.g. wait-for cycle detection). -->
<InternalsVisibleTo Include="Esiur.Tests.Unit" />
<!-- The ASP.NET adapter supplies a host-wide transport memory budget. -->
<InternalsVisibleTo Include="Esiur.AspNetCore" />
</ItemGroup>
<ItemGroup>
@@ -50,7 +55,9 @@
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
<!-- Compile the generator against the .NET 8 Roslyn baseline so the combined
runtime/analyzer assembly does not require System.Collections.Immutable 9. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="4.14.0" PrivateAssets="all" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.Reflection.Emit" Version="4.7.0" />
+13 -3
View File
@@ -53,6 +53,13 @@ public abstract class NetworkServer<TConnection> : IDestructible where TConnecti
public event DestroyedEvent OnDestroy;
protected NetworkServer()
{
// Connection tracking is also used by externally hosted transports (for example,
// ASP.NET Core WebSockets) that don't create an ISocket listener through Start().
Connections = new AutoList<TConnection, NetworkServer<TConnection>>(this);
}
private void MinuteThread(object state)
{
@@ -93,8 +100,6 @@ public abstract class NetworkServer<TConnection> : IDestructible where TConnecti
if (listener != null)
return;
Connections = new AutoList<TConnection, NetworkServer<TConnection>>(this);
if (Timeout > 0 && Clock > 0)
{
timer = new Timer(MinuteThread, null, TimeSpan.FromMinutes(0), TimeSpan.FromSeconds(Clock));
@@ -273,7 +278,12 @@ public abstract class NetworkServer<TConnection> : IDestructible where TConnecti
finally
{
try { currentTimer?.Dispose(); } catch { }
Global.Log("NetworkServer", LogType.Warning, $"Server on port {port} is down.");
if (currentListener != null
|| currentTimer != null
|| (connections?.Length ?? 0) > 0)
{
Global.Log("NetworkServer", LogType.Debug, $"Server on port {port} is down.");
}
}
}
File diff suppressed because it is too large Load Diff
+425 -85
View File
@@ -115,6 +115,12 @@ public partial class EpConnection : NetworkConnection, IStore
Session _session;
AsyncReply<bool> _openReply;
readonly object _connectLifecycleLock = new object();
long _connectAttemptGeneration;
bool _isDestroyed;
bool _connectAttemptIsRetrying;
CancellationTokenSource _connectRetryCancellation;
volatile bool _autoReconnect;
bool _authenticated;
bool _authenticationHandshakeSucceeded;
@@ -191,7 +197,44 @@ public partial class EpConnection : NetworkConnection, IStore
//[Attribute]
public bool AutoReconnect { get; set; } = false;
public bool AutoReconnect
{
get => _autoReconnect;
set
{
AsyncReply<bool> canceledReply = null;
CancellationTokenSource retryCancellation = null;
lock (_connectLifecycleLock)
{
_autoReconnect = value;
if (value || !_connectAttemptIsRetrying)
return;
_connectAttemptIsRetrying = false;
retryCancellation = _connectRetryCancellation;
_connectRetryCancellation = null;
var pending = Volatile.Read(ref _openReply);
if (pending != null
&& ReferenceEquals(
Interlocked.CompareExchange(ref _openReply, null, pending),
pending))
{
Status = EpConnectionStatus.Closed;
canceledReply = pending;
}
}
CancelAndDispose(retryCancellation);
TriggerOpenError(
canceledReply,
new AsyncException(
ErrorType.Management,
0,
"Automatic reconnect was canceled."));
}
}
//[Attribute]
public uint ReconnectInterval { get; set; } = 5;
@@ -199,11 +242,15 @@ public partial class EpConnection : NetworkConnection, IStore
//[Attribute]
//public string Username { get; set; }
//[Attribute]
public bool UseWebSocket { get; set; }
/// <summary>
/// Full WebSocket endpoint used by outbound connections. When null, EP uses
/// its native TCP transport (except on browser runtimes).
/// </summary>
public Uri WebSocketUri { get; set; }
//[Attribute]
public bool SecureWebSocket { get; set; }
// Test seam for verifying the same fresh-socket policy used by native TCP and
// FrameworkWebSocket reconnects without expanding the public API.
internal Func<ISocket> ClientSocketFactory { get; set; }
//[Attribute]
//public string Password { get; set; }
@@ -857,17 +904,16 @@ public partial class EpConnection : NetworkConnection, IStore
void FailPendingOpen(Exception exception)
{
var pending = Interlocked.Exchange(ref _openReply, null);
TriggerOpenError(pending, exception);
}
static void TriggerOpenError(AsyncReply<bool> pending, Exception exception)
{
if (pending == null)
return;
try
{
pending.TriggerError(exception);
}
catch (Exception ex)
{
Global.Log(ex);
}
try { pending.TriggerError(exception); }
catch (Exception ex) { Global.Log(ex); }
}
void BeginPlaintextHandshake()
@@ -1005,14 +1051,9 @@ public partial class EpConnection : NetworkConnection, IStore
{
if (cancellation == null)
return;
try
{
cancellation.Cancel();
}
finally
{
cancellation.Dispose();
}
try { cancellation.Cancel(); } catch (ObjectDisposedException) { }
try { cancellation.Dispose(); } catch (ObjectDisposedException) { }
}
bool TryPublishAuthenticationReady(
@@ -1233,6 +1274,23 @@ public partial class EpConnection : NetworkConnection, IStore
public override void Destroy()
{
CancellationTokenSource retryCancellation;
lock (_connectLifecycleLock)
{
_isDestroyed = true;
_connectAttemptGeneration++;
_autoReconnect = false;
_connectAttemptIsRetrying = false;
retryCancellation = _connectRetryCancellation;
_connectRetryCancellation = null;
Status = EpConnectionStatus.Closed;
}
CancelAndDispose(retryCancellation);
FailPendingOpen(new AsyncException(
ErrorType.Management,
0,
"The connection was destroyed before it finished opening."));
TerminateInvocations();
UnsubscribeAll();
EndAuthenticationAttempt();
@@ -1479,8 +1537,24 @@ public partial class EpConnection : NetworkConnection, IStore
Socket?.Unhold();
var res = new HttpResponsePacket();
HttpConnection.Upgrade(
req,
res,
new[] { FrameworkWebSocket.SubProtocol },
out var selectedSubProtocol);
HttpConnection.Upgrade(req, res);
if (selectedSubProtocol != FrameworkWebSocket.SubProtocol)
{
res = new HttpResponsePacket
{
Number = HttpResponseCode.BadRequest,
Text = "The EP WebSocket subprotocol is required."
};
res.Compose(HttpComposeOption.AllCalculateLength);
Send(res.Data);
Close();
return ends;
}
res.Compose(HttpComposeOption.AllCalculateLength);
Send(res.Data);
@@ -3101,8 +3175,7 @@ public partial class EpConnection : NetworkConnection, IStore
ReconnectInterval = epContext.ReconnectInterval;
AuthenticationTimeout = epContext.AuthenticationTimeout;
ExceptionLevel = epContext.ExceptionLevel;
UseWebSocket = epContext.UseWebSocket;
SecureWebSocket = epContext.SecureWebSocket;
WebSocketUri = epContext.WebSocketUri;
AutoReconnect = epContext.AutoReconnect;
_hostname = address;
_port = port;
@@ -3124,77 +3197,335 @@ public partial class EpConnection : NetworkConnection, IStore
public AsyncReply<bool> Connect(ISocket socket = null, string hostname = null, ushort port = 0, string domain = null)
{
if (IsConnected || Status == EpConnectionStatus.Connected)
throw new AsyncException(ErrorType.Exception, 0, "Connection is already established");
var openReply = new AsyncReply<bool>();
if (Interlocked.CompareExchange(ref _openReply, openReply, null) != null)
throw new AsyncException(ErrorType.Exception, 0, "Connection in progress");
AsyncReply<bool> openReply;
long attemptGeneration;
bool canCreateReplacement;
Status = EpConnectionStatus.Connecting;
// set auth direction to initiator
_authDirection = AuthenticationDirection.Initiator;
if (hostname != null)
lock (_connectLifecycleLock)
{
DisposeSessionEncryption();
_session = new Session();
_authDirection = AuthenticationDirection.Initiator;
_invalidCredentials = false;
if (_isDestroyed)
throw new AsyncException(ErrorType.Exception, 0, "Connection was destroyed");
if (IsConnected || Status == EpConnectionStatus.Connected)
throw new AsyncException(ErrorType.Exception, 0, "Connection is already established");
_session.LocalHeaders.Domain = domain;
_hostname = hostname;
openReply = new AsyncReply<bool>();
if (Interlocked.CompareExchange(ref _openReply, openReply, null) != null)
throw new AsyncException(ErrorType.Exception, 0, "Connection in progress");
attemptGeneration = ++_connectAttemptGeneration;
_connectAttemptIsRetrying = false;
try
{
Status = EpConnectionStatus.Connecting;
// set auth direction to initiator
_authDirection = AuthenticationDirection.Initiator;
if (hostname != null)
{
DisposeSessionEncryption();
_session = new Session();
_authDirection = AuthenticationDirection.Initiator;
_invalidCredentials = false;
_session.LocalHeaders.Domain = domain;
_hostname = hostname;
}
if (port > 0)
this._port = port;
if (_session == null)
throw new AsyncException(ErrorType.Exception, 0, "Session not initialized");
if (_authenticationProvider != null && _authenticationContext != null)
{
DisposeAuthenticationHandler();
_session.AuthenticationHandler =
_authenticationProvider.CreateAuthenticationHandler(_authenticationContext);
}
BeginPlaintextHandshake();
canCreateReplacement = socket == null;
socket ??= CreateClientSocket();
}
catch
{
Interlocked.CompareExchange(ref _openReply, null, openReply);
_connectAttemptIsRetrying = false;
Status = EpConnectionStatus.Closed;
throw;
}
}
if (port > 0)
this._port = port;
if (_session == null)
throw new AsyncException(ErrorType.Exception, 0, "Session not initialized");
if (_authenticationProvider != null && _authenticationContext != null)
{
DisposeAuthenticationHandler();
_session.AuthenticationHandler =
_authenticationProvider.CreateAuthenticationHandler(_authenticationContext);
}
BeginPlaintextHandshake();
if (socket == null)
{
var os = RuntimeInformation.FrameworkDescription;
if (UseWebSocket || RuntimeInformation.OSDescription == "Browser")
socket = new FrameworkWebSocket();
else
socket = new TcpSocket();
}
connectSocket(socket);
connectSocket(socket, canCreateReplacement, openReply, attemptGeneration);
return openReply;
}
void connectSocket(ISocket socket)
ISocket CreateClientSocket()
{
socket.Connect(this._hostname, this._port).Then(x =>
if (ClientSocketFactory != null)
return ClientSocketFactory();
if (WebSocketUri != null)
return new FrameworkWebSocket(WebSocketUri);
if (RuntimeInformation.OSDescription == "Browser")
{
Assign(socket);
}).Error((x) =>
{
if (AutoReconnect)
{
Global.Log("EpConnection", LogType.Debug, "Reconnecting socket...");
Task.Delay(TimeSpan.FromSeconds(ReconnectInterval))
.ContinueWith((x) => connectSocket(socket));
}
else
{
FailPendingOpen(x);
}
});
return new FrameworkWebSocket(
new UriBuilder("ws", _hostname, _port).Uri);
}
return new TcpSocket();
}
void connectSocket(
ISocket socket,
bool canCreateReplacement,
AsyncReply<bool> expectedOpenReply,
long attemptGeneration)
{
if (!IsCurrentConnectAttempt(expectedOpenReply, attemptGeneration))
{
DestroyStaleSocket(socket);
return;
}
AsyncReply<bool> connectReply;
try
{
connectReply = socket.Connect(this._hostname, this._port);
}
catch (Exception exception)
{
HandleSocketConnectFailure(
socket,
canCreateReplacement,
expectedOpenReply,
attemptGeneration,
exception);
return;
}
connectReply.Then(connected =>
{
if (!connected)
{
HandleSocketConnectFailure(
socket,
canCreateReplacement,
expectedOpenReply,
attemptGeneration,
new AsyncException(
ErrorType.Management,
0,
"The socket did not establish a connection."));
return;
}
try
{
var staleAttempt = false;
lock (_connectLifecycleLock)
{
staleAttempt = !IsCurrentConnectAttemptLocked(
expectedOpenReply,
attemptGeneration);
if (!staleAttempt)
{
_connectAttemptIsRetrying = false;
Assign(socket);
// Some transports begin their receive loop as part of Connect
// and report false here to mean "already started". Preserve that
// valid contract; assignment/handshake failures still throw.
socket.Begin();
}
}
if (staleAttempt)
DestroyStaleSocket(socket);
}
catch (Exception exception)
{
// A connected transport that cannot be assigned or initialize the EP
// handshake has a deterministic configuration/protocol failure. A new
// socket cannot correct it.
HandleSocketConnectFailure(
socket,
canCreateReplacement: false,
expectedOpenReply,
attemptGeneration,
exception);
}
}).Error(exception =>
HandleSocketConnectFailure(
socket,
canCreateReplacement,
expectedOpenReply,
attemptGeneration,
exception));
}
void HandleSocketConnectFailure(
ISocket socket,
bool canCreateReplacement,
AsyncReply<bool> expectedOpenReply,
long attemptGeneration,
Exception exception)
{
if (!IsCurrentConnectAttempt(expectedOpenReply, attemptGeneration))
{
DestroyStaleSocket(socket);
return;
}
if (ReferenceEquals(Socket, socket))
Unassign();
DestroyStaleSocket(socket);
CancellationTokenSource retryCancellation = null;
CancellationTokenSource previousRetryCancellation = null;
if (canCreateReplacement)
{
lock (_connectLifecycleLock)
{
if (_autoReconnect
&& IsCurrentConnectAttemptLocked(expectedOpenReply, attemptGeneration))
{
_connectAttemptIsRetrying = true;
retryCancellation = new CancellationTokenSource();
previousRetryCancellation = _connectRetryCancellation;
_connectRetryCancellation = retryCancellation;
}
}
}
CancelAndDispose(previousRetryCancellation);
if (retryCancellation != null)
{
Global.Log(
"EpConnection",
LogType.Debug,
$"Reconnecting with a fresh socket after: {exception.Message}");
_ = RetryConnectSocketAsync(
retryCancellation,
expectedOpenReply,
attemptGeneration);
return;
}
FailConnectAttempt(expectedOpenReply, attemptGeneration, exception);
}
async Task RetryConnectSocketAsync(
CancellationTokenSource retryCancellation,
AsyncReply<bool> expectedOpenReply,
long attemptGeneration)
{
try
{
await Task.Delay(
TimeSpan.FromSeconds(ReconnectInterval),
retryCancellation.Token);
}
catch (OperationCanceledException) when (retryCancellation.IsCancellationRequested)
{
return;
}
finally
{
lock (_connectLifecycleLock)
{
if (ReferenceEquals(_connectRetryCancellation, retryCancellation))
_connectRetryCancellation = null;
}
retryCancellation.Dispose();
}
lock (_connectLifecycleLock)
{
if (!_autoReconnect
|| !IsCurrentConnectAttemptLocked(expectedOpenReply, attemptGeneration))
return;
}
try
{
connectSocket(
CreateClientSocket(),
canCreateReplacement: true,
expectedOpenReply,
attemptGeneration);
}
catch (Exception retryException)
{
FailConnectAttempt(
expectedOpenReply,
attemptGeneration,
retryException);
}
}
bool IsCurrentConnectAttempt(
AsyncReply<bool> expectedOpenReply,
long attemptGeneration)
{
lock (_connectLifecycleLock)
return IsCurrentConnectAttemptLocked(expectedOpenReply, attemptGeneration);
}
bool IsCurrentConnectAttemptLocked(
AsyncReply<bool> expectedOpenReply,
long attemptGeneration)
=> !_isDestroyed
&& _connectAttemptGeneration == attemptGeneration
&& ReferenceEquals(Volatile.Read(ref _openReply), expectedOpenReply);
void FailConnectAttempt(
AsyncReply<bool> expectedOpenReply,
long attemptGeneration,
Exception exception)
{
var ownsAttempt = false;
CancellationTokenSource retryCancellation = null;
lock (_connectLifecycleLock)
{
if (IsCurrentConnectAttemptLocked(expectedOpenReply, attemptGeneration))
{
ownsAttempt = ReferenceEquals(
Interlocked.CompareExchange(
ref _openReply,
null,
expectedOpenReply),
expectedOpenReply);
if (ownsAttempt)
{
Status = EpConnectionStatus.Closed;
_connectAttemptIsRetrying = false;
retryCancellation = _connectRetryCancellation;
_connectRetryCancellation = null;
}
}
}
if (!ownsAttempt)
return;
CancelAndDispose(retryCancellation);
TriggerOpenError(expectedOpenReply, exception);
}
void DestroyStaleSocket(ISocket socket)
{
if (ReferenceEquals(Socket, socket))
return;
try { socket.Destroy(); } catch { }
}
public async AsyncReply<bool> Reconnect()
@@ -3414,8 +3745,17 @@ public partial class EpConnection : NetworkConnection, IStore
if (AutoReconnect && !_invalidCredentials)
{
Task.Delay(TimeSpan.FromSeconds(ReconnectInterval))
.ContinueWith((x) => Reconnect());
_ = Task.Delay(TimeSpan.FromSeconds(ReconnectInterval))
.ContinueWith(completedDelay =>
{
lock (_connectLifecycleLock)
{
if (!_autoReconnect || _isDestroyed)
return;
_ = Reconnect();
}
});
}
else
{
@@ -15,23 +15,6 @@ namespace Esiur.Protocol;
public class EpConnectionContext : IResourceContext
{
//public EpConnectionContext()
// : base(0, new Map<string, object>(), null, null)
//{
//}
//public override void Build()
//{
// Attributes["AutoConnect"] = AutoReconnect;
// Attributes["ReconnectInterval"] = ReconnectInterval;
// Attributes["UseWebSocket"] = UseWebSocket;
// Attributes["SecureWebSocket"] = SecureWebSocket;
// Attributes["Domain"] = SecureWebSocket;
// Attributes["AuthenticationProtocol"] = SecureWebSocket;
// Attributes["Identity"] = SecureWebSocket;
//}
public ExceptionLevel ExceptionLevel { get; set; }
= ExceptionLevel.Code | ExceptionLevel.Message | ExceptionLevel.Source | ExceptionLevel.Trace;
@@ -74,9 +57,11 @@ public class EpConnectionContext : IResourceContext
//public string Username { get; set; }
public bool UseWebSocket { get; set; }
public bool SecureWebSocket { get; set; }
/// <summary>
/// Uses WebSocket transport when set. The absolute <c>ws</c> or <c>wss</c>
/// URI can include the endpoint path and query string.
/// </summary>
public Uri WebSocketUri { get; set; }
// public string Password { get; set; }
+72 -5
View File
@@ -52,9 +52,11 @@ public class EpServer : NetworkServer<EpConnection>, IResource
readonly object _peerConnectionsLock = new object();
readonly Dictionary<IPAddress, int> _peerConnectionCounts = new Dictionary<IPAddress, int>();
readonly Dictionary<EpConnection, IPAddress> _admittedConnections = new Dictionary<EpConnection, IPAddress>();
readonly Dictionary<EpConnection, IPAddress?> _admittedConnections =
new Dictionary<EpConnection, IPAddress?>();
readonly Dictionary<IPAddress, PeerAttemptWindow> _peerAttemptWindows =
new Dictionary<IPAddress, PeerAttemptWindow>();
readonly PeerAttemptWindow _globalAttemptWindow = new PeerAttemptWindow();
uint _attemptSweepSequence;
@@ -136,6 +138,12 @@ public class EpServer : NetworkServer<EpConnection>, IResource
set;
} = 10518;
/// <summary>
/// Controls whether warehouse initialization opens Esiur's native TCP listener.
/// Disable this when an external host, such as ASP.NET Core, supplies connections.
/// </summary>
public bool EnableTcpListener { get; set; } = true;
//[Attribute]
public ExceptionLevel ExceptionLevel { get; set; }
@@ -157,6 +165,9 @@ public class EpServer : NetworkServer<EpConnection>, IResource
{
if (operation == ResourceOperation.Initialize)
{
if (!EnableTcpListener)
return new AsyncReply<bool>(true);
TcpSocket listener;
if (IP != null)
@@ -194,15 +205,31 @@ public class EpServer : NetworkServer<EpConnection>, IResource
}
public override void Add(EpConnection connection)
=> TryAdd(connection);
/// <summary>
/// Applies admission controls, configures, and tracks an accepted EP connection.
/// The connection must already have its socket assigned so peer-based policies can
/// inspect the actual remote endpoint.
/// </summary>
/// <returns><see langword="true"/> when the connection was admitted.</returns>
public bool TryAdd(EpConnection connection)
{
if (connection == null)
throw new ArgumentNullException(nameof(connection));
if (connection.Socket == null)
throw new InvalidOperationException(
"Assign a socket before adding an EP connection to the server.");
if (!TryAdmitConnection(connection, out var rejectionReason))
{
Global.Log(
"EpServer:ConnectionLimit",
LogType.Warning,
LogType.Debug,
$"Rejected connection from {connection.RemoteEndPoint?.Address}: {rejectionReason}");
connection.Close();
return;
return false;
}
try
@@ -218,6 +245,7 @@ public class EpServer : NetworkServer<EpConnection>, IResource
connection.AuthenticationTimeout = AuthenticationTimeout;
connection.RestartAuthenticationDeadline();
base.Add(connection);
return true;
}
catch
{
@@ -243,14 +271,50 @@ public class EpServer : NetworkServer<EpConnection>, IResource
{
rejectionReason = null;
var address = NormalizeAddress(connection.RemoteEndPoint?.Address);
if (address == null)
return true;
lock (_peerConnectionsLock)
{
var configuration = Instance.Warehouse.Configuration.Connections;
var now = DateTime.UtcNow;
if (_admittedConnections.ContainsKey(connection))
{
rejectionReason = "connection is already admitted";
return false;
}
if (configuration.MaximumConnectionAttempts > 0
&& configuration.ConnectionAttemptWindow > TimeSpan.Zero)
{
if (now - _globalAttemptWindow.StartedUtc
>= configuration.ConnectionAttemptWindow)
{
_globalAttemptWindow.StartedUtc = now;
_globalAttemptWindow.Count = 0;
}
if (_globalAttemptWindow.Count >= configuration.MaximumConnectionAttempts)
{
rejectionReason = "global connection-attempt rate reached";
return false;
}
_globalAttemptWindow.Count++;
}
var globalLimit = configuration.MaximumConnections;
if (globalLimit > 0 && _admittedConnections.Count >= globalLimit)
{
rejectionReason = "global concurrent-connection limit reached";
return false;
}
if (address == null)
{
_admittedConnections[connection] = null;
return true;
}
if (++_attemptSweepSequence % 256 == 0)
{
foreach (var expired in _peerAttemptWindows
@@ -310,6 +374,9 @@ public class EpServer : NetworkServer<EpConnection>, IResource
_admittedConnections.Remove(connection);
if (address == null)
return;
if (!_peerConnectionCounts.TryGetValue(address, out var count))
return;
+42 -28
View File
@@ -41,7 +41,7 @@ Esiur has implementations in C#, JavaScript, and Dart, making it highly versatil
* Game Development: Enable multiplayer synchronization of game states and events, ensuring real-time feedback for interactive experiences.
* Financial Services: Support real-time, distributed data sharing for trading platforms or monitoring systems where latency is critical.
Esiurs robust, feature-rich approach allows developers to focus on building applications without worrying about the complexities of distributed systems, while its self-describing API and broad data type support enable flexible and scalable application design.
Esiurs robust, feature-rich approach allows developers to focus on building applications without worrying about the complexities of distributed systems, while its self-describing API and broad data type support enable flexible and scalable application design.
## Installation
- Nuget
@@ -68,42 +68,53 @@ Esiur for C# uses source generator feature of .Net framework to implement the ne
>```
### Setting up the server
Esiur resources are arrange by stores (IStore) and accessed in a similar *nix file system paths, each store is responsible for storing and retriving of it's resources from memory, files or database.
Esiur resources are arranged into stores (`IStore`) and accessed using paths similar to a Unix file system. Each store is responsible for keeping its resources in memory, files, or a database.
In this example we're going to use the built-in MemoryStore, which keeps its resources in RAM.
This example creates a Warehouse and uses the built-in `MemoryStore`, which keeps its resources in RAM.
```C#
// Warehouse is the singleton instance that holds all stores and active resources.
await Warehouse.Put("sys", new MemoryStore());
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
```
Now we can add our resource to the memory store using ***Warehouse.Put***
Now we can add our resource to the memory store using `warehouse.Put`.
```C#
await Warehouse.Put("sys/hello", new HelloResource());
await warehouse.Put("sys/hello", new HelloResource());
```
To distribute our resource using Esiur EP Protocol we need to add a DistributedServer
Add an `EpServer` to expose the resource through the Esiur EP protocol. Anonymous access is enabled here only to make the development example easy to run; production applications should configure an authentication provider.
```C#
await Warehouse.Put("sys/server", new DistributedServer());
await warehouse.Put("sys/server", new EpServer
{
AllowUnauthorizedAccess = true, // Development only.
});
```
Finally we call ***Warehouse.Open*** to initialize the system.
Finally, open the Warehouse to initialize the system.
```C#
await Warehouse.Open();
await warehouse.Open();
```
To sum up
>***Program.cs***
>```C#
> await Warehouse.Put("sys", new MemoryStore());
> await Warehouse.Put("sys/hello", new HelloResource());
> await Warehouse.Put("sys/server", new DistributedServer());
> await Warehouse.Open();
>using Esiur.Protocol;
>using Esiur.Resource;
>using Esiur.Stores;
>
>var warehouse = new Warehouse();
>await warehouse.Put("sys", new MemoryStore());
>await warehouse.Put("sys/hello", new HelloResource());
>await warehouse.Put("sys/server", new EpServer
>{
> AllowUnauthorizedAccess = true, // Development only.
>});
>await warehouse.Open();
>```
@@ -111,7 +122,8 @@ To sum up
To access our resource remotely, we need to use it's full path including the protocol, host and instance link.
```C#
dynamic res = await Warehouse.Get<IResource>("EP://localhost/sys/hello");
var warehouse = new Warehouse();
dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
```
Now we can invoke the exported functions and read/write properties;
@@ -126,14 +138,15 @@ Summing up
>***Program.cs***
>```C#
> using Esiur.Resource;
>
> dynamic res = await Warehouse.Get<IResource>("EP://localhost/sys/hello");
>
> var reply = await res.SayHi("Hi, I'm calling you from dotnet");
>
> Console.WriteLine(reply);
> Console.WriteLine($"Number of people said hi {res.Counts}");
>using Esiur.Resource;
>
>var warehouse = new Warehouse();
>dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
>
>var reply = await res.SayHi("Hi, I'm calling you from dotnet");
>
>Console.WriteLine(reply);
>Console.WriteLine($"Number of people said hi {res.Counts}");
>
>```
@@ -143,7 +156,7 @@ In the above client example, we relied on Esiur support for dynamic objects, but
Esiur has a self describing feature which comes with every language it supports, allowing the developer to fetch and generate classes that match the ones on the other side (i.e. server).
After installing the Esiur nuget package a new command is added to Visual Studio Package Console Manager that is called ***Get-Types***, which generates client side classes for robust static typing.
After installing the Esiur NuGet package, a new command named ***Get-Types*** is added to the Visual Studio Package Manager Console. It generates client-side classes for robust static typing.
```ps
Get-Types ep://localhost/sys/hello
@@ -153,8 +166,9 @@ This will generate and add wrappers for all types needed by our resource.
Allowing us to use
```C#
var res = await Warehouse.Get<MyResource>("EP://localhost/sys/hello");
var reply = await res.SayHi("Static typing is better");
Console.WriteLine(reply);
var warehouse = new Warehouse();
var res = await warehouse.Get<MyResource>("ep://localhost/sys/hello");
var reply = await res.SayHi("Static typing is better");
Console.WriteLine(reply);
```
+198 -86
View File
@@ -43,6 +43,7 @@ using System.Data;
using System.Linq;
using System.Reflection;
using System.Reflection.Metadata;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@@ -89,7 +90,8 @@ public class Warehouse
KeyList<string, KeyList<TypeDefKind, KeyList<string, Type>>> _proxyTypeDefs = new();
Map<string, IAuthenticationProvider> _authenticationProviders = new Map<string, IAuthenticationProvider>();
readonly ConcurrentDictionary<string, IAuthenticationProvider> _authenticationProviders
= new ConcurrentDictionary<string, IAuthenticationProvider>(StringComparer.Ordinal);
readonly ConcurrentDictionary<string, IEncryptionProvider> _encryptionProviders
= new ConcurrentDictionary<string, IEncryptionProvider>(StringComparer.Ordinal);
readonly ConcurrentDictionary<Type, IResourceManager> _resourceManagers
@@ -102,7 +104,10 @@ public class Warehouse
object _typeDefsLock = new object();
bool _warehouseIsOpen = false;
readonly object _warehouseLifecycleLock = new object();
readonly SemaphoreSlim _warehouseLifecycleGate = new SemaphoreSlim(1, 1);
volatile bool _warehouseIsOpen = false;
bool _warehouseRequiresTermination = false;
public delegate void StoreEvent(IStore store);
public event StoreEvent StoreConnected;
@@ -117,19 +122,46 @@ public class Warehouse
/// </summary>
public WarehouseConfiguration Configuration { get; }
/// <summary>Indicates whether this Warehouse is currently open.</summary>
public bool IsOpen
{
get
{
lock (_warehouseLifecycleLock)
return _warehouseIsOpen;
}
}
private Regex urlRegex = new Regex(@"^(?:([\S]*)://([^/]*)/?)");
public void RegisterAuthenticationProvider(IAuthenticationProvider provider)
{
if (provider == null)
throw new ArgumentNullException(nameof(provider));
RegisterAuthenticationProvider(provider.DefaultName, provider);
}
public void RegisterAuthenticationProvider(string name, IAuthenticationProvider provider)
{
_authenticationProviders.Add(name, provider);
if (string.IsNullOrWhiteSpace(name)
|| !string.Equals(name, name.Trim(), StringComparison.Ordinal))
throw new ArgumentException("An authentication provider name is required.", nameof(name));
if (provider == null)
throw new ArgumentNullException(nameof(provider));
if (!_authenticationProviders.TryAdd(name, provider))
throw new InvalidOperationException(
$"An authentication provider named `{name}` is already registered.");
}
/// <summary>Removes an authentication provider only when the same instance is registered.</summary>
public bool UnregisterAuthenticationProvider(string name, IAuthenticationProvider provider)
=> !string.IsNullOrWhiteSpace(name)
&& provider != null
&& ((ICollection<KeyValuePair<string, IAuthenticationProvider>>)_authenticationProviders)
.Remove(new KeyValuePair<string, IAuthenticationProvider>(name, provider));
/// <summary>
/// Registers an encryption provider using its default protocol name.
/// </summary>
@@ -146,7 +178,8 @@ public class Warehouse
/// </summary>
public void RegisterEncryptionProvider(string name, IEncryptionProvider provider)
{
if (string.IsNullOrWhiteSpace(name))
if (string.IsNullOrWhiteSpace(name)
|| !string.Equals(name, name.Trim(), StringComparison.Ordinal))
throw new ArgumentException("An encryption provider name is required.", nameof(name));
if (provider == null)
throw new ArgumentNullException(nameof(provider));
@@ -154,6 +187,13 @@ public class Warehouse
throw new InvalidOperationException($"An encryption provider named `{name}` is already registered.");
}
/// <summary>Removes an encryption provider only when the same instance is registered.</summary>
public bool UnregisterEncryptionProvider(string name, IEncryptionProvider provider)
=> !string.IsNullOrWhiteSpace(name)
&& provider != null
&& ((ICollection<KeyValuePair<string, IEncryptionProvider>>)_encryptionProviders)
.Remove(new KeyValuePair<string, IEncryptionProvider>(name, provider));
/// <summary>
/// Registers a manager instance by its concrete type. Type attributes and
@@ -560,15 +600,16 @@ public class Warehouse
public IAuthenticationProvider GetAuthenticationProvider(string name)
{
if (_authenticationProviders.ContainsKey(name))
return _authenticationProviders[name];
if (_authenticationProviders.TryGetValue(name, out var provider))
return provider;
throw new Exception("Authentication provider not found.");
}
public IAuthenticationProvider? TryGetAuthenticationProvider(string name)
{
if (_authenticationProviders.ContainsKey(name))
return _authenticationProviders[name];
if (!string.IsNullOrWhiteSpace(name)
&& _authenticationProviders.TryGetValue(name, out var provider))
return provider;
return null;
}
@@ -689,52 +730,70 @@ public class Warehouse
/// <returns>True, if no problem occurred.</returns>
public async AsyncReply<bool> Open()
{
if (_warehouseIsOpen)
return false;
// Load generated models
//LoadGenerated();
_warehouseIsOpen = true;
var resSnap = _resources.Select(x =>
await _warehouseLifecycleGate.WaitAsync();
try
{
IResource r;
if (x.Value.TryGetTarget(out r))
return r;
else
return null;
}).Where(r => r != null).ToArray();
foreach (var r in resSnap)
{
//IResource r;
//if (rk.Value.TryGetTarget(out r))
//{
var rt = await r.Handle(ResourceOperation.Initialize);
//if (!rt)
// return false;
if (!rt)
lock (_warehouseLifecycleLock)
{
Global.Log("Warehouse", LogType.Warning, $"Resource failed at Initialize {r.Instance.Name} [{r.Instance.Definition.Name}]");
if (_warehouseIsOpen || _warehouseRequiresTermination)
return false;
_warehouseIsOpen = true;
_warehouseRequiresTermination = true;
}
//}
}
foreach (var r in resSnap)
{
var rt = await r.Handle(ResourceOperation.SystemReady);
if (!rt)
try
{
Global.Log("Warehouse", LogType.Warning, $"Resource failed at SystemInitialized {r.Instance.Name} [{r.Instance.Definition.Name}]");
// Load generated models
//LoadGenerated();
var resSnap = _resources.Select(x =>
{
IResource r;
if (x.Value.TryGetTarget(out r))
return r;
else
return null;
}).Where(r => r != null).ToArray();
foreach (var r in resSnap)
{
//IResource r;
//if (rk.Value.TryGetTarget(out r))
//{
var rt = await r.Handle(ResourceOperation.Initialize);
//if (!rt)
// return false;
if (!rt)
{
Global.Log("Warehouse", LogType.Warning, $"Resource failed at Initialize {r.Instance.Name} [{r.Instance.Definition.Name}]");
}
//}
}
foreach (var r in resSnap)
{
var rt = await r.Handle(ResourceOperation.SystemReady);
if (!rt)
{
Global.Log("Warehouse", LogType.Warning, $"Resource failed at SystemInitialized {r.Instance.Name} [{r.Instance.Definition.Name}]");
}
}
return true;
}
catch
{
lock (_warehouseLifecycleLock)
_warehouseIsOpen = false;
throw;
}
}
return true;
finally
{
_warehouseLifecycleGate.Release();
}
}
/// <summary>
@@ -742,56 +801,109 @@ public class Warehouse
/// This function issues terminate trigger to all resources and stores.
/// </summary>
/// <returns>True, if no problem occurred.</returns>
public AsyncReply<bool> Close()
public async AsyncReply<bool> Close()
{
await _warehouseLifecycleGate.WaitAsync();
try
{
lock (_warehouseLifecycleLock)
{
if (!_warehouseRequiresTermination)
return false;
var bag = new AsyncBag<bool>();
// Resources added after this point must not initialize while the
// existing graph is terminating.
_warehouseIsOpen = false;
}
// Use the same stable resource graph for both shutdown phases. Stores are
// also retained by _stores, so include that registry in case its weak
// _resources entry was concurrently removed or became unavailable.
var resources = SnapshotResources();
// Every Terminate callback must settle before SystemTerminated starts.
// Each phase captures failures per resource so one synchronous throw or
// failed reply cannot prevent the remaining callbacks from running.
var terminate = await SettleOperation(resources, ResourceOperation.Terminate);
var systemTerminated = await SettleOperation(resources, ResourceOperation.SystemTerminated);
var errors = terminate.Errors.Concat(systemTerminated.Errors).ToArray();
if (errors.Length == 1)
throw errors[0];
if (errors.Length > 1)
throw new AggregateException(
"One or more resources failed while closing the warehouse.",
errors);
return terminate.Success && systemTerminated.Success;
}
finally
{
lock (_warehouseLifecycleLock)
{
_warehouseIsOpen = false;
_warehouseRequiresTermination = false;
}
_warehouseLifecycleGate.Release();
}
}
private IResource[] SnapshotResources()
{
var resources = new HashSet<IResource>(ResourceReferenceComparer.Instance);
foreach (var resource in _resources.Values)
{
IResource r;
if (resource.TryGetTarget(out r))
{
if (!(r is IStore))
bag.Add(r.Handle(ResourceOperation.Terminate));
if (resource.TryGetTarget(out var target))
resources.Add(target);
}
foreach (var store in _stores.Keys)
resources.Add(store);
return resources.ToArray();
}
private static async Task<(bool Success, Exception[] Errors)> SettleOperation(
IResource[] resources,
ResourceOperation operation)
{
var pending = resources
.Select(resource => SettleResourceOperation(resource, operation))
.ToArray();
var outcomes = await Task.WhenAll(pending);
return (
outcomes.All(outcome => outcome.Success),
outcomes
.Where(outcome => outcome.Error != null)
.Select(outcome => outcome.Error!)
.ToArray());
}
private static async Task<(bool Success, Exception? Error)> SettleResourceOperation(
IResource resource,
ResourceOperation operation)
{
try
{
var reply = resource.Handle(operation)
?? throw new InvalidOperationException(
$"Resource `{resource.GetType().FullName}` returned no reply for `{operation}`.");
return (await reply, null);
}
foreach (var store in _stores)
bag.Add(store.Key.Handle(ResourceOperation.Terminate));
foreach (var resource in _resources.Values)
catch (Exception exception)
{
IResource r;
if (resource.TryGetTarget(out r))
{
if (!(r is IStore))
bag.Add(r.Handle(ResourceOperation.SystemTerminated));
}
return (false, exception);
}
}
private sealed class ResourceReferenceComparer : IEqualityComparer<IResource>
{
internal static readonly ResourceReferenceComparer Instance = new ResourceReferenceComparer();
foreach (var store in _stores)
bag.Add(store.Key.Handle(ResourceOperation.SystemTerminated));
public bool Equals(IResource x, IResource y) => ReferenceEquals(x, y);
bag.Seal();
var rt = new AsyncReply<bool>();
bag.Then((x) =>
{
foreach (var b in x)
if (!b)
{
rt.Trigger(false);
return;
}
rt.Trigger(true);
});
return rt;
public int GetHashCode(IResource resource) => RuntimeHelpers.GetHashCode(resource);
}
@@ -65,8 +65,18 @@ public sealed class ResourceAttachmentConfiguration
/// </summary>
public sealed class ConnectionConfiguration
{
/// <summary>Maximum concurrent connections admitted by one EP server.</summary>
public int MaximumConnections { get; set; } = 1_024;
public int MaximumConnectionsPerIpAddress { get; set; } = 64;
/// <summary>
/// Maximum connection attempts admitted by one EP server during
/// <see cref="ConnectionAttemptWindow"/>. This also bounds clients that rotate IP
/// addresses. A value of zero disables the limit.
/// </summary>
public int MaximumConnectionAttempts { get; set; } = 4_096;
/// <summary>
/// Maximum connection attempts admitted from one IP during
/// <see cref="ConnectionAttemptWindow"/>. This limits repeated pre-authentication
@@ -1,6 +1,7 @@
using Esiur.Core;
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
namespace Esiur.Security.Authority.Providers
@@ -12,15 +13,41 @@ namespace Esiur.Security.Authority.Providers
/// </summary>
public const string ProtocolName = "password-sha3-v1";
/// <summary>
/// Previous protocol name, retained only to make explicit migration aliases possible.
/// New connections should use <see cref="ProtocolName"/>.
/// </summary>
[Obsolete("Use ProtocolName (`password-sha3-v1`) for new connections.")]
public const string LegacyProtocolName = "hash";
public string DefaultName => ProtocolName;
/// <summary>
/// Creates the salted credential stored by a server for the
/// <c>password-sha3-v1</c> protocol. Call this once during account enrollment,
/// persist the returned hash and salt, and discard the plaintext password.
/// The hash is a password-equivalent protocol verifier and must be protected
/// from disclosure, logs, and client-visible data.
/// </summary>
public static PasswordHash CreateCredential(byte[] password)
{
if (password == null)
throw new ArgumentNullException(nameof(password));
if (password.Length == 0)
throw new ArgumentException("A password cannot be empty.", nameof(password));
var salt = new byte[32];
using (var random = RandomNumberGenerator.Create())
random.GetBytes(salt);
var material = new byte[password.Length + salt.Length];
try
{
Buffer.BlockCopy(password, 0, material, 0, password.Length);
Buffer.BlockCopy(salt, 0, material, password.Length, salt.Length);
return new PasswordHash(
PasswordAuthenticationHandler.ComputeSha3(material),
salt);
}
finally
{
Array.Clear(material, 0, material.Length);
}
}
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
{
var authHandler = new PasswordAuthenticationHandler(context.Mode,