diff --git a/AyCode.Core.Serializers.Console/BenchmarkTestDataProvider.cs b/AyCode.Core.Tests/TestModels/BenchmarkTestDataProvider.cs similarity index 84% rename from AyCode.Core.Serializers.Console/BenchmarkTestDataProvider.cs rename to AyCode.Core.Tests/TestModels/BenchmarkTestDataProvider.cs index 1697752..98c0a72 100644 --- a/AyCode.Core.Serializers.Console/BenchmarkTestDataProvider.cs +++ b/AyCode.Core.Tests/TestModels/BenchmarkTestDataProvider.cs @@ -1,23 +1,22 @@ using AyCode.Core.Serializers.Binaries; -using AyCode.Core.Tests.TestModels; -namespace AyCode.Core.Serializers.Console; +namespace AyCode.Core.Tests.TestModels; -internal static class BenchmarkTestDataProvider +public static class BenchmarkTestDataProvider { - internal static List CreateTestDataSets() + public static List CreateTestDataSets(bool resetId = true) { return new List { - CreateSmallTestData(), - CreateMediumTestData(), - CreateLargeTestData(), - CreateRepeatedStringsTestData(), - CreateDeepNestedTestData() + CreateSmallTestData(resetId), + CreateMediumTestData(resetId), + CreateLargeTestData(resetId), + CreateRepeatedStringsTestData(resetId), + CreateDeepNestedTestData(resetId) }; } - internal static TestOrder CreateProfilerOrder() + public static TestOrder CreateProfilerOrder() { TestDataFactory.ResetIdCounter(); var sharedTag = TestDataFactory.CreateTag("SharedTag"); @@ -31,9 +30,9 @@ internal static class BenchmarkTestDataProvider sharedUser: sharedUser); } - private static TestDataSet CreateSmallTestData() + private static TestDataSet CreateSmallTestData(bool resetId = true) { - TestDataFactory.ResetIdCounter(); + if (resetId) TestDataFactory.ResetIdCounter(); var sharedTag = TestDataFactory.CreateTag("SharedTag"); var sharedUser = TestDataFactory.CreateUser("shareduser"); @@ -51,9 +50,9 @@ internal static class BenchmarkTestDataProvider return new TestDataSet("Small (2x2x2x2)", order, iidRefPercent: 10); } - private static TestDataSet CreateMediumTestData() + private static TestDataSet CreateMediumTestData(bool resetId = true) { - TestDataFactory.ResetIdCounter(); + if (resetId) TestDataFactory.ResetIdCounter(); var sharedTag = TestDataFactory.CreateTag("SharedTag"); var sharedUser = TestDataFactory.CreateUser("shareduser"); @@ -83,9 +82,9 @@ internal static class BenchmarkTestDataProvider return new TestDataSet("Medium (3x3x3x4)", order, iidRefPercent: 10); } - private static TestDataSet CreateLargeTestData() + private static TestDataSet CreateLargeTestData(bool resetId = true) { - TestDataFactory.ResetIdCounter(); + if (resetId) TestDataFactory.ResetIdCounter(); var sharedTag = TestDataFactory.CreateTag("SharedTag"); var sharedUser = TestDataFactory.CreateUser("shareduser"); @@ -113,9 +112,9 @@ internal static class BenchmarkTestDataProvider return new TestDataSet("Large (5x5x5x10)", order, iidRefPercent: 10); } - private static TestDataSet CreateRepeatedStringsTestData() + private static TestDataSet CreateRepeatedStringsTestData(bool resetId = true) { - TestDataFactory.ResetIdCounter(); + if (resetId) TestDataFactory.ResetIdCounter(); var sharedTag = TestDataFactory.CreateTag("RepeatedTag"); var sharedUser = TestDataFactory.CreateUser("repeateduser"); @@ -149,9 +148,9 @@ internal static class BenchmarkTestDataProvider return new TestDataSet("Repeated Strings (10 items)", order, iidRefPercent: 10); } - private static TestDataSet CreateDeepNestedTestData() + private static TestDataSet CreateDeepNestedTestData(bool resetId = true) { - TestDataFactory.ResetIdCounter(); + if (resetId) TestDataFactory.ResetIdCounter(); var sharedTag = TestDataFactory.CreateTag("DeepTag"); var sharedUser = TestDataFactory.CreateUser("deepuser"); @@ -207,7 +206,7 @@ internal static class BenchmarkTestDataProvider } } -internal sealed class TestDataSet +public sealed class TestDataSet { public string Name { get; } public TestOrder Order { get; } diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs index 71ead3a..ce49fd2 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; @@ -140,10 +141,21 @@ public static partial class AcBinaryDeserializer var flags = ints[3]; var isNegative = (flags & unchecked((int)0x80000000)) != 0; var scale = (byte)((flags >> 16) & 0x7F); + LogDecimalDrift(scale); _position += 16; return new decimal(lo, mid, hi, isNegative, scale); } + [Conditional("DEBUG")] + private void LogDecimalDrift(byte scale) + { + if (scale <= 28) return; + var hex = BitConverter.ToString(_buffer, _position, Math.Min(16, _bufferLength - _position)); + throw new AcBinaryDeserializationException( + $"[DECIMAL_DRIFT] scale={scale}, pos={_position}, bufLen={_bufferLength}, " + + $"bufArray={_buffer.Length}, hex={hex}", _position); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public DateTime ReadDateTimeUnsafe() { @@ -444,7 +456,17 @@ public static partial class AcBinaryDeserializer { if (!Input.TryAdvanceSegment(ref _buffer, ref _position, ref _bufferLength, length)) throw new AcBinaryDeserializationException("Unexpected end of binary payload.", _position); + AssertGuarantee(length); } } + + [Conditional("DEBUG")] + private void AssertGuarantee(int needed) + { + if (_bufferLength - _position < needed) + throw new AcBinaryDeserializationException( + $"[GUARANTEE_VIOLATED] TryAdvanceSegment returned true but available={_bufferLength - _position} < needed={needed}, " + + $"pos={_position}, bufLen={_bufferLength}, bufArray={_buffer.Length}", _position); + } } } diff --git a/AyCode.Core/Serializers/Binaries/SequenceBinaryInput.cs b/AyCode.Core/Serializers/Binaries/SequenceBinaryInput.cs index 229578b..5b79e70 100644 --- a/AyCode.Core/Serializers/Binaries/SequenceBinaryInput.cs +++ b/AyCode.Core/Serializers/Binaries/SequenceBinaryInput.cs @@ -82,13 +82,18 @@ public struct SequenceBinaryInput : IBinaryInputBase var remaining = bufferLength - position; if (remaining > 0 && remaining < needed) - { - // Cross-boundary: value spans segment boundary return TryReadCrossBoundary(ref buffer, ref position, ref bufferLength, needed, remaining); - } // Current segment fully consumed — advance to next - return TryLoadNextSegment(ref buffer, ref position, ref bufferLength); + if (!TryLoadNextSegment(ref buffer, ref position, ref bufferLength)) + return false; + + // Loaded segment smaller than needed — cross-boundary into subsequent segments + remaining = bufferLength - position; + if (remaining < needed) + return TryReadCrossBoundary(ref buffer, ref position, ref bufferLength, needed, remaining); + + return true; } /// diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs index b97f595..1ba14db 100644 --- a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs @@ -1046,6 +1046,30 @@ public abstract class SignalRClientToHubTestBase } #endregion + + #region Large Dataset Tests + + [TestMethod] + public async Task RoundTrip_LargeOrderList_PreservesAllData() + { + TestDataFactory.ResetIdCounter(); + var dataSets = BenchmarkTestDataProvider.CreateTestDataSets(resetId: false); + var orders = dataSets.Select(ds => ds.Order).ToList(); + + var result = await _client.PostDataAsync, List>( + TestSignalRTags.TestOrderListParam, orders); + + Assert.IsNotNull(result); + Assert.AreEqual(orders.Count, result.Count); + for (int i = 0; i < orders.Count; i++) + { + Assert.AreEqual(orders[i].Id, result[i].Id); + Assert.AreEqual(orders[i].OrderNumber, result[i].OrderNumber); + Assert.AreEqual(orders[i].Items.Count, result[i].Items.Count); + } + } + + #endregion } /// diff --git a/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs b/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs index 4840708..c9d3aec 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Diagnostics.CodeAnalysis; +using System.IO.Pipelines; using AyCode.Services.SignalRs; using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR.Protocol; @@ -7,32 +8,60 @@ using Microsoft.AspNetCore.SignalR.Protocol; namespace AyCode.Services.Server.Tests.SignalRs; /// -/// Test protocol that forces multi-segment ReadOnlySequence parsing. -/// Splits serialized bytes into chunks before calling base.TryParseMessage, -/// exercising SequenceBinaryInput cross-boundary reads and SequenceToByteArray multi-segment paths. +/// Test protocol that simulates production Kestrel pipe behavior. +/// +/// Write side: uses Pipe (not ArrayBufferWriter) so GetSpan/GetMemory return stable slab segments +/// — matching Kestrel's memory pool behavior. This ensures Span back-patching for length prefixes works. +/// +/// Read side: splits the serialized bytes into 256-byte segments before parsing, +/// exercising SequenceBinaryInput cross-boundary reads at every boundary. /// internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol { - private const int SegmentSize = 4096; + private const int SegmentSize = 256; + /// + /// Serialize via Pipe (production-like stable memory blocks) instead of ArrayBufferWriter. + /// + public new ReadOnlyMemory GetMessageBytes(HubMessage message) + { + var pipe = new Pipe(); + WriteMessage(message, pipe.Writer); + pipe.Writer.Complete(); + pipe.Reader.TryRead(out var result); + var bytes = result.Buffer.ToArray(); + pipe.Reader.Complete(); + return bytes; + } + + /// + /// Split input into 256-byte segments before parsing — forces multi-segment ReadOnlySequence + /// through SequenceBinaryInput, exercising cross-boundary reads on every test. + /// public override bool TryParseMessage(ref ReadOnlySequence input, IInvocationBinder binder, [NotNullWhen(true)] out HubMessage? message) { - // Temporarily bypass multi-segment to isolate the issue - return base.TryParseMessage(ref input, binder, out message); + var multiSegment = CreateMultiSegmentSequence(input, SegmentSize); + return base.TryParseMessage(ref multiSegment, binder, out message); } private static ReadOnlySequence CreateMultiSegmentSequence(ReadOnlySequence source, int chunkSize) { var bytes = source.ToArray(); - var first = new MemorySegment(bytes.AsMemory(0, Math.Min(chunkSize, bytes.Length))); + // Each segment gets its own byte[] — matching Kestrel pool slab behavior + // where each pipe segment is a separate memory block. + var firstChunk = new byte[Math.Min(chunkSize, bytes.Length)]; + Buffer.BlockCopy(bytes, 0, firstChunk, 0, firstChunk.Length); + var first = new MemorySegment(firstChunk); var current = first; for (var offset = chunkSize; offset < bytes.Length; offset += chunkSize) { var length = Math.Min(chunkSize, bytes.Length - offset); - current = current.Append(bytes.AsMemory(offset, length)); + var chunk = new byte[length]; + Buffer.BlockCopy(bytes, offset, chunk, 0, length); + current = current.Append(chunk); } return new ReadOnlySequence(first, 0, current, current.Memory.Length); diff --git a/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs b/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs index 2626655..18e0058 100644 --- a/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs +++ b/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs @@ -261,6 +261,16 @@ public class AcBinaryHubProtocol : IHubProtocol [Conditional("DEBUG")] private static void LogDiagnostic(string message) => DiagnosticLogger?.Invoke(message); + [Conditional("DEBUG")] + private static void LogReadSingleArgument(ReadOnlySequence argSlice, int argLength, Type targetType) + { + if (DiagnosticLogger == null) return; + var segmentCount = 0; + foreach (var _ in argSlice) + segmentCount++; + DiagnosticLogger($"[AcBinaryHubProtocol] ReadSingleArgument: argLength={argLength}, isSingleSegment={argSlice.IsSingleSegment}, segments={segmentCount}, type={targetType.Name}"); + } + [Conditional("DEBUG")] private static void LogParseInvocation(string target, IReadOnlyList paramTypes, long remaining) { @@ -455,6 +465,8 @@ public class AcBinaryHubProtocol : IHubProtocol var argSlice = r.UnreadSequence.Slice(0, argLength); r.Advance(argLength); + LogReadSingleArgument(argSlice, argLength, targetType); + // byte[] fast-path: first byte is BinaryTypeCode.ByteArray tag → // strip tag + VarUInt length prefix, return raw payload. No deserializer. var argReader = new SequenceReader(argSlice); diff --git a/AyCode.Services/SignalRs/AcSignalRClientBase.cs b/AyCode.Services/SignalRs/AcSignalRClientBase.cs index 7bf0489..abe2ac8 100644 --- a/AyCode.Services/SignalRs/AcSignalRClientBase.cs +++ b/AyCode.Services/SignalRs/AcSignalRClientBase.cs @@ -70,6 +70,7 @@ namespace AyCode.Services.SignalRs if (useAcBinaryProtocol) { hubBuilder.Services.AddSingleton(); + AcBinaryHubProtocol.DiagnosticLogger = msg => Logger.Debug(msg); } HubConnection = hubBuilder.Build();