feat(network)!: add v2 framing, pooled buffers and bounded dispatch
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
@@ -8,4 +9,10 @@ namespace ShrinkNetwork
|
||||
object Deserialize(byte[] payload, Type type);
|
||||
T Deserialize<T>(byte[] payload);
|
||||
}
|
||||
|
||||
public interface IShrinkNetworkBufferSerializer : IShrinkNetworkSerializer
|
||||
{
|
||||
void Serialize(IBufferWriter<byte> writer, object value);
|
||||
object Deserialize(ReadOnlyMemory<byte> payload, Type type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
/// <summary>Owns rented memory until Dispose. A sender must await completion before disposing.</summary>
|
||||
public sealed class ShrinkBufferWriter : IBufferWriter<byte>, IDisposable
|
||||
{
|
||||
private byte[]? _buffer;
|
||||
public ShrinkBufferWriter(int initialCapacity = 256) => _buffer = ArrayPool<byte>.Shared.Rent(Math.Max(1, initialCapacity));
|
||||
public int WrittenCount { get; private set; }
|
||||
public ReadOnlyMemory<byte> WrittenMemory => Buffer.AsMemory(0, WrittenCount);
|
||||
internal Span<byte> WrittenSpan => Buffer.AsSpan(0, WrittenCount);
|
||||
private byte[] Buffer => _buffer ?? throw new ObjectDisposedException(nameof(ShrinkBufferWriter));
|
||||
public void Advance(int count)
|
||||
{
|
||||
if (count < 0 || count > Buffer.Length - WrittenCount) throw new ArgumentOutOfRangeException(nameof(count));
|
||||
WrittenCount += count;
|
||||
}
|
||||
public Memory<byte> GetMemory(int sizeHint = 0) { Ensure(sizeHint); return Buffer.AsMemory(WrittenCount); }
|
||||
public Span<byte> GetSpan(int sizeHint = 0) { Ensure(sizeHint); return Buffer.AsSpan(WrittenCount); }
|
||||
private void Ensure(int sizeHint)
|
||||
{
|
||||
if (sizeHint < 0) throw new ArgumentOutOfRangeException(nameof(sizeHint));
|
||||
sizeHint = Math.Max(1, sizeHint);
|
||||
if (sizeHint <= Buffer.Length - WrittenCount) return;
|
||||
var next = ArrayPool<byte>.Shared.Rent(checked(Math.Max(Buffer.Length * 2, WrittenCount + sizeHint)));
|
||||
Buffer.AsSpan(0, WrittenCount).CopyTo(next);
|
||||
ArrayPool<byte>.Shared.Return(Buffer, clearArray: true);
|
||||
_buffer = next;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
var buffer = _buffer;
|
||||
_buffer = null;
|
||||
if (buffer != null) ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 88c6239e9fc049f43b2999d7311eec1d
|
||||
guid: 9ae3dee10fcf29f4da1b7ace3063bf92
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Buffers;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkJsonNetworkSerializer : IShrinkNetworkSerializer
|
||||
public sealed class ShrinkJsonNetworkSerializer : IShrinkNetworkBufferSerializer
|
||||
{
|
||||
private static readonly JsonSerializerSettings Settings = new()
|
||||
{
|
||||
@@ -25,5 +27,33 @@ namespace ShrinkNetwork
|
||||
public T Deserialize<T>(byte[] payload)
|
||||
=> JsonConvert.DeserializeObject<T>(Encoding.UTF8.GetString(payload), Settings)
|
||||
?? throw new JsonSerializationException($"Failed to deserialize payload into {typeof(T).FullName}.");
|
||||
|
||||
public void Serialize(IBufferWriter<byte> writer, object value)
|
||||
{
|
||||
using var stream = new BufferStream(writer);
|
||||
using var text = new StreamWriter(stream, new UTF8Encoding(false), 1024, leaveOpen: true);
|
||||
using var json = new JsonTextWriter(text);
|
||||
JsonSerializer.Create(Settings).Serialize(json, value);
|
||||
}
|
||||
|
||||
public object Deserialize(ReadOnlyMemory<byte> payload, Type type) =>
|
||||
JsonConvert.DeserializeObject(Encoding.UTF8.GetString(payload.Span), type, Settings)
|
||||
?? throw new JsonSerializationException($"Failed to deserialize payload into {type.FullName}.");
|
||||
|
||||
private sealed class BufferStream : Stream
|
||||
{
|
||||
private readonly IBufferWriter<byte> _writer;
|
||||
public BufferStream(IBufferWriter<byte> writer) => _writer = writer;
|
||||
public override bool CanRead => false;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => true;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override void Write(byte[] buffer, int offset, int count) { buffer.AsSpan(offset, count).CopyTo(_writer.GetSpan(count)); _writer.Advance(count); }
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkMessagePackNetworkSerializer : IShrinkNetworkSerializer
|
||||
{
|
||||
private readonly MethodInfo _serializeMethod;
|
||||
private readonly MethodInfo _deserializeMethod;
|
||||
private readonly object? _serializerOptions;
|
||||
|
||||
public ShrinkMessagePackNetworkSerializer()
|
||||
{
|
||||
var serializerType = Type.GetType("MessagePack.MessagePackSerializer, MessagePack");
|
||||
if (serializerType == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"MessagePack assembly was not found. Please install MessagePack-CSharp before using ShrinkMessagePackNetworkSerializer.");
|
||||
}
|
||||
|
||||
_serializerOptions = ResolveSerializerOptions(serializerType.Assembly);
|
||||
|
||||
var serializeMethod = serializerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(m =>
|
||||
{
|
||||
if (m.Name != "Serialize")
|
||||
return false;
|
||||
var parameters = m.GetParameters();
|
||||
return parameters.Length >= 2 &&
|
||||
parameters[0].ParameterType == typeof(Type) &&
|
||||
parameters[1].ParameterType == typeof(object);
|
||||
});
|
||||
|
||||
var deserializeMethod = serializerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(m =>
|
||||
{
|
||||
if (m.Name != "Deserialize")
|
||||
return false;
|
||||
var parameters = m.GetParameters();
|
||||
return parameters.Length >= 2 &&
|
||||
parameters[0].ParameterType == typeof(Type) &&
|
||||
(parameters[1].ParameterType == typeof(byte[]) ||
|
||||
parameters[1].ParameterType == typeof(ReadOnlyMemory<byte>));
|
||||
});
|
||||
|
||||
if (serializeMethod == null || deserializeMethod == null)
|
||||
throw new MissingMethodException("MessagePack serialize/deserialize API not found.");
|
||||
|
||||
_serializeMethod = serializeMethod;
|
||||
_deserializeMethod = deserializeMethod;
|
||||
}
|
||||
|
||||
public byte[] Serialize(object value)
|
||||
{
|
||||
if (value == null)
|
||||
return Array.Empty<byte>();
|
||||
|
||||
var parameters = BuildParameters(_serializeMethod, value.GetType(), value, _serializerOptions);
|
||||
return (byte[])_serializeMethod.Invoke(null, parameters)!;
|
||||
}
|
||||
|
||||
public object Deserialize(byte[] payload, Type type)
|
||||
{
|
||||
payload ??= Array.Empty<byte>();
|
||||
var parameters = BuildParameters(_deserializeMethod, type, payload, _serializerOptions);
|
||||
return _deserializeMethod.Invoke(null, parameters)
|
||||
?? throw new InvalidOperationException($"MessagePack returned null for type {type.FullName}.");
|
||||
}
|
||||
|
||||
public T Deserialize<T>(byte[] payload)
|
||||
{
|
||||
return (T)Deserialize(payload, typeof(T));
|
||||
}
|
||||
|
||||
private static object?[] BuildParameters(MethodInfo method, Type type, object valueOrBytes, object? serializerOptions)
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
var args = new object?[parameters.Length];
|
||||
|
||||
if (parameters.Length > 0)
|
||||
args[0] = type;
|
||||
if (parameters.Length > 1)
|
||||
args[1] = ConvertPrimaryArgument(parameters[1].ParameterType, valueOrBytes);
|
||||
|
||||
for (var i = 2; i < parameters.Length; i++)
|
||||
{
|
||||
args[i] = ResolveAdditionalArgument(parameters[i], serializerOptions);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private static object? ResolveAdditionalArgument(ParameterInfo parameter, object? serializerOptions)
|
||||
{
|
||||
if (serializerOptions != null && parameter.ParameterType.IsInstanceOfType(serializerOptions))
|
||||
return serializerOptions;
|
||||
|
||||
return parameter.HasDefaultValue
|
||||
? parameter.DefaultValue
|
||||
: GetDefault(parameter.ParameterType);
|
||||
}
|
||||
|
||||
private static object ConvertPrimaryArgument(Type parameterType, object value)
|
||||
{
|
||||
if (parameterType == typeof(ReadOnlyMemory<byte>) && value is byte[] bytes)
|
||||
return new ReadOnlyMemory<byte>(bytes);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static object? ResolveSerializerOptions(Assembly serializerAssembly)
|
||||
{
|
||||
var contractlessResolverType = serializerAssembly.GetType("MessagePack.Resolvers.ContractlessStandardResolver");
|
||||
if (contractlessResolverType != null)
|
||||
{
|
||||
var optionsField = contractlessResolverType.GetField("Options",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var options = optionsField?.GetValue(null);
|
||||
if (options != null)
|
||||
return options;
|
||||
|
||||
var instanceField = contractlessResolverType.GetField("Instance",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var instance = instanceField?.GetValue(null);
|
||||
if (instance != null)
|
||||
{
|
||||
var optionsType = serializerAssembly.GetType("MessagePack.MessagePackSerializerOptions");
|
||||
var standardProperty = optionsType?.GetProperty("Standard", BindingFlags.Public | BindingFlags.Static);
|
||||
var standardOptions = standardProperty?.GetValue(null);
|
||||
var withResolverMethod = optionsType?.GetMethod("WithResolver", BindingFlags.Public | BindingFlags.Instance);
|
||||
var resolvedOptions = withResolverMethod?.Invoke(standardOptions, new[] { instance });
|
||||
if (resolvedOptions != null)
|
||||
return resolvedOptions;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object? GetDefault(Type type)
|
||||
{
|
||||
return type.IsValueType ? Activator.CreateInstance(type) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public sealed class ShrinkProtocolException : IOException
|
||||
{
|
||||
public ShrinkProtocolException(string message) : base(message) { }
|
||||
public ShrinkProtocolException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
|
||||
/// <summary>V2 little-endian framing, independent of the payload serializer. Decode borrows input memory.</summary>
|
||||
public static class ShrinkPacketCodec
|
||||
{
|
||||
private const uint Magic = 0x324B4853; // SHK2
|
||||
public const int HeaderSize = 33;
|
||||
public const int MaximumPacketBytes = 16 * 1024 * 1024;
|
||||
private static readonly UTF8Encoding Utf8 = new(false, true);
|
||||
|
||||
public static ShrinkBufferWriter Encode(ShrinkNetworkPacket packet, IShrinkNetworkSerializer? serializer = null, object? message = null)
|
||||
{
|
||||
var route = packet.Route ?? string.Empty;
|
||||
var token = packet.SessionToken ?? string.Empty;
|
||||
var routeBytes = Utf8.GetByteCount(route);
|
||||
var tokenBytes = Utf8.GetByteCount(token);
|
||||
if (routeBytes > ushort.MaxValue || tokenBytes > ushort.MaxValue) throw new ShrinkProtocolException("Route or session token exceeds framing limit.");
|
||||
if (packet.ProtocolVersion != ShrinkNetworkProtocol.CurrentProtocolVersion || packet.SchemaVersion is < 0 or > ushort.MaxValue || packet.Kind < 0 || packet.Kind > ShrinkNetworkPacketKind.Response)
|
||||
throw new ShrinkProtocolException("Invalid packet header.");
|
||||
var writer = new ShrinkBufferWriter(HeaderSize + routeBytes + tokenBytes);
|
||||
try
|
||||
{
|
||||
var header = writer.GetSpan(HeaderSize + routeBytes + tokenBytes);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header, Magic);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header.Slice(4), (ushort)packet.ProtocolVersion);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header.Slice(6), (ushort)packet.SchemaVersion);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.Slice(8), packet.Opcode);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header.Slice(12), packet.RequestToken.Value);
|
||||
header[16] = (byte)packet.Kind;
|
||||
BinaryPrimitives.WriteInt64LittleEndian(header.Slice(17), packet.SessionTokenExpiresAtUnixTimeSeconds);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header.Slice(25), (ushort)routeBytes);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header.Slice(27), (ushort)tokenBytes);
|
||||
Utf8.GetBytes(route.AsSpan(), header.Slice(HeaderSize, routeBytes));
|
||||
Utf8.GetBytes(token.AsSpan(), header.Slice(HeaderSize + routeBytes, tokenBytes));
|
||||
writer.Advance(HeaderSize + routeBytes + tokenBytes);
|
||||
var payloadStart = writer.WrittenCount;
|
||||
if (message != null)
|
||||
{
|
||||
if (serializer is IShrinkNetworkBufferSerializer buffered) buffered.Serialize(writer, message);
|
||||
else
|
||||
{
|
||||
var bytes = (serializer ?? throw new ArgumentNullException(nameof(serializer))).Serialize(message);
|
||||
bytes.CopyTo(writer.GetSpan(bytes.Length)); writer.Advance(bytes.Length);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
packet.Payload.Span.CopyTo(writer.GetSpan(packet.Payload.Length));
|
||||
writer.Advance(packet.Payload.Length);
|
||||
}
|
||||
if (writer.WrittenCount > MaximumPacketBytes) throw new ShrinkProtocolException("Packet exceeds framing limit.");
|
||||
BinaryPrimitives.WriteInt32LittleEndian(writer.WrittenSpan.Slice(29), writer.WrittenCount - payloadStart);
|
||||
return writer;
|
||||
}
|
||||
catch { writer.Dispose(); throw; }
|
||||
}
|
||||
|
||||
internal static bool IsResponse(ReadOnlyMemory<byte> memory) => memory.Length >= HeaderSize &&
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(memory.Span) == Magic && memory.Span[16] == (byte)ShrinkNetworkPacketKind.Response;
|
||||
|
||||
public static ShrinkNetworkPacket Decode(ReadOnlyMemory<byte> memory)
|
||||
{
|
||||
var span = memory.Span;
|
||||
if (span.Length < HeaderSize || span.Length > MaximumPacketBytes || BinaryPrimitives.ReadUInt32LittleEndian(span) != Magic)
|
||||
throw new ShrinkProtocolException("Expected ShrinkNetwork protocol v2 binary envelope. Legacy JSON/MessagePack envelopes are unsupported.");
|
||||
var protocol = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(4));
|
||||
if (protocol != ShrinkNetworkProtocol.CurrentProtocolVersion) throw new ShrinkProtocolException("Unsupported protocol version " + protocol);
|
||||
var routeLength = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(25));
|
||||
var tokenLength = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(27));
|
||||
var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(29));
|
||||
var start = HeaderSize + routeLength + tokenLength;
|
||||
if (payloadLength < 0 || start > span.Length || payloadLength != span.Length - start || span[16] > (byte)ShrinkNetworkPacketKind.Response)
|
||||
throw new ShrinkProtocolException("Invalid packet lengths or kind.");
|
||||
string route, token;
|
||||
try
|
||||
{
|
||||
route = Utf8.GetString(span.Slice(HeaderSize, routeLength));
|
||||
token = Utf8.GetString(span.Slice(HeaderSize + routeLength, tokenLength));
|
||||
}
|
||||
catch (DecoderFallbackException exception)
|
||||
{
|
||||
throw new ShrinkProtocolException("Invalid UTF-8 in packet header.", exception);
|
||||
}
|
||||
return new ShrinkNetworkPacket {
|
||||
ProtocolVersion = protocol, SchemaVersion = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(6)),
|
||||
Opcode = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(8)), RequestToken = new ShrinkRequestToken(BinaryPrimitives.ReadInt32LittleEndian(span.Slice(12))),
|
||||
Kind = (ShrinkNetworkPacketKind)span[16], SessionTokenExpiresAtUnixTimeSeconds = BinaryPrimitives.ReadInt64LittleEndian(span.Slice(17)),
|
||||
Route = route, SessionToken = token,
|
||||
Payload = memory.Slice(start, payloadLength)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a01225f12864f142b0d0c6395ef381b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ShrinkNetwork
|
||||
{
|
||||
public interface IShrinkMessageCodec<T>
|
||||
{
|
||||
void Write(IBufferWriter<byte> writer, T value);
|
||||
T Read(ReadOnlyMemory<byte> payload);
|
||||
}
|
||||
|
||||
/// <summary>Explicit codecs work with AOT and external modules without runtime generic reflection.</summary>
|
||||
public class ShrinkRegisteredNetworkSerializer : IShrinkNetworkBufferSerializer
|
||||
{
|
||||
private interface ICodec
|
||||
{
|
||||
void Write(IBufferWriter<byte> writer, object value);
|
||||
object Read(ReadOnlyMemory<byte> payload);
|
||||
}
|
||||
private sealed class Codec<T> : ICodec
|
||||
{
|
||||
private readonly IShrinkMessageCodec<T> _codec;
|
||||
public Codec(IShrinkMessageCodec<T> codec) => _codec = codec;
|
||||
public void Write(IBufferWriter<byte> writer, object value) => _codec.Write(writer, (T)value);
|
||||
public object Read(ReadOnlyMemory<byte> payload) => _codec.Read(payload)!;
|
||||
}
|
||||
private readonly Dictionary<Type, ICodec> _codecs = new();
|
||||
public void Register<T>(IShrinkMessageCodec<T> codec)
|
||||
{
|
||||
if (codec == null) throw new ArgumentNullException(nameof(codec));
|
||||
_codecs.Add(typeof(T), new Codec<T>(codec));
|
||||
}
|
||||
public bool Unregister<T>() => _codecs.Remove(typeof(T));
|
||||
private ICodec Resolve(Type type) => _codecs.TryGetValue(type, out var codec) ? codec :
|
||||
throw new InvalidOperationException($"SHRINK-NET-CODEC: No codec registered for {type.FullName}. Register a generated formatter before binding the transport.");
|
||||
public void Serialize(IBufferWriter<byte> writer, object value) => Resolve(value.GetType()).Write(writer, value);
|
||||
public object Deserialize(ReadOnlyMemory<byte> payload, Type type) => Resolve(type).Read(payload);
|
||||
public byte[] Serialize(object value)
|
||||
{
|
||||
using var writer = new ShrinkBufferWriter();
|
||||
Serialize(writer, value);
|
||||
return writer.WrittenMemory.ToArray();
|
||||
}
|
||||
public object Deserialize(byte[] payload, Type type) => Deserialize((ReadOnlyMemory<byte>)payload, type);
|
||||
public T Deserialize<T>(byte[] payload) => (T)Deserialize(payload, typeof(T));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b0225bad560778f43827ddefb85fd61c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user