feat(cordis): 接入上下文组合与模组事务热替换
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkTcpClientTransport : IShrinkNetworkAsyncTransport
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly long _sessionId;
|
||||
private readonly int _maxPacketSize;
|
||||
private readonly ShrinkTcpTlsOptions? _tlsOptions;
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
|
||||
private TcpClient? _client;
|
||||
private Stream? _stream;
|
||||
private CancellationTokenSource? _cts;
|
||||
private int _started;
|
||||
|
||||
public ShrinkTcpClientTransport(string host, int port, long sessionId = 1, int maxPacketSize = 64 * 1024,
|
||||
ShrinkTcpTlsOptions? tlsOptions = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
throw new ArgumentException("Host cannot be empty.", nameof(host));
|
||||
if (port <= 0 || port > 65535)
|
||||
throw new ArgumentOutOfRangeException(nameof(port));
|
||||
if (maxPacketSize <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxPacketSize));
|
||||
|
||||
_host = host.Trim();
|
||||
_port = port;
|
||||
_sessionId = sessionId;
|
||||
_maxPacketSize = maxPacketSize;
|
||||
_tlsOptions = tlsOptions?.Clone();
|
||||
}
|
||||
|
||||
public bool IsStarted => Volatile.Read(ref _started) == 1;
|
||||
|
||||
public event Action<ShrinkNetworkTransportEvent>? OnEvent;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) == 1)
|
||||
return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
ConnectAsync(_cts.Token).Forget();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 0) == 0)
|
||||
return;
|
||||
|
||||
_cts?.Cancel();
|
||||
|
||||
try
|
||||
{
|
||||
_stream?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_client?.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_client = null;
|
||||
_stream = null;
|
||||
}
|
||||
|
||||
public void Send(long sessionId, byte[] packetData)
|
||||
{
|
||||
SendAsync(sessionId, packetData).Forget();
|
||||
}
|
||||
|
||||
public UniTask SendAsync(long sessionId, byte[] packetData)
|
||||
{
|
||||
if (!IsStarted)
|
||||
throw new InvalidOperationException("Transport is not started.");
|
||||
if (sessionId != _sessionId)
|
||||
throw new InvalidOperationException($"Unsupported session id {sessionId}. This transport only supports {_sessionId}.");
|
||||
if (_client == null || !_client.Connected || _stream == null)
|
||||
throw new InvalidOperationException("TCP client is not connected.");
|
||||
if ((packetData?.Length ?? 0) > _maxPacketSize)
|
||||
throw new InvalidOperationException($"TCP packet is too large. Size={(packetData?.Length ?? 0)}, Limit={_maxPacketSize}.");
|
||||
|
||||
return SendInternalAsync(_stream, _sendLock, packetData ?? Array.Empty<byte>(),
|
||||
_cts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
|
||||
private async UniTaskVoid ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_client = new TcpClient();
|
||||
_client.NoDelay = true;
|
||||
await _client.ConnectAsync(_host, _port);
|
||||
var stream = await CreateConnectedStreamAsync(_client, cancellationToken);
|
||||
_stream = stream;
|
||||
|
||||
if (!IsStarted)
|
||||
{
|
||||
stream.Dispose();
|
||||
_client.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
var remote = _client.Client.RemoteEndPoint?.ToString() ?? $"{_host}:{_port}";
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(_sessionId, remote));
|
||||
ReceiveLoopAsync(_client, stream, cancellationToken).Forget();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShrinkNetworkLogger.Exception(ex);
|
||||
ShrinkNetworkLogger.Error($"[ShrinkNetwork] TCP connect failed: {_host}:{_port} {ex.Message}");
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTask<Stream> CreateConnectedStreamAsync(TcpClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
Stream stream = client.GetStream();
|
||||
var tlsOptions = _tlsOptions;
|
||||
if (tlsOptions == null || !tlsOptions.Enabled)
|
||||
return stream;
|
||||
|
||||
var targetHost = string.IsNullOrWhiteSpace(tlsOptions.TargetHost) ? _host : tlsOptions.TargetHost.Trim();
|
||||
var sslStream = new SslStream(stream, false, (_, _, _, errors) =>
|
||||
{
|
||||
if (tlsOptions.AllowInvalidServerCertificate)
|
||||
return true;
|
||||
|
||||
return errors == SslPolicyErrors.None;
|
||||
});
|
||||
await sslStream.AuthenticateAsClientAsync(targetHost, null, tlsOptions.EnabledProtocols,
|
||||
tlsOptions.CheckCertificateRevocation);
|
||||
return sslStream;
|
||||
}
|
||||
|
||||
private async UniTaskVoid ReceiveLoopAsync(TcpClient client, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lengthBuffer = new byte[4];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await ReadExactlyAsync(stream, lengthBuffer, cancellationToken);
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer);
|
||||
if (length <= 0)
|
||||
throw new InvalidDataException($"Invalid packet length: {length}");
|
||||
if (length > _maxPacketSize)
|
||||
throw new InvalidDataException($"TCP packet length exceeded limit. Length={length}, Limit={_maxPacketSize}");
|
||||
|
||||
var payload = new byte[length];
|
||||
await ReadExactlyAsync(stream, payload, cancellationToken);
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(_sessionId, payload));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (IOException ex) when (IsExpectedDisconnect(ex))
|
||||
{
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] TCP receive loop ended: {ex.Message}");
|
||||
}
|
||||
catch (SocketException ex) when (IsExpectedDisconnect(ex))
|
||||
{
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] TCP receive loop ended: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShrinkNetworkLogger.Exception(ex);
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] TCP receive loop ended: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
var remote = client.Client.RemoteEndPoint?.ToString() ?? $"{_host}:{_port}";
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(_sessionId, remote));
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExpectedDisconnect(IOException ex)
|
||||
{
|
||||
if (string.Equals(ex.Message, "Remote closed.", StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
return ex.InnerException is SocketException socketException && IsExpectedDisconnect(socketException);
|
||||
}
|
||||
|
||||
private static bool IsExpectedDisconnect(SocketException ex)
|
||||
{
|
||||
return ex.SocketErrorCode == SocketError.ConnectionReset ||
|
||||
ex.SocketErrorCode == SocketError.ConnectionAborted ||
|
||||
ex.SocketErrorCode == SocketError.Shutdown;
|
||||
}
|
||||
|
||||
private static async UniTask SendInternalAsync(Stream stream, SemaphoreSlim sendLock, byte[] packetData,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await sendLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var header = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header, packetData.Length);
|
||||
await stream.WriteAsync(header, cancellationToken);
|
||||
if (packetData.Length > 0)
|
||||
await stream.WriteAsync(packetData, cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async UniTask ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken);
|
||||
if (read <= 0)
|
||||
throw new IOException("Remote closed.");
|
||||
offset += read;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d7ce61d2fb059e44a8aad25fc98d6b65
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,295 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkTcpServerTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, TcpClient> _clients = new();
|
||||
private readonly ConcurrentDictionary<long, SemaphoreSlim> _sendLocks = new();
|
||||
private readonly ConcurrentDictionary<long, Stream> _streams = new();
|
||||
private readonly TcpListener _listener;
|
||||
private readonly int _maxPacketSize;
|
||||
private readonly ShrinkTcpTlsOptions? _tlsOptions;
|
||||
private CancellationTokenSource? _cts;
|
||||
private long _sessionIdGenerator;
|
||||
private X509Certificate2? _serverCertificate;
|
||||
|
||||
public ShrinkTcpServerTransport(IPAddress ipAddress, int port, int maxPacketSize = 64 * 1024,
|
||||
ShrinkTcpTlsOptions? tlsOptions = null)
|
||||
{
|
||||
if (port <= 0 || port > 65535)
|
||||
throw new ArgumentOutOfRangeException(nameof(port));
|
||||
if (maxPacketSize <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxPacketSize));
|
||||
|
||||
_listener = new TcpListener(ipAddress, port);
|
||||
_maxPacketSize = maxPacketSize;
|
||||
_tlsOptions = tlsOptions?.Clone();
|
||||
ListeningAddress = ipAddress;
|
||||
ListeningPort = port;
|
||||
}
|
||||
|
||||
public IPAddress ListeningAddress { get; }
|
||||
public int ListeningPort { get; }
|
||||
public bool IsStarted { get; private set; }
|
||||
|
||||
public event Action<ShrinkNetworkTransportEvent>? OnEvent;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (IsStarted)
|
||||
return;
|
||||
|
||||
IsStarted = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
EnsureTlsCertificateLoaded();
|
||||
_listener.Start();
|
||||
_ = AcceptLoopAsync(_cts.Token);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!IsStarted)
|
||||
return;
|
||||
|
||||
IsStarted = false;
|
||||
_cts?.Cancel();
|
||||
_listener.Stop();
|
||||
|
||||
foreach (var pair in _clients)
|
||||
{
|
||||
try
|
||||
{
|
||||
pair.Value.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_clients.Clear();
|
||||
foreach (var pair in _streams)
|
||||
{
|
||||
try
|
||||
{
|
||||
pair.Value.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_streams.Clear();
|
||||
|
||||
foreach (var pair in _sendLocks)
|
||||
{
|
||||
pair.Value.Dispose();
|
||||
}
|
||||
|
||||
_sendLocks.Clear();
|
||||
}
|
||||
|
||||
public void Send(long sessionId, byte[] packetData)
|
||||
{
|
||||
SendAsync(sessionId, packetData).Forget();
|
||||
}
|
||||
|
||||
public bool DisconnectSession(long sessionId, string? reason = null)
|
||||
{
|
||||
if (!_clients.TryGetValue(sessionId, out var client))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
if (_streams.TryRemove(sessionId, out var stream))
|
||||
stream.Dispose();
|
||||
client.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reason))
|
||||
ShrinkNetworkLogger.Warn($"[ShrinkNetwork][TCP-Server] Disconnect session {sessionId}: {reason}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public async UniTask SendAsync(long sessionId, byte[] packetData)
|
||||
{
|
||||
if (!_clients.TryGetValue(sessionId, out var client))
|
||||
throw new InvalidOperationException($"Session {sessionId} is not connected.");
|
||||
if (!_streams.TryGetValue(sessionId, out var stream))
|
||||
throw new InvalidOperationException($"Session {sessionId} stream is not initialized.");
|
||||
|
||||
if (!_sendLocks.TryGetValue(sessionId, out var sendLock))
|
||||
throw new InvalidOperationException($"Session {sessionId} send lock is not initialized.");
|
||||
if ((packetData?.Length ?? 0) > _maxPacketSize)
|
||||
throw new InvalidOperationException($"TCP packet is too large. Size={(packetData?.Length ?? 0)}, Limit={_maxPacketSize}.");
|
||||
|
||||
await SendInternalAsync(stream, sendLock, packetData ?? Array.Empty<byte>(), CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try
|
||||
{
|
||||
var acceptTask = _listener.AcceptTcpClientAsync();
|
||||
var completedTask = await Task.WhenAny(acceptTask, Task.Delay(Timeout.Infinite, cancellationToken));
|
||||
if (completedTask != acceptTask)
|
||||
break;
|
||||
|
||||
client = await acceptTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!IsStarted)
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
var sessionId = Interlocked.Increment(ref _sessionIdGenerator);
|
||||
client.NoDelay = true;
|
||||
var stream = await CreateServerStreamAsync(client, cancellationToken);
|
||||
_clients[sessionId] = client;
|
||||
_streams[sessionId] = stream;
|
||||
_sendLocks[sessionId] = new SemaphoreSlim(1, 1);
|
||||
var remoteAddress = client.Client.RemoteEndPoint?.ToString() ?? "unknown";
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteAddress));
|
||||
_ = ReceiveLoopAsync(sessionId, client, stream, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Stream> CreateServerStreamAsync(TcpClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
Stream stream = client.GetStream();
|
||||
var tlsOptions = _tlsOptions;
|
||||
if (tlsOptions == null || !tlsOptions.Enabled)
|
||||
return stream;
|
||||
|
||||
if (_serverCertificate == null)
|
||||
throw new InvalidOperationException("TCP TLS is enabled, but the server certificate is not loaded.");
|
||||
|
||||
var sslStream = new SslStream(stream, false);
|
||||
await sslStream.AuthenticateAsServerAsync(_serverCertificate, false, tlsOptions.EnabledProtocols,
|
||||
tlsOptions.CheckCertificateRevocation);
|
||||
return sslStream;
|
||||
}
|
||||
|
||||
private void EnsureTlsCertificateLoaded()
|
||||
{
|
||||
var tlsOptions = _tlsOptions;
|
||||
if (tlsOptions == null || !tlsOptions.Enabled)
|
||||
{
|
||||
_serverCertificate = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tlsOptions.ServerCertificatePath))
|
||||
throw new InvalidOperationException("TCP TLS is enabled, but ServerCertificatePath is empty.");
|
||||
|
||||
_serverCertificate = new X509Certificate2(tlsOptions.ServerCertificatePath, tlsOptions.ServerCertificatePassword);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(long sessionId, TcpClient client, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lengthBuffer = new byte[4];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await ReadExactlyAsync(stream, lengthBuffer, cancellationToken);
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer);
|
||||
if (length <= 0)
|
||||
throw new InvalidDataException($"Invalid packet length: {length}");
|
||||
if (length > _maxPacketSize)
|
||||
throw new InvalidDataException($"TCP packet length exceeded limit. Length={length}, Limit={_maxPacketSize}");
|
||||
|
||||
var payload = new byte[length];
|
||||
await ReadExactlyAsync(stream, payload, cancellationToken);
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(sessionId, payload));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_clients.TryRemove(sessionId, out var removed))
|
||||
{
|
||||
if (_streams.TryRemove(sessionId, out var ownedStream))
|
||||
ownedStream.Dispose();
|
||||
if (_sendLocks.TryRemove(sessionId, out var sendLock))
|
||||
sendLock.Dispose();
|
||||
|
||||
var remoteAddress = removed.Client.RemoteEndPoint?.ToString() ?? "unknown";
|
||||
try
|
||||
{
|
||||
removed.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, remoteAddress));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendInternalAsync(Stream stream, SemaphoreSlim sendLock, byte[] packetData,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await sendLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var header = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header, packetData.Length);
|
||||
await stream.WriteAsync(header, cancellationToken);
|
||||
if (packetData.Length > 0)
|
||||
await stream.WriteAsync(packetData, cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken);
|
||||
if (read <= 0)
|
||||
throw new IOException("Remote closed.");
|
||||
offset += read;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 734d880602bc42eb8a14ae1e811e1114
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,2 @@
|
||||
// 占位文件:兼容尚未刷新的 Unity / 模板工程引用。
|
||||
// 实际定义已移动到 Runtime/Transport/Abstractions/IShrinkNetworkTransport.cs。
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 253f494145698ce49ba13db227f91603
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user