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:
Loretta 2026-04-10 09:27:40 +02:00
parent 27cac570be
commit 8ff75de55c
14 changed files with 569 additions and 7 deletions

View File

@ -51,7 +51,8 @@
"Bash(2)",
"Bash(dotnet --version)",
"WebSearch",
"Bash(dotnet script:*)"
"Bash(dotnet script:*)",
"Bash(xargs wc:*)"
]
}
}

View File

@ -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();

View File

@ -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>

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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)
{

View File

@ -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,
}

View File

@ -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` |

View File

@ -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)