# 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`, no intermediate `byte[]`. ``` WriteMessage(HubMessage, IBufferWriter 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 ```csharp 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`: ``` 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` 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)