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