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

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.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[] (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:

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

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)