feat(network)!: add v2 framing, pooled buffers and bounded dispatch
Publish UPM package / publish (push) Successful in 2s
Publish NuGet packages / publish (push) Successful in 3m5s

This commit is contained in:
2026-09-29 10:14:47 +08:00
parent c7c45b26f8
commit 93c2b8520b
36 changed files with 752 additions and 398 deletions
+105 -177
View File
@@ -54,7 +54,7 @@ namespace ShrinkNetwork
Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
MessageRegistry = messageRegistry ?? throw new ArgumentNullException(nameof(messageRegistry));
Router = router ?? throw new ArgumentNullException(nameof(router));
_dispatchScheduler = dispatchScheduler ?? ShrinkNetworkDispatchSchedulers.Inline;
_dispatchScheduler = dispatchScheduler ?? new ShrinkNetworkInlineDispatchScheduler();
}
public IShrinkNetworkSerializer Serializer { get; }
@@ -74,6 +74,8 @@ namespace ShrinkNetwork
set => _dispatchScheduler = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>Reliable receive work could not be processed. Session-control transports also disconnect the peer.</summary>
public event Action<long>? OnDispatchRejected;
public event Action<ShrinkNetworkSession>? OnSessionConnected;
public event Action<ShrinkNetworkSession>? OnSessionDisconnected;
@@ -273,11 +275,11 @@ namespace ShrinkNetwork
if (messageType == null)
throw new ArgumentNullException(nameof(messageType));
return SendPacketAsync(session, messageType, kind, requestToken, route, Serializer.Serialize(message));
return SendPacketAsync(session, messageType, kind, requestToken, route, Array.Empty<byte>(), message);
}
private async UniTask SendPacketAsync(ShrinkNetworkSession session, Type messageType,
ShrinkNetworkPacketKind kind, ShrinkRequestToken requestToken, string? route, byte[] payload)
ShrinkNetworkPacketKind kind, ShrinkRequestToken requestToken, string? route, byte[] payload, object? message = null)
{
if (session == null)
throw new ArgumentNullException(nameof(session));
@@ -299,16 +301,22 @@ namespace ShrinkNetwork
Payload = payload
};
var packetData = Serializer.Serialize(packet);
using var encoded = ShrinkPacketCodec.Encode(packet, Serializer, message);
var packetData = encoded.WrittenMemory;
Interlocked.Increment(ref _packetsSent);
Interlocked.Add(ref _bytesSent, packetData.Length);
if (transport is IShrinkNetworkMemoryTransport memoryTransport)
{
await memoryTransport.SendAsync(session.SessionId, packetData);
return;
}
if (transport is IShrinkNetworkAsyncTransport asyncTransport)
{
await asyncTransport.SendAsync(session.SessionId, packetData);
await asyncTransport.SendAsync(session.SessionId, packetData.ToArray());
return;
}
transport.Send(session.SessionId, packetData);
transport.Send(session.SessionId, packetData.ToArray());
}
private void OnTransportEvent(ShrinkNetworkTransportEvent evt)
@@ -330,6 +338,12 @@ namespace ShrinkNetwork
return;
}
// Responses complete pending RPCs independently of the serial handler queue, including nested calls.
if (evt.Type == ShrinkNetworkTransportEventType.Packet && ShrinkPacketCodec.IsResponse(evt.PacketData))
{
HandlePacketAsync(evt).Forget();
return;
}
ScheduleTransportEventAsync(evt).Forget();
}
@@ -337,9 +351,23 @@ namespace ShrinkNetwork
{
try
{
var scheduled = await DispatchScheduler.ScheduleAsync(() => HandleTransportEventAsync(evt));
var scheduler = DispatchScheduler;
_sessions.TryGetValue(evt.SessionId, out var receivedSession);
UniTask DispatchCurrent()
{
return receivedSession != null && _sessions.TryGetValue(evt.SessionId, out var current) && ReferenceEquals(current, receivedSession)
? HandleTransportEventAsync(evt) : UniTask.CompletedTask;
}
var scheduled = scheduler is IShrinkNetworkPacketDispatchScheduler queue
? await queue.ScheduleAsync(evt.SessionId, evt.PacketData?.Length ?? 0, DispatchCurrent)
: await scheduler.ScheduleAsync(DispatchCurrent);
if (!scheduled)
{
Interlocked.Increment(ref _dispatchQueueRejectedCount);
OnDispatchRejected?.Invoke(evt.SessionId);
if (_transport is IShrinkNetworkSessionControlTransport control)
control.DisconnectSession(evt.SessionId, "SHRINK-NET-CONGESTION: reliable receive queue rejected work.");
}
}
catch (Exception ex)
{
@@ -395,12 +423,12 @@ namespace ShrinkNetwork
{
Interlocked.Increment(ref _packetsReceived);
Interlocked.Add(ref _bytesReceived, evt.PacketData.Length);
var packet = Serializer.Deserialize<ShrinkNetworkPacket>(evt.PacketData);
var packet = ShrinkPacketCodec.Decode(evt.PacketData);
if (!_sessions.TryGetValue(evt.SessionId, out var session))
{
session = _sessions.GetOrAdd(evt.SessionId,
id => new ShrinkNetworkSession(id, evt.RemoteAddress, this));
// Queued packets from a disconnected session cannot resurrect its state.
return;
}
if (!ValidatePacketCompatibility(packet, evt.SessionId))
@@ -410,7 +438,7 @@ namespace ShrinkNetwork
if (packet.Kind == ShrinkNetworkPacketKind.Response)
{
HandleResponse(packet);
HandleResponse(session, packet);
return;
}
@@ -425,7 +453,7 @@ namespace ShrinkNetwork
}
var resolvedMeta = meta!;
var message = Serializer.Deserialize(packet.Payload, resolvedMeta.MessageType);
var message = DeserializePayload(packet.Payload, resolvedMeta.MessageType);
if (message == null)
{
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Failed to deserialize message for opcode {packet.Opcode}.");
@@ -442,6 +470,12 @@ namespace ShrinkNetwork
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] No handler found for {resolvedMeta.MessageType.FullName}");
}
}
catch (ShrinkProtocolException ex)
{
Interlocked.Increment(ref _protocolViolations);
if (DisconnectOnProtocolViolation && _transport is IShrinkNetworkSessionControlTransport control)
control.DisconnectSession(evt.SessionId, ex.Message);
}
catch (Exception ex)
{
Interlocked.Increment(ref _serializationErrorCount);
@@ -450,9 +484,10 @@ namespace ShrinkNetwork
}
}
private void HandleResponse(ShrinkNetworkPacket packet)
private void HandleResponse(ShrinkNetworkSession session, ShrinkNetworkPacket packet)
{
if (!_pendingRequests.TryRemove(packet.RequestToken, out var pending))
if (!_pendingRequests.TryGetValue(packet.RequestToken, out var expected) || expected.SessionId != session.SessionId ||
!_pendingRequests.TryRemove(packet.RequestToken, out var pending))
{
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] Pending request not found. RequestToken={packet.RequestToken}");
return;
@@ -460,7 +495,7 @@ namespace ShrinkNetwork
try
{
var response = Serializer.Deserialize(packet.Payload, pending.ResponseType);
var response = DeserializePayload(packet.Payload, pending.ResponseType);
pending.CompletionSource.TrySetResult(response);
}
catch (Exception ex)
@@ -469,6 +504,10 @@ namespace ShrinkNetwork
}
}
private object DeserializePayload(ReadOnlyMemory<byte> payload, Type type) =>
Serializer is IShrinkNetworkBufferSerializer buffered
? buffered.Deserialize(payload, type) : Serializer.Deserialize(payload.ToArray(), type);
private async UniTask<TResponse> WaitForPendingResponse<TResponse>(ShrinkRequestToken requestToken, PendingRequest pending,
ShrinkRpcCallOptions? options)
where TResponse : class, IShrinkNetworkResponse
@@ -627,20 +666,42 @@ namespace ShrinkNetwork
public static class ShrinkNetworkDispatchSchedulers
{
public static IShrinkNetworkDispatchScheduler Inline { get; } =
new ShrinkNetworkInlineDispatchScheduler();
public static IShrinkNetworkDispatchScheduler Inline => new ShrinkNetworkInlineDispatchScheduler();
}
public sealed class ShrinkNetworkInlineDispatchScheduler : IShrinkNetworkDispatchScheduler
public interface IShrinkNetworkPacketDispatchScheduler : IShrinkNetworkDispatchScheduler
{
public async UniTask<bool> ScheduleAsync(Func<UniTask> callback)
{
if (callback == null)
throw new ArgumentNullException(nameof(callback));
UniTask<bool> ScheduleAsync(long sessionId, int bytes, Func<UniTask> callback);
}
await callback();
return true;
/// <summary>Automatically drains a bounded serial queue. Callbacks start on the initiating transport/continuation thread.</summary>
public sealed class ShrinkNetworkInlineDispatchScheduler : IShrinkNetworkPacketDispatchScheduler, IDisposable
{
private readonly object _gate = new();
private readonly ShrinkNetworkWorkQueue _queue = new(4096, 64 * 1024 * 1024);
private bool _draining;
public ShrinkNetworkQueueDiagnostics CaptureDiagnostics() => _queue.CaptureDiagnostics();
public UniTask<bool> ScheduleAsync(Func<UniTask> callback) => ScheduleAsync(0, 0, callback);
public async UniTask<bool> ScheduleAsync(long sessionId, int bytes, Func<UniTask> callback)
{
var completion = _queue.EnqueueAsync(sessionId, "receive", bytes, callback);
var start = false;
lock (_gate) { if (!_draining) { _draining = true; start = true; } }
if (start) DrainAsync().Forget();
return await completion == ShrinkNetworkQueueResult.Completed;
}
private async UniTask DrainAsync()
{
while (true)
{
await _queue.PumpAsync(int.MaxValue);
lock (_gate)
{
if (_queue.PendingCount == 0) { _draining = false; return; }
}
}
}
public void Dispose() => _queue.Dispose();
}
public enum ShrinkNetworkDispatchOverflowPolicy
@@ -650,163 +711,30 @@ namespace ShrinkNetwork
DropOldest = 2
}
/// <summary>
/// A caller-pumped, bounded dispatch queue. Unity can pump it from Update
/// while a dedicated server can keep the default inline scheduler.
/// </summary>
public sealed class ShrinkNetworkDispatchQueue : IShrinkNetworkDispatchScheduler, IDisposable
/// <summary>Caller-pumped serial receive queue; rejects overflow without silently dropping reliable packets.</summary>
public sealed class ShrinkNetworkDispatchQueue : IShrinkNetworkPacketDispatchScheduler, IDisposable
{
private sealed class WorkItem
{
public Func<UniTask> Callback = null!;
public UniTaskCompletionSource<bool> Completion = null!;
}
private readonly ConcurrentQueue<WorkItem> _queue = new();
private readonly object _lifecycleLock = new();
private readonly int _capacity;
private readonly ShrinkNetworkDispatchOverflowPolicy _overflowPolicy;
private int _queuedCount;
private int _pumping;
private int _disposed;
private long _rejectedCount;
private long _droppedCount;
private readonly ShrinkNetworkWorkQueue _queue;
public ShrinkNetworkDispatchQueue(int capacity,
ShrinkNetworkDispatchOverflowPolicy overflowPolicy = ShrinkNetworkDispatchOverflowPolicy.Reject)
ShrinkNetworkDispatchOverflowPolicy overflowPolicy = ShrinkNetworkDispatchOverflowPolicy.Reject,
long byteCapacity = 64 * 1024 * 1024, int perSessionCapacity = int.MaxValue)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
_capacity = capacity;
_overflowPolicy = overflowPolicy;
}
public int Capacity => _capacity;
public int PendingCount => Volatile.Read(ref _queuedCount);
public long RejectedCount => Volatile.Read(ref _rejectedCount);
public long DroppedCount => Volatile.Read(ref _droppedCount);
public UniTask<bool> ScheduleAsync(Func<UniTask> callback)
{
if (callback == null)
throw new ArgumentNullException(nameof(callback));
if (Volatile.Read(ref _disposed) != 0)
return UniTask.FromException<bool>(new ObjectDisposedException(nameof(ShrinkNetworkDispatchQueue)));
var item = new WorkItem
{
Callback = callback,
Completion = new UniTaskCompletionSource<bool>()
};
while (true)
{
if (Volatile.Read(ref _queuedCount) >= _capacity)
{
switch (_overflowPolicy)
{
case ShrinkNetworkDispatchOverflowPolicy.Reject:
Interlocked.Increment(ref _rejectedCount);
return UniTask.FromResult(false);
case ShrinkNetworkDispatchOverflowPolicy.DropNewest:
Interlocked.Increment(ref _droppedCount);
return UniTask.FromResult(false);
case ShrinkNetworkDispatchOverflowPolicy.DropOldest:
if (_queue.TryDequeue(out var dropped))
{
Interlocked.Decrement(ref _queuedCount);
Interlocked.Increment(ref _droppedCount);
dropped.Completion.TrySetResult(false);
continue;
}
Thread.Yield();
continue;
default:
throw new ArgumentOutOfRangeException();
}
}
var currentCount = Volatile.Read(ref _queuedCount);
if (currentCount >= _capacity ||
Interlocked.CompareExchange(ref _queuedCount, currentCount + 1, currentCount) != currentCount)
{
continue;
}
lock (_lifecycleLock)
{
if (Volatile.Read(ref _disposed) != 0)
{
Interlocked.Decrement(ref _queuedCount);
item.Completion.TrySetResult(false);
return item.Completion.Task;
}
_queue.Enqueue(item);
return item.Completion.Task;
}
}
}
public UniTask<int> PumpAsync(int maxItems)
{
if (maxItems <= 0)
throw new ArgumentOutOfRangeException(nameof(maxItems));
if (Interlocked.Exchange(ref _pumping, 1) == 1)
return UniTask.FromResult(0);
return PumpCoreAsync(maxItems);
}
public void Dispose()
{
lock (_lifecycleLock)
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
while (_queue.TryDequeue(out var item))
{
Interlocked.Decrement(ref _queuedCount);
item.Completion.TrySetResult(false);
}
}
}
private async UniTask<int> PumpCoreAsync(int maxItems)
{
var processed = 0;
try
{
while (processed < maxItems && _queue.TryDequeue(out var item))
{
Interlocked.Decrement(ref _queuedCount);
await ExecuteItemAsync(item);
processed++;
}
return processed;
}
finally
{
Volatile.Write(ref _pumping, 0);
}
}
private static async UniTask ExecuteItemAsync(WorkItem item)
{
try
{
await item.Callback();
item.Completion.TrySetResult(true);
}
catch (Exception ex)
{
item.Completion.TrySetException(ex);
}
if (overflowPolicy != ShrinkNetworkDispatchOverflowPolicy.Reject)
throw new ArgumentException("Protocol v2 reliable dispatch requires Reject. Use an explicit state key on ShrinkNetworkWorkQueue for replaceable state.", nameof(overflowPolicy));
Capacity = capacity;
_queue = new ShrinkNetworkWorkQueue(capacity, byteCapacity, perSessionCapacity);
}
public int Capacity { get; }
public int PendingCount => _queue.PendingCount;
public long RejectedCount => CaptureDiagnostics().Rejected;
public long DroppedCount => 0;
public ShrinkNetworkQueueDiagnostics CaptureDiagnostics() => _queue.CaptureDiagnostics();
public UniTask<bool> ScheduleAsync(Func<UniTask> callback) => ScheduleAsync(0, 0, callback);
public async UniTask<bool> ScheduleAsync(long sessionId, int bytes, Func<UniTask> callback) =>
await _queue.EnqueueAsync(sessionId, "receive", bytes, callback) == ShrinkNetworkQueueResult.Completed;
public UniTask<int> PumpAsync(int maxItems) => _queue.PumpAsync(maxItems);
public UniTask<int> PumpAsync(int maxItems, long maxBytes, TimeSpan timeBudget) => _queue.PumpAsync(maxItems, maxBytes, timeBudget);
public void Dispose() => _queue.Dispose();
}
public sealed class ShrinkNetworkServiceDiagnosticsSnapshot