Initial version
This commit is contained in:
14
Demo/Demo.csproj
Normal file
14
Demo/Demo.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net481;net6.0;net8.0</TargetFrameworks>
|
||||
<Nullable>disable</Nullable>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EonaCat.QuicNet\EonaCat.QuicNet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
259
Demo/Program.cs
Normal file
259
Demo/Program.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
namespace EonaCat.QuicNet
|
||||
{
|
||||
/// <summary>
|
||||
/// EonaCat.QuicNet — Demo
|
||||
/// Starts a server and two clients to demonstrate various features of the library, including sending messages, groups, nicknames, and graceful disconnects.
|
||||
/// </summary>
|
||||
internal static class Demo
|
||||
{
|
||||
private static void Main()
|
||||
{
|
||||
Console.WriteLine("EonaCat QuicNet — Features demo ");
|
||||
|
||||
// 1. Server setup
|
||||
|
||||
var serverOptions = new QuicServerOptions
|
||||
{
|
||||
Port = 9876,
|
||||
MaxConnections = 100_000,
|
||||
HeartbeatIntervalInMilliseconds = 5_000,
|
||||
ClientTimeoutInMilliseconds = 15_000,
|
||||
EnableHeartbeat = true,
|
||||
NoDelay = true
|
||||
};
|
||||
|
||||
var server = new QuicServer(serverOptions);
|
||||
|
||||
server.Started += (s, e) => Log("SERVER", $"Listening on port {e.Port}");
|
||||
server.Stopped += (s, e) => Log("SERVER", "Stopped");
|
||||
server.ClientConnected += (s, e) => Log("SERVER", string.Format("+ Connected [{0}...]", e.Client.SessionId.Substring(0, 8)));
|
||||
server.ClientDisconnected += (s, e) => Log("SERVER", string.Format("- Disconnected [{0}...] Reason={1}", e.Client.SessionId.Substring(0, 8), e.Reason));
|
||||
|
||||
server.DataReceived += (s, e) =>
|
||||
{
|
||||
var id = e.Client.Nickname ??
|
||||
(e.Client.SessionId.Length > 8
|
||||
? e.Client.SessionId.Substring(0, 8)
|
||||
: e.Client.SessionId);
|
||||
|
||||
Log("SERVER", $" Data from [{id}]: {e.Text}");
|
||||
};
|
||||
|
||||
server.ClientJoinedGroup += (s, e) => Log("SERVER", $" [{e.Client.Nickname ?? "?"}] joined group '{e.GroupName}'");
|
||||
server.ClientLeftGroup += (s, e) => Log("SERVER", $" [{e.Client.Nickname ?? "?"}] left group '{e.GroupName}'");
|
||||
server.Error += (s, e) => Log("SERVER", $" ERROR: {e.Exception.Message} [{e.Context}]");
|
||||
|
||||
server.Start();
|
||||
Thread.Sleep(200);
|
||||
|
||||
// 2. Client A
|
||||
|
||||
var clientA = new QuicClient(new QuicClientOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 9876,
|
||||
Nickname = "Alice",
|
||||
AutoReconnect = true,
|
||||
ReconnectMaxAttempts = 5,
|
||||
ReconnectBaseDelayMs = 500
|
||||
});
|
||||
|
||||
clientA.Connected += (s, e) => Log("CLIENT-A", string.Format("Connected session={0}...", e.SessionId.Substring(0, 8)));
|
||||
clientA.Disconnected += (s, e) => Log("CLIENT-A", $"Disconnected reason={e.Reason}");
|
||||
clientA.DataReceived += (s, e) => Log("CLIENT-A", $" ← {e.Text}");
|
||||
clientA.Reconnecting += (s, e) => Log("CLIENT-A", $" Reconnecting attempt {e.Attempt}/{e.MaxAttempts}...");
|
||||
clientA.ReconnectFailed += (s, e) => Log("CLIENT-A", " Reconnect gave up.");
|
||||
clientA.Error += (s, e) => Log("CLIENT-A", $" ERROR: {e.Exception.Message}");
|
||||
|
||||
clientA.Connect();
|
||||
Thread.Sleep(300);
|
||||
|
||||
// 3. Client B
|
||||
|
||||
var clientB = new QuicClient("127.0.0.1", 9876, "Bob");
|
||||
clientB.Connected += (s, e) => Log("CLIENT-B", string.Format("Connected session={0}...", e.SessionId.Substring(0, 8)));
|
||||
clientB.Disconnected += (s, e) => Log("CLIENT-B", $"Disconnected reason={e.Reason}");
|
||||
clientB.DataReceived += (s, e) => Log("CLIENT-B", $" ← {e.Text}");
|
||||
clientB.Error += (s, e) => Log("CLIENT-B", $" ERROR: {e.Exception.Message}");
|
||||
clientB.Connect();
|
||||
Thread.Sleep(300);
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "Basic sends");
|
||||
|
||||
// 4. Send string from client
|
||||
|
||||
clientA.Send("Hello from Alice!");
|
||||
clientB.Send("Hello from Bob!");
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 5. Send bytes
|
||||
|
||||
clientA.Send(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }); // raw bytes
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 6. Broadcast from server
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "Broadcast");
|
||||
int n = server.Broadcast("Server broadcast to everyone!");
|
||||
Log("SERVER", $" Sent to {n} clients");
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 7. Send to single client
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "SendTo (single)");
|
||||
|
||||
var clients = new System.Collections.Generic.List<IQuicClient>(server.GetClients());
|
||||
if (clients.Count >= 2)
|
||||
{
|
||||
var aliceSession = FindByNickname(server, "Alice");
|
||||
var bobSession = FindByNickname(server, "Bob");
|
||||
|
||||
if (aliceSession != null)
|
||||
{
|
||||
server.SendTo(aliceSession.SessionId, "Private message to Alice only");
|
||||
}
|
||||
|
||||
if (bobSession != null)
|
||||
{
|
||||
server.SendTo(bobSession.SessionId, "Private message to Bob only");
|
||||
}
|
||||
}
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 8. Groups
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "Groups");
|
||||
|
||||
var alice = FindByNickname(server, "Alice");
|
||||
var bob = FindByNickname(server, "Bob");
|
||||
|
||||
if (alice != null)
|
||||
{
|
||||
server.AddToGroup(alice.SessionId, "vip");
|
||||
}
|
||||
|
||||
if (bob != null)
|
||||
{
|
||||
server.AddToGroup(bob.SessionId, "vip");
|
||||
}
|
||||
|
||||
if (alice != null)
|
||||
{
|
||||
server.AddToGroup(alice.SessionId, "team-a");
|
||||
}
|
||||
|
||||
Thread.Sleep(100);
|
||||
|
||||
int vipCount = server.SendToGroup("vip", "VIP broadcast (Alice + Bob)");
|
||||
Log("SERVER", $" Sent to {vipCount} VIP clients");
|
||||
|
||||
int teamACount = server.SendToGroup("team-a", "Team-A message (Alice only)");
|
||||
Log("SERVER", $" Sent to {teamACount} team-a clients");
|
||||
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 9. Nickname change
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "Nickname change");
|
||||
clientA.Nickname = "Alice_Renamed";
|
||||
Thread.Sleep(100);
|
||||
Log("CLIENT-A", $"Nickname is now: {clientA.Nickname}");
|
||||
|
||||
// 10. Encoding variants
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "Unicode / Encoding");
|
||||
clientB.Send("あなたを決してあきらめない", Encoding.UTF8);
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 11. Server queries
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "Server queries");
|
||||
Log("SERVER", $" Connected clients: {server.ClientCount}");
|
||||
foreach (var g in server.GetGroups())
|
||||
{
|
||||
int cnt = 0;
|
||||
foreach (var _ in server.GetGroupClients(g))
|
||||
{
|
||||
cnt++;
|
||||
}
|
||||
|
||||
Log("SERVER", $" Group '{g}': {cnt} member(s)");
|
||||
}
|
||||
|
||||
// 12. Remove from group
|
||||
|
||||
if (alice != null)
|
||||
{
|
||||
server.RemoveFromGroup(alice.SessionId, "vip");
|
||||
Log("SERVER", " Alice removed from 'vip'");
|
||||
}
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 13. Extension methods
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "Extensions");
|
||||
server.BroadcastText("Extension broadcast text!");
|
||||
server.SendToGroupText("team-a", "Extension group-text to team-a");
|
||||
Thread.Sleep(100);
|
||||
|
||||
// 14. Graceful disconnect
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "Graceful disconnect");
|
||||
clientB.Disconnect("Bob says goodbye");
|
||||
Thread.Sleep(300);
|
||||
|
||||
// 15. Server kick
|
||||
|
||||
if (alice != null)
|
||||
{
|
||||
server.Kick(alice.SessionId, "Demo kick");
|
||||
Thread.Sleep(300);
|
||||
}
|
||||
|
||||
// 16. Shutdown
|
||||
|
||||
Console.WriteLine();
|
||||
Log("DEMO", "Server shutdown");
|
||||
server.Stop();
|
||||
Thread.Sleep(200);
|
||||
|
||||
clientA.Dispose();
|
||||
clientB.Dispose();
|
||||
server.Dispose();
|
||||
|
||||
Console.WriteLine("Demo complete!");
|
||||
}
|
||||
|
||||
|
||||
private static void Log(string source, string message) => Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{source,-10}] {message}");
|
||||
|
||||
private static IQuicClient FindByNickname(QuicServer server, string nickname)
|
||||
{
|
||||
foreach (var client in server.GetClients())
|
||||
{
|
||||
if (string.Equals(client.Nickname, nickname, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
EonaCat.QuicNet.sln
Normal file
35
EonaCat.QuicNet.sln
Normal file
@@ -0,0 +1,35 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.3.11512.155
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0559F9D5-EA97-46D1-9C9D-392B8E511AB0}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
README.md = README.md
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.QuicNet", "EonaCat.QuicNet\EonaCat.QuicNet.csproj", "{FBE0FA86-1E39-A866-53BC-6287358661F1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demo", "Demo\Demo.csproj", "{D1CCB24F-A868-F185-9228-8CC249247C79}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{FBE0FA86-1E39-A866-53BC-6287358661F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FBE0FA86-1E39-A866-53BC-6287358661F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FBE0FA86-1E39-A866-53BC-6287358661F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FBE0FA86-1E39-A866-53BC-6287358661F1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1CCB24F-A868-F185-9228-8CC249247C79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1CCB24F-A868-F185-9228-8CC249247C79}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1CCB24F-A868-F185-9228-8CC249247C79}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1CCB24F-A868-F185-9228-8CC249247C79}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {F533E508-7B9E-4347-B48E-0BFD24D0756F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
302
EonaCat.QuicNet/ConnectedClient.cs
Normal file
302
EonaCat.QuicNet/ConnectedClient.cs
Normal file
@@ -0,0 +1,302 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace EonaCat.QuicNet
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Represents a connected remote client on the server side.
|
||||
/// </summary>
|
||||
public sealed class ConnectedClient : IQuicClient, IDisposable
|
||||
{
|
||||
private readonly Socket _socket;
|
||||
private readonly object _sendLock = new object();
|
||||
private readonly object _groupLock = new object();
|
||||
private readonly HashSet<string> _groups = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private volatile bool _connected = true;
|
||||
private volatile string _nickname;
|
||||
private long _lastActivityTick;
|
||||
|
||||
public string SessionId { get; }
|
||||
public string RemoteAddress { get; }
|
||||
public int RemotePort { get; }
|
||||
public DateTime ConnectedAt { get; } = DateTime.UtcNow;
|
||||
|
||||
public string Nickname
|
||||
{
|
||||
get => _nickname;
|
||||
set
|
||||
{
|
||||
_nickname = value;
|
||||
TrySendRaw(Protocol.EncodeNickname(value));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
|
||||
public IReadOnlyCollection<string> Groups
|
||||
{
|
||||
get { lock (_groupLock)
|
||||
{
|
||||
return new HashSet<string>(_groups);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal long LastActivityTick
|
||||
{
|
||||
get => Interlocked.Read(ref _lastActivityTick);
|
||||
set => Interlocked.Exchange(ref _lastActivityTick, value);
|
||||
}
|
||||
|
||||
internal Socket Socket => _socket;
|
||||
|
||||
// Events
|
||||
internal event Action<ConnectedClient, byte[]> OnDataReceived;
|
||||
internal event Action<ConnectedClient, DisconnectReason, string> OnDisconnected;
|
||||
internal event Action<ConnectedClient, Exception> OnError;
|
||||
|
||||
internal ConnectedClient(string sessionId, Socket socket, string nickname)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
_socket = socket;
|
||||
_nickname = nickname;
|
||||
_lastActivityTick = Environment.TickCount;
|
||||
|
||||
try
|
||||
{
|
||||
var ep = (System.Net.IPEndPoint)socket.RemoteEndPoint;
|
||||
RemoteAddress = ep.Address.ToString();
|
||||
RemotePort = ep.Port;
|
||||
}
|
||||
catch
|
||||
{
|
||||
RemoteAddress = "unknown";
|
||||
RemotePort = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public SendResult Send(byte[] data)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return SendResult.NotConnected;
|
||||
}
|
||||
|
||||
if (data == null || data.Length == 0)
|
||||
{
|
||||
return SendResult.Success;
|
||||
}
|
||||
|
||||
return TrySendRaw(Protocol.Encode(MessageType.Data, data))
|
||||
? SendResult.Success
|
||||
: SendResult.SendFailed;
|
||||
}
|
||||
|
||||
public SendResult Send(string text)
|
||||
=> Send(text, Encoding.UTF8);
|
||||
|
||||
public SendResult Send(string text, Encoding encoding)
|
||||
{
|
||||
if (text == null)
|
||||
{
|
||||
text = string.Empty;
|
||||
}
|
||||
|
||||
return Send(encoding.GetBytes(text));
|
||||
}
|
||||
|
||||
public void JoinGroup(string groupName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(groupName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_groupLock)
|
||||
{
|
||||
_groups.Add(groupName);
|
||||
}
|
||||
}
|
||||
|
||||
public void LeaveGroup(string groupName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(groupName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_groupLock)
|
||||
{
|
||||
_groups.Remove(groupName);
|
||||
}
|
||||
}
|
||||
|
||||
internal bool IsInGroup(string groupName)
|
||||
{
|
||||
lock (_groupLock)
|
||||
{
|
||||
return _groups.Contains(groupName);
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect(DisconnectReason reason = DisconnectReason.ServerShutdown, string message = null)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TrySendRaw(Protocol.EncodeDisconnect(reason, message));
|
||||
CloseInternal(reason, message);
|
||||
}
|
||||
|
||||
internal bool TrySendRaw(byte[] frame)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int sent = 0;
|
||||
while (sent < frame.Length)
|
||||
{
|
||||
int n = _socket.Send(frame, sent, frame.Length - sent, SocketFlags.None);
|
||||
if (n <= 0) { CloseInternal(DisconnectReason.ClientDisconnected); return false; }
|
||||
sent += n;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnError?.Invoke(this, ex);
|
||||
CloseInternal(DisconnectReason.ClientDisconnected);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal void CloseInternal(DisconnectReason reason = DisconnectReason.Unknown, string message = null)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_connected = false;
|
||||
try { _socket.Shutdown(SocketShutdown.Both); } catch { }
|
||||
try { _socket.Close(); } catch { }
|
||||
try { _socket.Dispose(); } catch { }
|
||||
OnDisconnected?.Invoke(this, reason, message);
|
||||
}
|
||||
|
||||
internal void StartReceiving(int bufferSize, int maxMsgSize, CancellationToken ct)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ => ReceiveLoop(bufferSize, maxMsgSize, ct));
|
||||
}
|
||||
|
||||
private void ReceiveLoop(int bufferSize, int maxMsgSize, CancellationToken ct)
|
||||
{
|
||||
var reader = new FrameReader();
|
||||
var buf = new byte[bufferSize];
|
||||
|
||||
while (_connected && !ct.IsCancellationRequested)
|
||||
{
|
||||
int n;
|
||||
try
|
||||
{
|
||||
n = _socket.Receive(buf, 0, buf.Length, SocketFlags.None);
|
||||
}
|
||||
catch (SocketException sex) when (sex.SocketErrorCode == SocketError.ConnectionReset
|
||||
|| sex.SocketErrorCode == SocketError.ConnectionAborted)
|
||||
{
|
||||
CloseInternal(DisconnectReason.ClientDisconnected);
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException) { return; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnError?.Invoke(this, ex);
|
||||
CloseInternal(DisconnectReason.ProtocolError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (n <= 0)
|
||||
{
|
||||
CloseInternal(DisconnectReason.ClientDisconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
LastActivityTick = Environment.TickCount;
|
||||
|
||||
// Feed bytes into the frame reader; may produce multiple frames
|
||||
int offset = 0;
|
||||
while (offset < n)
|
||||
{
|
||||
MessageType type;
|
||||
byte[] payload;
|
||||
if (reader.TryFeed(buf, offset, n - offset, out type, out payload))
|
||||
{
|
||||
offset += Protocol.HeaderSize + (payload?.Length ?? 0);
|
||||
HandleFrame(type, payload);
|
||||
}
|
||||
else
|
||||
{
|
||||
// partial — need more bytes
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly byte[] _emptyBytes = new byte[0];
|
||||
|
||||
private void HandleFrame(MessageType type, byte[] payload)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case MessageType.Data:
|
||||
OnDataReceived?.Invoke(this, payload ?? _emptyBytes);
|
||||
break;
|
||||
|
||||
case MessageType.Ping:
|
||||
TrySendRaw(Protocol.EncodePong());
|
||||
break;
|
||||
|
||||
case MessageType.Pong:
|
||||
// heartbeat acknowledged — nothing extra needed
|
||||
break;
|
||||
|
||||
case MessageType.NicknameSet:
|
||||
_nickname = payload != null ? Encoding.UTF8.GetString(payload) : string.Empty;
|
||||
break;
|
||||
|
||||
case MessageType.Disconnect:
|
||||
CloseInternal(DisconnectReason.ClientDisconnected,
|
||||
payload != null ? Encoding.UTF8.GetString(payload) : null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void Dispose() => CloseInternal(DisconnectReason.ServerShutdown);
|
||||
|
||||
public override string ToString()
|
||||
=> $"[{SessionId}] {Nickname ?? RemoteAddress}:{RemotePort}";
|
||||
}
|
||||
}
|
||||
52
EonaCat.QuicNet/EonaCat.QuicNet.csproj
Normal file
52
EonaCat.QuicNet/EonaCat.QuicNet.csproj
Normal file
@@ -0,0 +1,52 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net481;net6.0;net7.0;net8.0</TargetFrameworks>
|
||||
<RootNamespace>EonaCat.QuicNet</RootNamespace>
|
||||
<AssemblyName>EonaCat.QuicNet</AssemblyName>
|
||||
<Nullable>disable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
||||
<Optimize>true</Optimize>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<PackageId>EonaCat.QuicNet</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>EonaCat (Jeroen Saey)</Authors>
|
||||
<Description>High-performance TCP networking library with QUIC-like semantics.
|
||||
Supports 100,000+ concurrent connections. </Description>
|
||||
<PackageTags>tcp;networking;quic;client;server;realtime</PackageTags>
|
||||
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
|
||||
<Title>EonaCat.QuicNet</Title>
|
||||
<Company>EonaCat (Jeroen Saey)</Company>
|
||||
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||
<PackageProjectUrl>https://git.saey.me/EonaCat/EonaCat.QuicNet</PackageProjectUrl>
|
||||
<PackageIcon>icon.png</PackageIcon>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.QuicNet</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net481'">
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Net" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Runtime.InteropServices" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\icon.png">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
<None Include="..\LICENSE">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
<None Include="..\README.md">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
299
EonaCat.QuicNet/Protocol.cs
Normal file
299
EonaCat.QuicNet/Protocol.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
namespace EonaCat.QuicNet
|
||||
{
|
||||
// Frame layout
|
||||
//
|
||||
// [ 1 byte ] MessageType
|
||||
// [ 4 bytes ] Payload length (big-endian uint32)
|
||||
// [ N bytes ] Payload
|
||||
//
|
||||
|
||||
internal static class Protocol
|
||||
{
|
||||
internal const int HeaderSize = 5; // 1 (type) + 4 (length)
|
||||
internal const int MaxFramePayload = 64 * 1024 * 1024; // 64 MB
|
||||
|
||||
internal static byte[] Encode(MessageType type, byte[] payload)
|
||||
{
|
||||
int len = payload == null ? 0 : payload.Length;
|
||||
byte[] frame = new byte[HeaderSize + len];
|
||||
frame[0] = (byte)type;
|
||||
WriteUInt32BE(frame, 1, (uint)len);
|
||||
if (len > 0)
|
||||
{
|
||||
Buffer.BlockCopy(payload, 0, frame, HeaderSize, len);
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
internal static byte[] EncodeText(MessageType type, string text) => Encode(type, Encoding.UTF8.GetBytes(text ?? string.Empty));
|
||||
|
||||
internal static byte[] EncodeHandshake(string sessionId, string nickname)
|
||||
{
|
||||
var obj = new SimpleJson();
|
||||
obj.Set("sessionId", sessionId);
|
||||
obj.Set("nickname", nickname ?? string.Empty);
|
||||
return EncodeText(MessageType.Handshake, obj.ToString());
|
||||
}
|
||||
|
||||
internal static byte[] EncodeHandshakeAck(string sessionId) => EncodeText(MessageType.HandshakeAck, sessionId);
|
||||
|
||||
internal static byte[] EncodePing() => Encode(MessageType.Ping, null);
|
||||
internal static byte[] EncodePong() => Encode(MessageType.Pong, null);
|
||||
|
||||
internal static byte[] EncodeDisconnect(DisconnectReason reason, string message)
|
||||
{
|
||||
var simpleJson = new SimpleJson();
|
||||
simpleJson.Set("reason", ((int)reason).ToString());
|
||||
simpleJson.Set("message", message ?? string.Empty);
|
||||
return EncodeText(MessageType.Disconnect, simpleJson.ToString());
|
||||
}
|
||||
|
||||
internal static byte[] EncodeGroupMessage(string group, byte[] data)
|
||||
{
|
||||
// group name length (2 bytes BE) + group name bytes + data
|
||||
byte[] groupBytes = Encoding.UTF8.GetBytes(group);
|
||||
byte[] payload = new byte[2 + groupBytes.Length + data.Length];
|
||||
payload[0] = (byte)(groupBytes.Length >> 8);
|
||||
payload[1] = (byte)(groupBytes.Length & 0xFF);
|
||||
Buffer.BlockCopy(groupBytes, 0, payload, 2, groupBytes.Length);
|
||||
Buffer.BlockCopy(data, 0, payload, 2 + groupBytes.Length, data.Length);
|
||||
return Encode(MessageType.GroupJoin, payload);
|
||||
}
|
||||
|
||||
internal static byte[] EncodeNickname(string nickname) => EncodeText(MessageType.NicknameSet, nickname ?? string.Empty);
|
||||
|
||||
|
||||
// Decoding
|
||||
internal static bool TryReadHeader(byte[] buf, int offset, out MessageType type, out int payloadLen)
|
||||
{
|
||||
type = 0;
|
||||
payloadLen = 0;
|
||||
if (buf.Length - offset < HeaderSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
type = (MessageType)buf[offset];
|
||||
payloadLen = (int)ReadUInt32BE(buf, offset + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WriteUInt32BE(byte[] buf, int offset, uint value)
|
||||
{
|
||||
buf[offset] = (byte)(value >> 24);
|
||||
buf[offset + 1] = (byte)(value >> 16);
|
||||
buf[offset + 2] = (byte)(value >> 8);
|
||||
buf[offset + 3] = (byte)(value);
|
||||
}
|
||||
|
||||
private static uint ReadUInt32BE(byte[] buf, int offset)
|
||||
=> ((uint)buf[offset] << 24)
|
||||
| ((uint)buf[offset + 1] << 16)
|
||||
| ((uint)buf[offset + 2] << 8)
|
||||
| (uint)buf[offset + 3];
|
||||
}
|
||||
|
||||
internal sealed class SimpleJson
|
||||
{
|
||||
private readonly System.Collections.Generic.Dictionary<string, string> _map = new System.Collections.Generic.Dictionary<string, string>();
|
||||
|
||||
internal void Set(string key, string value) => _map[key] = value;
|
||||
|
||||
internal string Get(string key)
|
||||
{
|
||||
string v;
|
||||
return _map.TryGetValue(key, out v) ? v : null;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var stringBuilder = new StringBuilder("{");
|
||||
bool first = true;
|
||||
foreach (var kv in _map)
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
stringBuilder.Append(',');
|
||||
}
|
||||
|
||||
stringBuilder.Append('"').Append(Escape(kv.Key)).Append("\":\"").Append(Escape(kv.Value)).Append('"');
|
||||
first = false;
|
||||
}
|
||||
stringBuilder.Append('}');
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
internal static SimpleJson Parse(string json)
|
||||
{
|
||||
var result = new SimpleJson();
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
json = json.Trim();
|
||||
if (json.StartsWith("{"))
|
||||
{
|
||||
json = json.Substring(1);
|
||||
}
|
||||
|
||||
if (json.EndsWith("}"))
|
||||
{
|
||||
json = json.Substring(0, json.Length - 1);
|
||||
}
|
||||
|
||||
// simple key:value tokenizer
|
||||
int i = 0;
|
||||
while (i < json.Length)
|
||||
{
|
||||
SkipWhiteSpace(json, ref i);
|
||||
if (i >= json.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (json[i] != '"')
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = ReadString(json, ref i);
|
||||
SkipWhiteSpace(json, ref i);
|
||||
if (i < json.Length && json[i] == ':')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
SkipWhiteSpace(json, ref i);
|
||||
string value = ReadString(json, ref i);
|
||||
result.Set(key, value);
|
||||
SkipWhiteSpace(json, ref i);
|
||||
|
||||
if (i < json.Length && json[i] == ',')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void SkipWhiteSpace(string s, ref int i)
|
||||
{ while (i < s.Length && char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadString(string s, ref int i)
|
||||
{
|
||||
if (i >= s.Length || s[i] != '"')
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
i++; // skip opening "
|
||||
var stringBuilder = new StringBuilder();
|
||||
while (i < s.Length && s[i] != '"')
|
||||
{
|
||||
if (s[i] == '\\' && i + 1 < s.Length)
|
||||
{
|
||||
i++;
|
||||
switch (s[i])
|
||||
{
|
||||
case '"': stringBuilder.Append('"'); break;
|
||||
case '\\': stringBuilder.Append('\\'); break;
|
||||
case 'n': stringBuilder.Append('\n'); break;
|
||||
case 'r': stringBuilder.Append('\r'); break;
|
||||
case 't': stringBuilder.Append('\t'); break;
|
||||
default: stringBuilder.Append(s[i]); break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.Append(s[i]);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
if (i < s.Length)
|
||||
{
|
||||
i++; // skip closing
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string current)
|
||||
{
|
||||
return current.Replace("\\", "\\\\").Replace("\"", "\\\"")
|
||||
.Replace("\n", "\\n").Replace("\r", "\\r").Replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal sealed class FrameReader
|
||||
{
|
||||
private readonly byte[] _headerBuf = new byte[Protocol.HeaderSize];
|
||||
private int _headerRead;
|
||||
private byte[] _payloadBuf;
|
||||
private int _payloadRead;
|
||||
private bool _readingPayload;
|
||||
private MessageType _currentType;
|
||||
|
||||
public bool TryFeed(byte[] chunk, int offset, int count, out MessageType type, out byte[] payload)
|
||||
{
|
||||
type = 0;
|
||||
payload = null;
|
||||
int position = offset;
|
||||
int end = offset + count;
|
||||
|
||||
while (position < end)
|
||||
{
|
||||
if (!_readingPayload)
|
||||
{
|
||||
int need = Protocol.HeaderSize - _headerRead;
|
||||
int take = Math.Min(need, end - position);
|
||||
Buffer.BlockCopy(chunk, position, _headerBuf, _headerRead, take);
|
||||
_headerRead += take;
|
||||
position += take;
|
||||
|
||||
if (_headerRead == Protocol.HeaderSize)
|
||||
{
|
||||
Protocol.TryReadHeader(_headerBuf, 0, out _currentType, out int len);
|
||||
_payloadBuf = new byte[len];
|
||||
_payloadRead = 0;
|
||||
_readingPayload = true;
|
||||
_headerRead = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int need = _payloadBuf.Length - _payloadRead;
|
||||
int take = Math.Min(need, end - position);
|
||||
Buffer.BlockCopy(chunk, position, _payloadBuf, _payloadRead, take);
|
||||
_payloadRead += take;
|
||||
position += take;
|
||||
|
||||
if (_payloadRead == _payloadBuf.Length)
|
||||
{
|
||||
type = _currentType;
|
||||
payload = _payloadBuf;
|
||||
_payloadBuf = null;
|
||||
_readingPayload = false;
|
||||
return true; // one complete frame decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
492
EonaCat.QuicNet/QuicClient.cs
Normal file
492
EonaCat.QuicNet/QuicClient.cs
Normal file
@@ -0,0 +1,492 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace EonaCat.QuicNet
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// High-performance, zero-dependency TCP client with QUIC-like semantics,
|
||||
/// session tracking, automatic reconnection, and heartbeat support.
|
||||
/// </summary>
|
||||
public sealed class QuicClient : IDisposable
|
||||
{
|
||||
// State
|
||||
|
||||
private readonly QuicClientOptions _options;
|
||||
private Socket _socket;
|
||||
private readonly object _sendLock = new object();
|
||||
|
||||
private volatile bool _connected;
|
||||
private volatile bool _disposed;
|
||||
private volatile string _sessionId;
|
||||
private volatile string _nickname;
|
||||
private int _reconnectAttempts;
|
||||
private long _lastServerActivityTick;
|
||||
|
||||
private CancellationTokenSource _cts;
|
||||
private Thread _receiveThread;
|
||||
private Thread _heartbeatThread;
|
||||
private FrameReader _frameReader;
|
||||
|
||||
// Events
|
||||
|
||||
/// <summary>Fired after successful connect + handshake.</summary>
|
||||
public event EventHandler<ConnectedEventArgs> Connected;
|
||||
|
||||
/// <summary>Fired when disconnected from server.</summary>
|
||||
public event EventHandler<ClientDisconnectedEventArgs> Disconnected;
|
||||
|
||||
/// <summary>Fired when data is received from the server.</summary>
|
||||
public event EventHandler<DataReceivedEventArgs> DataReceived;
|
||||
|
||||
/// <summary>Fired when a reconnect attempt starts.</summary>
|
||||
public event EventHandler<ReconnectingEventArgs> Reconnecting;
|
||||
|
||||
/// <summary>Fired when reconnect ultimately fails.</summary>
|
||||
public event EventHandler<ErrorEventArgs> ReconnectFailed;
|
||||
|
||||
/// <summary>Fired on any internal error.</summary>
|
||||
public event EventHandler<ErrorEventArgs> Error;
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
public string SessionId => _sessionId;
|
||||
public QuicClientOptions Options => _options;
|
||||
|
||||
public string Nickname
|
||||
{
|
||||
get => _nickname;
|
||||
set
|
||||
{
|
||||
_nickname = value;
|
||||
if (_connected)
|
||||
{
|
||||
TrySendRaw(Protocol.EncodeNickname(value ?? string.Empty));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public QuicClient() : this(new QuicClientOptions()) { }
|
||||
public QuicClient(QuicClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException("options");
|
||||
_nickname = options.Nickname;
|
||||
}
|
||||
|
||||
public QuicClient(string host, int port, string nickname = null)
|
||||
: this(new QuicClientOptions { Host = host, Port = port, Nickname = nickname }) { }
|
||||
|
||||
/// <summary>Connects to the server. Throws on failure.</summary>
|
||||
public void Connect()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException("QuicClient");
|
||||
}
|
||||
|
||||
if (_connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_frameReader = new FrameReader();
|
||||
ConnectInternal();
|
||||
}
|
||||
|
||||
/// <summary>Disconnects gracefully.</summary>
|
||||
public void Disconnect(string reason = null)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_options.AutoReconnect = false; // don't re-trigger reconnect
|
||||
TrySendRaw(Protocol.EncodeDisconnect(DisconnectReason.ClientDisconnected, reason));
|
||||
CloseSocket(DisconnectReason.ClientDisconnected, reason);
|
||||
}
|
||||
|
||||
/// <summary>Sends raw bytes to the server.</summary>
|
||||
public SendResult Send(byte[] data)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return SendResult.NotConnected;
|
||||
}
|
||||
|
||||
if (data == null || data.Length == 0)
|
||||
{
|
||||
return SendResult.Success;
|
||||
}
|
||||
|
||||
return TrySendRaw(Protocol.Encode(MessageType.Data, data))
|
||||
? SendResult.Success
|
||||
: SendResult.SendFailed;
|
||||
}
|
||||
|
||||
/// <summary>Sends a UTF-8 string to the server.</summary>
|
||||
public SendResult Send(string text) => Send(text, Encoding.UTF8);
|
||||
|
||||
/// <summary>Sends a string with the specified encoding.</summary>
|
||||
public SendResult Send(string text, Encoding encoding)
|
||||
{
|
||||
if (text == null)
|
||||
{
|
||||
text = string.Empty;
|
||||
}
|
||||
|
||||
return Send(encoding.GetBytes(text));
|
||||
}
|
||||
|
||||
/// <summary>Sends a serializable object as JSON (manual serialization).</summary>
|
||||
public SendResult SendJson(string json) => Send(json);
|
||||
|
||||
|
||||
private void ConnectInternal()
|
||||
{
|
||||
_socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
_socket.NoDelay = _options.NoDelay;
|
||||
_socket.ReceiveBufferSize = _options.ReceiveBufferSize;
|
||||
_socket.SendBufferSize = _options.SendBufferSize;
|
||||
|
||||
var ar = _socket.BeginConnect(
|
||||
new IPEndPoint(ParseHost(_options.Host), _options.Port), null, null);
|
||||
|
||||
bool connected = ar.AsyncWaitHandle.WaitOne(_options.ConnectTimeoutInMilliseconds);
|
||||
if (!connected)
|
||||
{
|
||||
_socket.Close();
|
||||
throw new TimeoutException($"Connection to {_options.Host}:{_options.Port} timed out.");
|
||||
}
|
||||
|
||||
_socket.EndConnect(ar);
|
||||
_connected = true;
|
||||
_reconnectAttempts = 0;
|
||||
_lastServerActivityTick = Environment.TickCount;
|
||||
|
||||
// Send handshake
|
||||
TrySendRaw(Protocol.EncodeHandshake(_sessionId ?? string.Empty, _nickname ?? string.Empty));
|
||||
|
||||
// Start background threads
|
||||
_receiveThread = new Thread(() => ReceiveLoop(_cts.Token))
|
||||
{ IsBackground = true, Name = "EonaCat.QuicNet.Client.Receive" };
|
||||
_receiveThread.Start();
|
||||
|
||||
_heartbeatThread = new Thread(() => HeartbeatLoop(_cts.Token))
|
||||
{ IsBackground = true, Name = "EonaCat.QuicNet.Client.Heartbeat" };
|
||||
_heartbeatThread.Start();
|
||||
}
|
||||
|
||||
// Receive loop
|
||||
|
||||
private void ReceiveLoop(CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[_options.ReceiveBufferSize];
|
||||
|
||||
while (_connected && !ct.IsCancellationRequested)
|
||||
{
|
||||
int n;
|
||||
try
|
||||
{
|
||||
n = _socket.Receive(buf, 0, buf.Length, SocketFlags.None);
|
||||
}
|
||||
catch (SocketException sex) when (sex.SocketErrorCode == SocketError.ConnectionReset
|
||||
|| sex.SocketErrorCode == SocketError.ConnectionAborted
|
||||
|| sex.SocketErrorCode == SocketError.Interrupted)
|
||||
{
|
||||
HandleLostConnection(DisconnectReason.ClientDisconnected);
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException) { return; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
RaiseError(ex, "ReceiveLoop");
|
||||
HandleLostConnection(DisconnectReason.ProtocolError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (n <= 0)
|
||||
{
|
||||
HandleLostConnection(DisconnectReason.ClientDisconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _lastServerActivityTick, Environment.TickCount);
|
||||
|
||||
// Feed into frame reader — loop because multiple frames may arrive
|
||||
int offset = 0;
|
||||
while (offset < n)
|
||||
{
|
||||
MessageType type;
|
||||
byte[] payload;
|
||||
if (_frameReader.TryFeed(buf, offset, n - offset, out type, out payload))
|
||||
{
|
||||
// Advance by what was consumed
|
||||
offset += Protocol.HeaderSize + (payload?.Length ?? 0);
|
||||
HandleFrame(type, payload);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleFrame(MessageType type, byte[] payload)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case MessageType.HandshakeAck:
|
||||
_sessionId = payload != null ? Encoding.UTF8.GetString(payload) : _sessionId;
|
||||
// Send nickname if set
|
||||
if (!string.IsNullOrEmpty(_nickname))
|
||||
{
|
||||
TrySendRaw(Protocol.EncodeNickname(_nickname));
|
||||
}
|
||||
|
||||
RaiseEvent(Connected, new ConnectedEventArgs(_sessionId));
|
||||
break;
|
||||
|
||||
case MessageType.Data:
|
||||
var fakeClient = new FakeClientRef(_sessionId, _nickname);
|
||||
RaiseEvent(DataReceived, new DataReceivedEventArgs(fakeClient, payload ?? new byte[0]));
|
||||
break;
|
||||
|
||||
case MessageType.Ping:
|
||||
TrySendRaw(Protocol.EncodePong());
|
||||
break;
|
||||
|
||||
case MessageType.Pong:
|
||||
// server alive
|
||||
break;
|
||||
|
||||
case MessageType.Disconnect:
|
||||
DisconnectReason reason = DisconnectReason.ServerShutdown;
|
||||
string message = null;
|
||||
if (payload != null)
|
||||
{
|
||||
var j = SimpleJson.Parse(Encoding.UTF8.GetString(payload));
|
||||
int r;
|
||||
if (int.TryParse(j.Get("reason") ?? "0", out r))
|
||||
{
|
||||
reason = (DisconnectReason)r;
|
||||
}
|
||||
|
||||
message = j.Get("message");
|
||||
}
|
||||
CloseSocket(reason, message);
|
||||
break;
|
||||
|
||||
case MessageType.System:
|
||||
// reserved for future system messages
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HeartbeatLoop(CancellationToken ct)
|
||||
{
|
||||
while (_connected && !ct.IsCancellationRequested)
|
||||
{
|
||||
Thread.Sleep(_options.HeartbeatIntervalInMilliseconds);
|
||||
if (!_connected || ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
long last = Interlocked.Read(ref _lastServerActivityTick);
|
||||
int elapsed = unchecked(Environment.TickCount - (int)last);
|
||||
|
||||
if (elapsed > _options.ServerTimeoutMs)
|
||||
{
|
||||
HandleLostConnection(DisconnectReason.Timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
TrySendRaw(Protocol.EncodePing());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void HandleLostConnection(DisconnectReason reason)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_connected = false;
|
||||
|
||||
try { _socket?.Close(); } catch { }
|
||||
|
||||
var fakeClient = new FakeClientRef(_sessionId, _nickname);
|
||||
RaiseEvent(Disconnected, new ClientDisconnectedEventArgs(fakeClient, reason));
|
||||
|
||||
if (_options.AutoReconnect && !_disposed
|
||||
&& reason != DisconnectReason.Kicked
|
||||
&& reason != DisconnectReason.AuthenticationFailed)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ => ReconnectLoop());
|
||||
}
|
||||
}
|
||||
|
||||
private void ReconnectLoop()
|
||||
{
|
||||
int maxAttempts = _options.ReconnectMaxAttempts > 0
|
||||
? _options.ReconnectMaxAttempts
|
||||
: int.MaxValue;
|
||||
|
||||
while (!_disposed && _reconnectAttempts < maxAttempts)
|
||||
{
|
||||
_reconnectAttempts++;
|
||||
int delay = _options.ReconnectBaseDelayMs * (int)Math.Pow(2, Math.Min(_reconnectAttempts - 1, 6));
|
||||
delay = Math.Min(delay, 60_000); // cap at 60 s
|
||||
|
||||
RaiseEvent(Reconnecting, new ReconnectingEventArgs(_reconnectAttempts, maxAttempts));
|
||||
|
||||
Thread.Sleep(delay);
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_frameReader = new FrameReader();
|
||||
ConnectInternal();
|
||||
return; // success
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RaiseError(ex, $"Reconnect attempt {_reconnectAttempts}");
|
||||
}
|
||||
}
|
||||
|
||||
RaiseEvent(ReconnectFailed,
|
||||
new ErrorEventArgs(new Exception($"Reconnect failed after {_reconnectAttempts} attempts."),
|
||||
"ReconnectLoop"));
|
||||
}
|
||||
|
||||
|
||||
private bool TrySendRaw(byte[] frame)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int sent = 0;
|
||||
while (sent < frame.Length)
|
||||
{
|
||||
int n = _socket.Send(frame, sent, frame.Length - sent, SocketFlags.None);
|
||||
if (n <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
sent += n;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RaiseError(ex, "TrySendRaw");
|
||||
HandleLostConnection(DisconnectReason.ProtocolError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseSocket(DisconnectReason reason, string message = null)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_connected = false;
|
||||
_cts?.Cancel();
|
||||
try { _socket?.Shutdown(SocketShutdown.Both); } catch { }
|
||||
try { _socket?.Close(); } catch { }
|
||||
try { _socket?.Dispose(); } catch { }
|
||||
|
||||
var fakeClient = new FakeClientRef(_sessionId, _nickname);
|
||||
RaiseEvent(Disconnected, new ClientDisconnectedEventArgs(fakeClient, reason, message));
|
||||
}
|
||||
|
||||
private static IPAddress ParseHost(string host)
|
||||
{
|
||||
IPAddress ip;
|
||||
if (IPAddress.TryParse(host, out ip))
|
||||
{
|
||||
return ip;
|
||||
}
|
||||
|
||||
return Dns.GetHostEntry(host).AddressList[0];
|
||||
}
|
||||
|
||||
private void RaiseEvent<T>(EventHandler<T> handler, T args) where T : EventArgs
|
||||
{
|
||||
try { handler?.Invoke(this, args); }
|
||||
catch (Exception ex) { RaiseError(ex, "EventHandler"); }
|
||||
}
|
||||
|
||||
private void RaiseError(Exception ex, string context)
|
||||
{
|
||||
try { Error?.Invoke(this, new ErrorEventArgs(ex, context)); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_options.AutoReconnect = false;
|
||||
_cts?.Cancel();
|
||||
CloseSocket(DisconnectReason.ClientDisconnected, "Disposed");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeClientRef : IQuicClient
|
||||
{
|
||||
public string SessionId { get; }
|
||||
public string Nickname { get; set; }
|
||||
public bool IsConnected => false;
|
||||
public string RemoteAddress => "server";
|
||||
public int RemotePort => 0;
|
||||
public DateTime ConnectedAt { get; } = DateTime.UtcNow;
|
||||
public System.Collections.Generic.IReadOnlyCollection<string> Groups
|
||||
=> new string[0];
|
||||
|
||||
internal FakeClientRef(string sessionId, string nickname)
|
||||
{ SessionId = sessionId; Nickname = nickname; }
|
||||
|
||||
public SendResult Send(byte[] data) => SendResult.NotConnected;
|
||||
public SendResult Send(string text) => SendResult.NotConnected;
|
||||
public SendResult Send(string text, Encoding enc) => SendResult.NotConnected;
|
||||
public void Disconnect(DisconnectReason r = DisconnectReason.ClientDisconnected, string m = null) { }
|
||||
public void JoinGroup(string g) { }
|
||||
public void LeaveGroup(string g) { }
|
||||
}
|
||||
}
|
||||
111
EonaCat.QuicNet/QuicExtensions.cs
Normal file
111
EonaCat.QuicNet/QuicExtensions.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace EonaCat.QuicNet
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Convenience extension methods for <see cref="QuicServer"/> and <see cref="QuicClient"/>.
|
||||
/// </summary>
|
||||
public static class QuicExtensions
|
||||
{
|
||||
/// <summary>Broadcasts a UTF-8 string to all connected clients.</summary>
|
||||
public static int BroadcastText(this QuicServer server, string text, string excludeSessionId = null) => server.Broadcast(Encoding.UTF8.GetBytes(text ?? string.Empty), excludeSessionId);
|
||||
|
||||
/// <summary>Broadcasts raw bytes to all clients in a group.</summary>
|
||||
public static int SendToGroupText(this QuicServer server, string groupName, string text, string excludeSessionId = null) => server.SendToGroup(groupName, Encoding.UTF8.GetBytes(text ?? string.Empty), excludeSessionId: excludeSessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a system-level notification string to a single client (appears as MessageType.System).
|
||||
/// </summary>
|
||||
public static SendResult SendSystem(this QuicServer server, string sessionId, string message)
|
||||
{
|
||||
var client = server.GetClient(sessionId) as ConnectedClient;
|
||||
if (client == null)
|
||||
{
|
||||
return SendResult.ClientNotFound;
|
||||
}
|
||||
|
||||
return client.TrySendRaw(Protocol.EncodeText(MessageType.System, message))
|
||||
? SendResult.Success
|
||||
: SendResult.SendFailed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kicks all clients NOT in the specified groups.
|
||||
/// </summary>
|
||||
public static void KickNotInGroup(this QuicServer server, string groupName, string reason = null)
|
||||
{
|
||||
foreach (var client in server.GetClients())
|
||||
{
|
||||
if (!client.IsInGroup(groupName))
|
||||
{
|
||||
server.Kick(client.SessionId, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Checks group membership on the client interface.</summary>
|
||||
public static bool IsInGroup(this IQuicClient client, string groupName)
|
||||
{
|
||||
foreach (var g in client.Groups)
|
||||
{
|
||||
if (string.Equals(g, groupName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Sends an object serialized with a custom serializer.</summary>
|
||||
public static SendResult SendObject<T>(this QuicClient client, T obj, Func<T, byte[]> serializer)
|
||||
{
|
||||
if (serializer == null)
|
||||
{
|
||||
throw new ArgumentNullException("serializer");
|
||||
}
|
||||
|
||||
return client.Send(serializer(obj));
|
||||
}
|
||||
|
||||
/// <summary>Sends a struct or value type as raw binary (little-endian).</summary>
|
||||
public static SendResult SendStruct<T>(this QuicClient client, T value) where T : struct
|
||||
{
|
||||
int size = System.Runtime.InteropServices.Marshal.SizeOf(typeof(T));
|
||||
byte[] bytes = new byte[size];
|
||||
IntPtr ptr = System.Runtime.InteropServices.Marshal.AllocHGlobal(size);
|
||||
try
|
||||
{
|
||||
System.Runtime.InteropServices.Marshal.StructureToPtr(value, ptr, false);
|
||||
System.Runtime.InteropServices.Marshal.Copy(ptr, bytes, 0, size);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Runtime.InteropServices.Marshal.FreeHGlobal(ptr);
|
||||
}
|
||||
return client.Send(bytes);
|
||||
}
|
||||
|
||||
/// <summary>Sends a struct as raw binary to a single client.</summary>
|
||||
public static SendResult SendStruct<T>(this QuicServer server, string sessionId, T value) where T : struct
|
||||
{
|
||||
int size = System.Runtime.InteropServices.Marshal.SizeOf(typeof(T));
|
||||
byte[] bytes = new byte[size];
|
||||
IntPtr ptr = System.Runtime.InteropServices.Marshal.AllocHGlobal(size);
|
||||
try
|
||||
{
|
||||
System.Runtime.InteropServices.Marshal.StructureToPtr(value, ptr, false);
|
||||
System.Runtime.InteropServices.Marshal.Copy(ptr, bytes, 0, size);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Runtime.InteropServices.Marshal.FreeHGlobal(ptr);
|
||||
}
|
||||
return server.SendTo(sessionId, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
153
EonaCat.QuicNet/QuicNetTypes.cs
Normal file
153
EonaCat.QuicNet/QuicNetTypes.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace EonaCat.QuicNet
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public enum DisconnectReason
|
||||
{
|
||||
Unknown,
|
||||
ClientDisconnected,
|
||||
ServerShutdown,
|
||||
Timeout,
|
||||
ProtocolError,
|
||||
AuthenticationFailed,
|
||||
Kicked
|
||||
}
|
||||
|
||||
public enum MessageType : byte
|
||||
{
|
||||
Handshake = 0x01,
|
||||
HandshakeAck = 0x02,
|
||||
Data = 0x03,
|
||||
Ping = 0x04,
|
||||
Pong = 0x05,
|
||||
Disconnect = 0x06,
|
||||
GroupJoin = 0x07,
|
||||
GroupLeave = 0x08,
|
||||
NicknameSet = 0x09,
|
||||
System = 0x0A
|
||||
}
|
||||
|
||||
public enum SendResult
|
||||
{
|
||||
Success,
|
||||
ClientNotFound,
|
||||
GroupNotFound,
|
||||
SendFailed,
|
||||
NotConnected
|
||||
}
|
||||
|
||||
public sealed class ClientConnectedEventArgs : EventArgs
|
||||
{
|
||||
public IQuicClient Client { get; }
|
||||
public ClientConnectedEventArgs(IQuicClient client) { Client = client; }
|
||||
}
|
||||
|
||||
public sealed class ClientDisconnectedEventArgs : EventArgs
|
||||
{
|
||||
public IQuicClient Client { get; }
|
||||
public DisconnectReason Reason { get; }
|
||||
public string Message { get; }
|
||||
public ClientDisconnectedEventArgs(IQuicClient client, DisconnectReason reason, string message = null)
|
||||
{
|
||||
Client = client;
|
||||
Reason = reason;
|
||||
Message = message ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DataReceivedEventArgs : EventArgs
|
||||
{
|
||||
public IQuicClient Client { get; }
|
||||
public byte[] Data { get; }
|
||||
public string Text => Encoding.UTF8.GetString(Data);
|
||||
public DataReceivedEventArgs(IQuicClient client, byte[] data) { Client = client; Data = data; }
|
||||
}
|
||||
|
||||
public sealed class ErrorEventArgs : EventArgs
|
||||
{
|
||||
public Exception Exception { get; }
|
||||
public string Context { get; }
|
||||
public ErrorEventArgs(Exception ex, string context = null) { Exception = ex; Context = context ?? string.Empty; }
|
||||
}
|
||||
|
||||
public sealed class ServerStartedEventArgs : EventArgs
|
||||
{
|
||||
public int Port { get; }
|
||||
public ServerStartedEventArgs(int port) { Port = port; }
|
||||
}
|
||||
|
||||
public sealed class ReconnectingEventArgs : EventArgs
|
||||
{
|
||||
public int Attempt { get; }
|
||||
public int MaxAttempts { get; }
|
||||
public ReconnectingEventArgs(int attempt, int maxAttempts) { Attempt = attempt; MaxAttempts = maxAttempts; }
|
||||
}
|
||||
|
||||
public sealed class ConnectedEventArgs : EventArgs
|
||||
{
|
||||
public string SessionId { get; }
|
||||
public ConnectedEventArgs(string sessionId) { SessionId = sessionId; }
|
||||
}
|
||||
|
||||
public sealed class GroupEventArgs : EventArgs
|
||||
{
|
||||
public string GroupName { get; }
|
||||
public IQuicClient Client { get; }
|
||||
public GroupEventArgs(string groupName, IQuicClient client) { GroupName = groupName; Client = client; }
|
||||
}
|
||||
|
||||
public interface IQuicClient
|
||||
{
|
||||
string SessionId { get; }
|
||||
string Nickname { get; set; }
|
||||
bool IsConnected { get; }
|
||||
string RemoteAddress { get; }
|
||||
int RemotePort { get; }
|
||||
DateTime ConnectedAt { get; }
|
||||
IReadOnlyCollection<string> Groups { get; }
|
||||
|
||||
SendResult Send(byte[] data);
|
||||
SendResult Send(string text);
|
||||
SendResult Send(string text, Encoding encoding);
|
||||
void Disconnect(DisconnectReason reason = DisconnectReason.ServerShutdown, string message = null);
|
||||
void JoinGroup(string groupName);
|
||||
void LeaveGroup(string groupName);
|
||||
}
|
||||
|
||||
public sealed class QuicServerOptions
|
||||
{
|
||||
public int Port { get; set; } = 9000;
|
||||
public string BindAddress { get; set; } = "0.0.0.0";
|
||||
public int MaxConnections { get; set; } = 100_000;
|
||||
public int BacklogSize { get; set; } = 1000;
|
||||
public int ReceiveBufferSize { get; set; } = 8192;
|
||||
public int SendBufferSize { get; set; } = 8192;
|
||||
public int HeartbeatIntervalInMilliseconds { get; set; } = 15_000;
|
||||
public int ClientTimeoutInMilliseconds { get; set; } = 45_000;
|
||||
public int MaxMessageSizeBytes { get; set; } = 10 * 1024 * 1024; // 10 MB
|
||||
public bool NoDelay { get; set; } = true;
|
||||
public bool EnableHeartbeat { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class QuicClientOptions
|
||||
{
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 9000;
|
||||
public string Nickname { get; set; }
|
||||
public int ReceiveBufferSize { get; set; } = 8192;
|
||||
public int SendBufferSize { get; set; } = 8192;
|
||||
public int ConnectTimeoutInMilliseconds { get; set; } = 10_000;
|
||||
public int HeartbeatIntervalInMilliseconds { get; set; } = 15_000;
|
||||
public int ServerTimeoutMs { get; set; } = 45_000;
|
||||
public int ReconnectMaxAttempts { get; set; } = 5;
|
||||
public int ReconnectBaseDelayMs { get; set; } = 1_000;
|
||||
public bool AutoReconnect { get; set; } = true;
|
||||
public bool NoDelay { get; set; } = true;
|
||||
public int MaxMessageSizeBytes { get; set; } = 10 * 1024 * 1024; // 10 MB
|
||||
}
|
||||
}
|
||||
443
EonaCat.QuicNet/QuicServer.cs
Normal file
443
EonaCat.QuicNet/QuicServer.cs
Normal file
@@ -0,0 +1,443 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace EonaCat.QuicNet
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// High-performance, zero-dependency TCP server with QUIC-like semantics.
|
||||
/// Supports 100 000+ concurrent connections with minimal memory overhead.
|
||||
/// </summary>
|
||||
public sealed class QuicServer : IDisposable
|
||||
{
|
||||
private readonly QuicServerOptions _options;
|
||||
private Socket _listener;
|
||||
private volatile bool _running;
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private readonly ConcurrentDictionary<string, ConnectedClient> _clients
|
||||
= new ConcurrentDictionary<string, ConnectedClient>(StringComparer.Ordinal);
|
||||
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _groups = new ConcurrentDictionary<string, ConcurrentDictionary<string, byte>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Fired when a new client completes the handshake.</summary>
|
||||
public event EventHandler<ClientConnectedEventArgs> ClientConnected;
|
||||
|
||||
/// <summary>Fired when a client disconnects for any reason.</summary>
|
||||
public event EventHandler<ClientDisconnectedEventArgs> ClientDisconnected;
|
||||
|
||||
/// <summary>Fired when data is received from any client.</summary>
|
||||
public event EventHandler<DataReceivedEventArgs> DataReceived;
|
||||
|
||||
/// <summary>Fired when an internal error occurs.</summary>
|
||||
public event EventHandler<ErrorEventArgs> Error;
|
||||
|
||||
/// <summary>Fired when the server starts listening.</summary>
|
||||
public event EventHandler<ServerStartedEventArgs> Started;
|
||||
|
||||
/// <summary>Fired when the server stops.</summary>
|
||||
public event EventHandler Stopped;
|
||||
|
||||
/// <summary>Fired when a client joins a group.</summary>
|
||||
public event EventHandler<GroupEventArgs> ClientJoinedGroup;
|
||||
|
||||
/// <summary>Fired when a client leaves a group.</summary>
|
||||
public event EventHandler<GroupEventArgs> ClientLeftGroup;
|
||||
|
||||
public bool IsRunning => _running;
|
||||
public int ClientCount => _clients.Count;
|
||||
public QuicServerOptions Options => _options;
|
||||
|
||||
public QuicServer() : this(new QuicServerOptions()) { }
|
||||
public QuicServer(QuicServerOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException("options");
|
||||
}
|
||||
|
||||
public QuicServer(int port) : this(new QuicServerOptions { Port = port }) { }
|
||||
|
||||
/// <summary>Starts listening for connections.</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_running)
|
||||
{
|
||||
throw new InvalidOperationException("Server is already running.");
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_running = true;
|
||||
|
||||
var ep = new IPEndPoint(IPAddress.Parse(_options.BindAddress), _options.Port);
|
||||
_listener = new Socket(ep.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
_listener.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
if (_options.NoDelay)
|
||||
{
|
||||
_listener.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true);
|
||||
}
|
||||
|
||||
_listener.ReceiveBufferSize = _options.ReceiveBufferSize;
|
||||
_listener.SendBufferSize = _options.SendBufferSize;
|
||||
_listener.Bind(ep);
|
||||
_listener.Listen(_options.BacklogSize);
|
||||
|
||||
RaiseEvent(Started, new ServerStartedEventArgs(_options.Port));
|
||||
|
||||
// Accept loop
|
||||
Thread acceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "EonaCat.QuicNet.Accept" };
|
||||
acceptThread.Start();
|
||||
|
||||
// Heartbeat / timeout monitor
|
||||
if (_options.EnableHeartbeat)
|
||||
{
|
||||
Thread heartbeatThread = new Thread(HeartbeatLoop) { IsBackground = true, Name = "EonaCat.QuicNet.Heartbeat" };
|
||||
heartbeatThread.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops the server and disconnects all clients.</summary>
|
||||
public void Stop()
|
||||
{
|
||||
if (!_running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_running = false;
|
||||
_cts.Cancel();
|
||||
|
||||
// Disconnect everyone gracefully
|
||||
foreach (var client in _clients.Values.ToArray())
|
||||
{
|
||||
TryDisconnectClient(client, DisconnectReason.ServerShutdown, "Server stopped");
|
||||
}
|
||||
|
||||
try { _listener.Close(); } catch { }
|
||||
_clients.Clear();
|
||||
_groups.Clear();
|
||||
|
||||
RaiseEvent(Stopped, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Returns all connected clients.</summary>
|
||||
public IEnumerable<IQuicClient> GetClients()
|
||||
=> _clients.Values.Cast<IQuicClient>();
|
||||
|
||||
/// <summary>Returns a client by session ID, or null.</summary>
|
||||
public IQuicClient GetClient(string sessionId)
|
||||
{
|
||||
ConnectedClient c;
|
||||
return _clients.TryGetValue(sessionId, out c) ? c : null;
|
||||
}
|
||||
|
||||
/// <summary>Returns all clients in a group.</summary>
|
||||
public IEnumerable<IQuicClient> GetGroupClients(string groupName)
|
||||
{
|
||||
ConcurrentDictionary<string, byte> ids;
|
||||
if (!_groups.TryGetValue(groupName, out ids))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (string id in ids.Keys)
|
||||
{
|
||||
ConnectedClient c;
|
||||
if (_clients.TryGetValue(id, out c))
|
||||
{
|
||||
yield return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns all group names.</summary>
|
||||
public IEnumerable<string> GetGroups() => _groups.Keys;
|
||||
|
||||
/// <summary>Sends data to one specific client by session ID.</summary>
|
||||
public SendResult SendTo(string sessionId, byte[] data)
|
||||
{
|
||||
ConnectedClient client;
|
||||
if (!_clients.TryGetValue(sessionId, out client))
|
||||
{
|
||||
return SendResult.ClientNotFound;
|
||||
}
|
||||
|
||||
return client.Send(data);
|
||||
}
|
||||
|
||||
public SendResult SendTo(string sessionId, string text, Encoding encoding = null)
|
||||
=> SendTo(sessionId, (encoding ?? Encoding.UTF8).GetBytes(text ?? string.Empty));
|
||||
|
||||
/// <summary>Sends data to a specific IQuicClient reference.</summary>
|
||||
public SendResult SendTo(IQuicClient client, byte[] data)
|
||||
{
|
||||
var cc = client as ConnectedClient;
|
||||
if (cc == null)
|
||||
{
|
||||
return SendResult.ClientNotFound;
|
||||
}
|
||||
|
||||
return cc.Send(data);
|
||||
}
|
||||
|
||||
public SendResult SendTo(IQuicClient client, string text, Encoding encoding = null)
|
||||
=> SendTo(client, (encoding ?? Encoding.UTF8).GetBytes(text ?? string.Empty));
|
||||
|
||||
/// <summary>Sends data to all members of a group, optionally excluding one client.</summary>
|
||||
public int SendToGroup(string groupName, byte[] data, string excludeSessionId = null)
|
||||
{
|
||||
ConcurrentDictionary<string, byte> ids;
|
||||
if (!_groups.TryGetValue(groupName, out ids))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var frame = Protocol.Encode(MessageType.Data, data);
|
||||
int count = 0;
|
||||
foreach (string id in ids.Keys)
|
||||
{
|
||||
if (id == excludeSessionId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ConnectedClient c;
|
||||
if (_clients.TryGetValue(id, out c) && c.TrySendRaw(frame))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public int SendToGroup(string groupName, string text, Encoding encoding = null, string excludeSessionId = null)
|
||||
=> SendToGroup(groupName, (encoding ?? Encoding.UTF8).GetBytes(text ?? string.Empty), excludeSessionId);
|
||||
|
||||
/// <summary>Broadcasts data to every connected client.</summary>
|
||||
public int Broadcast(byte[] data, string excludeSessionId = null)
|
||||
{
|
||||
var frame = Protocol.Encode(MessageType.Data, data);
|
||||
int count = 0;
|
||||
foreach (var c in _clients.Values)
|
||||
{
|
||||
if (c.SessionId == excludeSessionId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.TrySendRaw(frame))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public int Broadcast(string text, Encoding encoding = null, string excludeSessionId = null)
|
||||
=> Broadcast((encoding ?? Encoding.UTF8).GetBytes(text ?? string.Empty), excludeSessionId);
|
||||
|
||||
|
||||
/// <summary>Adds a client to a server-managed group.</summary>
|
||||
public void AddToGroup(string sessionId, string groupName)
|
||||
{
|
||||
ConnectedClient client;
|
||||
if (!_clients.TryGetValue(sessionId, out client))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddToGroupInternal(client, groupName);
|
||||
}
|
||||
|
||||
/// <summary>Removes a client from a server-managed group.</summary>
|
||||
public void RemoveFromGroup(string sessionId, string groupName)
|
||||
{
|
||||
ConnectedClient client;
|
||||
if (!_clients.TryGetValue(sessionId, out client))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveFromGroupInternal(client, groupName);
|
||||
}
|
||||
|
||||
/// <summary>Kicks (disconnects) a client.</summary>
|
||||
public bool Kick(string sessionId, string reason = null)
|
||||
{
|
||||
ConnectedClient c;
|
||||
if (!_clients.TryGetValue(sessionId, out c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
TryDisconnectClient(c, DisconnectReason.Kicked, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
private void AcceptLoop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
try
|
||||
{
|
||||
Socket sock = _listener.Accept();
|
||||
if (_clients.Count >= _options.MaxConnections)
|
||||
{
|
||||
try { sock.Close(); } catch { }
|
||||
continue;
|
||||
}
|
||||
ThreadPool.QueueUserWorkItem(_ => HandleNewSocket(sock));
|
||||
}
|
||||
catch (SocketException sex) when (sex.SocketErrorCode == SocketError.Interrupted
|
||||
|| sex.SocketErrorCode == SocketError.OperationAborted)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (Exception ex) { RaiseError(ex, "AcceptLoop"); }
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleNewSocket(Socket sock)
|
||||
{
|
||||
try
|
||||
{
|
||||
sock.NoDelay = _options.NoDelay;
|
||||
sock.ReceiveBufferSize = _options.ReceiveBufferSize;
|
||||
sock.SendBufferSize = _options.SendBufferSize;
|
||||
|
||||
string sessionId = GenerateSessionId();
|
||||
var client = new ConnectedClient(sessionId, sock, null);
|
||||
|
||||
// Wire up events before starting receive
|
||||
client.OnDataReceived += HandleClientData;
|
||||
client.OnDisconnected += HandleClientDisconnected;
|
||||
client.OnError += (c, ex) => RaiseError(ex, $"Client [{c.SessionId}]");
|
||||
|
||||
// Send handshake ACK
|
||||
client.TrySendRaw(Protocol.EncodeHandshakeAck(sessionId));
|
||||
|
||||
_clients[sessionId] = client;
|
||||
client.StartReceiving(_options.ReceiveBufferSize, _options.MaxMessageSizeBytes, _cts.Token);
|
||||
|
||||
RaiseEvent(ClientConnected, new ClientConnectedEventArgs(client));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RaiseError(ex, "HandleNewSocket");
|
||||
try { sock.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleClientData(ConnectedClient client, byte[] data)
|
||||
{
|
||||
RaiseEvent(DataReceived, new DataReceivedEventArgs(client, data));
|
||||
}
|
||||
|
||||
private void HandleClientDisconnected(ConnectedClient client, DisconnectReason reason, string message)
|
||||
{
|
||||
ConnectedClient removed;
|
||||
_clients.TryRemove(client.SessionId, out removed);
|
||||
|
||||
// Remove from all groups
|
||||
foreach (var groupKv in _groups)
|
||||
{
|
||||
byte dummy;
|
||||
if (groupKv.Value.TryRemove(client.SessionId, out dummy))
|
||||
{
|
||||
RaiseEvent(ClientLeftGroup, new GroupEventArgs(groupKv.Key, client));
|
||||
}
|
||||
}
|
||||
|
||||
RaiseEvent(ClientDisconnected, new ClientDisconnectedEventArgs(client, reason, message));
|
||||
}
|
||||
|
||||
private void AddToGroupInternal(ConnectedClient client, string groupName)
|
||||
{
|
||||
var groupSet = _groups.GetOrAdd(groupName, _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
|
||||
if (groupSet.TryAdd(client.SessionId, 0))
|
||||
{
|
||||
client.JoinGroup(groupName);
|
||||
RaiseEvent(ClientJoinedGroup, new GroupEventArgs(groupName, client));
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveFromGroupInternal(ConnectedClient client, string groupName)
|
||||
{
|
||||
ConcurrentDictionary<string, byte> groupSet;
|
||||
byte dummy;
|
||||
if (_groups.TryGetValue(groupName, out groupSet) && groupSet.TryRemove(client.SessionId, out dummy))
|
||||
{
|
||||
client.LeaveGroup(groupName);
|
||||
RaiseEvent(ClientLeftGroup, new GroupEventArgs(groupName, client));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void HeartbeatLoop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
Thread.Sleep(_options.HeartbeatIntervalInMilliseconds);
|
||||
if (!_running)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int now = Environment.TickCount;
|
||||
var pingFrame = Protocol.EncodePing();
|
||||
|
||||
foreach (var client in _clients.Values.ToArray())
|
||||
{
|
||||
int elapsed = unchecked(now - (int)client.LastActivityTick);
|
||||
if (elapsed > _options.ClientTimeoutInMilliseconds)
|
||||
{
|
||||
TryDisconnectClient(client, DisconnectReason.Timeout, "Heartbeat timeout");
|
||||
}
|
||||
else
|
||||
{
|
||||
client.TrySendRaw(pingFrame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDisconnectClient(ConnectedClient client, DisconnectReason reason, string message = null)
|
||||
{
|
||||
try { client.Disconnect(reason, message); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string GenerateSessionId()
|
||||
{
|
||||
return Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
private void RaiseEvent<T>(EventHandler<T> handler, T args) where T : EventArgs
|
||||
{
|
||||
try { handler?.Invoke(this, args); }
|
||||
catch (Exception ex) { RaiseError(ex, "EventHandler"); }
|
||||
}
|
||||
|
||||
private void RaiseEvent(EventHandler handler, EventArgs args)
|
||||
{
|
||||
try { handler?.Invoke(this, args); }
|
||||
catch (Exception ex) { RaiseError(ex, "EventHandler"); }
|
||||
}
|
||||
|
||||
private void RaiseError(Exception ex, string context)
|
||||
{
|
||||
try { Error?.Invoke(this, new ErrorEventArgs(ex, context)); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
}
|
||||
212
LICENSE
212
LICENSE
@@ -1,73 +1,203 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://EonaCat.com/license/
|
||||
|
||||
1. Definitions.
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
OF SOFTWARE BY EONACAT (JEROEN SAEY)
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
1. Definitions.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
Copyright 2026 EonaCat
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
227
README.md
227
README.md
@@ -1,3 +1,228 @@
|
||||
# EonaCat.QuicNet
|
||||
|
||||
EonaCat.QuicNet
|
||||
EonaCat.QuicNet is a High-performance C# TCP networking library with **QUIC-like semantics** — sessions, groups, heartbeats, auto-reconnect, and 100 000+ concurrent connections with a minimal memory footprint.
|
||||
Compatible with **.NET Framework 4.8.1** and **.NET 6 / 7 / 8**.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Detail |
|
||||
|---|---|
|
||||
| **100 k+ connections** | Pooled `ThreadPool` + `ConcurrentDictionary` |
|
||||
| **Sessions** | Every client gets a `Guid`-based `SessionId` |
|
||||
| **Nicknames** | Set on connect or at runtime, synced both ways |
|
||||
| **Groups** | Server- or client-initiated join/leave |
|
||||
| **Send variants** | `byte[]`, `string`, custom encoding, structs |
|
||||
| **Targeting** | `SendTo`, `SendToGroup`, `Broadcast` |
|
||||
| **Heartbeat** | Configurable ping/pong keeps connections alive |
|
||||
| **Auto-reconnect** | Exponential back-off, configurable max attempts |
|
||||
| **Events** | Connected, Disconnected, DataReceived, Error, GroupJoined, … |
|
||||
| **Graceful shutdown** | Server and client both send disconnect frames |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Server
|
||||
|
||||
```csharp
|
||||
var server = new QuicServer(new QuicServerOptions { Port = 9000 });
|
||||
|
||||
server.ClientConnected += (_, e) => Console.WriteLine($"+ {e.Client.Nickname}");
|
||||
server.ClientDisconnected += (_, e) => Console.WriteLine($"- {e.Client.Nickname}: {e.Reason}");
|
||||
server.DataReceived += (_, e) => Console.WriteLine($"[{e.Client.Nickname}] {e.Text}");
|
||||
|
||||
server.Start();
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
```csharp
|
||||
var client = new QuicClient(new QuicClientOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 9000,
|
||||
Nickname = "Alice",
|
||||
AutoReconnect = true
|
||||
});
|
||||
|
||||
client.Connected += (_, e) => Console.WriteLine("Connected: " + e.SessionId);
|
||||
client.DataReceived += (_, e) => Console.WriteLine("Server says: " + e.Text);
|
||||
|
||||
client.Connect();
|
||||
client.Send("Hello, world!");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### `QuicServerOptions`
|
||||
|
||||
| Property | Default | Description |
|
||||
|---|---|---|
|
||||
| `Port` | 9000 | TCP port to listen on |
|
||||
| `BindAddress` | `0.0.0.0` | Network interface |
|
||||
| `MaxConnections` | 100 000 | Hard cap |
|
||||
| `BacklogSize` | 1000 | `Socket.Listen` backlog |
|
||||
| `ReceiveBufferSize` | 8 192 | Per-socket receive buffer (bytes) |
|
||||
| `SendBufferSize` | 8 192 | Per-socket send buffer (bytes) |
|
||||
| `HeartbeatIntervalInMilliseconds` | 15 000 | How often to ping clients |
|
||||
| `ClientTimeoutInMilliseconds` | 45 000 | Idle → disconnect |
|
||||
| `MaxMessageSizeBytes` | 10 MB | Frame size limit |
|
||||
| `NoDelay` | `true` | TCP_NODELAY (low latency) |
|
||||
| `EnableHeartbeat` | `true` | Auto-ping all clients |
|
||||
|
||||
### `QuicClientOptions`
|
||||
|
||||
| Property | Default | Description |
|
||||
|---|---|---|
|
||||
| `Host` | `127.0.0.1` | Server host or IP |
|
||||
| `Port` | 9000 | Server port |
|
||||
| `Nickname` | `null` | Initial nickname |
|
||||
| `ConnectTimeoutInMilliseconds` | 10 000 | Connect attempt timeout |
|
||||
| `HeartbeatIntervalInMilliseconds` | 15 000 | Ping interval |
|
||||
| `ServerTimeoutMs` | 45 000 | Idle server → disconnect |
|
||||
| `AutoReconnect` | `true` | Re-connect on drop |
|
||||
| `ReconnectMaxAttempts` | 5 | 0 = unlimited |
|
||||
| `ReconnectBaseDelayMs` | 1 000 | Exponential back-off base |
|
||||
| `NoDelay` | `true` | TCP_NODELAY |
|
||||
|
||||
---
|
||||
|
||||
### Server Methods
|
||||
|
||||
```csharp
|
||||
// Lifecycle
|
||||
server.Start();
|
||||
server.Stop();
|
||||
|
||||
// Queries
|
||||
IEnumerable<IQuicClient> server.GetClients();
|
||||
IQuicClient server.GetClient(string sessionId); // null if not found
|
||||
IEnumerable<IQuicClient> server.GetGroupClients(string group);
|
||||
IEnumerable<string> server.GetGroups();
|
||||
int server.ClientCount;
|
||||
|
||||
// Send to one
|
||||
SendResult server.SendTo(string sessionId, byte[] data);
|
||||
SendResult server.SendTo(string sessionId, string text, Encoding enc = null);
|
||||
SendResult server.SendTo(IQuicClient client, byte[] data);
|
||||
SendResult server.SendTo(IQuicClient client, string text, Encoding enc = null);
|
||||
|
||||
// Send to group
|
||||
int server.SendToGroup(string group, byte[] data, string excludeSessionId = null);
|
||||
int server.SendToGroup(string group, string text, Encoding enc = null, string excludeSessionId = null);
|
||||
|
||||
// Broadcast
|
||||
int server.Broadcast(byte[] data, string excludeSessionId = null);
|
||||
int server.Broadcast(string text, Encoding enc = null, string excludeSessionId = null);
|
||||
|
||||
// Group management
|
||||
void server.AddToGroup(string sessionId, string group);
|
||||
void server.RemoveFromGroup(string sessionId, string group);
|
||||
|
||||
// Disconnect / kick
|
||||
bool server.Kick(string sessionId, string reason = null);
|
||||
```
|
||||
|
||||
### Server Events
|
||||
|
||||
```csharp
|
||||
server.Started += (s, e) => { /* e.Port */ };
|
||||
server.Stopped += (s, e) => { };
|
||||
server.ClientConnected += (s, e) => { /* e.Client */ };
|
||||
server.ClientDisconnected += (s, e) => { /* e.Client, e.Reason, e.Message */ };
|
||||
server.DataReceived += (s, e) => { /* e.Client, e.Data, e.Text */ };
|
||||
server.ClientJoinedGroup += (s, e) => { /* e.Client, e.GroupName */ };
|
||||
server.ClientLeftGroup += (s, e) => { /* e.Client, e.GroupName */ };
|
||||
server.Error += (s, e) => { /* e.Exception, e.Context */ };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Client Methods
|
||||
|
||||
```csharp
|
||||
void client.Connect();
|
||||
void client.Disconnect(string reason = null);
|
||||
|
||||
// Send
|
||||
SendResult client.Send(byte[] data);
|
||||
SendResult client.Send(string text);
|
||||
SendResult client.Send(string text, Encoding encoding);
|
||||
SendResult client.SendJson(string json); // alias for Send(string)
|
||||
SendResult client.SendObject<T>(T obj, Func<T, byte[]> ser); // extension
|
||||
SendResult client.SendStruct<T>(T value) where T : struct; // extension
|
||||
```
|
||||
|
||||
### Client Events
|
||||
|
||||
```csharp
|
||||
client.Connected += (s, e) => { /* e.SessionId */ };
|
||||
client.Disconnected += (s, e) => { /* e.Client, e.Reason */ };
|
||||
client.DataReceived += (s, e) => { /* e.Data, e.Text */ };
|
||||
client.Reconnecting += (s, e) => { /* e.Attempt, e.MaxAttempts */ };
|
||||
client.ReconnectFailed += (s, e) => { /* e.Exception */ };
|
||||
client.Error += (s, e) => { /* e.Exception, e.Context */ };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
EonaCat.QuicNet uses a simple, efficient binary framing protocol — no XML, JSON, or text overhead on the wire:
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────────────┬──────────────────────────┐
|
||||
│ Type (1 B) │ Payload length (4 B)│ Payload (N bytes) │
|
||||
│ MessageType │ big-endian uint32 │ arbitrary bytes │
|
||||
└──────────────┴──────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
`MessageType` values: `Handshake`, `HandshakeAck`, `Data`, `Ping`, `Pong`, `Disconnect`, `GroupJoin`, `GroupLeave`, `NicknameSet`, `System`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Performance
|
||||
|
||||
- **Accept loop** runs on a dedicated background thread.
|
||||
- **Each client** gets its own receive thread pulled from `ThreadPool.QueueUserWorkItem`.
|
||||
At 100 k connections, this means 100 k lightweight ThreadPool work items — not 100 k OS threads.
|
||||
- **`ConcurrentDictionary`** used for sessions and groups — lock-free reads.
|
||||
- **Send** uses a per-client `lock` only during the actual socket write;
|
||||
- **`FrameReader`** is a tiny stateful struct that handles partial reads with zero heap allocations per frame (beyond the payload buffer).
|
||||
- **Heartbeat** is one thread that iterates all clients every `HeartbeatIntervalInMilliseconds` per cycle.
|
||||
|
||||
### Memory
|
||||
|
||||
| Item | Size |
|
||||
|---|---|
|
||||
| `ConnectedClient` object | ~200 bytes + socket buffers |
|
||||
| Per-client receive buffer | `ReceiveBufferSize` (default 8 KB) |
|
||||
| Per-client send buffer | `SendBufferSize` (default 8 KB) |
|
||||
| Groups | `HashSet<string>` per client (only allocated if joined) |
|
||||
|
||||
With defaults, 100 000 connections use roughly **1.6 GB** of socket buffer memory (OS-level) plus ~20 MB of managed heap — well within modern server specs.
|
||||
|
||||
To reduce further: lower `ReceiveBufferSize` / `SendBufferSize` to 4096 or even 2048.
|
||||
|
||||
---
|
||||
|
||||
## Extension Methods (`QuicExtensions`)
|
||||
|
||||
```csharp
|
||||
// Server
|
||||
server.BroadcastText("hello");
|
||||
server.SendToGroupText("vip", "VIP message");
|
||||
server.SendSystem(sessionId, "system notice");
|
||||
server.KickNotInGroup("authenticated"); // kicks everyone NOT in a group
|
||||
|
||||
// Client
|
||||
client.SendObject(myObj, obj => Serialize(obj));
|
||||
client.SendStruct(myValueTypeStruct);
|
||||
|
||||
// IQuicClient
|
||||
client.IsInGroup("vip");
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user