# 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`, 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[] (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: ```csharp 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 ```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). ## Read: Argument Deserialization Base `AcBinaryHubProtocol.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): 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` 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` (lazy `TryGet` iteration, cross-boundary scratch) | Zerocopy write. No pipeline overlap. | | `AsyncSegment` | `AsyncPipeWriterOutput` → self-describing chunked framing via `PipeWriter`, per-chunk `FlushAsync().Forget()` | `PipeReaderBinaryInput` from internal `Pipe` → background `Task` deser, on-demand `ReadAsync` | Zerocopy write + pipeline parallelism (ser/network/deser overlap). Max chunk: 65535 bytes (UINT16). | In `AsyncSegment` mode, `WriteMessage` dispatches to `WriteMessageChunked` which sends: (1) CHUNK_START — standard SignalR framing `[INT32 len][200][original message with INT32 -1 for streamed arg]`, (2) N x CHUNK_DATA — `[201][UINT16 size][data]` per chunk (written by `AsyncPipeWriterOutput` with 3-byte header reservation, zero-copy), (3) CHUNK_END — `[202]` (1 byte, no data). The receiver's `TryParseMessage` enters chunk accumulation mode after CHUNK_START, feeding data to an internal `Pipe` where a background `Task` deserializes via `PipeReaderBinaryInput`. In `Bytes` and `Segment` mode, the standard `WriteMessage` path is used. ### WebAssembly compatibility The send and receive paths handle WASM (`OperatingSystem.IsBrowser()`) asymmetrically — **send** is strictly bound to `_protocolMode`, **receive** adapts to the wire format and falls back to a synchronous path only when the platform cannot support the optimal strategy. - **Send path**: `AsyncSegment` is **not supported on WebAssembly**. The constructor throws `PlatformNotSupportedException` if `IsBrowser && protocolMode == AsyncSegment` (the `AsyncPipeWriterOutput.SyncAwaitFlush` sync-over-async pattern would block the single UI thread). WASM clients must use `Bytes` or `Segment`. - **Receive path**: works on WASM with **any** server-side mode (including `AsyncSegment` → chunked wire). `TryParseChunkData` detects the platform at runtime: - **Non-browser**: first `CHUNK_DATA` spawns a background `Task.Run` over a `SegmentBufferReader` (pipeline parallelism — serialize / network / deserialize overlap). `CHUNK_END` awaits the task's result. - **Browser**: the background task is skipped. Chunks accumulate in `SegmentBufferReader`; on `CHUNK_END` the buffer is `Complete()`d and the deserializer runs synchronously on the current thread. `SegmentBufferReaderInput.TryAdvanceSegment` sees `_completed=true` and never calls `ManualResetEventSlim.Wait()` (which throws `PlatformNotSupportedException` on WASM). Consequence: a mixed topology (desktop server in `AsyncSegment`, WASM client in `Bytes`) works without any negotiation or protocol-name variation — the client converts the incoming chunked wire to its own synchronous processing model. **Source:** `AyCode.Services/SignalRs/AcBinaryHubProtocol.cs` (base), `AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs` (consumer logic), `AyCode.Services/SignalRs/BinaryProtocolMode.cs` (enum)