diff --git a/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs b/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs index c9d3aec..446e507 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestMultiSegmentProtocol.cs @@ -8,24 +8,30 @@ using Microsoft.AspNetCore.SignalR.Protocol; namespace AyCode.Services.Server.Tests.SignalRs; /// -/// Test protocol that simulates production Kestrel pipe behavior. +/// Test protocol that simulates production Kestrel pipe behavior with 256-byte segments. /// -/// 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. +/// Production: SignalR → WriteMessage(PipeWriter) → Kestrel slab 4096-byte segments → PipeReader → TryParseMessage +/// Test: SignalR → WriteMessage(PipeWriter) → FixedSizePool 256-byte segments → PipeReader → TryParseMessage /// -/// Read side: splits the serialized bytes into 256-byte segments before parsing, -/// exercising SequenceBinaryInput cross-boundary reads at every boundary. +/// Both sides go through a real Pipe with fixed-size memory segments, +/// exercising BWO chunk writes and SequenceBinaryInput cross-boundary reads. /// internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol { private const int SegmentSize = 256; + public TestMultiSegmentProtocol() + { + Options.BufferWriterChunkSize = SegmentSize; + } + /// - /// Serialize via Pipe (production-like stable memory blocks) instead of ArrayBufferWriter. + /// Write side: WriteMessage → PipeWriter backed by 256-byte pool segments. + /// Same code path as production, just smaller segments. /// public new ReadOnlyMemory GetMessageBytes(HubMessage message) { - var pipe = new Pipe(); + var pipe = new Pipe(new PipeOptions(pool: new SlabSimulatingPool(SegmentSize))); WriteMessage(message, pipe.Writer); pipe.Writer.Complete(); pipe.Reader.TryRead(out var result); @@ -35,47 +41,62 @@ internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol } /// - /// Split input into 256-byte segments before parsing — forces multi-segment ReadOnlySequence - /// through SequenceBinaryInput, exercising cross-boundary reads on every test. + /// Read side: fill PipeWriter 256 bytes at a time → PipeReader gives multi-segment sequence. + /// Same as production Kestrel PipeReader delivering 4096-byte segments. /// public override bool TryParseMessage(ref ReadOnlySequence input, IInvocationBinder binder, [NotNullWhen(true)] out HubMessage? message) { - var multiSegment = CreateMultiSegmentSequence(input, SegmentSize); - return base.TryParseMessage(ref multiSegment, binder, out message); - } + var bytes = input.ToArray(); + var pipe = new Pipe(new PipeOptions(pool: new SlabSimulatingPool(SegmentSize))); + var writer = pipe.Writer; - private static ReadOnlySequence CreateMultiSegmentSequence(ReadOnlySequence source, int chunkSize) - { - var bytes = source.ToArray(); - - // 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) + // Write in chunks — GetMemory may return less than requested (like Kestrel slab) + var remaining = bytes.AsSpan(); + while (remaining.Length > 0) { - var length = Math.Min(chunkSize, bytes.Length - offset); - var chunk = new byte[length]; - Buffer.BlockCopy(bytes, offset, chunk, 0, length); - current = current.Append(chunk); + var mem = writer.GetMemory(Math.Min(SegmentSize, remaining.Length)); + var chunk = Math.Min(mem.Length, remaining.Length); + remaining[..chunk].CopyTo(mem.Span); + writer.Advance(chunk); + remaining = remaining[chunk..]; } - return new ReadOnlySequence(first, 0, current, current.Memory.Length); + writer.Complete(); + pipe.Reader.TryRead(out var result); + var seq = result.Buffer; + var success = base.TryParseMessage(ref seq, binder, out message); + pipe.Reader.Complete(); + return success; } - private sealed class MemorySegment : ReadOnlySequenceSegment + /// + /// MemoryPool that returns -byte blocks at random offsets + /// within a larger backing array — simulating Kestrel's slab allocator where segments + /// share a large slab and have non-zero offsets. + /// + private sealed class SlabSimulatingPool(int segmentSize) : MemoryPool { - public MemorySegment(ReadOnlyMemory memory) => Memory = memory; + private readonly Random _rng = new(42); // deterministic seed for reproducibility - public MemorySegment Append(ReadOnlyMemory memory) + public override int MaxBufferSize => segmentSize; + + public override IMemoryOwner Rent(int minBufferSize = -1) { - var next = new MemorySegment(memory) { RunningIndex = RunningIndex + Memory.Length }; - Next = next; - return next; + 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 array = new byte[actualSize + offset]; + return new Owner(array, offset, actualSize); + } + + protected override void Dispose(bool disposing) { } + + private sealed class Owner(byte[] array, int offset, int length) : IMemoryOwner + { + public Memory Memory { get; } = array.AsMemory(offset, length); + public void Dispose() { } } } }