# 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<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 0x44 wrapper — first byte is AcBinary FormatVersion 0x01, 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 0x44 wrapper tag; first byte is the AcBinary FormatVersion 0x01 (the discriminator the receiver uses), 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<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 `ReadOnlySequence<byte>` without linearizing. Lazy iteration via `ReadOnlySequence.TryGet` — zero ctor alloc, no pre-extracted segment array.

Context `_buffer` points directly to current segment's backing `byte[]` (zero-copy). Cross-boundary reads (value straddling segments) copy only affected bytes into `ArrayPool`-rented scratch. After scratch read, `_afterCrossBoundary` flag restores context to next segment's backing array.

Typical overhead — 225KB payload, 4096-byte segments: ~224.5KB zero-copy + ~500 bytes scratch at ~55 boundaries. Scratch is rented once (lazy, first boundary) and reused; `Release()` returns it to `ArrayPool`.

> Known issues: `AyCode.Core/AyCode.Core/docs/BINARY/BINARY_ISSUES.md`

## Configuration

Hub protocol settings via **`AcBinaryHubProtocolOptions`** (mutable class). Pass to ctor directly, or configure via DI in `Program.cs` (`services.Configure<AcBinaryHubProtocolOptions>(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). |
| `FlushPolicy` | `Coalesced` | 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`.

### `FlushPolicy` (AsyncSegment-only)

| Value | Peak memory | Pro | Con |
|-------|------------|-----|-----|
| **`FlushPolicy.PerChunk`** | ~chunk × 1 | Strictly bounded peak memory regardless of consumer speed; guaranteed end-to-end zero-copy. | No producer/flush parallelism — wall-clock = sum of (serialize + flush) per chunk. |
| **`FlushPolicy.DoubleBuffered`** | ~chunk × 2 | Maximum producer/flush parallelism with bounded memory — wall-clock = max(serialize, flush) × N_chunks. Recommended for balanced streaming. | Slow consumer propagates back as server-thread blocking at next `Grow` (bounded by `FlushTimeout`). |
| **`FlushPolicy.Coalesced`** (default) | ~64 KB per window | Batches chunks into ~64 KB flush windows: while a flush is in-flight, new chunks accumulate; when the window fills, one batched `FlushAsync` covers the whole window. Major throughput win — a 9.5 MB payload at 4 KB chunks fires ~150 flushes (one per window) instead of ~2 300 per-chunk flushes. Especially impactful on transports with non-trivial `FlushAsync` overhead (network sockets, Kestrel WebSocket, kernel TCP). | Per-window peak memory ~64 KB; 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 + `[202]` end marker (or `[203]` CHUNK_ABORT on mid-stream serialize failure — fault isolation, see below), per-chunk `FlushAsync` with timeout-bounded sync-await | `AsyncPipeReaderInput` (growing contiguous byte[] with sliding-window cycle); the streaming deser runs on a dedicated off-pool thread via `AcBinaryDeserExecutor` while chunks arrive. WASM: synchronous deser on `CHUNK_END`. | **Pro**: zero-copy write + pipeline parallelism (ser / network / deser overlap); per-message fault isolation via CHUNK_ABORT. **Con**: send-side not WASM-compatible (see below); send-side per-chunk sync-flush blocks under client backpressure (`ACCORE-SBP-I-T3W9`, send-side fix TBD). 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 + final CHUNK_END — `[201][UINT16 size][data]` per chunk + `[202]` end marker, **all emitted by `AsyncPipeWriterOutput`** in framed mode (zero-copy via `PipeWriter.Advance` with 3-byte header reservation; protocol layer no longer writes its own `[202]` or extra `FlushAsync`). The receiver's `TryParseChunkData` accumulates into an `AsyncPipeReaderInput` (multiMessage:false — protocol parses `[201]/[202]` framing externally and feeds raw data via `Feed`); on non-WASM platforms the streaming deser runs in parallel on a dedicated off-pool thread via `AcBinaryDeserExecutor` (NOT the .NET thread pool — the deser blocks in `ManualResetEventSlim.Wait()` between chunks; running it on the pool causes starvation deadlock, see `ACCORE-SBP-I-T3W9`), on WASM the deserializer runs synchronously on `CHUNK_END` over the already-buffered data.

**Fault isolation (CHUNK_ABORT, `[203]`):** if the streamed-arg serialize throws after CHUNK_START has been sent (e.g. a property-getter NRE on the data graph), `WriteMessageChunked`'s `catch` emits a single `[203]` byte on the host `PipeWriter` instead of letting the exception propagate. The receiver's `TryParseChunkData` recognises `[203]` as a first-class abort: tears down the chunk-state safely (mirrors the `[202]` Complete → DeserTask observe → Dispose ordering), then surfaces the failure to the awaiting caller as a synthesised `CompletionMessage.WithError(invocationId, ...)` — the caller's task faults with an actionable error instead of waiting for a transport-level timeout. Blast radius = one message; the SignalR transport connection stays alive and other in-flight invocations on the same WebSocket are unaffected. If the abort marker itself can't be emitted (transport-level fault), behaviour falls back to the baseline (exception propagates, SignalR aborts the connection). Any other unexpected byte in chunk mode (not `[201]/[202]/[203]`) is a protocol-invariant violation: `TryParseChunkData` tears down safely and throws `System.IO.InvalidDataException` rather than guessing. Edge case: a mid-header serialize failure (post-`[201][UINT16=N]` commit, pre-data) is currently caught by the protocol-violation path; the robust graceful-abort fix is tracked in [`../../../AyCode.Core/docs/BINARY/BINARY_ASYNCPIPE_TODO.md#accore-bin-t-z6n2`](../../../AyCode.Core/docs/BINARY/BINARY_ASYNCPIPE_TODO.md#accore-bin-t-z6n2).

In `Bytes` and `Segment` mode, the standard `WriteMessage` path is used.

### WebAssembly compatibility

Send and receive paths handle WASM (`OperatingSystem.IsBrowser()`) asymmetrically — **send** strictly bound to `_protocolMode`; **receive** adapts to wire format, falls back synchronously when platform can't 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` starts the streaming deser over an `AsyncPipeReaderInput` on a dedicated off-pool thread via `AcBinaryDeserExecutor` (pipeline parallelism — serialize / network / deserialize overlap; off-pool to avoid the starvation deadlock in `ACCORE-SBP-I-T3W9`). `CHUNK_END` awaits the task's result.
  - **Browser**: the background task is skipped. Chunks accumulate in `AsyncPipeReaderInput`; on `CHUNK_END` the buffer is `Complete()`d and the deserializer runs synchronously on the current thread via the streaming overload `AcBinaryDeserializer.Deserialize(AsyncPipeReaderInput, Type, opts)`. After `Complete()`, `TryAdvanceSegment` sees `_completed=true` and never calls `ManualResetEventSlim.Wait()` (which throws `PlatformNotSupportedException` on WASM).

Consequence: mixed topology (desktop server `AsyncSegment` + WASM client `Bytes`) works without negotiation or protocol-name variation — client converts incoming chunked wire to its synchronous processing model.

> Future: enabling true pipeline streaming on WASM via `WasmEnableThreads` (Atomics-backed `ManualResetEventSlim.Wait()`) is documented in [`SIGNALR_BINARY_PROTOCOL_WASMTHREADING.md`](SIGNALR_BINARY_PROTOCOL_WASMTHREADING.md) — doc-only, not implemented; revisit at .NET 10 stable / .NET 11.

## 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<HubConnectionBuilder>(sp =>
{
    var logger = sp.GetRequiredService<MyLoggerClient>();
    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<MySignalRClient>();   // 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.

## Related ADRs

- [`adr/0001-acbinary-decorator-feature-stack-design.md`](../adr/0001-acbinary-decorator-feature-stack-design.md) — *AcBinaryHubProtocol optional feature stack — decorator-based composition design* (Status: Proposed). Umbrella ADR for optional decorator-based feature stack (encryption, compression with `MinSize`, OpenTelemetry tracing, HMAC signing). NuGet-competitiveness TODO entries (`ACCORE-SBP-T-H7M5` / `N9F3` / `J5W8` / `B3K6` in `SIGNALR_BINARY_PROTOCOL_TODO.md`) resolve under this umbrella. Leaf ADRs (0002-0005) for per-feature design + threat model.
- [`AyCode.Core/docs/adr/0003-acbinary-streaming-receive-architecture.md`](../../../docs/adr/0003-acbinary-streaming-receive-architecture.md) — *AcBinary streaming receive — AsyncPipeReaderInput unified primitive* (Status: Accepted (2026-05-03), partially executed — Steps 4 & 5 NamedPipe / FileStream helpers dropped). Repo-level cross-cutting ADR. The receive-side migration delivered: `SegmentBufferReader` + `SegmentBufferReaderInput` consolidated into a single `AsyncPipeReaderInput` class; SignalR's `TryParseChunkData` migrated (`ACCORE-SBP-T-G7T2`). The unified AsyncSegment chunked wire format (`[INT32 length][200 CHUNK_START][201][UINT16 size][data][202 CHUNK_END]`) documented in this README's "Wire Format" / "BinaryProtocolMode" sections is preserved verbatim. Transport-agnostic helpers (NamedPipe / FileStream wrappers — Steps 4 & 5) were dropped during execution: the framework stays consumer-implements-transport (only `PipeWriter` / `PipeReader` primitives exposed); see ADR-0003's "Status" section for the rationale.

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