# 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/AyCode.Core/docs/BINARY/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/AyCode.Core/docs/BINARY/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/AyCode.Core/docs/BINARY/BINARY_ISSUES.md` ## Configuration Hub protocol settings are controlled via **`AcBinaryHubProtocolOptions`** (mutable class). Pass directly to the protocol constructor, or configure via DI in `Program.cs` with `services.Configure(opts => …)`. | Property | Default | Purpose | |----------|---------|---------| | `SerializerOptions` | `AcBinarySerializerOptions.Default` | Binary serializer options (also usable standalone via `ToBinary`/`BinaryTo`). | | `ProtocolMode` | `Bytes` | Wire format and pipeline strategy — see **BinaryProtocolMode** below. | | `BufferSize` | 4096 | Per-chunk size. 4 KB aligns with Kestrel's slab. Max 65535 (UINT16). | | `WaitForFlush` | `true` | AsyncSegment flush strategy — see trade-off below. | | `FlushTimeout` | 10 s | Per-flush wait limit. `Timeout.InfiniteTimeSpan` = disabled. | | `Name` | `"acbinary"` | SignalR handshake protocol name. Client and server must match. | | `Logger` | `null` | Optional `ILogger`; injected from DI when registered. | Inner `AcBinarySerializerOptions` defaults relevant for SignalR: `UseGeneratedCode=true` (hybrid source-gen + reflection), `UseStringInterning=All`, `InitialBufferCapacity=16384`. ### `WaitForFlush` (AsyncSegment-only) | Value | Pro | Con | |-------|-----|-----| | **`true`** (default) | Max pipeline parallelism + guaranteed end-to-end zero-copy on send. | Slow consumer propagates back as server-thread blocking (bounded by `FlushTimeout`). | | **`false`** | Adaptive — fire-and-forget per chunk, blocks only when ~60 KB memory threshold is hit. | Under heavy backpressure a chunk may fall back to an owned (copied) buffer, losing zero-copy for that chunk. | ### `FlushTimeout` rationale (10 s default) - AsyncSegment chunks are ≤ 65 KB (UINT16). Even GPRS-class links (~60 Kbit/s) transfer 65 KB in ~9 s — so any flush exceeding 10 s indicates a genuinely stuck consumer. - **Pro**: fast failure detection; server thread never blocks indefinitely. - **Con**: an unusually slow but otherwise healthy consumer will be disconnected — tune up for satellite / throttled links. - Complementary to SignalR's connection-level timeouts (`ClientTimeoutInterval`, `KeepAliveInterval`). Set `FlushTimeout < ClientTimeoutInterval` so this per-operation guard fires first. - Set to `Timeout.InfiniteTimeSpan` to fully disable (legacy behavior). ## BinaryProtocolMode `enum BinaryProtocolMode` — constructor parameter for `AcBinaryHubProtocol`, selects serialization + transport strategy: | Value | Serialize | Deserialize (non-WASM) | Pro / Con | |-------|-----------|----------------------|-----------| | `Bytes` (default) | `ArrayBinaryOutput` → `byte[]` → write to pipe as raw blob | `ArrayBinaryInput` (single contiguous buffer via `MemoryMarshal.TryGetArray` zero-copy / pool-rent). | **Pro**: simplest, fastest per-call, WASM-safe on both sides. **Con**: no zero-copy write, no pipeline overlap. | | `Segment` | `BufferWriterBinaryOutput` → directly to `PipeWriter`, chunk-by-chunk, single `Flush` at end | Same as Bytes (unified `ArrayBinaryInput` receive path — `_protocolMode` affects send only). | **Pro**: zero-copy write, WASM-safe. **Con**: no pipeline overlap — receiver must wait for full payload before deser starts. | | `AsyncSegment` | `AsyncPipeWriterOutput` → self-describing chunked framing `[201][UINT16 size][data]` per chunk, per-chunk `FlushAsync` with timeout-bounded sync-await | `SegmentBufferReader` (growing contiguous byte[]) + `SegmentBufferReaderInput`; background `Task.Run` deserializes while chunks arrive. WASM: synchronous deser on `CHUNK_END`. | **Pro**: zero-copy write + pipeline parallelism (ser / network / deser overlap). **Con**: send-side not WASM-compatible (see below); slow consumer propagates as server-thread blocking (bounded by `FlushTimeout`). Max chunk: 65535 bytes. | 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 (zero-copy via `PipeWriter.Advance` with 3-byte header reservation), (3) CHUNK_END — `[202]` (1 byte). The receiver's `TryParseChunkData` accumulates into a `SegmentBufferReader`; on non-WASM platforms a background `Task.Run` deserializes in parallel via `SegmentBufferReaderInput`, on WASM the deserializer runs synchronously on `CHUNK_END` over the already-buffered data. 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**. `AcBinaryHubProtocolOptions.Validate()` 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. ## Registration in `Program.cs` ### Server ```csharp builder.Services.AddSignalR(hubOptions => { hubOptions.EnableDetailedErrors = true; hubOptions.MaximumReceiveMessageSize = 30_000_000; hubOptions.KeepAliveInterval = TimeSpan.FromSeconds(60); hubOptions.ClientTimeoutInterval = TimeSpan.FromSeconds(180); hubOptions.StatefulReconnectBufferSize = 30_000_000; }) .AddAcBinaryProtocol(opts => { opts.ProtocolMode = BinaryProtocolMode.AsyncSegment; // opts.FlushTimeout = TimeSpan.FromSeconds(10); // default }); ``` ### Client — `HubConnectionBuilder` as a DI transient The consumer (e.g. a class derived from `AcSignalRClientBase`) receives the builder via DI: ```csharp services.AddTransient(sp => { var logger = sp.GetRequiredService(); var hubUrl = $"{Config.BaseUrl}/{Config.HubName}"; var builder = new HubConnectionBuilder() .WithUrl(hubUrl, HttpTransportType.WebSockets, options => { options.TransportMaxBufferSize = 30_000_000; options.ApplicationMaxBufferSize = 30_000_000; options.CloseTimeout = TimeSpan.FromSeconds(10); options.SkipNegotiation = true; }) .ConfigureLogging(logging => { logging.SetMinimumLevel(LogLevel.Information); logging.AddAcLogger(_ => logger); }) .WithAutomaticReconnect() .WithStatefulReconnect() .WithKeepAliveInterval(TimeSpan.FromSeconds(60)) .WithServerTimeout(TimeSpan.FromSeconds(180)); builder.AddAcBinaryProtocol(opts => { // Desktop / server / native: AsyncSegment for pipeline parallelism. // WebAssembly: must be Bytes or Segment (Validate throws on AsyncSegment). opts.ProtocolMode = OperatingSystem.IsBrowser() ? BinaryProtocolMode.Segment : BinaryProtocolMode.AsyncSegment; }); return builder; }); services.AddSingleton(); // derived from AcSignalRClientBase ``` **Note**: `AcSignalRClientBase` is `HubConnectionBuilder`-injected and calls only `Build()` + dispatch wiring internally. All transport/protocol configuration lives in `Program.cs` — visible, overridable per environment, and identical on both ends of the wire. **Source:** `AyCode.Services/SignalRs/AcBinaryHubProtocol.cs` (base), `AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs` (consumer logic), `AyCode.Services/SignalRs/BinaryProtocolMode.cs` (enum), `AyCode.Services/SignalRs/AcBinaryHubProtocolOptions.cs` (options), `AyCode.Services/SignalRs/AcSignalRProtocolExtensions.cs` (DI extensions)