Add segment streaming to SignalR binary protocol
Implements segment-level streaming for SignalR binary protocol via new AsyncPipeWriterOutput and PipeReaderBinaryInput types, enabling chunked serialization/deserialization directly over PipeWriter/PipeReader. Adds BinaryProtocolMode enum to select between standard and streaming modes. Updates protocol classes and documentation. Lays groundwork for future async streaming support.
This commit is contained in:
parent
27cac570be
commit
8ff75de55c
|
|
@ -51,7 +51,8 @@
|
|||
"Bash(2)",
|
||||
"Bash(dotnet --version)",
|
||||
"WebSearch",
|
||||
"Bash(dotnet script:*)"
|
||||
"Bash(dotnet script:*)",
|
||||
"Bash(xargs wc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
public static void Forget<T>(this ValueTask<T> task)
|
||||
{
|
||||
if (!task.IsCompleted || task.IsFaulted)
|
||||
_ = ForgetAwaited(task);
|
||||
|
||||
static async Task ForgetAwaited(ValueTask<T> task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow exception - fire and forget semantics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void RunOnThreadPool(this Action action) => ToThreadPoolTask(action).Forget();
|
||||
public static void RunOnThreadPool<T>(this Func<T> func) => ToThreadPoolTask(func).Forget();
|
||||
|
||||
|
|
|
|||
|
|
@ -291,6 +291,19 @@ public static partial class AcBinaryDeserializer
|
|||
return DeserializeSequence<SequenceBinaryInput>(new SequenceBinaryInput(data), targetType, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize from PipeReader with segment streaming (read per chunk via PipeReaderBinaryInput).
|
||||
/// Data is consumed as it arrives from the network, enabling pipeline parallelism.
|
||||
/// </summary>
|
||||
public static T? Deserialize<T>(System.IO.Pipelines.PipeReader pipeReader, AcBinarySerializerOptions options)
|
||||
=> DeserializeSequence<T, PipeReaderBinaryInput>(new PipeReaderBinaryInput(pipeReader), typeof(T), options);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize from PipeReader to specified type with segment streaming.
|
||||
/// </summary>
|
||||
public static object? Deserialize(System.IO.Pipelines.PipeReader pipeReader, Type targetType, AcBinarySerializerOptions options)
|
||||
=> DeserializeSequence<PipeReaderBinaryInput>(new PipeReaderBinaryInput(pipeReader), targetType, options);
|
||||
|
||||
/// <summary>
|
||||
/// Internal: Deserialize with any TInput (multi-segment or other future input types).
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -422,6 +422,66 @@ public static partial class AcBinarySerializer
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize to PipeWriter with segment streaming (flush per chunk via AsyncPipeWriterOutput).
|
||||
/// Each chunk is flushed to the network as it fills, enabling pipeline parallelism.
|
||||
/// Returns total bytes written.
|
||||
/// </summary>
|
||||
public static int Serialize<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
var span = pipeWriter.GetSpan(1);
|
||||
span[0] = BinaryTypeCode.Null;
|
||||
pipeWriter.Advance(1);
|
||||
pipeWriter.FlushAsync().GetAwaiter().GetResult();
|
||||
return 1;
|
||||
}
|
||||
|
||||
var runtimeType = value.GetType();
|
||||
var context = BinarySerializationContextPool<AsyncPipeWriterOutput>.Get(options);
|
||||
context.Output = new AsyncPipeWriterOutput(pipeWriter, options.BufferWriterChunkSize);
|
||||
context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
|
||||
|
||||
try
|
||||
{
|
||||
if (options.UseGeneratedCode)
|
||||
{
|
||||
var wrapper = context.GetWrapper(runtimeType);
|
||||
if (wrapper.GeneratedWriter != null)
|
||||
{
|
||||
ScanForDuplicates(value, runtimeType, context);
|
||||
context.WriteHeader();
|
||||
WriteObject(value, wrapper, context, 0);
|
||||
|
||||
if (options.UseCompression != Compression.Lz4CompressionMode.None)
|
||||
ThrowCompressionNotSupportedWithPipeWriter(context);
|
||||
|
||||
var bytesWritten = context.Output.GetTotalPosition(context._position);
|
||||
context.Output.Flush(context._buffer, context._position);
|
||||
return bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
var actualValue = ConvertExpressionValue(value, ref runtimeType);
|
||||
ScanForDuplicates(actualValue, runtimeType, context);
|
||||
context.WriteHeader();
|
||||
WriteValue(actualValue, runtimeType, context, 0);
|
||||
|
||||
if (options.UseCompression != Compression.Lz4CompressionMode.None)
|
||||
ThrowCompressionNotSupportedWithPipeWriter(context);
|
||||
|
||||
var totalBytesWritten = context.Output.GetTotalPosition(context._position);
|
||||
context.Output.Flush(context._buffer, context._position);
|
||||
return totalBytesWritten;
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.Output = default;
|
||||
ReturnContext(context, options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the serialized size without allocating the final array.
|
||||
/// Useful for pre-allocating buffers.
|
||||
|
|
@ -541,6 +601,15 @@ public static partial class AcBinarySerializer
|
|||
"Use the byte[] overload or disable compression.");
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void ThrowCompressionNotSupportedWithPipeWriter(BinarySerializationContext<AsyncPipeWriterOutput> context)
|
||||
{
|
||||
context.Output.Flush(context._buffer, context._position);
|
||||
throw new NotSupportedException(
|
||||
"Compression is not supported with PipeWriter output. " +
|
||||
"Use the byte[] overload or disable compression.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
using System;
|
||||
using System.Buffers;
|
||||
using System.IO.Pipelines;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using AyCode.Core.Helpers;
|
||||
|
||||
namespace AyCode.Core.Serializers.Binaries;
|
||||
|
||||
/// <summary>
|
||||
/// Binary output that writes to a PipeWriter with per-chunk network flush.
|
||||
///
|
||||
/// Identical to BufferWriterBinaryOutput except: Grow() calls PipeWriter.FlushAsync().Forget()
|
||||
/// after committing each chunk, so data flows to the network as it's being serialized
|
||||
/// rather than waiting for the full serialization to complete.
|
||||
///
|
||||
/// Backpressure: stores the last FlushAsync ValueTask. If the previous flush hasn't completed
|
||||
/// by the next Grow(), blocks until it does. This bounds memory to ~2 chunks.
|
||||
///
|
||||
/// The first Grow() skips the flush to keep the length prefix span valid for patching.
|
||||
/// </summary>
|
||||
public struct AsyncPipeWriterOutput : IBinaryOutputBase
|
||||
{
|
||||
private readonly PipeWriter _pipeWriter;
|
||||
private readonly int _chunkSize;
|
||||
private int _committedBytes;
|
||||
private int _currentChunkStart;
|
||||
private bool _ownedBuffer;
|
||||
private ValueTask<FlushResult> _lastFlush;
|
||||
private bool _firstGrow;
|
||||
|
||||
public AsyncPipeWriterOutput(PipeWriter pipeWriter, int chunkSize = 4096)
|
||||
{
|
||||
_pipeWriter = pipeWriter;
|
||||
_chunkSize = chunkSize;
|
||||
_committedBytes = 0;
|
||||
_ownedBuffer = false;
|
||||
_lastFlush = default;
|
||||
_firstGrow = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides the initial buffer from the PipeWriter.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Initialize(out byte[] buffer, out int position, out int bufferEnd)
|
||||
{
|
||||
_committedBytes = 0;
|
||||
_lastFlush = default;
|
||||
_firstGrow = true;
|
||||
AcquireChunk(_chunkSize, out buffer, out position, out bufferEnd);
|
||||
_currentChunkStart = position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the context's buffer is full. Commits current chunk to the PipeWriter,
|
||||
/// fires a background flush (except on the first call — length prefix must stay valid),
|
||||
/// and acquires a new chunk.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed)
|
||||
{
|
||||
// Backpressure: wait for previous flush if still in progress
|
||||
if (!_lastFlush.IsCompleted)
|
||||
_lastFlush.GetAwaiter().GetResult();
|
||||
|
||||
// Commit bytes written in current chunk
|
||||
var bytesInChunk = position - _currentChunkStart;
|
||||
if (bytesInChunk > 0)
|
||||
{
|
||||
if (_ownedBuffer)
|
||||
FlushOwnedBuffer(buffer, bytesInChunk);
|
||||
else
|
||||
_pipeWriter.Advance(bytesInChunk);
|
||||
_committedBytes += bytesInChunk;
|
||||
}
|
||||
|
||||
// Fire-and-forget flush — EXCEPT first chunk (length prefix span must stay valid)
|
||||
if (!_firstGrow)
|
||||
{
|
||||
_lastFlush = _pipeWriter.FlushAsync();
|
||||
_lastFlush.Forget();
|
||||
}
|
||||
_firstGrow = false;
|
||||
|
||||
// Acquire new chunk
|
||||
AcquireChunk(Math.Max(needed, _chunkSize), out buffer, out position, out bufferEnd);
|
||||
_currentChunkStart = position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns total bytes written: committed + pending in current chunk.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public int GetTotalPosition(int currentPosition)
|
||||
=> _committedBytes + (currentPosition - _currentChunkStart);
|
||||
|
||||
/// <summary>
|
||||
/// Commits final pending bytes and performs a synchronous flush.
|
||||
/// Must be called after all writes are complete.
|
||||
/// </summary>
|
||||
public void Flush(byte[] buffer, int position)
|
||||
{
|
||||
// Wait for any in-flight flush
|
||||
if (!_lastFlush.IsCompleted)
|
||||
_lastFlush.GetAwaiter().GetResult();
|
||||
|
||||
var bytesInChunk = position - _currentChunkStart;
|
||||
if (bytesInChunk > 0)
|
||||
{
|
||||
if (_ownedBuffer)
|
||||
FlushOwnedBuffer(buffer, bytesInChunk);
|
||||
else
|
||||
_pipeWriter.Advance(bytesInChunk);
|
||||
}
|
||||
|
||||
// Final synchronous flush — ensures all data reaches the network
|
||||
_pipeWriter.FlushAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op for PipeWriter-based output — chunks are owned by PipeWriter, not us.
|
||||
/// </summary>
|
||||
public void Reset() { }
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private void FlushOwnedBuffer(byte[] buffer, int bytesInChunk)
|
||||
{
|
||||
var span = _pipeWriter.GetSpan(bytesInChunk);
|
||||
buffer.AsSpan(_currentChunkStart, bytesInChunk).CopyTo(span);
|
||||
_pipeWriter.Advance(bytesInChunk);
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
_ownedBuffer = false;
|
||||
}
|
||||
|
||||
private void AcquireChunk(int requestSize, out byte[] buffer, out int position, out int bufferEnd)
|
||||
{
|
||||
var actualRequest = Math.Max(requestSize, _chunkSize);
|
||||
var memory = _pipeWriter.GetMemory(actualRequest);
|
||||
|
||||
if (MemoryMarshal.TryGetArray(memory, out ArraySegment<byte> segment) && segment.Array != null)
|
||||
{
|
||||
buffer = segment.Array;
|
||||
position = segment.Offset;
|
||||
bufferEnd = segment.Offset + segment.Count;
|
||||
_ownedBuffer = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback for non-array-backed PipeWriter (e.g. Kestrel PinnedBlockMemoryPool)
|
||||
var owned = ArrayPool<byte>.Shared.Rent(actualRequest);
|
||||
buffer = owned;
|
||||
position = 0;
|
||||
bufferEnd = owned.Length;
|
||||
_ownedBuffer = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
using System;
|
||||
using System.Buffers;
|
||||
using System.IO.Pipelines;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AyCode.Core.Serializers.Binaries;
|
||||
|
||||
/// <summary>
|
||||
/// Binary input that reads from a PipeReader, requesting more data when the current buffer is exhausted.
|
||||
///
|
||||
/// Mirrors SequenceBinaryInput's segment iteration and cross-boundary handling, but instead of
|
||||
/// reading from a fixed ReadOnlySequence, it calls PipeReader.ReadAsync() to get more data
|
||||
/// as chunks arrive from the network.
|
||||
///
|
||||
/// When the writer flushes per chunk (AsyncPipeWriterOutput), ReadAsync() returns as soon as
|
||||
/// data is available — typically completing synchronously. This enables pipeline parallelism:
|
||||
/// serialization, network transfer, and deserialization overlap.
|
||||
/// </summary>
|
||||
public struct PipeReaderBinaryInput : IBinaryInputBase
|
||||
{
|
||||
private readonly PipeReader _pipeReader;
|
||||
private ReadOnlySequence<byte> _currentBuffer;
|
||||
private SequencePosition _nextSegmentPosition;
|
||||
private SequencePosition _consumedUpTo;
|
||||
private bool _pipeCompleted;
|
||||
|
||||
// Cross-boundary scratch — same pattern as SequenceBinaryInput
|
||||
private byte[]? _scratchBuffer;
|
||||
private bool _afterCrossBoundary;
|
||||
private byte[]? _savedBuffer;
|
||||
private int _savedPosition;
|
||||
private int _savedBufferLength;
|
||||
|
||||
public PipeReaderBinaryInput(PipeReader pipeReader)
|
||||
{
|
||||
_pipeReader = pipeReader;
|
||||
_currentBuffer = default;
|
||||
_nextSegmentPosition = default;
|
||||
_consumedUpTo = default;
|
||||
_pipeCompleted = false;
|
||||
_scratchBuffer = null;
|
||||
_afterCrossBoundary = false;
|
||||
_savedBuffer = null;
|
||||
_savedPosition = 0;
|
||||
_savedBufferLength = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the first data from the PipeReader and provides the first segment's buffer.
|
||||
/// </summary>
|
||||
public void Initialize(out byte[] buffer, out int position, out int bufferLength)
|
||||
{
|
||||
var result = _pipeReader.ReadAsync().GetAwaiter().GetResult();
|
||||
_currentBuffer = result.Buffer;
|
||||
_pipeCompleted = result.IsCompleted;
|
||||
_consumedUpTo = _currentBuffer.Start;
|
||||
_nextSegmentPosition = _currentBuffer.Start;
|
||||
|
||||
if (!_currentBuffer.TryGet(ref _nextSegmentPosition, out var memory) || memory.Length == 0)
|
||||
throw new AcBinaryDeserializationException("Empty pipe — no data to read.");
|
||||
|
||||
ExtractArray(memory, out buffer, out position, out bufferLength);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances to the next segment. If the current ReadResult buffer is exhausted,
|
||||
/// calls PipeReader.ReadAsync() to get more data from the pipe.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public bool TryAdvanceSegment(ref byte[] buffer, ref int position, ref int bufferLength, int needed)
|
||||
{
|
||||
// After cross-boundary scratch read: restore to the last touched segment
|
||||
if (_afterCrossBoundary)
|
||||
{
|
||||
_afterCrossBoundary = false;
|
||||
buffer = _savedBuffer!;
|
||||
position = _savedPosition;
|
||||
bufferLength = _savedBufferLength;
|
||||
|
||||
if (bufferLength - position >= needed)
|
||||
return true;
|
||||
}
|
||||
|
||||
var remaining = bufferLength - position;
|
||||
|
||||
if (remaining > 0 && remaining < needed)
|
||||
return TryReadCrossBoundary(ref buffer, ref position, ref bufferLength, needed, remaining);
|
||||
|
||||
// Try next segment in current buffer
|
||||
if (TryLoadNextSegmentFromBuffer(ref buffer, ref position, ref bufferLength))
|
||||
{
|
||||
remaining = bufferLength - position;
|
||||
if (remaining >= needed) return true;
|
||||
return TryReadCrossBoundary(ref buffer, ref position, ref bufferLength, needed, remaining);
|
||||
}
|
||||
|
||||
// Current buffer exhausted — request more data from PipeReader
|
||||
if (_pipeCompleted)
|
||||
return false;
|
||||
|
||||
_pipeReader.AdvanceTo(_consumedUpTo, _currentBuffer.End);
|
||||
|
||||
var result = _pipeReader.ReadAsync().GetAwaiter().GetResult();
|
||||
_currentBuffer = result.Buffer;
|
||||
_pipeCompleted = result.IsCompleted;
|
||||
_consumedUpTo = _currentBuffer.Start;
|
||||
_nextSegmentPosition = _currentBuffer.Start;
|
||||
|
||||
if (!TryLoadNextSegmentFromBuffer(ref buffer, ref position, ref bufferLength))
|
||||
return false;
|
||||
|
||||
remaining = bufferLength - position;
|
||||
if (remaining >= needed) return true;
|
||||
return TryReadCrossBoundary(ref buffer, ref position, ref bufferLength, needed, remaining);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the scratch buffer and signals the PipeReader that all data has been consumed.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Release()
|
||||
{
|
||||
if (_scratchBuffer != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(_scratchBuffer);
|
||||
_scratchBuffer = null;
|
||||
}
|
||||
|
||||
_pipeReader.AdvanceTo(_currentBuffer.End);
|
||||
}
|
||||
|
||||
private bool TryLoadNextSegmentFromBuffer(ref byte[] buffer, ref int position, ref int bufferLength)
|
||||
{
|
||||
if (!_currentBuffer.TryGet(ref _nextSegmentPosition, out var memory) || memory.Length == 0)
|
||||
return false;
|
||||
|
||||
ExtractArray(memory, out buffer, out position, out bufferLength);
|
||||
_consumedUpTo = _nextSegmentPosition;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryReadCrossBoundary(ref byte[] buffer, ref int position, ref int bufferLength, int needed, int remaining)
|
||||
{
|
||||
if (_scratchBuffer == null || _scratchBuffer.Length < needed)
|
||||
{
|
||||
if (_scratchBuffer != null)
|
||||
ArrayPool<byte>.Shared.Return(_scratchBuffer);
|
||||
_scratchBuffer = ArrayPool<byte>.Shared.Rent(needed);
|
||||
}
|
||||
|
||||
// 1) Copy tail of current segment
|
||||
Buffer.BlockCopy(buffer, position, _scratchBuffer, 0, remaining);
|
||||
var filled = remaining;
|
||||
|
||||
// 2) Copy from subsequent segments (may span multiple segments or pipe reads)
|
||||
while (filled < needed)
|
||||
{
|
||||
// Try next segment in current buffer
|
||||
if (_currentBuffer.TryGet(ref _nextSegmentPosition, out var memory) && memory.Length > 0)
|
||||
{
|
||||
ExtractArray(memory, out var segArray, out var segOffset, out var segBufferLength);
|
||||
_consumedUpTo = _nextSegmentPosition;
|
||||
var segCount = segBufferLength - segOffset;
|
||||
var take = Math.Min(needed - filled, segCount);
|
||||
Buffer.BlockCopy(segArray, segOffset, _scratchBuffer, filled, take);
|
||||
filled += take;
|
||||
|
||||
_savedBuffer = segArray;
|
||||
_savedPosition = segOffset + take;
|
||||
_savedBufferLength = segBufferLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Current buffer exhausted — read more from pipe
|
||||
if (_pipeCompleted)
|
||||
return false;
|
||||
|
||||
_pipeReader.AdvanceTo(_consumedUpTo, _currentBuffer.End);
|
||||
var result = _pipeReader.ReadAsync().GetAwaiter().GetResult();
|
||||
_currentBuffer = result.Buffer;
|
||||
_pipeCompleted = result.IsCompleted;
|
||||
_consumedUpTo = _currentBuffer.Start;
|
||||
_nextSegmentPosition = _currentBuffer.Start;
|
||||
|
||||
if (_currentBuffer.IsEmpty && _pipeCompleted)
|
||||
return false;
|
||||
}
|
||||
|
||||
buffer = _scratchBuffer;
|
||||
position = 0;
|
||||
bufferLength = filled;
|
||||
_afterCrossBoundary = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void ExtractArray(ReadOnlyMemory<byte> memory, out byte[] buffer, out int position, out int bufferLength)
|
||||
{
|
||||
if (MemoryMarshal.TryGetArray(memory, out var segment))
|
||||
{
|
||||
buffer = segment.Array!;
|
||||
position = segment.Offset;
|
||||
bufferLength = segment.Offset + segment.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
var temp = new byte[memory.Length];
|
||||
memory.Span.CopyTo(temp);
|
||||
buffer = temp;
|
||||
position = 0;
|
||||
bufferLength = temp.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ SGen fast path:
|
|||
|
||||
**Non-SGen penalty:** +1 bool check (`options.UseGeneratedCode`) + 1 `GetWrapper` (cached) + 1 null check ≈ ~10-15ns.
|
||||
|
||||
**Location:** `AcBinarySerializer.cs` — both `Serialize<T>(T, options)` (byte[] path) and `Serialize<T>(T, IBufferWriter, options)`.
|
||||
**Location:** `AcBinarySerializer.cs` — `Serialize<T>(T, options)` (byte[] path), `Serialize<T>(T, IBufferWriter, options)` (BWO path), and `Serialize<T>(T, PipeWriter, options)` (AsyncPipeWriterOutput segment-streaming path).
|
||||
|
||||
### Full Runtime Dispatch Chain
|
||||
|
||||
|
|
@ -130,6 +130,7 @@ Two-phase:
|
|||
|
||||
- `BinarySerializationContextPool<ArrayBinaryOutput>` — byte[] path
|
||||
- `BinarySerializationContextPool<BufferWriterBinaryOutput>` — IBufferWriter path
|
||||
- `BinarySerializationContextPool<AsyncPipeWriterOutput>` — PipeWriter segment-streaming path
|
||||
- `options.UseAsync` → `ReturnAsync` (ThreadPool enqueue) to avoid lock contention
|
||||
- Pooled contexts retain wrapper caches, buffer instances across serializations
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,33 @@ The scratch buffer is `ArrayPool.Rent`-ed on first cross-boundary read and reuse
|
|||
|
||||
When `MemoryMarshal.TryGetArray` fails on `IBufferWriter.GetMemory()` (native memory-backed writer), a `byte[]` is rented from `ArrayPool` per chunk and copied to the writer on `Grow`/`Flush`. Same as DESER-1 — non-array-backed writers are extremely rare.
|
||||
|
||||
### SER-2: AsyncPipeWriterOutput uses sync GetResult() for backpressure
|
||||
|
||||
**Status:** By design (v1)
|
||||
**Affects:** `AsyncPipeWriterOutput.Grow()` — `_lastFlush.GetAwaiter().GetResult()`
|
||||
|
||||
When the previous `PipeWriter.FlushAsync()` hasn't completed by the next `Grow()` call, the serializer blocks the thread until the flush completes. This is necessary because `IHubProtocol.WriteMessage` is `void` (synchronous by design).
|
||||
|
||||
**Impact:** Minimal under normal conditions. `PipeWriter.FlushAsync()` writes to an in-memory Kestrel pipe (not directly to the network) and typically completes synchronously. Only blocks when the pipe's internal buffer hits its pause threshold (~1MB), which requires an extremely slow client + large payload. The `Bytes` mode (default) has the same blocking characteristic — it blocks the thread for the entire serialization + single flush.
|
||||
|
||||
**Possible optimization:** `AsyncSegment` mode (future) with a custom async `WriteMessageAsync` protocol interface, enabling `await` on flush instead of `GetResult()`.
|
||||
|
||||
### SER-3: AsyncPipeWriterOutput fallback path — same as SER-1
|
||||
|
||||
**Status:** Acceptable
|
||||
**Affects:** `AsyncPipeWriterOutput.AcquireChunk` fallback
|
||||
|
||||
Same `TryGetArray` fallback as `BufferWriterBinaryOutput` (SER-1). Kestrel `PipeWriter.GetMemory()` always returns array-backed memory — fallback is for non-standard `PipeWriter` implementations only.
|
||||
|
||||
## Deserialization (PipeReader)
|
||||
|
||||
### DESER-5: PipeReaderBinaryInput uses sync ReadAsync().GetResult()
|
||||
|
||||
**Status:** By design (v1)
|
||||
**Affects:** `PipeReaderBinaryInput.Initialize()` and `TryAdvanceSegment()`
|
||||
|
||||
Same constraint as SER-2 — `IBinaryInputBase` interface is synchronous. `ReadAsync().GetAwaiter().GetResult()` blocks when waiting for more data from the pipe. Currently not used in production (SignalR delivers complete messages via `TryParseMessage`). Reserved for future direct-pipe deserialization scenarios.
|
||||
|
||||
## Source Generator (SGen)
|
||||
|
||||
### SGEN-1: CS8625 warnings for non-nullable reference types
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -47,13 +47,15 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
private const byte MsgSequence = 9;
|
||||
|
||||
protected volatile AcBinarySerializerOptions _options;
|
||||
protected readonly BinaryProtocolMode _protocolMode;
|
||||
|
||||
public AcBinaryHubProtocol() : this(AcBinarySerializerOptions.Default) { }
|
||||
|
||||
public AcBinaryHubProtocol(AcBinarySerializerOptions options)
|
||||
public AcBinaryHubProtocol(AcBinarySerializerOptions options, BinaryProtocolMode protocolMode = BinaryProtocolMode.Bytes)
|
||||
{
|
||||
_options = options;
|
||||
_options.BufferWriterChunkSize = 4096;
|
||||
_protocolMode = protocolMode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -417,7 +419,9 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
var argLenSpan = output.GetSpan(LengthPrefixSize);
|
||||
output.Advance(LengthPrefixSize);
|
||||
|
||||
var argBytes = AcBinarySerializer.Serialize(value, output, _options);
|
||||
var argBytes = _protocolMode == BinaryProtocolMode.Segment
|
||||
? AcBinarySerializer.Serialize(value, (System.IO.Pipelines.PipeWriter)output, _options)
|
||||
: AcBinarySerializer.Serialize(value, output, _options);
|
||||
|
||||
Unsafe.WriteUnaligned(ref argLenSpan[0], argBytes);
|
||||
externalBytes += LengthPrefixSize + argBytes;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol
|
|||
private SignalParams? _currentSignalParams;
|
||||
|
||||
public AyCodeBinaryHubProtocol() : this(AcBinarySerializerOptions.Default) { }
|
||||
public AyCodeBinaryHubProtocol(AcBinarySerializerOptions options) : base(options) { }
|
||||
public AyCodeBinaryHubProtocol(AcBinarySerializerOptions options, BinaryProtocolMode protocolMode = BinaryProtocolMode.Bytes) : base(options, protocolMode) { }
|
||||
|
||||
protected override void OnArgumentRead(object? value, int index)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
namespace AyCode.Services.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Controls how the binary protocol transports serialized data over the network.
|
||||
/// </summary>
|
||||
public enum BinaryProtocolMode
|
||||
{
|
||||
/// <summary>Standard: serialize → egyben küld/fogad.</summary>
|
||||
Bytes = 0,
|
||||
|
||||
/// <summary>Szinkron segment streaming: flush Grow()-ban → chunk-onként hálózatra.</summary>
|
||||
Segment = 1,
|
||||
|
||||
/// <summary>Async segment streaming: async serializer + async output (jövő).</summary>
|
||||
AsyncSegment = 2,
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ CRUD helpers (`PostAsync`, `GetByIdAsync`, `GetAllAsync`, `PostDataAsync`) are g
|
|||
|
||||
### AcBinaryHubProtocol / AyCodeBinaryHubProtocol
|
||||
|
||||
Custom `IHubProtocol` (`"acbinary"`), replaces JSON. Zero-copy write via `BufferWriterBinaryOutput` standalone mode + `AcBinarySerializer.Serialize(value, output)` directly to pipe. Zero-copy read via `SequenceReader<byte>` from pipe's `ReadOnlySequence`.
|
||||
Custom `IHubProtocol` (`"acbinary"`), replaces JSON. Zero-copy write via `BufferWriterBinaryOutput` standalone mode + `AcBinarySerializer.Serialize(value, output)` directly to pipe. Zero-copy read via `SequenceReader<byte>` from pipe's `ReadOnlySequence`. `BinaryProtocolMode` constructor parameter selects transport strategy: `Bytes` (default, single flush), `Segment` (per-chunk flush via `AsyncPipeWriterOutput`), `AsyncSegment` (reserved).
|
||||
|
||||
`AcBinaryHubProtocol` is the base (unsealed) — general binary framing only. `AyCodeBinaryHubProtocol` derives from it with consumer-specific logic: `SignalParams` capture (via `OnArgumentRead` hook), `IsRawBytesData` path, `SignalDataType` type resolution. Register `AyCodeBinaryHubProtocol` in both client and server.
|
||||
|
||||
|
|
@ -182,6 +182,7 @@ Type-guided deserialization — each parameter is individually serialized/deseri
|
|||
| Client base | `SignalRs/AcSignalRClientBase.cs` |
|
||||
| Binary protocol (base) | `SignalRs/AcBinaryHubProtocol.cs` |
|
||||
| Binary protocol (derived) | `SignalRs/AyCodeBinaryHubProtocol.cs` |
|
||||
| Protocol mode enum | `SignalRs/BinaryProtocolMode.cs` |
|
||||
| Tag attributes | `SignalRs/SignalMessageTagAttribute.cs` |
|
||||
| Base tags | `SignalRs/AcSignalRTags.cs` |
|
||||
| CRUD tags | `SignalRs/SignalRCrudTags.cs` |
|
||||
|
|
|
|||
|
|
@ -158,4 +158,16 @@ Typical overhead for 225KB payload with 4096-byte segments: ~224.5KB zero-copy,
|
|||
| `BufferWriterChunkSize` | 65536 | 4096 | Chunk size for BWO. SignalR sets 4096 in `AcBinaryHubProtocol` constructor. |
|
||||
| `InitialBufferCapacity` | 16384 | — | Starting buffer for `ArrayBinaryOutput` (byte[] serialize path) |
|
||||
|
||||
**Source:** `AyCode.Services/SignalRs/AcBinaryHubProtocol.cs` (base), `AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs` (consumer logic)
|
||||
## BinaryProtocolMode
|
||||
|
||||
`enum BinaryProtocolMode` — constructor parameter for `AcBinaryHubProtocol`, selects transport strategy:
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `Bytes` (default) | Standard: serialize to `BufferWriterBinaryOutput`, single flush at end. |
|
||||
| `Segment` | Segment streaming: serialize to `AsyncPipeWriterOutput`, flush per 4096-byte chunk via `PipeWriter.FlushAsync().Forget()`. Network transfer overlaps with serialization. |
|
||||
| `AsyncSegment` | Reserved for future async serializer. |
|
||||
|
||||
In `Segment` mode, `WriteArgument` casts `IBufferWriter<byte> output` to `PipeWriter` and calls `AcBinarySerializer.Serialize(value, pipeWriter, options)` which uses `AsyncPipeWriterOutput` internally. The reader side currently uses the same `SequenceBinaryInput` path (SignalR delivers complete messages via `TryParseMessage`). `PipeReaderBinaryInput` is available for future direct-pipe deserialization.
|
||||
|
||||
**Source:** `AyCode.Services/SignalRs/AcBinaryHubProtocol.cs` (base), `AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs` (consumer logic), `AyCode.Services/SignalRs/BinaryProtocolMode.cs` (enum)
|
||||
|
|
|
|||
Loading…
Reference in New Issue