AyCode.Core/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md

7.3 KiB

SignalR Binary Protocol

AcBinaryHubProtocol (unsealed base) — custom IHubProtocol (name: "acbinary") replacing SignalR JSON+Base64 with AcBinarySerializer. AyCodeBinaryHubProtocol (derived, currently empty) exists for registration and future project-specific hooks.

Architecture (tag system, dispatch, request/response): SIGNALR.md Output 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[] → byte[] fast-path through BWO (size known, no patching)
│  │  └─ object → FlushAndReset() → reserve INT32 arg prefix
│  │     → AcBinarySerializer.Serialize(value, output) → patch prefix
│  ├─ WriteStringArray(streamIds)
│  └─ WriteHeaders(headers)
├─ Patch outer length = BWO.Position + externalBytes
└─ BWO.Flush()

Dual BWO Pattern

Protocol and serializer each create own BufferWriterBinaryOutput on the same IBufferWriter. Sequential, never concurrent:

  1. Protocol BWO (standalone): framing — message type, strings, headers. Cached chunk, zero dispatch.
  2. FlushAndReset(): commits bytes to pipe, invalidates chunk.
  3. Serializer BWO (context mode): Serialize() creates internal BWO, acquires fresh chunk, writes, flushes.
  4. 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).

Write: byte[] Fast-Path

When argument is byte[], bypasses serializer entirely — writes through BWO with known size:

WriteArgument(byte[] value):
  argPayload = 1 (BinaryTypeCode) + VarUIntSize(length) + length
  Write INT32 argPayload (no patching needed — size known upfront)
  Write BinaryTypeCode.ByteArray (0x44)
  Write VarUInt length
  Write raw bytes via BWO

Write: Object Zero-Copy Path

When argument is any other object, serializes directly to the pipe (zero-copy):

WriteArgument(object value):
  FlushAndReset() BWO  — hand pipe to serializer
  Reserve INT32 arg length prefix on pipe
  AcBinarySerializer.Serialize(value, output, options)  — writes directly to pipe
  Patch arg length prefix with actual bytes written

No intermediate byte[] — serializer writes to the pipe's IBufferWriter segments.

Read: Three-Path Argument Deserialization

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):
       skip tag + VarUInt length → return payload as byte[]
       Detection is wire-format only — 0x44 is unambiguous (no AcBinary object starts with it)

  2. IsRawBytesData path:
     if _currentSignalParams.IsRawBytesData == true:
       return SequenceToByteArray(argSlice)  — entire arg as raw byte[], no deserialization
       Consumer (DataSource.PopulateMerge) handles deserialization

  3. Typed deserialization:
     if targetType == object && SignalDataType != null:
       resolve Type from SignalDataType (AssemblyQualifiedName)
     DeserializeFromSequence(argSlice, resolvedType, options)
       → AcBinaryDeserializer.Deserialize(ReadOnlySequence, Type)
       → single-segment: ArrayBinaryInput (zero-copy via TryGetArray)
       → multi-segment: SequenceBinaryInput (lazy iteration, no pre-allocation)

SignalParams Capture

_currentSignalParams field captures the parsed SignalParams (arg[2]) during ReadArguments. The 4th arg (data) uses it for type-aware deserialization. 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 Purpose
Options AcBinarySerializerOptions.Default Serializer options (volatile, runtime-replaceable)
BufferWriterChunkSize 65536 Chunk size for both BWOs

Source: AyCode.Services/SignalRs/AcBinaryHubProtocol.cs (base), AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs (derived)