From cfc18d9c8e8b20009f27cc01f38003d1faf7dfbd Mon Sep 17 00:00:00 2001 From: Loretta Date: Wed, 8 Apr 2026 08:25:48 +0200 Subject: [PATCH] Simulate Kestrel slab transport for SignalR BWO tests Add a production-faithful test harness for SignalR binary protocol, introducing SlabTransportWriter to simulate Kestrel's slab allocator and always force the BufferWriterOutput owned-buffer path. Add large-payload round-trip tests (including non-ASCII cases) to catch position drift and data corruption bugs. Enhance protocol tests to validate multi-segment output and byte-for-byte correctness. All protocol round-trips now exercise the multi-segment, non-array-backed buffer path. --- .../SignalRs/SignalRClientToHubTest.cs | 119 ++++++++++ .../SignalRs/SlabTransportWriter.cs | 215 ++++++++++++++++++ .../SignalRs/TestMultiSegmentProtocol.cs | 143 ++++++++++-- .../SignalRs/TestableSignalRClient2.cs | 2 +- .../SignalRs/TestableSignalRHub2.cs | 2 +- 5 files changed, 460 insertions(+), 21 deletions(-) create mode 100644 AyCode.Services.Server.Tests/SignalRs/SlabTransportWriter.cs diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs index 1ba14db..86e804b 100644 --- a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs @@ -1069,6 +1069,125 @@ public abstract class SignalRClientToHubTestBase } } + /// + /// 250KB+ payload with ASCII-only strings. + /// Validates BWO owned-buffer path with many Grow cycles. + /// + [TestMethod] + public async Task RoundTrip_VeryLargeOrderList_250KB_PreservesAllData() + { + TestDataFactory.ResetIdCounter(); + var orders = new List(); + for (var i = 0; i < 100; i++) + { + var tag = TestDataFactory.CreateTag($"Tag_{i}"); + var user = TestDataFactory.CreateUser($"user_{i}"); + orders.Add(TestDataFactory.CreateOrder( + itemCount: 5, + palletsPerItem: 3, + measurementsPerPallet: 3, + pointsPerMeasurement: 5, + sharedTag: tag, + sharedUser: user)); + } + + var result = await _client.PostDataAsync, List>( + TestSignalRTags.TestOrderListParam, orders); + + Assert.IsNotNull(result, "Deserialization returned null — likely BWO GetTotalPosition drift on large payload"); + Assert.AreEqual(orders.Count, result.Count, "Order count mismatch"); + for (var i = 0; i < orders.Count; i++) + { + Assert.AreEqual(orders[i].Id, result[i].Id, $"Order[{i}].Id mismatch"); + Assert.AreEqual(orders[i].Items.Count, result[i].Items.Count, $"Order[{i}].Items.Count mismatch"); + } + } + + /// + /// Large payload with non-ASCII (Hungarian) strings — targets the WriteStringUtf8 + /// savedPosition bug that causes BWO GetTotalPosition drift when a non-ASCII string + /// straddles a chunk boundary and Grow resets _position. + /// + /// Production evidence: Array=44111, BWO_pipe=56340, BWO_temp=44111 + /// The bug only manifests when TryGetArray fails (MemoryManager-backed Memory) + /// AND non-ASCII strings trigger the UTF-8 fallback with savedPosition rewind. + /// + [TestMethod] + public async Task RoundTrip_LargeOrderList_NonAsciiStrings_PreservesAllData() + { + // Non-ASCII strings that trigger UTF-8 fallback in WriteStringUtf8 + var hungarianNames = new[] + { + "Kürtőskalács Értékesítő Kft.", + "Széchenyi István Tér", + "Büfé és Étkezde Zrt.", + "Különleges Árú Raktár", + "Möbius Szállítmányozás", + "Tündérkert Óvoda és Bölcsőde", + "Győri Főpályaudvar", + "Hősök Tere Múzeum", + "Péterfy Sándor Utcai Kórház", + "Közgondnokság Ügyfélfogadó" + }; + + TestDataFactory.ResetIdCounter(); + var orders = new List(); + for (var i = 0; i < 80; i++) + { + var tag = TestDataFactory.CreateTag($"Cég_{i}_{hungarianNames[i % hungarianNames.Length]}"); + var user = TestDataFactory.CreateUser($"felhasználó_{i}"); + user.FirstName = hungarianNames[i % hungarianNames.Length]; + user.LastName = hungarianNames[(i + 3) % hungarianNames.Length]; + user.Email = $"felhasználó_{i}@székesfehérvár.hu"; + user.Username = $"ügyfél_{hungarianNames[(i + 5) % hungarianNames.Length]}"; + + var order = TestDataFactory.CreateOrder( + itemCount: 4, + palletsPerItem: 3, + measurementsPerPallet: 3, + pointsPerMeasurement: 4, + sharedTag: tag, + sharedUser: user); + + // Inject non-ASCII product names to maximize chunk-boundary collisions + foreach (var item in order.Items) + { + item.ProductName = $"Termék_{hungarianNames[(i + item.Id) % hungarianNames.Length]}_{i}"; + } + + order.OrderNumber = $"RND-{i:D4}-{hungarianNames[i % hungarianNames.Length]}"; + orders.Add(order); + } + + var result = await _client.PostDataAsync, List>( + TestSignalRTags.TestOrderListParam, orders); + + Assert.IsNotNull(result, + "Deserialization returned null — BWO savedPosition drift on non-ASCII strings at chunk boundary"); + Assert.AreEqual(orders.Count, result.Count, "Order count mismatch"); + for (var i = 0; i < orders.Count; i++) + { + Assert.AreEqual(orders[i].Id, result[i].Id, $"Order[{i}].Id mismatch"); + Assert.AreEqual(orders[i].OrderNumber, result[i].OrderNumber, $"Order[{i}].OrderNumber mismatch"); + Assert.AreEqual(orders[i].Items.Count, result[i].Items.Count, $"Order[{i}].Items.Count mismatch"); + for (var j = 0; j < orders[i].Items.Count; j++) + { + Assert.AreEqual(orders[i].Items[j].ProductName, result[i].Items[j].ProductName, + $"Order[{i}].Items[{j}].ProductName mismatch — non-ASCII string corrupted"); + } + } + } + + /// + /// Sanity check: SlabTransportWriter.GetMemory returns MemoryManager-backed Memory + /// so TryGetArray fails, forcing BWO into the owned-buffer code path. + /// + [TestMethod] + public void SlabTransportWriter_GetMemory_TryGetArrayFails() + { + SlabTransportWriter.VerifyMemoryManagerBacked(); + } + #endregion } diff --git a/AyCode.Services.Server.Tests/SignalRs/SlabTransportWriter.cs b/AyCode.Services.Server.Tests/SignalRs/SlabTransportWriter.cs new file mode 100644 index 0000000..f7a85c6 --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/SlabTransportWriter.cs @@ -0,0 +1,215 @@ +using System.Buffers; +using System.Runtime.InteropServices; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Custom IBufferWriter that simulates Kestrel's PinnedBlockMemoryPool transport behavior: +/// +/// Production: Kestrel PipeWriter → PinnedBlockMemoryPool → MemoryManager-backed Memory +/// → TryGetArray FAILS → BWO takes owned-buffer fallback path +/// +/// Test: SlabTransportWriter → SlabMemoryManager-backed Memory +/// → TryGetArray FAILS → BWO takes owned-buffer fallback path (same as production) +/// +/// Key behaviors: +/// - GetMemory returns MemoryManager-backed Memory (TryGetArray always fails) +/// - Fixed-size slab segments with random offsets (simulates slab allocator) +/// - After Advance, remaining slab space is reused (offset grows) +/// - GetMemory may return less than sizeHint (remaining slab space) +/// - Deterministic via seeded Random for reproducible tests +/// +internal sealed class SlabTransportWriter : IBufferWriter +{ + private readonly int _slabSize; + private readonly Random _rng; + + // Committed data tracking + private readonly List _segments = new(); + private int _totalCommitted; + + // Current slab state + private byte[]? _currentSlab; + private int _writePos; // current write position within slab + private int _slabEnd; // end of usable area in slab + + public SlabTransportWriter(int slabSize = 256, int seed = 42) + { + _slabSize = slabSize; + _rng = new Random(seed); + } + + /// Total bytes committed via Advance. + public int WrittenCount => _totalCommitted; + + /// Number of slab segments allocated. + public int SlabCount { get; private set; } + + public void Advance(int count) + { + if (_currentSlab == null) + throw new InvalidOperationException("Call GetMemory/GetSpan before Advance"); + if (count < 0 || _writePos + count > _slabEnd) + throw new InvalidOperationException( + $"Advance({count}) invalid: writePos={_writePos}, slabEnd={_slabEnd}, remaining={_slabEnd - _writePos}"); + + _segments.Add(new CommittedSegment(_currentSlab, _writePos, count)); + _writePos += count; + _totalCommitted += count; + } + + /// + /// Returns MemoryManager-backed Memory so that TryGetArray ALWAYS fails. + /// May return fewer bytes than sizeHint (remaining slab space) — legal per IBufferWriter contract. + /// This forces BWO to rent from ArrayPool (owned-buffer path), matching production behavior. + /// + public Memory GetMemory(int sizeHint = 0) + { + sizeHint = Math.Max(1, sizeHint); + var remaining = _currentSlab != null ? _slabEnd - _writePos : 0; + + if (remaining <= 0) + { + // Allocate new slab — at least sizeHint to avoid starving the caller + AllocateNewSlab(Math.Max(sizeHint, _slabSize)); + remaining = _slabEnd - _writePos; + } + + // Return MemoryManager-backed Memory: TryGetArray will fail + return new SlabMemoryManager(_currentSlab!, _writePos, remaining).Memory; + } + + /// + /// Returns Span with at least sizeHint bytes. + /// Used by FlushOwnedBuffer: _writer.GetSpan(bytesInChunk) must be large enough for CopyTo. + /// Allocates new slab if remaining space is insufficient. + /// + public Span GetSpan(int sizeHint = 0) + { + sizeHint = Math.Max(1, sizeHint); + var remaining = _currentSlab != null ? _slabEnd - _writePos : 0; + + if (remaining < sizeHint) + { + AllocateNewSlab(Math.Max(sizeHint, _slabSize)); + remaining = _slabEnd - _writePos; + } + + return _currentSlab.AsSpan(_writePos, remaining); + } + + private void AllocateNewSlab(int minSize) + { + // Random offset within slab — simulates Kestrel slab allocator non-zero offsets + var offset = _rng.Next(0, Math.Max(1, _slabSize / 4)); + // ±12% size jitter for variety + var jitter = _rng.Next(-_slabSize / 8, _slabSize / 8 + 1); + var actualSize = Math.Max(minSize, _slabSize + jitter); + _currentSlab = new byte[actualSize + offset]; + _writePos = offset; + _slabEnd = offset + actualSize; + SlabCount++; + } + + /// + /// Get all committed bytes as a contiguous array. + /// + public byte[] ToArray() + { + var result = new byte[_totalCommitted]; + var pos = 0; + foreach (var seg in _segments) + { + Buffer.BlockCopy(seg.Array, seg.Offset, result, pos, seg.Length); + pos += seg.Length; + } + return result; + } + + /// + /// Build a multi-segment ReadOnlySequence from committed data, splitting at slab boundaries. + /// Each committed segment becomes a separate ReadOnlySequence segment. + /// + public ReadOnlySequence ToReadOnlySequence() + { + if (_segments.Count == 0) + return ReadOnlySequence.Empty; + + if (_segments.Count == 1) + { + var seg = _segments[0]; + return new ReadOnlySequence(seg.Array, seg.Offset, seg.Length); + } + + // Build linked segment list + var first = new MemorySegment(new ReadOnlyMemory(_segments[0].Array, _segments[0].Offset, _segments[0].Length)); + var current = first; + for (var i = 1; i < _segments.Count; i++) + { + var seg = _segments[i]; + current = current.Append(new ReadOnlyMemory(seg.Array, seg.Offset, seg.Length)); + } + + return new ReadOnlySequence(first, 0, current, current.Memory.Length); + } + + /// + /// Verify that TryGetArray fails on our Memory (sanity check for test correctness). + /// + public static void VerifyMemoryManagerBacked() + { + var writer = new SlabTransportWriter(64); + var mem = writer.GetMemory(16); + if (MemoryMarshal.TryGetArray(mem, out ArraySegment _)) + throw new InvalidOperationException( + "SlabTransportWriter.GetMemory returned array-backed Memory — TryGetArray should fail!"); + } + + private record struct CommittedSegment(byte[] Array, int Offset, int Length); + + /// + /// MemoryManager that wraps an array region but returns Memory where TryGetArray fails. + /// This is the key difference from production Kestrel PinnedBlockMemoryPool. + /// + private sealed class SlabMemoryManager : MemoryManager + { + private readonly byte[] _array; + private readonly int _offset; + private readonly int _length; + + public SlabMemoryManager(byte[] array, int offset, int length) + { + _array = array; + _offset = offset; + _length = length; + } + + public override Span GetSpan() => _array.AsSpan(_offset, _length); + + public override MemoryHandle Pin(int elementIndex = 0) + => throw new NotSupportedException("SlabMemoryManager does not support pinning"); + + public override void Unpin() + => throw new NotSupportedException("SlabMemoryManager does not support pinning"); + + protected override void Dispose(bool disposing) { } + } + + /// + /// ReadOnlySequenceSegment for building multi-segment sequences from committed data. + /// + private sealed class MemorySegment : ReadOnlySequenceSegment + { + public MemorySegment(ReadOnlyMemory memory) + { + Memory = memory; + } + + public MemorySegment Append(ReadOnlyMemory memory) + { + var next = new MemorySegment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = next; + return next; + } + } +} diff --git a/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs b/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs index 446e507..e0dc280 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs @@ -8,13 +8,19 @@ using Microsoft.AspNetCore.SignalR.Protocol; namespace AyCode.Services.Server.Tests.SignalRs; /// -/// Test protocol that simulates production Kestrel pipe behavior with 256-byte segments. +/// Test protocol that simulates production Kestrel transport behavior: /// -/// Production: SignalR → WriteMessage(PipeWriter) → Kestrel slab 4096-byte segments → PipeReader → TryParseMessage -/// Test: SignalR → WriteMessage(PipeWriter) → FixedSizePool 256-byte segments → PipeReader → TryParseMessage +/// WRITE SIDE (GetMessageBytesMultiSegment): +/// WriteMessage → SlabTransportWriter (MemoryManager-backed, TryGetArray fails) +/// → BWO takes owned-buffer fallback path (same as production Kestrel PipeWriter) +/// → Automatic byte-by-byte comparison against ArrayBufferWriter reference /// -/// Both sides go through a real Pipe with fixed-size memory segments, -/// exercising BWO chunk writes and SequenceBinaryInput cross-boundary reads. +/// READ SIDE (TryParseMessage): +/// Bytes → Pipe(SlabSimulatingPool, 256B segments) → multi-segment ReadOnlySequence +/// → SequenceBinaryInput cross-boundary reads (same as production Kestrel PipeReader) +/// +/// Production: SignalR → WriteMessage(PipeWriter/Kestrel) → 4096B slab segments → TryParseMessage +/// Test: SignalR → WriteMessage(SlabTransportWriter) → 256B slab segments → TryParseMessage /// internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol { @@ -26,23 +32,35 @@ internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol } /// - /// Write side: WriteMessage → PipeWriter backed by 256-byte pool segments. - /// Same code path as production, just smaller segments. + /// Write side: WriteMessage → SlabTransportWriter (MemoryManager-backed Memory). + /// Forces BWO into owned-buffer fallback path — same code path as production Kestrel. + /// Validates output against ArrayBufferWriter reference on every call. /// - public new ReadOnlyMemory GetMessageBytes(HubMessage message) + public ReadOnlyMemory GetMessageBytesMultiSegment(HubMessage message) { - var pipe = new Pipe(new PipeOptions(pool: new SlabSimulatingPool(SegmentSize))); - WriteMessage(message, pipe.Writer); - pipe.Writer.Complete(); - pipe.Reader.TryRead(out var result); - var bytes = result.Buffer.ToArray(); - pipe.Reader.Complete(); - return bytes; + // ── Transport-double path (production simulation) ────────────────── + var transport = new SlabTransportWriter(SegmentSize); + WriteMessage(message, transport); + var transportBytes = transport.ToArray(); + + // ── Reference path: ArrayBufferWriter with large capacity (no resize → lengthSpan stays valid) ── + // NOTE: base.GetMessageBytes uses capacity = chunkSize + 4 = 260, which causes resize + // on any non-trivial message → invalidates the back-patched lengthSpan. + // We use a large capacity to ensure no resize ever happens. + var referenceCapacity = Math.Max(transportBytes.Length + 256, 65536); + var reference = new ArrayBufferWriter(referenceCapacity); + WriteMessage(message, reference); + var referenceBytes = reference.WrittenSpan; + + // ── Validate: transport output must match reference byte-for-byte ── + ValidateAgainstReference(transportBytes, referenceBytes); + + return transportBytes; } /// - /// Read side: fill PipeWriter 256 bytes at a time → PipeReader gives multi-segment sequence. - /// Same as production Kestrel PipeReader delivering 4096-byte segments. + /// Read side: fill Pipe with 256-byte slab segments → multi-segment ReadOnlySequence. + /// Same as production Kestrel PipeReader delivering slab-sized segments. /// public override bool TryParseMessage(ref ReadOnlySequence input, IInvocationBinder binder, [NotNullWhen(true)] out HubMessage? message) @@ -65,11 +83,98 @@ internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol writer.Complete(); pipe.Reader.TryRead(out var result); var seq = result.Buffer; + + // Assert multi-segment: if payload > SegmentSize, sequence must NOT be single-segment + if (bytes.Length > SegmentSize) + { + AssertMultiSegment(seq, bytes.Length); + } + var success = base.TryParseMessage(ref seq, binder, out message); pipe.Reader.Complete(); return success; } + #region Validation + + /// + /// Byte-by-byte comparison of transport-double output vs ArrayBufferWriter reference. + /// Catches any BWO owned-buffer path bug (position drift, data corruption, length mismatch). + /// + private static void ValidateAgainstReference(byte[] transportBytes, ReadOnlySpan referenceBytes) + { + var refArray = referenceBytes.ToArray(); + + // ── Total byte count ── + if (transportBytes.Length != refArray.Length) + { + // Also check outer length prefix for diagnostics + var transportPrefix = transportBytes.Length >= 4 ? BitConverter.ToInt32(transportBytes, 0) : -1; + var referencePrefix = refArray.Length >= 4 ? BitConverter.ToInt32(refArray, 0) : -1; + + throw new InvalidOperationException( + $"[TRANSPORT_DOUBLE] Total byte count mismatch: transport={transportBytes.Length}, reference={refArray.Length}. " + + $"Diff={transportBytes.Length - refArray.Length} bytes. " + + $"Outer length prefix: transport={transportPrefix}, reference={referencePrefix}. " + + $"BWO GetTotalPosition drift detected."); + } + + // ── Outer length prefix check (first 4 bytes) ── + if (transportBytes.Length >= 4) + { + var transportPrefix = BitConverter.ToInt32(transportBytes, 0); + var referencePrefix = BitConverter.ToInt32(refArray, 0); + if (transportPrefix != referencePrefix) + { + throw new InvalidOperationException( + $"[TRANSPORT_DOUBLE] Outer length prefix mismatch: transport={transportPrefix}, reference={referencePrefix}. " + + $"Total bytes match ({transportBytes.Length}) but prefix differs. " + + $"BWO owned-buffer path likely has position drift."); + } + } + + // ── Byte-by-byte content ── + var minLen = Math.Min(transportBytes.Length, refArray.Length); + for (int i = 0; i < minLen; i++) + { + if (transportBytes[i] != refArray[i]) + { + var start = Math.Max(0, i - 8); + var end = Math.Min(minLen, i + 16); + var refHex = Convert.ToHexString(refArray.AsSpan(start, end - start)); + var trnHex = Convert.ToHexString(transportBytes.AsSpan(start, end - start)); + throw new InvalidOperationException( + $"[TRANSPORT_DOUBLE] Content mismatch at byte {i}/{minLen}: " + + $"ref={refHex} transport={trnHex}. " + + $"BWO owned-buffer path writing corrupt data."); + } + } + } + + private static void AssertMultiSegment(ReadOnlySequence seq, int totalLength) + { + if (seq.IsSingleSegment) + { + throw new InvalidOperationException( + $"[MULTI_SEGMENT] Expected multi-segment sequence for {totalLength} bytes " + + $"(> {SegmentSize}B segment size), but got single segment. " + + $"SlabSimulatingPool or Pipe configuration is wrong."); + } + + // Count segments + var segmentCount = 0; + foreach (var _ in seq) + segmentCount++; + + if (segmentCount < 2) + { + throw new InvalidOperationException( + $"[MULTI_SEGMENT] Expected >= 2 segments for {totalLength} bytes, got {segmentCount}."); + } + } + + #endregion + /// /// MemoryPool that returns -byte blocks at random offsets /// within a larger backing array — simulating Kestrel's slab allocator where segments @@ -85,8 +190,8 @@ internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol { var size = Math.Max(minBufferSize, segmentSize); var offset = _rng.Next(0, segmentSize); // random slab offset - var jitter = _rng.Next(-1, 2); // -1, 0, or +1 - var actualSize = Math.Max(1, size + jitter); // random segment size variance + var jitter = _rng.Next(-segmentSize / 4, segmentSize / 4 + 1); // ±25% size variance + var actualSize = Math.Max(16, size + jitter); var array = new byte[actualSize + offset]; return new Owner(array, offset, actualSize); } diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs index c6fb215..d0264e0 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs @@ -65,7 +65,7 @@ public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServ nameof(IAcSignalRHubClient.OnReceiveMessage), [messageTag, requestId, signalParams, data ?? Array.Empty()]); - var bytes = _protocol.GetMessageBytes(invocation); + var bytes = _protocol.GetMessageBytesMultiSegment(invocation); var sequence = new ReadOnlySequence(bytes); if (!_protocol.TryParseMessage(ref sequence, _binder, out var parsed) || parsed is not InvocationMessage invMsg) throw new InvalidOperationException( diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs index db0809e..04482e6 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs @@ -122,7 +122,7 @@ public class TestableSignalRHub2 : AcWebSignalRHubBase()]); - var bytes = _protocol.GetMessageBytes(invocation); + var bytes = _protocol.GetMessageBytesMultiSegment(invocation); var sequence = new ReadOnlySequence(bytes); _protocol.TryParseMessage(ref sequence, _binder, out var parsed);