9.0 KiB
SignalR Binary Protocol
AcBinaryHubProtocol (unsealed base) — custom IHubProtocol (name: "acbinary") replacing SignalR JSON+Base64 with AcBinarySerializer. General binary framing only — no consumer-specific logic.
AyCodeBinaryHubProtocol (derived) — project-specific consumer logic: SignalParams capture via OnArgumentRead, IsRawBytesData path, SignalDataType type resolution in ReadSingleArgument override.
Architecture (tag system, dispatch, request/response):
SIGNALR.mdOutput writers (cached chunk, buffer states, chunk sizing):AyCode.Core/docs/BINARY_WRITERS.md
Wire Format
[INT32 LE payload length] [1 byte message type] [message-specific fields]
| Type | Byte | Fields |
|---|---|---|
| Invocation | 1 | nullable invocationId, target, arguments, streamIds, headers |
| StreamItem | 2 | invocationId, argument, headers |
| Completion | 3 | invocationId, nullable error, hasResult, optional result, headers |
| StreamInvocation | 4 | invocationId, target, arguments, streamIds, headers |
| CancelInvocation | 5 | invocationId, headers |
| Ping | 6 | (empty) |
| Close | 7 | nullable error, allowReconnect |
| Ack | 8 | sequenceId (INT64) |
| Sequence | 9 | sequenceId (INT64) |
Argument framing: [INT32 LE arg length] [serialized bytes] — deferred deserialization (target parsed first → IInvocationBinder resolves types).
Strings: [VarUInt byte length] [UTF-8]. Nullable: 0=null, 1=value follows.
Headers: [VarUInt count] [key-value pairs...]. Count=0 → no headers.
Zero-Copy Write Pipeline
Writes directly to SignalR pipe's IBufferWriter<byte>, no intermediate byte[].
WriteMessage(HubMessage, IBufferWriter<byte> output)
├─ Reserve 4-byte outer length prefix
├─ BWO = BufferWriterBinaryOutput(output) [standalone mode]
│ ├─ WriteByte(messageType)
│ ├─ WriteStringUtf8(invocationId, target)
│ ├─ WriteVarUInt(argCount)
│ ├─ Per argument:
│ │ ├─ byte[] (AcBinary) → raw length + bytes (no tag, no VarUInt)
│ │ ├─ byte[] (other) → tag (0x44) + raw bytes (no VarUInt — argLength implies size)
│ │ └─ object → FlushAndReset() → reserve INT32 arg prefix
│ │ → AcBinarySerializer.Serialize(value, output) → patch prefix
│ ├─ WriteStringArray(streamIds)
│ └─ WriteHeaders(headers)
├─ Patch outer length = BWO.Position + externalBytes
└─ BWO.Flush()
byte[] Write: isAcBinary Detection
When argument is byte[], the protocol checks if it's already AcBinary-serialized data:
var isAcBinary = byteArray.Length >= 2
&& byteArray[0] == AcBinarySerializerOptions.FormatVersion // 1
&& (byteArray[1] & 0xF0) == BinaryTypeCode.HeaderFlagsBase; // 0x90
- isAcBinary = true:
[argLength=N][raw AcBinary bytes]— no tag, reader deserializes directly - isAcBinary = false:
[argLength=1+N][0x44 tag][raw bytes]— tag for type detection, no VarUInt (argLength implies size)
Dual BWO Pattern
Protocol and serializer each create own BufferWriterBinaryOutput on the same IBufferWriter. Sequential, never concurrent:
- Protocol BWO (standalone): framing — message type, strings, headers. Cached chunk, zero dispatch.
FlushAndReset(): commits bytes to pipe, invalidates chunk.- Serializer BWO (context mode):
Serialize()creates internal BWO, acquires fresh chunk, writes, flushes. - Protocol BWO re-acquires chunk on next write (via
Grow).
Cost: one extra GetMemory per argument (nanoseconds).
Benefit: zero-copy end-to-end, no intermediate byte[], no wrapper class.
Why two BWOs: serializer writes must live on BinarySerializationContext (sealed class) for JIT optimization — context owns its own BWO. See AyCode.Core/docs/BINARY_WRITERS.md § "Why Writes Are on the Context".
Length Prefix Patching
var lengthSpan = output.GetSpan(4);
output.Advance(4);
// ... write payload ...
Unsafe.WriteUnaligned(ref lengthSpan[0], payloadLength);
Safe for PipeWriter — segments writable until FlushAsync.
GetMessageBytes caveat: ArrayBufferWriter initial capacity must include LengthPrefixSize to prevent resize after prefix reservation (stale span).
Read: Argument Deserialization
Base AcBinaryHubProtocol.ReadSingleArgument reads [INT32 argLength] [argBytes] from the pipe's ReadOnlySequence via SequenceReader<byte>:
ReadSingleArgument(SequenceReader, targetType):
Read INT32 argLength
if argLength == 0 → return null
if argLength == 1 && first byte == 0 → return null (null marker)
argSlice = UnreadSequence.Slice(0, argLength) — zero-copy reference
Advance(argLength)
1. byte[] fast-path:
if first byte == BinaryTypeCode.ByteArray (0x44):
return argSlice.Slice(1) as byte[] — skip tag, rest is raw payload
No VarUInt — argLength implies size
2. Default: DeserializeFromSequence(argSlice, targetType, options)
AyCodeBinaryHubProtocol.ReadSingleArgument overrides with consumer-specific paths:
(same argLength/null/slice logic as base)
1. byte[] fast-path (same as base)
2. IsRawBytesData path:
if _currentSignalParams.IsRawBytesData == true:
return SequenceToByteArray(argSlice) — entire arg as raw byte[]
Consumer (DataSource.PopulateMerge) handles deserialization
3. Typed deserialization:
if targetType == object && SignalDataType != null:
resolve Type from SignalDataType (AssemblyQualifiedName)
DeserializeFromSequence(argSlice, resolvedType, options)
SignalParams Capture
Base AcBinaryHubProtocol.ReadArguments calls OnArgumentRead(value, index) after each argument. AyCodeBinaryHubProtocol overrides this hook to capture SignalParams (arg[2]) for type-aware deserialization of subsequent args (arg[3] = data). Thread-safe: SignalR processes messages sequentially per connection.
SequenceToByteArray
Zero-copy when possible: if single-segment and backing array matches exactly → return the array directly. Otherwise ReadOnlySequence.ToArray().
SequenceBinaryInput (Multi-Segment Deserialization)
struct SequenceBinaryInput : IBinaryInputBase — reads from ReadOnlySequence<byte> without linearizing. Lazy iteration via ReadOnlySequence.TryGet — zero constructor allocation, no pre-extracted segment array.
The context's _buffer always points directly to the current segment's backing byte[] (zero-copy). Cross-boundary reads (value straddling segment boundary) copy only the affected bytes into a small ArrayPool-rented scratch buffer. After the scratch read, _afterCrossBoundary flag restores the context to the next segment's backing array.
Typical overhead for 225KB payload with 4096-byte segments: ~224.5KB zero-copy, ~500 bytes scratch copy at ~55 boundaries. The scratch buffer is rented once (lazy, on first boundary) and reused across all boundaries. Release() returns it to ArrayPool after deserialization.
Known issues:
AyCode.Core/docs/BINARY_ISSUES.md
Config
| Property | Default | SignalR | Purpose |
|---|---|---|---|
Options |
AcBinarySerializerOptions.Default |
— | Serializer options (volatile, runtime-replaceable) |
BufferWriterChunkSize |
65536 | 4096 | Chunk size for BWO. SignalR sets 4096 in AcBinaryHubProtocol constructor. |
InitialBufferCapacity |
16384 | — | Starting buffer for ArrayBinaryOutput (byte[] serialize path) |
BinaryProtocolMode
enum BinaryProtocolMode — constructor parameter for AcBinaryHubProtocol, selects serialization + transport strategy:
| Value | Serialize | Deserialize | Characteristics |
|---|---|---|---|
Bytes (default) |
ArrayBinaryOutput → byte[] → write to pipe as raw blob |
SequenceReader.ToArray() → ArrayBinaryInput (single contiguous buffer, TryAdvanceSegment → false, JIT-eliminated) |
Fastest individual ser/deser. No zerocopy. No pipeline overlap. |
Segment |
BufferWriterBinaryOutput → directly to PipeWriter, chunk-by-chunk, single Flush at end |
SequenceBinaryInput → multi-segment ReadOnlySequence<byte> (lazy TryGet iteration, cross-boundary scratch) |
Zerocopy write. No pipeline overlap. |
AsyncSegment |
AsyncPipeWriterOutput → directly to PipeWriter, per-chunk FlushAsync().Forget() with backpressure |
PipeReaderBinaryInput → on-demand ReadAsync, processes chunks as they arrive from the network |
Zerocopy write + pipeline parallelism (ser/network/deser overlap). Highest roundtrip potential for large payloads. |
In AsyncSegment mode, WriteArgument casts IBufferWriter<byte> output to PipeWriter and calls AcBinarySerializer.Serialize(value, pipeWriter, options) which uses AsyncPipeWriterOutput internally. In Bytes and Segment mode, the standard AcBinarySerializer.Serialize(value, output, options) path is used (BWO on IBufferWriter<byte>).
Source: AyCode.Services/SignalRs/AcBinaryHubProtocol.cs (base), AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs (consumer logic), AyCode.Services/SignalRs/BinaryProtocolMode.cs (enum)