diff --git a/AyCode.Benchmark/SignalRRoundTripBenchmarks.cs b/AyCode.Benchmark/SignalRRoundTripBenchmarks.cs index 7873bb1..5845062 100644 --- a/AyCode.Benchmark/SignalRRoundTripBenchmarks.cs +++ b/AyCode.Benchmark/SignalRRoundTripBenchmarks.cs @@ -212,7 +212,7 @@ public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServ public TResponse? GetAllSync(int tag) => GetAllAsync(tag).GetAwaiter().GetResult(); - protected override Task MessageReceived(int messageTag, SignalParams signalParams, byte[] data) => Task.CompletedTask; + protected override Task MessageReceived(int messageTag, SignalParams signalParams, SignalData data) => Task.CompletedTask; protected override HubConnectionState GetConnectionState() => HubConnectionState.Connected; protected override bool IsConnected() => true; protected override Task StartConnectionInternal() => Task.CompletedTask; @@ -221,7 +221,7 @@ public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServ protected override async Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, byte[]? messageBytes) { - await _hub.OnReceiveMessage(messageTag, requestId, signalParams, messageBytes ?? []); + await _hub.OnReceiveMessage(messageTag, requestId, signalParams, new SignalData(messageBytes ?? [])); } } diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs index 3a4cfb3..286e720 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs @@ -29,7 +29,7 @@ public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServ #region Override virtual methods for testing - protected override async Task MessageReceived(int messageTag, SignalParams signalParams, byte[] data) + protected override async Task MessageReceived(int messageTag, SignalParams signalParams, SignalData data) { throw new NotImplementedException(); } @@ -54,7 +54,7 @@ public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServ protected override async Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, byte[]? messageBytes) { - await _signalRHub.OnReceiveMessage(messageTag, requestId, signalParams, messageBytes ?? []); + await _signalRHub.OnReceiveMessage(messageTag, requestId, signalParams, new SignalData(messageBytes ?? [])); } #endregion diff --git a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs index 11345db..66ba1a2 100644 --- a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs +++ b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs @@ -288,15 +288,15 @@ namespace AyCode.Services.Server.SignalRs BeginSync(); // Request SignalResponseDataMessage directly to avoid deserializing ResponseData return SignalRClient.GetAllAsync(SignalRCrudTags.GetAllMessageTag, GetContextParams()) - .ContinueWith(async task => + .ContinueWith(async responseTask => { try { - var response = task.Result; + var response = await responseTask; if (response?.Status != SignalResponseStatus.Success || response.ResponseData == null) throw new NullReferenceException($"LoadDataSourceAsync; Status: {response?.Status}"); - await LoadDataSourceFromResponseData(response.ResponseData, response.DataSerializerType, + await LoadDataSourceFromResponseData(response.ResponseData, response.DataSerializerType, false, false, clearChangeTracking); } finally @@ -309,7 +309,7 @@ namespace AyCode.Services.Server.SignalRs /// /// Loads data source directly from ResponseData byte[], avoiding double deserialization. /// - public async Task LoadDataSourceFromResponseData(byte[] responseData, AcSerializerType serializerType, + public async Task LoadDataSourceFromResponseData(SignalData responseData, AcSerializerType serializerType, bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true) { await _asyncLock.WaitAsync(); @@ -325,7 +325,7 @@ namespace AyCode.Services.Server.SignalRs observable.BeginUpdate(); try { - responseData.BinaryToMerge(InnerList); + responseData.Span.BinaryToMerge(InnerList); } finally { @@ -334,13 +334,13 @@ namespace AyCode.Services.Server.SignalRs } else { - responseData.BinaryTo(InnerList); + responseData.Span.BinaryTo(InnerList); } } else { - // JSON mode - decompress GZip first - var json = GzipHelper.DecompressToString(responseData); + // JSON mode - decompress GZip first (no span overload for DecompressToString) + var json = GzipHelper.DecompressToString(responseData.ToArray()); if (InnerList is IAcObservableCollection observable) { observable.PopulateFromJson(json); @@ -356,9 +356,9 @@ namespace AyCode.Services.Server.SignalRs // Deserialize to new list and set as reference TIList? fromSource; if (serializerType == AcSerializerType.Binary) - fromSource = responseData.BinaryTo(); + fromSource = responseData.Span.BinaryTo(); else - fromSource = GzipHelper.DecompressToString(responseData).JsonTo(); + fromSource = GzipHelper.DecompressToString(responseData.ToArray()).JsonTo(); if (fromSource != null) { diff --git a/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs b/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs index c6f1ff6..47a63ad 100644 --- a/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs +++ b/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs @@ -15,7 +15,8 @@ public abstract class AcSignalRSendToClientService(IConfiguration #region Message Processing - public virtual Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, byte[] data) + public virtual Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, SignalData data) { return ProcessOnReceiveMessage(messageTag, signalParams, requestId, null); } @@ -194,9 +194,9 @@ public abstract class AcWebSignalRHubBase(IConfiguration } // Log binary diagnostics if enabled - if (EnableBinaryDiagnostics && responseMessage is SignalResponseDataMessage dataMsg && dataMsg.ResponseData != null) + if (EnableBinaryDiagnostics && responseMessage is SignalResponseDataMessage dataMsg && dataMsg.ResponseData is { IsEmpty: false }) { - LogResponseDataDiagnostics(messageTag, tagName, requestId, dataMsg.ResponseData); + LogResponseDataDiagnostics(messageTag, tagName, requestId, dataMsg.ResponseData.ToArray()); } await ResponseToCaller(messageTag, responseMessage, requestId); @@ -490,7 +490,7 @@ public abstract class AcWebSignalRHubBase(IConfiguration Status = responseMessage.Status, DataSerializerType = responseMessage.DataSerializerType }; - var responseData = responseMessage.ResponseData ?? []; + var responseData = responseMessage.ResponseData ?? new SignalData([]); var tagName = ConstHelper.NameByValue(messageTag); diff --git a/AyCode.Services.Server/docs/SIGNALR_SERVER.md b/AyCode.Services.Server/docs/SIGNALR_SERVER.md index 0239e0a..76df27b 100644 --- a/AyCode.Services.Server/docs/SIGNALR_SERVER.md +++ b/AyCode.Services.Server/docs/SIGNALR_SERVER.md @@ -8,7 +8,7 @@ Server-side SignalR hub infrastructure: method dispatch, session management, bro ## Server Processing ``` -6. OnReceiveMessage(tag, requestId, signalParams, data) +6. OnReceiveMessage(tag, requestId, signalParams, SignalData data) 7. Extract parameterBytes from signalParams.Parameters 8. DynamicMethodRegistry.GetMethodByMessageTag(tag) <- ConcurrentDictionary lookup 9. signalParams.GetParameterValues(paramInfos): @@ -20,8 +20,8 @@ Server-side SignalR hub infrastructure: method dispatch, session management, bro 10. MethodInfo.InvokeMethod(instance, params) <- unwraps Task/ValueTask 11. CreateResponseMessage(tag, Success, result) <- Binary serialize payload -> byte[] 12. SendMessageToClient(caller, tag, message, requestId): - |- Extract signalParams { Status, DataSerializerType } + responseData byte[] from message - '- caller.OnReceiveMessage(tag, requestId, signalParams, responseData) + |- Extract signalParams { Status, DataSerializerType } + SignalData from message + '- caller.OnReceiveMessage(tag, requestId, signalParams, SignalData) (metadata + payload as separate args -- no envelope serialization) 13. If SendToOtherClientType != None: '- SendMessageToOthers(sendToOtherClientTag, result) <- uses sendToOtherClientTag, not messageTag @@ -34,7 +34,7 @@ See also: `AyCode.Models.Server/DynamicMethods/README.md` ### Server-Side Lookup ``` -1. OnReceiveMessage(tag=100, requestId, signalParams, data) +1. OnReceiveMessage(tag=100, requestId, signalParams, SignalData data) 2. DynamicMethodRegistry.GetMethodByMessageTag(100) |- Check static ConcurrentDictionary cache @@ -83,7 +83,7 @@ ConcurrentDictionary Sessions | `SendMessageToUser(userId)` | User (all connections) | | `SendMessageToUsers(userIds)` | Multiple users | -All messages serialized to `byte[]` payload + `SignalParams` metadata (Parameters=null for server->client push) -> sent as separate hub arguments via `OnReceiveMessage` (no envelope wrapping). +All messages serialized to `SignalData` payload + `SignalParams` metadata (Parameters=null for server->client push) -> sent as separate hub arguments via `OnReceiveMessage` (no envelope wrapping). Server wraps `byte[]` in non-pooled `SignalData`; client receives as `ArrayPool`-backed `SignalData` via `AyCodeBinaryHubProtocol`. ## Hub Events @@ -96,7 +96,7 @@ Enable with `AcWebSignalRHubBase.EnableBinaryDiagnostics = true`. Logs: hex dump (500 byte sample), header parsing (version, marker), property count + names via VarUInt reading. -`SignalResponseDataMessage.DiagnosticLogger` -- per-response logging: target type info, property list, inheritance chain, hex dump. +`SignalResponseDataMessage.DiagnosticLogger` -- per-response logging: target type info, property list, inheritance chain, hex dump. Uses `SignalData.Span` for zero-alloc diagnostics. ## Key Source Files diff --git a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs index 0dc0453..b2cb617 100644 --- a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs +++ b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs @@ -42,12 +42,17 @@ public class PostJsonDataMessageTests Assert.IsNotNull(serverParams); Assert.AreEqual(testValue, serverParams![0]); - // Response round-trip + // Response round-trip (SignalResponseDataMessage is in-memory DTO, not serialized as envelope on wire) var serviceResult = $"{serverParams[0]}"; - var response = new SignalResponseDataMessage(100, SignalResponseStatus.Success, serviceResult, AyCode.Core.Serializers.Binaries.AcBinarySerializerOptions.Default); - var responseBytes = response.ToBinary(); - var clientResponse = responseBytes.BinaryTo(); - var finalResult = clientResponse?.GetResponseData(); + var responseData = SignalRSerializationHelper.CreateResponseData(serviceResult, AyCode.Core.Serializers.Binaries.AcBinarySerializerOptions.Default); + var clientResponse = new SignalResponseDataMessage + { + MessageTag = 100, + Status = SignalResponseStatus.Success, + DataSerializerType = AyCode.Core.Serializers.AcSerializerType.Binary, + ResponseData = responseData != null ? new SignalData(responseData) : null + }; + var finalResult = clientResponse.GetResponseData(); Assert.AreEqual(testValue.ToString(), finalResult); } diff --git a/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs b/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs index 1fd1e14..0d5944c 100644 --- a/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs +++ b/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs @@ -28,7 +28,7 @@ namespace AyCode.Services.SignalRs; /// on the hot path. Argument payloads are serialized directly to the pipe /// via AcBinarySerializer (zero-copy). Length prefixes are patched in-place. /// -public sealed class AcBinaryHubProtocol : IHubProtocol +public class AcBinaryHubProtocol : IHubProtocol { private const int LengthPrefixSize = 4; @@ -43,7 +43,7 @@ public sealed class AcBinaryHubProtocol : IHubProtocol private const byte MsgAck = 8; private const byte MsgSequence = 9; - private volatile AcBinarySerializerOptions _options; + protected volatile AcBinarySerializerOptions _options; public AcBinaryHubProtocol() : this(AcBinarySerializerOptions.Default) { } @@ -416,6 +416,18 @@ public sealed class AcBinaryHubProtocol : IHubProtocol return; } + if (value is SignalData signalData) + { + // SignalData fast-path: same wire format as byte[], reads from Span + var span = signalData.Span; + var argPayload = 1 + VarUIntSize((uint)span.Length) + span.Length; + bw.WriteRaw(argPayload); + bw.WriteByte(BinaryTypeCode.ByteArray); + bw.WriteVarUInt((uint)span.Length); + bw.WriteBytes(span); + return; + } + // Flush BWO to pipe, then serialize directly to the pipe via AcBinarySerializer bw.FlushAndReset(); @@ -469,12 +481,26 @@ public sealed class AcBinaryHubProtocol : IHubProtocol { var byteReader = new SpanReader(argSpan.Slice(1)); var len = (int)byteReader.ReadVarUInt(); - return byteReader.ReadSpan(len).ToArray(); + var payloadSpan = byteReader.ReadSpan(len); + // Skip virtual dispatch for plain byte[] (most common case — SignalParams.Parameters). + // Only call virtual hook when targetType is not byte[] (e.g. SignalData). + return targetType == typeof(byte[]) || targetType == typeof(object) + ? payloadSpan.ToArray() + : CreateByteArrayResult(payloadSpan, targetType); } return AcBinaryDeserializer.Deserialize(argSpan, targetType, _options); } + /// + /// Hook for derived protocols to customize byte[] argument creation. + /// Called from the byte[] fast-path (ByteArray wire marker 0x44). + /// Base implementation: allocates new byte[] via .ToArray(). + /// Override to use ArrayPool, return SignalData, etc. + /// + protected virtual object CreateByteArrayResult(ReadOnlySpan data, Type targetType) + => data.ToArray(); + #endregion #region Framing Helpers diff --git a/AyCode.Services/SignalRs/AcSignalRClientBase.cs b/AyCode.Services/SignalRs/AcSignalRClientBase.cs index f9ba340..180f149 100644 --- a/AyCode.Services/SignalRs/AcSignalRClientBase.cs +++ b/AyCode.Services/SignalRs/AcSignalRClientBase.cs @@ -21,7 +21,7 @@ namespace AyCode.Services.SignalRs protected readonly HubConnection? HubConnection; protected readonly AcLoggerBase Logger; - protected abstract Task MessageReceived(int messageTag, SignalParams signalParams, byte[] data); + protected abstract Task MessageReceived(int messageTag, SignalParams signalParams, SignalData data); public int MsDelay = 25; public int MsFirstDelay = 50; @@ -64,13 +64,13 @@ namespace AyCode.Services.SignalRs if (useAcBinaryProtocol) { - hubBuilder.Services.AddSingleton(); + hubBuilder.Services.AddSingleton(); } HubConnection = hubBuilder.Build(); HubConnection.Closed += HubConnection_Closed; - _ = HubConnection.On(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage); + _ = HubConnection.On(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage); } protected AcSignalRClientBase(AcLoggerBase logger) @@ -416,11 +416,11 @@ namespace AyCode.Services.SignalRs protected virtual int GetNextRequestId() => AcDomain.NextUniqueInt32; - public virtual Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, byte[] data) + public virtual Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, SignalData data) { var logText = $"Client OnReceiveMessage; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"; - if (data.Length == 0) Logger.Warning($"data.Length == 0! {logText}"); + if (data.IsEmpty) Logger.Warning($"data.IsEmpty! {logText}"); try { @@ -431,7 +431,7 @@ namespace AyCode.Services.SignalRs Logger.Debug($"[{requestModel.ResponseDateTime.Subtract(requestModel.RequestDateTime).TotalMilliseconds:N0}ms][{data.Length / 1024}kb]{logText}"); // Diagnostic logging for binary deserialization debugging - LogBinaryDiagnostics(messageTag, data, requestId); + LogBinaryDiagnostics(messageTag, data); // No envelope deserialization — construct directly from params + data var responseMessage = new SignalResponseDataMessage @@ -475,9 +475,9 @@ namespace AyCode.Services.SignalRs catch (Exception ex) { // Enhanced error logging with binary diagnostics - if (data.Length > 0) + if (!data.IsEmpty) { - LogBinaryDiagnosticsOnError(messageTag, data, requestId, ex); + LogBinaryDiagnosticsOnError(messageTag, data, ex); } if (requestId.HasValue && _responseByRequestId.TryRemove(requestId.Value, out var exModel)) @@ -499,36 +499,37 @@ namespace AyCode.Services.SignalRs /// /// Logs binary diagnostics for debugging serialization issues. /// - private void LogBinaryDiagnostics(int messageTag, byte[] messageBytes, int? requestId) + private void LogBinaryDiagnostics(int messageTag, SignalData data) { - if (!EnableBinaryDiagnostics || messageBytes.Length == 0) return; + if (!EnableBinaryDiagnostics || data.IsEmpty) return; try { - var hexDump = Convert.ToHexString(messageBytes.AsSpan(0, Math.Min(500, messageBytes.Length))); - Logger.Info($"=== BINARY DIAGNOSTICS === Tag: {messageTag}; RequestId: {requestId}; Length: {messageBytes.Length}"); + var span = data.Span; + var hexDump = Convert.ToHexString(span[..Math.Min(500, span.Length)]); + Logger.Info($"=== BINARY DIAGNOSTICS === Tag: {messageTag}; Length: {data.Length}"); Logger.Info($"HEX (first 500 bytes): {hexDump}"); - + // Parse header info - if (messageBytes.Length >= 3) + if (span.Length >= 3) { - var version = messageBytes[0]; - var marker = messageBytes[1]; + var version = span[0]; + var marker = span[1]; Logger.Info($"Version: {version}; Marker: 0x{marker:X2}"); - - if ((marker & 0x10) != 0 && messageBytes.Length > 2) + + if ((marker & 0x10) != 0 && span.Length > 2) { - var propCount = messageBytes[2]; + var propCount = span[2]; Logger.Info($"Header property count: {propCount}"); - + // Parse first 10 property names var pos = 3; - for (int i = 0; i < Math.Min((int)propCount, 10) && pos < messageBytes.Length; i++) + for (int i = 0; i < Math.Min((int)propCount, 10) && pos < span.Length; i++) { - var strLen = messageBytes[pos++]; - if (pos + strLen <= messageBytes.Length) + var strLen = span[pos++]; + if (pos + strLen <= span.Length) { - var propName = System.Text.Encoding.UTF8.GetString(messageBytes, pos, strLen); + var propName = System.Text.Encoding.UTF8.GetString(span.Slice(pos, strLen)); pos += strLen; Logger.Info($" [{i}]: '{propName}'"); } @@ -545,37 +546,38 @@ namespace AyCode.Services.SignalRs /// /// Logs binary diagnostics when an error occurs during deserialization. /// - private void LogBinaryDiagnosticsOnError(int messageTag, byte[] messageBytes, int? requestId, Exception error) + private void LogBinaryDiagnosticsOnError(int messageTag, SignalData data, Exception error) { try { + var span = data.Span; Logger.Error($"=== BINARY DESERIALIZATION ERROR ==="); - Logger.Error($"Tag: {messageTag}; RequestId: {requestId}; Length: {messageBytes.Length}"); + Logger.Error($"Tag: {messageTag}; Length: {data.Length}"); Logger.Error($"Error: {error.Message}"); - - var hexDump = Convert.ToHexString(messageBytes.AsSpan(0, Math.Min(1000, messageBytes.Length))); + + var hexDump = Convert.ToHexString(span[..Math.Min(1000, span.Length)]); Logger.Error($"HEX (first 1000 bytes): {hexDump}"); - + // Parse header info - if (messageBytes.Length >= 3) + if (span.Length >= 3) { - var version = messageBytes[0]; - var marker = messageBytes[1]; + var version = span[0]; + var marker = span[1]; Logger.Error($"Version: {version}; Marker: 0x{marker:X2}"); - - if ((marker & 0x10) != 0 && messageBytes.Length > 2) + + if ((marker & 0x10) != 0 && span.Length > 2) { - var propCount = messageBytes[2]; + var propCount = span[2]; Logger.Error($"Header property count: {propCount}"); - + // Parse ALL property names var pos = 3; - for (int i = 0; i < propCount && pos < messageBytes.Length; i++) + for (int i = 0; i < propCount && pos < span.Length; i++) { - var strLen = messageBytes[pos++]; - if (pos + strLen <= messageBytes.Length) + var strLen = span[pos++]; + if (pos + strLen <= span.Length) { - var propName = System.Text.Encoding.UTF8.GetString(messageBytes, pos, strLen); + var propName = System.Text.Encoding.UTF8.GetString(span.Slice(pos, strLen)); pos += strLen; Logger.Error($" Header[{i}]: '{propName}'"); } diff --git a/AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs b/AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs new file mode 100644 index 0000000..a49a9ce --- /dev/null +++ b/AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs @@ -0,0 +1,27 @@ +using System.Buffers; +using AyCode.Core.Serializers.Binaries; + +namespace AyCode.Services.SignalRs; + +/// +/// Project-specific binary protocol. Uses ArrayPool for byte[] arguments +/// when the target type is SignalData (client receive path optimization). +/// Register this in PluginNopStartup.cs and AcSignalRClientBase instead of AcBinaryHubProtocol. +/// +public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol +{ + public AyCodeBinaryHubProtocol() { } + public AyCodeBinaryHubProtocol(AcBinarySerializerOptions options) : base(options) { } + + protected override object CreateByteArrayResult(ReadOnlySpan data, Type targetType) + { + if (targetType == typeof(SignalData)) + { + var rented = ArrayPool.Shared.Rent(data.Length); + data.CopyTo(rented); + return new SignalData(rented, data.Length, isRented: true); + } + + return base.CreateByteArrayResult(data, targetType); + } +} diff --git a/AyCode.Services/SignalRs/IAcSignalRHubBase.cs b/AyCode.Services/SignalRs/IAcSignalRHubBase.cs index 80da6f3..d2a5302 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubBase.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubBase.cs @@ -3,5 +3,5 @@ public interface IAcSignalRHubBase { //Task OnRequestMessage(int messageTag, int requestId); - Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, byte[] data); + Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, SignalData data); } \ No newline at end of file diff --git a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs index 61379eb..e4c2450 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs @@ -2,6 +2,7 @@ using AyCode.Core.Interfaces; using System.Buffers; using System.Runtime.CompilerServices; +using AyCode.Core.Serializers.Binaries; using AyCode.Core.Serializers.Jsons; using JsonIgnoreAttribute = Newtonsoft.Json.JsonIgnoreAttribute; using STJIgnore = System.Text.Json.Serialization.JsonIgnoreAttribute; @@ -150,7 +151,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa public int MessageTag { get; set; } public SignalResponseStatus Status { get; set; } public AcSerializerType DataSerializerType { get; set; } - public byte[]? ResponseData { get; set; } + public SignalData? ResponseData { get; set; } [JsonIgnore] [STJIgnore] private object? _cachedResponseData; [JsonIgnore] [STJIgnore] private byte[]? _rentedDecompressedBuffer; @@ -175,7 +176,8 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa : this(messageTag, status) { DataSerializerType = serializerOptions.SerializerType; - ResponseData = SignalRSerializationHelper.CreateResponseData(responseData, serializerOptions); + var bytes = SignalRSerializationHelper.CreateResponseData(responseData, serializerOptions); + ResponseData = bytes != null ? new SignalData(bytes) : null; } /// @@ -185,7 +187,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa public T? GetResponseData() { if (_cachedResponseData != null) return (T)_cachedResponseData; - if (ResponseData == null) return default; + if (ResponseData == null || ResponseData.IsEmpty) return default; try { @@ -194,7 +196,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa // Log diagnostics if enabled LogResponseDataDiagnostics(); - return (T)(_cachedResponseData = ResponseData.BinaryTo()!); + return (T)(_cachedResponseData = AcBinaryDeserializer.Deserialize(ResponseData.Span)!); } // Decompress GZip to pooled buffer and deserialize directly @@ -267,10 +269,10 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa } DiagnosticLogger($"ResponseData.Length: {ResponseData.Length}"); - DiagnosticLogger($"HEX (first 500 bytes): {Convert.ToHexString(ResponseData.AsSpan(0, Math.Min(500, ResponseData.Length)))}"); - + DiagnosticLogger($"HEX (first 500 bytes): {Convert.ToHexString(ResponseData.Span[..Math.Min(500, ResponseData.Length)])}"); + // Parse header with VarInt support - LogBinaryHeader(ResponseData); + LogBinaryHeader(ResponseData.Span); } catch (Exception ex) { @@ -297,37 +299,37 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa } } - private static void LogBinaryHeader(byte[] data) + private static void LogBinaryHeader(ReadOnlySpan data) { if (DiagnosticLogger == null || data.Length < 3) return; - + var version = data[0]; var marker = data[1]; DiagnosticLogger($"Binary Version: {version}; Marker: 0x{marker:X2}"); - + // Check if metadata flag is set if ((marker & 0x10) == 0) { DiagnosticLogger("Header: No metadata (property names inline)"); return; } - + // Read property count as VarUInt var pos = 2; - var (propCount, bytesRead) = ReadVarUIntFromSpan(data.AsSpan(pos)); + var (propCount, bytesRead) = ReadVarUIntFromSpan(data[pos..]); pos += bytesRead; - + DiagnosticLogger($"Header Property Count: {propCount}"); - + for (var i = 0; i < (int)propCount && pos < data.Length; i++) { // Read string length as VarUInt - var (strLen, strLenBytes) = ReadVarUIntFromSpan(data.AsSpan(pos)); + var (strLen, strLenBytes) = ReadVarUIntFromSpan(data[pos..]); pos += strLenBytes; - + if (pos + (int)strLen <= data.Length) { - var propName = System.Text.Encoding.UTF8.GetString(data, pos, (int)strLen); + var propName = System.Text.Encoding.UTF8.GetString(data.Slice(pos, (int)strLen)); pos += (int)strLen; DiagnosticLogger($" Header[{i}]: '{propName}'"); } @@ -393,10 +395,10 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa } DiagnosticLogger($"ResponseData.Length: {ResponseData.Length}"); - DiagnosticLogger($"HEX (first 1000 bytes): {Convert.ToHexString(ResponseData.AsSpan(0, Math.Min(1000, ResponseData.Length)))}"); - + DiagnosticLogger($"HEX (first 1000 bytes): {Convert.ToHexString(ResponseData.Span[..Math.Min(1000, ResponseData.Length)])}"); + // Parse header - LogBinaryHeader(ResponseData); + LogBinaryHeader(ResponseData.Span); // Log inner exception if present if (error.InnerException != null) @@ -418,7 +420,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa /// public ReadOnlySpan GetDecompressedJsonSpan() { - if (ResponseData == null) return ReadOnlySpan.Empty; + if (ResponseData == null || ResponseData.IsEmpty) return ReadOnlySpan.Empty; if (DataSerializerType == AcSerializerType.Binary) return ReadOnlySpan.Empty; EnsureDecompressed(); @@ -430,11 +432,17 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa { if (_rentedDecompressedBuffer != null) return; - (_rentedDecompressedBuffer, _decompressedLength) = SignalRSerializationHelper.DecompressToRentedBuffer(ResponseData!); + (_rentedDecompressedBuffer, _decompressedLength) = AyCode.Core.Compression.GzipHelper.DecompressToRentedBuffer(ResponseData!.Span); } public void Dispose() { + ResponseData?.Dispose(); + if (_rentedDecompressedBuffer != null) + { + ArrayPool.Shared.Return(_rentedDecompressedBuffer, clearArray: true); + _rentedDecompressedBuffer = null; + } } } diff --git a/AyCode.Services/SignalRs/README.md b/AyCode.Services/SignalRs/README.md index b737e01..a7ded62 100644 --- a/AyCode.Services/SignalRs/README.md +++ b/AyCode.Services/SignalRs/README.md @@ -8,13 +8,15 @@ Custom binary SignalR protocol, client infrastructure, message tagging, and seri ## Key Files ### Protocol -- **`AcBinaryHubProtocol.cs`** — Custom `IHubProtocol` replacing JSON+Base64 with `AcBinarySerializer`. Handles all 9 SignalR message types (Invocation, StreamItem, Completion, Ping, Close, etc.). Uses `BufferWriterBinaryOutput` standalone mode for zero-copy writes to the SignalR pipe. `byte[]` fast-path bypasses the serializer entirely. Inner `SpanReader` ref struct for zero-alloc parsing. +- **`AcBinaryHubProtocol.cs`** — Unsealed base `IHubProtocol` replacing JSON+Base64 with `AcBinarySerializer`. Handles all 9 SignalR message types. Uses `BufferWriterBinaryOutput` standalone mode for zero-copy writes. `byte[]`/`SignalData` fast-path bypasses serializer. `CreateByteArrayResult` virtual hook for derived protocols. Inner `SpanReader` ref struct for zero-alloc parsing. +- **`AyCodeBinaryHubProtocol.cs`** — Derived protocol. Overrides `CreateByteArrayResult` to rent from `ArrayPool` when `targetType == typeof(SignalData)`. Register this instead of `AcBinaryHubProtocol`. +- **`SignalData.cs`** — `IDisposable` wrapper for `byte[]` with optional `ArrayPool` lifecycle. `Span` for zero-copy access, `Dispose()` returns rented buffer. Created by `AyCodeBinaryHubProtocol` (pooled) or directly from `byte[]` (server send). ### Client - **`AcSignalRClientBase.cs`** — Abstract SignalR client managing `HubConnection`, request/response tracking via pooled `SignalRRequestModel`. Methods: `SendMessageToServerAsync()`, CRUD helpers (Post, Get, GetAll, GetAllInto). Configurable timeouts. -- **`IAcSignalRHubClient.cs`** — Client interface + `SignalResponseDataMessage` (sealed, supports JSON/Binary with GZip, caching, diagnostics). -- **`IAcSignalRHubBase.cs`** — Base hub interface: `OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, byte[] data)`. -- **`ISignalParams.cs`** — `ISignalParams` base interface + `SignalParams` (Status, DataSerializerType, Parameters `byte[][]?`). Metadata travels as separate hub argument (AcBinary serialized), payload `byte[]` uses protocol fast-path (zero-copy). Parameters and data are independent — both nullable in any direction. +- **`IAcSignalRHubClient.cs`** — Client interface + `SignalResponseDataMessage` (sealed, `ResponseData` is `SignalData?`, supports JSON/Binary with GZip, caching, diagnostics, `Dispose()` returns buffers to ArrayPool). +- **`IAcSignalRHubBase.cs`** — Base hub interface: `OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, SignalData data)`. +- **`ISignalParams.cs`** — `ISignalParams` base interface + `SignalParams` (Status, DataSerializerType, Parameters `byte[]?`). Metadata travels as separate hub argument (AcBinary serialized), payload `SignalData` uses protocol fast-path (ArrayPool-backed). Parameters and data are independent — both nullable in any direction. ### Message Tagging - **`SignalMessageTagAttribute.cs`** — Three attributes: `TagAttribute` (base, int messageTag), `SignalRAttribute` (server method routing + client notification), `SignalRSendToClientAttribute` (client-side receive). diff --git a/AyCode.Services/SignalRs/SignalData.cs b/AyCode.Services/SignalRs/SignalData.cs new file mode 100644 index 0000000..b358e83 --- /dev/null +++ b/AyCode.Services/SignalRs/SignalData.cs @@ -0,0 +1,52 @@ +using System.Buffers; + +namespace AyCode.Services.SignalRs; + +/// +/// Wrapper for byte[] response data with optional ArrayPool lifecycle. +/// Created by AyCodeBinaryHubProtocol for pooled buffers, +/// or directly from byte[] for non-pooled data (server send path). +/// Consumer must Dispose() to return rented buffer. +/// Supports future AsyncEnumerable streaming (per-chunk lifecycle). +/// +public sealed class SignalData : IDisposable +{ + private byte[]? _buffer; + private readonly int _length; + private readonly bool _isRented; + + /// Pooled buffer from ArrayPool (rented, length >= actual data). + public SignalData(byte[] rentedBuffer, int length, bool isRented) + { + _buffer = rentedBuffer; + _length = length; + _isRented = isRented; + } + + /// Non-pooled byte[] (server send, direct creation). + public SignalData(byte[] data) + { + _buffer = data; + _length = data?.Length ?? 0; + _isRented = false; + } + + public ReadOnlySpan Span => _buffer.AsSpan(0, _length); + public int Length => _length; + public bool IsEmpty => _length == 0 || _buffer == null; + + /// + /// Returns a copy as byte[]. Use only when a byte[] is absolutely required. + /// Prefer Span for zero-copy access. + /// + public byte[] ToArray() => Span.ToArray(); + + public void Dispose() + { + if (_isRented && _buffer != null) + { + ArrayPool.Shared.Return(_buffer, clearArray: true); + _buffer = null; + } + } +} diff --git a/AyCode.Services/docs/SIGNALR.md b/AyCode.Services/docs/SIGNALR.md index aa34f54..10f9913 100644 --- a/AyCode.Services/docs/SIGNALR.md +++ b/AyCode.Services/docs/SIGNALR.md @@ -15,13 +15,13 @@ Client ◄──OnReceiveMessage(tag, requestId, signalParams, data)── Serve ``` Tag (int) determines server method. All calls go through `OnReceiveMessage`. -Metadata (`SignalParams`) and payload (`byte[]`) travel as **separate hub arguments** — the `byte[]` uses the protocol's zero-copy fast-path, metadata is AcBinary serialized normally. +Metadata (`SignalParams`) and payload (`SignalData`) travel as **separate hub arguments** — `SignalData` wraps pooled `byte[]` from `ArrayPool` via `AyCodeBinaryHubProtocol` (zero-copy fast-path), metadata is AcBinary serialized normally. ``` Client: Server: AcSignalRClientBase AcWebSignalRHubBase ├─ HubConnection (WebSocket) ├─ Hub - ├─ AcBinaryHubProtocol ├─ DynamicMethodRegistry + ├─ AyCodeBinaryHubProtocol ├─ DynamicMethodRegistry ├─ Pending request tracking ├─ Parameter deserialization └─ Response callbacks └─ Broadcast to other clients ``` @@ -68,9 +68,11 @@ CRUD helpers (`PostAsync`, `GetByIdAsync`, `GetAllAsync`, `PostDataAsync`) are g ## Wire Protocol -### AcBinaryHubProtocol +### AcBinaryHubProtocol / AyCodeBinaryHubProtocol -Custom `IHubProtocol` (`"acbinary"`), replaces JSON. Zero-copy via `BufferWriterBinaryOutput` standalone mode. `byte[]` args bypass serializer. +Custom `IHubProtocol` (`"acbinary"`), replaces JSON. Zero-copy via `BufferWriterBinaryOutput` standalone mode. `byte[]` and `SignalData` args bypass serializer. + +`AcBinaryHubProtocol` is the base (unsealed, generic). `AyCodeBinaryHubProtocol` derives from it and uses `ArrayPool` for `SignalData` arguments — the `CreateByteArrayResult` hook rents from pool instead of `.ToArray()`. Register `AyCodeBinaryHubProtocol` in both client and server. > Wire format, argument framing, dual BWO pattern, length prefix patching: `SIGNALR_BINARY_PROTOCOL.md` @@ -89,18 +91,20 @@ Typed access via methods (PostDataJson pattern): - **Server**: `GetParameterValues(ParameterInfo[])` — unpacks `byte[]` → `byte[][]` → per-element `BinaryTo(targetType)` - Protocol never sees `byte[][]` — only `byte[]`. -`byte[] data` (separate hub argument, protocol fast-path, zero-copy). +`SignalData data` (separate hub argument, protocol fast-path, ArrayPool-backed via `AyCodeBinaryHubProtocol`). + +`SignalData` wraps pooled `byte[]` with `IDisposable` lifecycle. Consumer accesses via `Span` (zero-copy) or `ToArray()` (copy, rare). `Dispose()` returns rented buffer to `ArrayPool` with `clearArray: true`. `Parameters` and `data` are **independent** — both can be null or filled in any direction (SignalR is bidirectional). | Combination | Parameters | data | Example | |------------|-----------|------|---------| -| Request | `byte[]` (packed params) | null | client calls server method | -| Response | null | response payload | server returns result | -| Request + data | `byte[]` | `byte[]` | client responds to server with data | -| Signal | null | null | ping, status change, broadcast trigger | +| Request | `byte[]` (packed params) | null/empty | client calls server method | +| Response | null | SignalData (response payload) | server returns result | +| Request + data | `byte[]` | SignalData | client responds to server with data | +| Signal | null | null/empty | ping, status change, broadcast trigger | -`SignalResponseDataMessage` remains as **internal DTO** for callback routing — constructed in-memory from `signalParams` + `data`, never serialized as envelope on wire. `GetResponseData()` dispatches on `DataSerializerType`: Binary → `BinaryTo()`, JsonGZip → decompress → `JsonTo()`. +`SignalResponseDataMessage` remains as **internal DTO** for callback routing — constructed in-memory from `signalParams` + `data`, never serialized as envelope on wire. `ResponseData` is `SignalData?`. `GetResponseData()` dispatches on `DataSerializerType`: Binary → `AcBinaryDeserializer.Deserialize(Span)`, JsonGZip → decompress → `JsonTo()`. `Dispose()` returns both SignalData and JSON decompression buffers to ArrayPool. ## Request/Response Flow @@ -112,7 +116,7 @@ Typed access via methods (PostDataJson pattern): Each param ToBinary() → byte[][] → ToBinary() → byte[] (single wire blob) 3. SignalParams { Status = Success, Parameters = byte[] } 4. HubConnection.SendAsync("OnReceiveMessage", tag, requestId, signalParams, null) -5. AcBinaryHubProtocol frames on wire (signalParams via AcBinary, data = null for requests) +5. AyCodeBinaryHubProtocol frames on wire (signalParams via AcBinary, data = null for requests) ``` ### Server → Client @@ -120,13 +124,13 @@ Typed access via methods (PostDataJson pattern): ``` OnReceiveMessage(tag, requestId, signalParams, data) ├─ Construct SignalResponseDataMessage in-memory (no envelope deser): -│ └─ { Status, DataSerializerType, ResponseData } from signalParams + data +│ └─ { Status, DataSerializerType, ResponseData (SignalData) } from signalParams + data ├─ Matching requestId in pending dict: │ ├─ Route: null→sync wait, Action→invoke, Func→await │ └─ GetResponseData(): dispatches on DataSerializerType -│ Binary→BinaryTo(), JsonGZip→Decompress→JsonTo() +│ Binary→Deserialize(Span), JsonGZip→Decompress→JsonTo() └─ No match (broadcast): - └─ abstract MessageReceived(tag, signalParams, data).Forget() + └─ abstract MessageReceived(tag, signalParams, SignalData data).Forget() ``` Request pooling: `SignalRRequestModel` via `SignalRRequestModelPool` (ObjectPool + IResettable). @@ -173,7 +177,9 @@ Type-guided deserialization — each parameter is individually serialized/deseri | Component | Path | |-----------|------| | Client base | `SignalRs/AcSignalRClientBase.cs` | -| Binary protocol | `SignalRs/AcBinaryHubProtocol.cs` | +| Binary protocol (base) | `SignalRs/AcBinaryHubProtocol.cs` | +| Binary protocol (derived) | `SignalRs/AyCodeBinaryHubProtocol.cs` | +| Signal data wrapper | `SignalRs/SignalData.cs` | | Tag attributes | `SignalRs/SignalMessageTagAttribute.cs` | | Base tags | `SignalRs/AcSignalRTags.cs` | | CRUD tags | `SignalRs/SignalRCrudTags.cs` | diff --git a/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md b/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md index 5ef6105..9867f5b 100644 --- a/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md +++ b/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md @@ -1,6 +1,6 @@ # SignalR Binary Protocol -`AcBinaryHubProtocol` — custom `IHubProtocol` (name: `"acbinary"`) replacing SignalR JSON+Base64 with `AcBinarySerializer`. +`AcBinaryHubProtocol` (unsealed base) — custom `IHubProtocol` (name: `"acbinary"`) replacing SignalR JSON+Base64 with `AcBinarySerializer`. `AyCodeBinaryHubProtocol` (derived) adds `ArrayPool`-backed `SignalData` creation via `CreateByteArrayResult` hook. > Architecture (tag system, dispatch, request/response): `SIGNALR.md` > Output writers (cached chunk, buffer states, chunk sizing): `AyCode.Core/docs/BINARY_WRITERS.md` @@ -84,7 +84,9 @@ When argument is `byte[]`, bypasses serializer: 2. INT32 prefix written with actual value (no patching) 3. `BinaryTypeCode.ByteArray(68)` + VarUInt length + raw bytes via BWO -Read side mirrors: if first byte is `ByteArray(0x44)`, deserializer bypassed → direct `SpanReader`. Detection is **wire-format only** (no targetType check) — ByteArray marker is unambiguous since no AcBinary object starts with 0x44 (version=1). +Read side mirrors: if first byte is `ByteArray(0x44)`, deserializer bypassed → direct `SpanReader` → `CreateByteArrayResult(span, targetType)`. Base returns `data.ToArray()`. `AyCodeBinaryHubProtocol` overrides: if `targetType == typeof(SignalData)`, rents from `ArrayPool` and returns `SignalData(rented, length, isRented: true)`. Detection is **wire-format only** (no targetType check for the marker) — ByteArray marker is unambiguous since no AcBinary object starts with 0x44 (version=1). + +Write side: `WriteArgument` handles both `byte[]` and `SignalData` via the same ByteArray wire format. `SignalData.Span` is written directly — same marker + VarUInt length + raw bytes. ## Read Path @@ -103,4 +105,4 @@ Read side mirrors: if first byte is `ByteArray(0x44)`, deserializer bypassed → | `Options` | `AcBinarySerializerOptions.Default` | Serializer options (volatile, runtime-replaceable) | | `BufferWriterChunkSize` | 65536 | Chunk size for both BWOs | -**Source:** `AyCode.Services/SignalRs/AcBinaryHubProtocol.cs` +**Source:** `AyCode.Services/SignalRs/AcBinaryHubProtocol.cs` (base), `AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs` (derived, ArrayPool) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ff2953..c57a867 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,7 +36,7 @@ AyCode.Services ← AyCode.Services.Server - **AyCode.Services.Server** — Server-side: SignalR hub with custom binary protocol, email (SendGrid), JWT auth. - **AyCode.Models.Server/DynamicMethods** — Reflection-based tag→method dispatch used by the SignalR hub. -> **SignalR Dispatch:** Both directions use a single method `OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)` with integer tag-based routing instead of standard Hub methods. See `AyCode.Services/docs/SIGNALR.md` for full details. +> **SignalR Dispatch:** Both directions use a single method `OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, SignalData data)` with integer tag-based routing instead of standard Hub methods. `SignalData` wraps `ArrayPool`-backed byte buffers for zero-alloc receive path. See `AyCode.Services/docs/SIGNALR.md` for full details. ### Server Extensions - **AyCode.Core.Server**, **AyCode.Interfaces.Server**, **AyCode.Entities.Server**, **AyCode.Models.Server** — Server-only additions that don't belong in shared code. diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 4f3230e..4157613 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -26,10 +26,10 @@ See `AyCode.Services/docs/SIGNALR.md` for full architecture documentation. -- **Single dispatch method** — all communication goes through `OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)`. Do not add new hub methods. +- **Single dispatch method** — all communication goes through `OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, SignalData data)`. Do not add new hub methods. - **Tag-based routing** — associate methods with integer tags via `[SignalR(tag)]` (server) or `[SignalRSendToClient(tag)]` (client). Tags must be unique across the entire system. - **CRUD bundles** — entities use `SignalRCrudTags(getAllTag, getItemTag, addTag, updateTag, removeTag)` with 5 independent tag integers. Tags must be unique across the system. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE.md`. -- **Binary protocol** — `AcBinaryHubProtocol` is the transport protocol. Responses use pure Binary serialization. +- **Binary protocol** — `AyCodeBinaryHubProtocol` (derived from `AcBinaryHubProtocol`) is the transport protocol. Uses `ArrayPool`-backed `SignalData` for response payload. Responses use pure Binary serialization. ### ⚠️ Temporary: JSON-in-Binary Request Parameters diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index d14f6d7..6d98133 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -70,18 +70,20 @@ For full architecture see `AyCode.Services/docs/SIGNALR.md`. | Term | Definition | |---|---| -| **OnReceiveMessage** | The single SignalR method for all communication. Signature: `(int messageTag, int? requestId, SignalParams signalParams, byte[] data)`. Metadata and payload are separate hub arguments — `byte[]` uses protocol zero-copy fast-path. `SignalParams.Parameters` carries packed method params as `byte[]`. `data` carries response payload. Both are independent and nullable in any direction. | +| **OnReceiveMessage** | The single SignalR method for all communication. Signature: `(int messageTag, int? requestId, SignalParams signalParams, SignalData data)`. Metadata and payload are separate hub arguments — `SignalData` uses protocol zero-copy fast-path with `ArrayPool` backing. `SignalParams.Parameters` carries packed method params as `byte[]`. `data` carries response payload. Both are independent and nullable in any direction. | | **SignalParams** | Metadata sent alongside message payload as separate hub argument. Contains `Status`, `DataSerializerType`, and `Parameters` (`byte[]?` — packed `byte[][]` as single blob, protocol fast-path). Typed access via `SetParameterValues(object[])` / `GetParameterValues(ParameterInfo[])` — PostDataJson pattern. `[AcBinarySerializable]`. Never null — only `Parameters` inside is nullable. | | **Message Tag** | Integer identifier mapping to a method via `[SignalR(tag)]` or `[SignalRSendToClient(tag)]` attributes. | | **DynamicMethodRegistry** | Resolves message tags to `MethodInfo` at runtime. Static `ConcurrentDictionary` cache with lazy scan on miss. | | **SignalRCrudTags** | Sealed class bundling 5 independent tag integers (getAllTag, getItemTag, addTag, updateTag, removeTag) for entity CRUD. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE.md`. | -| **AcBinaryHubProtocol** | Custom `IHubProtocol` replacing SignalR's JSON+Base64 with `AcBinarySerializer`. Protocol name: `"acbinary"`. Uses `BufferWriterBinaryOutput` for zero-copy writes to the SignalR pipe. | -| **SignalResponseDataMessage** | Internal DTO for callback routing (not serialized on wire). Constructed in-memory from `signalParams` + `data`. Supports Binary or JSON+GZip via `GetResponseData()`. | +| **AcBinaryHubProtocol** | Unsealed base `IHubProtocol` replacing SignalR's JSON+Base64 with `AcBinarySerializer`. Protocol name: `"acbinary"`. Uses `BufferWriterBinaryOutput` for zero-copy writes. `CreateByteArrayResult` virtual hook for derived protocols. | +| **AyCodeBinaryHubProtocol** | Derived protocol. Overrides `CreateByteArrayResult`: when `targetType == typeof(SignalData)`, rents from `ArrayPool` instead of `.ToArray()`. Register this in both client and server. | +| **SignalData** | `IDisposable` wrapper for `byte[]` response data with optional `ArrayPool` lifecycle. `Span` for zero-copy read access, `ToArray()` for copy (rare). `Dispose()` returns rented buffer with `clearArray: true`. Created by `AyCodeBinaryHubProtocol` (pooled) or directly from `byte[]` (server send, non-pooled). | +| **SignalResponseDataMessage** | Internal DTO for callback routing (not serialized on wire). Constructed in-memory from `signalParams` + `SignalData`. `ResponseData` is `SignalData?`. `GetResponseData()` deserializes from `Span`. `Dispose()` returns both SignalData and JSON decompression buffers to ArrayPool. | | **SignalPostJsonDataMessage** | OBSOLETE — removed. Legacy: serialized params to JSON inside Binary envelope. | | **AcSignalRDataSource** | Generic real-time `IList` with change tracking, CRUD via SignalRCrudTags, binary merge, rollback, sync state. | | **TrackingItem** | Wraps a modified DataSource item with `TrackingState` (Add/Update/Remove) + `OriginalValue` for rollback. | | **SendToClientType** | Enum controlling broadcast scope: None, Others, Caller, All. | -| **AcWebSignalRHubBase** | Abstract server hub. Receives `OnReceiveMessage`, dispatches via DynamicMethodRegistry, responds/broadcasts via `SendMessageToClient` (metadata + payload as separate args, no envelope). | +| **AcWebSignalRHubBase** | Abstract server hub. Receives `OnReceiveMessage` (with `SignalData`), dispatches via DynamicMethodRegistry, responds/broadcasts via `SendMessageToClient` (metadata + `SignalData` payload as separate args, no envelope). | | **AcSignalRClientBase** | Abstract client. Manages `HubConnection`, request/response correlation via `requestId`, pooled `SignalRRequestModel`. | | **AcSessionService** | `ConcurrentDictionary`-based session tracker for connected SignalR clients. | | **AcSignalRSendToClientService** | Server-push service: `SendMessageToAllClients`, `SendMessageToConnection`, `SendMessageToUser`. |