diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs
index 5221121..c5b2fbe 100644
--- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs
+++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs
@@ -423,24 +423,28 @@ public static partial class AcBinarySerializer
}
///
- /// 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.
+ /// Serialize to PipeWriter with chunked protocol framing via AsyncPipeWriterOutput.
+ /// Each chunk (including the last) is framed as [201][UINT16 size][data] and committed
+ /// to the PipeWriter via Advance (zero-copy). The protocol layer writes a single [202]
+ /// byte after to signal end-of-stream.
///
- public static int Serialize(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
+ /// Total serialized data bytes (excluding framing overhead).
+ public static int Serialize(
+ T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options,
+ bool waitForFlush = true)
{
if (value == null)
{
+ // Null: write directly, no chunking needed
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.Get(options);
- context.Output = new AsyncPipeWriterOutput(pipeWriter, options.BufferWriterChunkSize);
+ context.Output = new AsyncPipeWriterOutput(pipeWriter, options.BufferWriterChunkSize, waitForFlush);
context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
try
diff --git a/AyCode.Core/Serializers/Binaries/AsyncPipeWriterOutput.cs b/AyCode.Core/Serializers/Binaries/AsyncPipeWriterOutput.cs
index 43bc563..8b953a9 100644
--- a/AyCode.Core/Serializers/Binaries/AsyncPipeWriterOutput.cs
+++ b/AyCode.Core/Serializers/Binaries/AsyncPipeWriterOutput.cs
@@ -1,5 +1,6 @@
using System;
using System.Buffers;
+using System.Buffers.Binary;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -9,114 +10,115 @@ using AyCode.Core.Helpers;
namespace AyCode.Core.Serializers.Binaries;
///
-/// Binary output that writes to a PipeWriter with per-chunk network flush.
+/// Binary output that writes to a PipeWriter with per-chunk network flush and self-describing framing.
///
-/// 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.
+/// Each chunk (including the last) is framed as [201][UINT16 size][data] with a 3-byte header
+/// reserved at the start of each buffer. The serializer context writes into the space after the
+/// reserved bytes; on Grow(), the header is patched and the full chunk is committed via Advance
+/// and flushed to the network. Flush() does the same for the last (partial) chunk — zero-copy
+/// for both intermediate and final chunks.
///
-/// 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 protocol layer writes a single [202] byte after all chunks to signal end-of-stream.
///
-/// The first Grow() skips the flush to keep the length prefix span valid for patching.
+/// Backpressure modes (controlled by waitForFlush constructor parameter):
+///
+/// waitForFlush=true (default): Grow() blocks if the previous FlushAsync hasn't completed.
+/// Bounds memory to ~2 chunks in flight.
+/// waitForFlush=false: Grow() never blocks. Data accumulates in the PipeWriter's internal
+/// buffer and is sent with the next completed flush. Maximum serialization throughput.
+///
+///
+/// Maximum chunk data size: 65535 bytes (UINT16 max).
///
public struct AsyncPipeWriterOutput : IBinaryOutputBase
{
+ /// MsgAsyncChunkData type marker (201).
+ private const byte ChunkDataMarker = 201;
+
+ /// Header size: 1 byte type + 2 bytes UINT16 size.
+ private const int HeaderSize = 3;
+
+ /// Maximum chunk data size (UINT16 max).
+ public const int MaxChunkSize = ushort.MaxValue;
+
private readonly PipeWriter _pipeWriter;
private readonly int _chunkSize;
+ private readonly bool _waitForFlush;
private int _committedBytes;
private int _currentChunkStart;
private bool _ownedBuffer;
private ValueTask _lastFlush;
- private bool _firstGrow;
- public AsyncPipeWriterOutput(PipeWriter pipeWriter, int chunkSize = 4096)
+ public AsyncPipeWriterOutput(PipeWriter pipeWriter, int chunkSize = 4096, bool waitForFlush = true)
{
+ if (chunkSize > MaxChunkSize)
+ throw new ArgumentOutOfRangeException(nameof(chunkSize), chunkSize,
+ $"Chunk size cannot exceed {MaxChunkSize} (UINT16 max).");
+
_pipeWriter = pipeWriter;
_chunkSize = chunkSize;
+ _waitForFlush = waitForFlush;
_committedBytes = 0;
_ownedBuffer = false;
_lastFlush = default;
- _firstGrow = true;
}
///
- /// Provides the initial buffer from the PipeWriter.
+ /// Provides the initial buffer from the PipeWriter with 3-byte header reservation.
///
[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;
}
///
- /// 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.
+ /// Called when the context's buffer is full. Patches the chunk header [201][UINT16 size],
+ /// commits the chunk to the PipeWriter, and fires a background flush.
///
[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)
+ if (_waitForFlush && !_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;
- }
+ CommitCurrentChunk(buffer, position);
- // Fire-and-forget flush — EXCEPT first chunk (length prefix span must stay valid)
- if (!_firstGrow)
+ // Fire-and-forget flush when previous is done
+ if (_lastFlush.IsCompleted)
{
_lastFlush = _pipeWriter.FlushAsync();
_lastFlush.Forget();
}
- _firstGrow = false;
- // Acquire new chunk
+ // Acquire new chunk with header reservation
AcquireChunk(Math.Max(needed, _chunkSize), out buffer, out position, out bufferEnd);
_currentChunkStart = position;
}
///
- /// Returns total bytes written: committed + pending in current chunk.
+ /// Returns total serialized data bytes (excluding framing overhead).
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int GetTotalPosition(int currentPosition)
=> _committedBytes + (currentPosition - _currentChunkStart);
///
- /// Commits final pending bytes and performs a synchronous flush.
- /// Must be called after all writes are complete.
+ /// Commits the last (partial) chunk to the PipeWriter with [201][UINT16 size] header.
+ /// Zero-copy: patches the reserved header bytes and calls Advance — no data copying.
+ /// Does NOT flush to network — the protocol writes [202] and flushes after.
///
public void Flush(byte[] buffer, int position)
{
- // Wait for any in-flight flush
+ // Wait for any in-flight flush from previous Grow
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();
+ CommitCurrentChunk(buffer, position);
}
///
@@ -124,35 +126,57 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
///
public void Reset() { }
- [MethodImpl(MethodImplOptions.NoInlining)]
- private void FlushOwnedBuffer(byte[] buffer, int bytesInChunk)
+ ///
+ /// Patches [201][UINT16 dataBytes] into the reserved header and commits via Advance.
+ /// For owned buffers, copies to PipeWriter first.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void CommitCurrentChunk(byte[] buffer, int position)
{
- var span = _pipeWriter.GetSpan(bytesInChunk);
- buffer.AsSpan(_currentChunkStart, bytesInChunk).CopyTo(span);
- _pipeWriter.Advance(bytesInChunk);
+ var dataBytes = position - _currentChunkStart;
+ if (dataBytes <= 0) return;
+
+ var headerStart = _currentChunkStart - HeaderSize;
+ buffer[headerStart] = ChunkDataMarker;
+ BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(headerStart + 1, 2), (ushort)dataBytes);
+
+ if (_ownedBuffer)
+ FlushOwnedBuffer(buffer, headerStart, HeaderSize + dataBytes);
+ else
+ _pipeWriter.Advance(HeaderSize + dataBytes);
+
+ _committedBytes += dataBytes; // only count data bytes, not framing
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void FlushOwnedBuffer(byte[] buffer, int start, int length)
+ {
+ var span = _pipeWriter.GetSpan(length);
+ buffer.AsSpan(start, length).CopyTo(span);
+ _pipeWriter.Advance(length);
ArrayPool.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);
+ var dataSize = Math.Min(Math.Max(requestSize, _chunkSize), MaxChunkSize);
+ var totalRequest = dataSize + HeaderSize;
+ var memory = _pipeWriter.GetMemory(totalRequest);
if (MemoryMarshal.TryGetArray(memory, out ArraySegment segment) && segment.Array != null)
{
buffer = segment.Array;
- position = segment.Offset;
- bufferEnd = segment.Offset + segment.Count;
+ position = segment.Offset + HeaderSize;
+ bufferEnd = segment.Offset + HeaderSize + dataSize;
_ownedBuffer = false;
}
else
{
- // Fallback for non-array-backed PipeWriter (e.g. Kestrel PinnedBlockMemoryPool)
- var owned = ArrayPool.Shared.Rent(actualRequest);
+ var owned = ArrayPool.Shared.Rent(totalRequest);
buffer = owned;
- position = 0;
- bufferEnd = owned.Length;
+ position = HeaderSize;
+ bufferEnd = HeaderSize + dataSize;
_ownedBuffer = true;
}
}
diff --git a/AyCode.Core/Serializers/Binaries/PipeReaderBinaryInput.cs b/AyCode.Core/Serializers/Binaries/PipeReaderBinaryInput.cs
index 33a51dc..86f158c 100644
--- a/AyCode.Core/Serializers/Binaries/PipeReaderBinaryInput.cs
+++ b/AyCode.Core/Serializers/Binaries/PipeReaderBinaryInput.cs
@@ -60,6 +60,7 @@ public struct PipeReaderBinaryInput : IBinaryInputBase
if (!_currentBuffer.TryGet(ref _nextSegmentPosition, out var memory) || memory.Length == 0)
throw new AcBinaryDeserializationException("Empty pipe — no data to read.");
+ _consumedUpTo = _nextSegmentPosition; // Mark first segment as consumed (same as TryLoadNextSegmentFromBuffer)
ExtractArray(memory, out buffer, out position, out bufferLength);
}
diff --git a/AyCode.Core/docs/BINARY_WRITERS.md b/AyCode.Core/docs/BINARY_WRITERS.md
index 5264672..6f83ef4 100644
--- a/AyCode.Core/docs/BINARY_WRITERS.md
+++ b/AyCode.Core/docs/BINARY_WRITERS.md
@@ -103,27 +103,40 @@ void Release();
## AsyncPipeWriterOutput
-`struct AsyncPipeWriterOutput : IBinaryOutputBase` — writes to `PipeWriter` with per-chunk network flush. Enables segment-level streaming: each serializer chunk goes to the network immediately.
+`struct AsyncPipeWriterOutput : IBinaryOutputBase` — writes to `PipeWriter` with per-chunk network flush and **self-describing chunked framing**. Each chunk is framed as `[201][UINT16 size][data]` — zero-copy for both intermediate and final chunks.
-### Differences from BufferWriterBinaryOutput
+### Chunked Protocol Framing
-Same cached chunk pattern (`GetMemory` → `TryGetArray` → direct array writes), but `Grow()` flushes the current chunk to the network before acquiring the next:
+Each chunk has a 3-byte header reserved via **header reservation** (skip 3 bytes in `AcquireChunk`, patch before `Advance`):
-1. Wait for previous flush if still in-flight (`_lastFlush` backpressure)
-2. `Advance(bytesInChunk)` — commit to `PipeWriter`
-3. `FlushAsync().Forget()` — fire-and-forget network send
-4. Acquire next chunk via `GetMemory`
+1. `AcquireChunk`: request `chunkSize + 3` from PipeWriter, set `position = offset + 3` (skip reserved header), force `bufferEnd = offset + 3 + chunkSize`
+2. Context writes serializer data into `buffer[position..bufferEnd]`
+3. `Grow()`: patch `[201][UINT16 dataBytes]` header, `Advance(3 + dataBytes)`, `FlushAsync().Forget()`
+4. `Flush()`: same as Grow — patch header, `Advance(3 + dataBytes)`. Zero-copy, no data copying. The protocol writes a single `[202]` byte after.
-**First-Grow skip:** the first `Grow()` call does NOT flush — the length prefix span (reserved by the protocol before serialization) must stay valid until patching. `_firstGrow` flag controls this.
+### Backpressure Modes
-**Backpressure:** `_lastFlush` (ValueTask) tracks the most recent flush. If the serializer produces chunks faster than the network consumes them, the next `Grow()` waits — max ~2 chunks in memory at any time.
+Constructor parameter `waitForFlush` (default `true`):
+
+- **`waitForFlush=true`**: `Grow()` blocks if previous `FlushAsync` is still in-flight. Max ~2 chunks in memory.
+- **`waitForFlush=false`**: `Grow()` never blocks. Data accumulates in PipeWriter's internal buffer and is sent with the next completed flush. Maximum serialization throughput.
+
+In both modes, flush is only initiated when `_lastFlush.IsCompleted` — no overlapping FlushAsync calls.
+
+### Wire Format (per chunk)
+
+```
+CHUNK_DATA: [201][UINT16 size][data bytes] — every chunk (self-describing, variable size)
+CHUNK_END: [202] — end signal (1 byte, no data)
+```
+
+Max chunk data size: 65535 bytes (UINT16 max).
### Usage
-Selected via `BinaryProtocolMode.AsyncSegment` in `AcBinaryHubProtocol`. The protocol casts `IBufferWriter output` to `PipeWriter` (safe — SignalR always provides `PipeWriter`).
+Selected via `BinaryProtocolMode.AsyncSegment` in `AcBinaryHubProtocol`. The protocol's `WriteMessageChunked` method sends CHUNK_START (standard SignalR framing), then calls the serializer which writes all chunks via `AsyncPipeWriterOutput`, then the protocol writes `[202]`.
```csharp
-AcBinarySerializer.Serialize(value, (PipeWriter)output, options) // AsyncPipeWriterOutput path
+AcBinarySerializer.Serialize(value, pipeWriter, options);
+// All chunks already committed to PipeWriter. Protocol writes [202] and flushes.
```
-
-> Known issues and limitations: `BINARY_ISSUES.md`
diff --git a/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs b/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs
index fee8746..944924b 100644
--- a/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs
+++ b/AyCode.Services/SignalRs/AcBinaryHubProtocol.cs
@@ -1,6 +1,7 @@
using System.Buffers;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
+using System.IO.Pipelines;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
@@ -8,6 +9,7 @@ using AyCode.Core.Serializers.Binaries;
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Protocol;
+using Microsoft.Extensions.Logging;
namespace AyCode.Services.SignalRs;
@@ -46,16 +48,42 @@ public class AcBinaryHubProtocol : IHubProtocol
private const byte MsgAck = 8;
private const byte MsgSequence = 9;
+ // Chunked protocol framing for AsyncSegment mode
+ private const byte MsgAsyncChunkStart = 200;
+ private const byte MsgAsyncChunkData = 201;
+ private const byte MsgAsyncChunkEnd = 202;
+
+ /// Sentinel object placed in the args array for the streamed argument (replaced after chunk deserialization).
+ protected static readonly object StreamedArgPlaceholder = new();
+
protected volatile AcBinarySerializerOptions _options;
protected readonly BinaryProtocolMode _protocolMode;
+ protected readonly ILogger? _logger;
+
+ /// Per-connection chunk accumulation state. Key is IInvocationBinder (per-connection, GC-friendly).
+ private readonly ConditionalWeakTable? _chunkStates;
+
+ private sealed class AsyncChunkState
+ {
+ public HubMessage PartialMessage = null!;
+ public object?[] Args = null!;
+ public int StreamedArgIndex;
+ public Type StreamedArgType = null!;
+ public Pipe InternalPipe = null!;
+ public Task