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.
This commit is contained in:
parent
7b1bce711e
commit
cfc18d9c8e
|
|
@ -1069,6 +1069,125 @@ public abstract class SignalRClientToHubTestBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 250KB+ payload with ASCII-only strings.
|
||||||
|
/// Validates BWO owned-buffer path with many Grow cycles.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public async Task RoundTrip_VeryLargeOrderList_250KB_PreservesAllData()
|
||||||
|
{
|
||||||
|
TestDataFactory.ResetIdCounter();
|
||||||
|
var orders = new List<TestOrder>();
|
||||||
|
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<TestOrder>, List<TestOrder>>(
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<TestOrder>();
|
||||||
|
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<TestOrder>, List<TestOrder>>(
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sanity check: SlabTransportWriter.GetMemory returns MemoryManager-backed Memory
|
||||||
|
/// so TryGetArray fails, forcing BWO into the owned-buffer code path.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void SlabTransportWriter_GetMemory_TryGetArrayFails()
|
||||||
|
{
|
||||||
|
SlabTransportWriter.VerifyMemoryManagerBacked();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SlabTransportWriter : IBufferWriter<byte>
|
||||||
|
{
|
||||||
|
private readonly int _slabSize;
|
||||||
|
private readonly Random _rng;
|
||||||
|
|
||||||
|
// Committed data tracking
|
||||||
|
private readonly List<CommittedSegment> _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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Total bytes committed via Advance.</summary>
|
||||||
|
public int WrittenCount => _totalCommitted;
|
||||||
|
|
||||||
|
/// <summary>Number of slab segments allocated.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public Memory<byte> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public Span<byte> 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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all committed bytes as a contiguous array.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a multi-segment ReadOnlySequence from committed data, splitting at slab boundaries.
|
||||||
|
/// Each committed segment becomes a separate ReadOnlySequence segment.
|
||||||
|
/// </summary>
|
||||||
|
public ReadOnlySequence<byte> ToReadOnlySequence()
|
||||||
|
{
|
||||||
|
if (_segments.Count == 0)
|
||||||
|
return ReadOnlySequence<byte>.Empty;
|
||||||
|
|
||||||
|
if (_segments.Count == 1)
|
||||||
|
{
|
||||||
|
var seg = _segments[0];
|
||||||
|
return new ReadOnlySequence<byte>(seg.Array, seg.Offset, seg.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build linked segment list
|
||||||
|
var first = new MemorySegment(new ReadOnlyMemory<byte>(_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<byte>(seg.Array, seg.Offset, seg.Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReadOnlySequence<byte>(first, 0, current, current.Memory.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verify that TryGetArray fails on our Memory (sanity check for test correctness).
|
||||||
|
/// </summary>
|
||||||
|
public static void VerifyMemoryManagerBacked()
|
||||||
|
{
|
||||||
|
var writer = new SlabTransportWriter(64);
|
||||||
|
var mem = writer.GetMemory(16);
|
||||||
|
if (MemoryMarshal.TryGetArray(mem, out ArraySegment<byte> _))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"SlabTransportWriter.GetMemory returned array-backed Memory — TryGetArray should fail!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private record struct CommittedSegment(byte[] Array, int Offset, int Length);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MemoryManager that wraps an array region but returns Memory where TryGetArray fails.
|
||||||
|
/// This is the key difference from production Kestrel PinnedBlockMemoryPool.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class SlabMemoryManager : MemoryManager<byte>
|
||||||
|
{
|
||||||
|
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<byte> 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) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ReadOnlySequenceSegment for building multi-segment sequences from committed data.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MemorySegment : ReadOnlySequenceSegment<byte>
|
||||||
|
{
|
||||||
|
public MemorySegment(ReadOnlyMemory<byte> memory)
|
||||||
|
{
|
||||||
|
Memory = memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MemorySegment Append(ReadOnlyMemory<byte> memory)
|
||||||
|
{
|
||||||
|
var next = new MemorySegment(memory) { RunningIndex = RunningIndex + Memory.Length };
|
||||||
|
Next = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,13 +8,19 @@ using Microsoft.AspNetCore.SignalR.Protocol;
|
||||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
/// WRITE SIDE (GetMessageBytesMultiSegment):
|
||||||
/// Test: SignalR → WriteMessage(PipeWriter) → FixedSizePool 256-byte segments → PipeReader → TryParseMessage
|
/// 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,
|
/// READ SIDE (TryParseMessage):
|
||||||
/// exercising BWO chunk writes and SequenceBinaryInput cross-boundary reads.
|
/// 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
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol
|
internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol
|
||||||
{
|
{
|
||||||
|
|
@ -26,23 +32,35 @@ internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Write side: WriteMessage → PipeWriter backed by 256-byte pool segments.
|
/// Write side: WriteMessage → SlabTransportWriter (MemoryManager-backed Memory).
|
||||||
/// Same code path as production, just smaller segments.
|
/// Forces BWO into owned-buffer fallback path — same code path as production Kestrel.
|
||||||
|
/// Validates output against ArrayBufferWriter reference on every call.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public new ReadOnlyMemory<byte> GetMessageBytes(HubMessage message)
|
public ReadOnlyMemory<byte> GetMessageBytesMultiSegment(HubMessage message)
|
||||||
{
|
{
|
||||||
var pipe = new Pipe(new PipeOptions(pool: new SlabSimulatingPool(SegmentSize)));
|
// ── Transport-double path (production simulation) ──────────────────
|
||||||
WriteMessage(message, pipe.Writer);
|
var transport = new SlabTransportWriter(SegmentSize);
|
||||||
pipe.Writer.Complete();
|
WriteMessage(message, transport);
|
||||||
pipe.Reader.TryRead(out var result);
|
var transportBytes = transport.ToArray();
|
||||||
var bytes = result.Buffer.ToArray();
|
|
||||||
pipe.Reader.Complete();
|
// ── Reference path: ArrayBufferWriter with large capacity (no resize → lengthSpan stays valid) ──
|
||||||
return bytes;
|
// 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<byte>(referenceCapacity);
|
||||||
|
WriteMessage(message, reference);
|
||||||
|
var referenceBytes = reference.WrittenSpan;
|
||||||
|
|
||||||
|
// ── Validate: transport output must match reference byte-for-byte ──
|
||||||
|
ValidateAgainstReference(transportBytes, referenceBytes);
|
||||||
|
|
||||||
|
return transportBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read side: fill PipeWriter 256 bytes at a time → PipeReader gives multi-segment sequence.
|
/// Read side: fill Pipe with 256-byte slab segments → multi-segment ReadOnlySequence.
|
||||||
/// Same as production Kestrel PipeReader delivering 4096-byte segments.
|
/// Same as production Kestrel PipeReader delivering slab-sized segments.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public override bool TryParseMessage(ref ReadOnlySequence<byte> input, IInvocationBinder binder,
|
public override bool TryParseMessage(ref ReadOnlySequence<byte> input, IInvocationBinder binder,
|
||||||
[NotNullWhen(true)] out HubMessage? message)
|
[NotNullWhen(true)] out HubMessage? message)
|
||||||
|
|
@ -65,11 +83,98 @@ internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol
|
||||||
writer.Complete();
|
writer.Complete();
|
||||||
pipe.Reader.TryRead(out var result);
|
pipe.Reader.TryRead(out var result);
|
||||||
var seq = result.Buffer;
|
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);
|
var success = base.TryParseMessage(ref seq, binder, out message);
|
||||||
pipe.Reader.Complete();
|
pipe.Reader.Complete();
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region Validation
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte-by-byte comparison of transport-double output vs ArrayBufferWriter reference.
|
||||||
|
/// Catches any BWO owned-buffer path bug (position drift, data corruption, length mismatch).
|
||||||
|
/// </summary>
|
||||||
|
private static void ValidateAgainstReference(byte[] transportBytes, ReadOnlySpan<byte> 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<byte> 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
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MemoryPool that returns <paramref name="segmentSize"/>-byte blocks at random offsets
|
/// MemoryPool that returns <paramref name="segmentSize"/>-byte blocks at random offsets
|
||||||
/// within a larger backing array — simulating Kestrel's slab allocator where segments
|
/// 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 size = Math.Max(minBufferSize, segmentSize);
|
||||||
var offset = _rng.Next(0, segmentSize); // random slab offset
|
var offset = _rng.Next(0, segmentSize); // random slab offset
|
||||||
var jitter = _rng.Next(-1, 2); // -1, 0, or +1
|
var jitter = _rng.Next(-segmentSize / 4, segmentSize / 4 + 1); // ±25% size variance
|
||||||
var actualSize = Math.Max(1, size + jitter); // random segment size variance
|
var actualSize = Math.Max(16, size + jitter);
|
||||||
var array = new byte[actualSize + offset];
|
var array = new byte[actualSize + offset];
|
||||||
return new Owner(array, offset, actualSize);
|
return new Owner(array, offset, actualSize);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServ
|
||||||
nameof(IAcSignalRHubClient.OnReceiveMessage),
|
nameof(IAcSignalRHubClient.OnReceiveMessage),
|
||||||
[messageTag, requestId, signalParams, data ?? Array.Empty<byte>()]);
|
[messageTag, requestId, signalParams, data ?? Array.Empty<byte>()]);
|
||||||
|
|
||||||
var bytes = _protocol.GetMessageBytes(invocation);
|
var bytes = _protocol.GetMessageBytesMultiSegment(invocation);
|
||||||
var sequence = new ReadOnlySequence<byte>(bytes);
|
var sequence = new ReadOnlySequence<byte>(bytes);
|
||||||
if (!_protocol.TryParseMessage(ref sequence, _binder, out var parsed) || parsed is not InvocationMessage invMsg)
|
if (!_protocol.TryParseMessage(ref sequence, _binder, out var parsed) || parsed is not InvocationMessage invMsg)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ public class TestableSignalRHub2 : AcWebSignalRHubBase<TestSignalRTags, TestLogg
|
||||||
nameof(IAcSignalRHubClient.OnReceiveMessage),
|
nameof(IAcSignalRHubClient.OnReceiveMessage),
|
||||||
[messageTag, requestId, signalParams, responseData ?? Array.Empty<byte>()]);
|
[messageTag, requestId, signalParams, responseData ?? Array.Empty<byte>()]);
|
||||||
|
|
||||||
var bytes = _protocol.GetMessageBytes(invocation);
|
var bytes = _protocol.GetMessageBytesMultiSegment(invocation);
|
||||||
var sequence = new ReadOnlySequence<byte>(bytes);
|
var sequence = new ReadOnlySequence<byte>(bytes);
|
||||||
_protocol.TryParseMessage(ref sequence, _binder, out var parsed);
|
_protocol.TryParseMessage(ref sequence, _binder, out var parsed);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue