Improve binary/SignalR docs, add protocol and writer deep-dives

Major documentation overhaul for binary serialization and SignalR:
- Simplified and clarified AyCode.Core README, with direct links to new deep-dive docs (BINARY_IMPLEMENTATION.md, BINARY_WRITERS.md)
- Added BINARY_WRITERS.md: detailed design and rationale for ArrayBinaryOutput and BufferWriterBinaryOutput, chunk sizing, and buffer management
- Refined BINARY_IMPLEMENTATION.md: clearer buffer management, output strategies, and hot-path rules; references new writers doc
- Added SIGNALR_BINARY_PROTOCOL.md: full wire format, zero-copy pipeline, dual BWO pattern, and read path for custom SignalR protocol
- Updated SignalRs README and SIGNALR.md: clarified protocol, tag system, request/response flow, and technical debt
- Improved cross-linking and discoverability throughout

These changes make the technical documentation clearer, more maintainable, and easier to navigate for advanced contributors.
This commit is contained in:
Loretta 2026-04-04 09:27:36 +02:00
parent 0cb2b6c2d8
commit 9150df6982
7 changed files with 330 additions and 194 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,95 +1,90 @@
# Binaries
High-performance binary serialization/deserialization with two-phase processing, multiple wire modes, string interning, and source generation support. The primary goal is **speed**: every design decision prioritizes minimal latency and maximum throughput.
High-performance binary serialization/deserialization. Two-phase processing, multiple wire modes, string interning, source generation. Primary goal: **speed**.
> For deep technical implementation details (Zero Virtual Dispatch, Direct Buffer Management), see `../../docs/BINARY_IMPLEMENTATION.md`.
> Implementation details (zero virtual dispatch, buffer management): `../../docs/BINARY_IMPLEMENTATION.md`
> Output writers (ArrayBinaryOutput, BufferWriterBinaryOutput, chunk sizing): `../../docs/BINARY_WRITERS.md`
## Architecture
### Two-Phase Serialization
1. **Scan Pass** (`AcBinarySerializer.ScanPass.cs`) — Walks the object graph to detect multi-referenced objects and build the reference table.
2. **Serialize Pass** (`AcBinarySerializer.BinarySerializationContext.cs`) — Writes the binary output using the reference table from the scan pass.
1. **Scan** (`ScanPass.cs`) — walks graph, detects multi-ref objects, builds reference table
2. **Serialize** (`BinarySerializationContext.cs`) — writes binary using reference table
The serializer is generic over `TOutput` for strategy selection (`ArrayBinaryOutput` vs `BufferWriterBinaryOutput`).
Generic over `TOutput` for strategy selection (`ArrayBinaryOutput` vs `BufferWriterBinaryOutput`).
### Wire Format
`BinaryTypeCode.cs` defines 100+ type markers:
`BinaryTypeCode.cs` 100+ type markers:
| Range | Purpose |
|---|---|
| 063 | **FixObj**Compiled type slot indices |
| 6471 | **Complex types** — Object, ObjectRef, Array, Dictionary, ByteArray (with/without metadata) |
| 7275 | **Polymorphic** — ObjectWithTypeName, ObjectWithTypeIndex (with/without RefFirst) |
| 063 | **FixObj**compiled type slot indices |
| 6471 | **Complex** — Object, ObjectRef, Array, Dictionary, ByteArray |
| 7275 | **Polymorphic** — ObjectWithTypeName/TypeIndex (±RefFirst) |
| 7690 | **Primitives** — Null, Bool, Int864, Float3264, Decimal, Char |
| 9194 | **Strings** — String, StringInterned, StringEmpty, StringInternFirst |
| 9598 | **Date/Time** — DateTime, DateTimeOffset, TimeSpan, Guid |
| 99102 | **Other** — Enum, Legacy headers, PropertySkip |
| 103134 | **FixStr**Short strings with length encoded in marker |
| 144+ | **Headers** — Metadata, RefHandling, CacheCount flags |
| 192255 | **Tiny ints**Single-byte encoding for values -16 to 47 |
| 99102 | **Other** — Enum, Legacy, PropertySkip |
| 103134 | **FixStr**short strings (length in marker) |
| 144+ | **Headers** — Metadata, RefHandling, CacheCount |
| 192255 | **Tiny ints**single-byte -16..47 |
For the complete wire format specification (encoding rules, header format, interning protocol), see `docs/BINARY_FORMAT.md`.
Full spec: `docs/BINARY_FORMAT.md`
## Key Files
### Serialization
- **`AcBinarySerializer.cs`** — Main serializer entry point, context pool management.
- **`AcBinarySerializer.BinarySerializationContext.cs`** — Core serialization logic, object graph traversal, type writing.
- **`AcBinarySerializer.BinarySerializationContext.PropertyWriters.cs`** — Per-type property write methods.
- **`AcBinarySerializer.BinarySerializationResult.cs`** — Result wrapper (byte[] or IBufferWriter output).
- **`AcBinarySerializer.BinarySerializeTypeMetadata.cs`** — Cached type metadata for serialization.
- **`AcBinarySerializer.ScanPass.cs`** — Reference scanning phase.
- **`AcBinarySerializer.cs`** — entry point, context pool
- **`...BinarySerializationContext.cs`** — core logic, graph traversal, type writing
- **`...PropertyWriters.cs`** — per-type property write methods
- **`...BinarySerializationResult.cs`** — result wrapper
- **`...BinarySerializeTypeMetadata.cs`** — cached type metadata
- **`...ScanPass.cs`** — reference scanning
### Deserialization
- **`AcBinaryDeserializer.cs`** — Main deserializer entry point.
- **`AcBinaryDeserializer.BinaryDeserializationContext.cs`** — Core deserialization logic.
- **`AcBinaryDeserializer.BinaryDeserializationContext.Read.cs`** — Primitive read operations.
- **`AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs`** — Cached type metadata for deserialization.
- **`AcBinaryDeserializer.CrossType.cs`** — Cross-type deserialization (schema evolution).
- **`AcBinaryDeserializer.Populate.cs`** — Object population and merge operations.
- **`AcBinaryDeserializer.cs`** — entry point
- **`...BinaryDeserializationContext.cs`** — core logic
- **`...BinaryDeserializationContext.Read.cs`** — primitive reads
- **`...BinaryDeserializeTypeMetadata.cs`** — cached type metadata
- **`...CrossType.cs`** — schema evolution
- **`...Populate.cs`** — object population/merge
### I/O Strategies
- **`BinaryOutputBase.cs`** — Output interface.
- **`ArrayBinaryOutput.cs`** — `ArrayPool`-backed output, fastest for `byte[]` result.
- **`BufferWriterBinaryOutput.cs`** — `IBufferWriter<byte>`-backed output for streaming. Two modes: context mode (serialization pipeline) and standalone mode (direct write methods for framing, e.g. `AcBinaryHubProtocol`).
- **`ArrayPooledBufferWriter.cs`** — Concrete `IBufferWriter` implementation.
- **`IBinaryInputBase.cs`** — Input interface.
- **`ArrayBinaryInput.cs`** — Single contiguous `byte[]` input.
- **`SequenceBinaryInput.cs`** — Multi-segment `ReadOnlySequence<byte>` input.
### I/O
- **`BinaryOutputBase.cs`** — output interface
- **`ArrayBinaryOutput.cs`** — ArrayPool-backed, fastest for `byte[]`
- **`BufferWriterBinaryOutput.cs`** — IBufferWriter-backed, zero-copy streaming. Context mode (serializer) + standalone mode (direct writes, e.g. `AcBinaryHubProtocol`)
- **`ArrayPooledBufferWriter.cs`** — concrete IBufferWriter
- **`IBinaryInputBase.cs`** / **`ArrayBinaryInput.cs`** / **`SequenceBinaryInput.cs`** — input strategies
### Configuration & Types
- **`AcBinarySerializerOptions.cs`** — All configuration options and presets.
- **`BinaryTypeCode.cs`** — Wire format type markers.
- **`StringInterningMode.cs`** — Enum: `None`, `Attribute`, `All`.
- **`AcStringInternAttribute.cs`** — Property-level string interning control.
- **`TypeConversionInfo.cs`** — Type conversion tracking for cross-type scenarios.
- **`BinaryPropertyFilterContext.cs`** — Instance-dependent property filtering.
### Config & Types
- **`AcBinarySerializerOptions.cs`** — options, presets
- **`BinaryTypeCode.cs`** — wire format markers
- **`StringInterningMode.cs`** — None/Attribute/All
- **`AcStringInternAttribute.cs`** — property-level interning
- **`TypeConversionInfo.cs`** — cross-type tracking
- **`BinaryPropertyFilterContext.cs`** — instance-dependent filtering
### Property Handling
- **`BinaryPropertyAccessorBase.cs`** — Property accessor for serialization.
- **`BinaryPropertySetterBase.cs`** — Property setter for deserialization.
- **`BinaryPropertyAccessorBase.cs`** / **`BinaryPropertySetterBase.cs`**
### Source Generation
- **`IGeneratedBinaryWriter.cs`** — Interface for source-generated writers.
- **`IGeneratedBinaryReader.cs`** — Interface for source-generated readers.
- **`IGeneratedBinaryWriter.cs`** / **`IGeneratedBinaryReader.cs`**
### Exceptions
- **`AcBinaryDeserializationException.cs`** — Rich exception with binary offset context.
- **`AcBinaryDeserializationException.cs`** — rich exception with binary offset
## Configuration Options
## Config Options
Key options that change wire format: `WireMode` (Compact/Fast), `ReferenceHandling` (None/OnlyId/All), `UseMetadata`, `UseStringInterning` (None/Attribute/All), `MaxDepth`, `UseCompression`, `PropertyFilter`.
Key wire-format options: `WireMode` (Compact/Fast), `ReferenceHandling` (None/OnlyId/All), `UseMetadata`, `UseStringInterning` (None/Attribute/All), `MaxDepth`, `UseCompression`, `PropertyFilter`.
`ReferenceHandling=None` + `UseStringInterning=None` = no scan pass (fastest, single-phase).
`ReferenceHandling=None` + `UseStringInterning=None` = no scan pass (single-phase, fastest).
Presets: `Default`, `FastMode`, `ShallowCopy`, `WasmOptimized`.
For detailed option documentation with wire format impact, code branches, and interactions, see `docs/BINARY_OPTIONS.md`.
Presets: `Default`, `FastMode`, `ShallowCopy`, `WasmOptimized`. Details: `docs/BINARY_OPTIONS.md`.
## Dependencies
- Base classes from parent `Serializers/` folder (`AcSerializerContextBase`, `TypeMetadataBase`, `IdentityMap`, etc.)
- Base classes: parent `Serializers/` (`AcSerializerContextBase`, `TypeMetadataBase`, `IdentityMap`)
- `System.Buffers` (ArrayPool, IBufferWriter)
- LZ4 (optional compression)

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,88 @@
# Binary Output Writers
Output strategies for `AcBinarySerializer`. Generic over `TOutput : struct, IBinaryOutputBase` → compile-time specialization, zero virtual dispatch.
> Buffer management, hot-path rules: `BINARY_IMPLEMENTATION.md`
## IBinaryOutputBase
Cold-path contract between serializer context and output strategy:
```csharp
void Initialize(out byte[] buffer, out int position, out int bufferEnd);
void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed);
int GetTotalPosition(int currentPosition);
void Reset();
```
**Critical:** All write methods (`WriteByte`, `WriteVarUInt`, `WriteStringUtf8`, etc.) live on `BinarySerializationContext<TOutput>` sealed class — NOT on the output. Output handles only buffer lifecycle. See [Why Writes Are on the Context](#why-writes-are-on-the-context).
## ArrayBinaryOutput
`struct ArrayBinaryOutput : IBinaryOutputBase, IDisposable` — fastest for `byte[]`/`ReadOnlySpan<byte>` result.
- **Initialize:** provides pooled buffer, position=0
- **Grow:** rent doubled buffer from `ArrayPool`, copy, return old
- **Pooling:** ≤32KB kept across serializations (faster than pool round-trip); >32KB returned, next rent halved
- **Results:** `ToArray` (allocate+memcpy), `DetachResult` (caller owns pooled buffer), `AsSpan` (zero-alloc view)
- **OutputInitialized** flag: single instance per pooled context, reused
## BufferWriterBinaryOutput
`struct BufferWriterBinaryOutput : IBinaryOutputBase` — writes directly to `IBufferWriter<byte>` (PipeWriter, ArrayBufferWriter). Zero-copy streaming.
### Cached Chunk Pattern
Instead of `GetSpan`/`Advance` per write (interface dispatch), acquires large chunk once:
1. `GetMemory(chunkSize)``TryGetArray` → backing `byte[]` + offset
2. All writes: `buffer[position++]` — direct array indexing, zero dispatch
3. `Grow`: `Advance(bytesInChunk)` → acquire next chunk
4. `Flush`: commit final bytes via `Advance`
5. **Fallback:** `TryGetArray` fails → rent temp buffer, copy on `Grow`/`Flush`
### Two Usage Modes
Separate buffer states, never concurrent:
**Context mode** — serialization pipeline:
- Buffer state on context: `_buffer`, `_position`, `_bufferEnd`
- BWO invoked only via `Initialize`/`Grow`/`Flush` with `out`/`ref` params
- Context write methods operate on own fields → max JIT optimization
**Standalone mode** — direct writes outside serializer (e.g. `AcBinaryHubProtocol` framing):
- Buffer state on struct: `_buffer`, `_position`, `_bufferEnd`
- Write methods: `WriteByte`, `WriteVarUInt`, `WriteStringUtf8`, `WriteBytes`, `WriteRaw<T>`
- `Position`: total bytes (committed + pending)
- `Flush()`: commit pending, finalize
- `FlushAndReset()`: commit pending, invalidate chunk → `IBufferWriter` available for another writer
Context/standalone share only `IBufferWriter` ref and `_committedBytes`.
### Known Limitations
1. **Struct copy semantics:** value type → assignment creates independent copy. State changes (e.g. `_committedBytes` via `Grow`/`Flush`) not reflected in original. Copy back after use if needed.
2. **Initialize resets tracking:** `_committedBytes = 0`. Standalone bytes lost if BWO then passed to context. Use `FlushAndReset()` before, track standalone bytes separately.
3. **Constructor acquires chunk:** `AcquireChunk` in ctor for standalone readiness. Redundant if only context mode used (context `Initialize` acquires its own). Not a leak — consecutive `GetMemory` without `Advance` returns overlapping memory.
4. **No mode mixing:** single instance must not use context+standalone simultaneously. Buffer states desynchronize. One mode per lifecycle phase, `FlushAndReset()` as boundary.
### Chunk Size
Default 65536 (64KB), configurable via `AcBinarySerializerOptions.BufferWriterChunkSize`.
- **Memory-backed** (ArrayBufferWriter): 64KB optimal — fewer `Grow` calls
- **Network-backed** (PipeWriter): smaller (4096) aligns with transport segments. But 64KB default is safe — `PipeWriter` may return less than requested
- Too-small default would cause excessive `Grow`; 64KB is never catastrophic
## Why Writes Are on the Context
Most important architectural decision in the output layer.
**Attempted:** write methods on output struct. Context calls `Output.WriteByte(value)`.
**Result:** measurably slower, even with struct + `AggressiveInlining` + generic constraint devirtualization.
**Root cause:** JIT generates better code for sealed class field access (`this._buffer`, `this._position` — fixed offsets) than generic struct field access (`this.Output._buffer` — extra address computation at lower optimization tiers).
**Current:** writes on `BinarySerializationContext<TOutput>` (sealed class, hot path). Output struct handles only `Initialize`/`Grow`/`Flush` (cold path).
**Rule:** Do NOT move write methods to output. Measure with full benchmark suite before proposing changes.

View File

@ -3,6 +3,7 @@
Custom binary SignalR protocol, client infrastructure, message tagging, and serialization helpers.
> **Architecture:** For full dispatch flow, tag system, and tech debt documentation see `AyCode.Services/docs/SIGNALR.md`.
> **Binary protocol:** For wire format, zero-copy pipeline, and dual BWO pattern see `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md`.
## Key Files

View File

@ -1,24 +1,23 @@
# SignalR Client
Client-side SignalR transport with custom binary protocol and tag-based dispatch. Source: `SignalRs/` in this project.
Client-side SignalR transport: custom binary protocol, tag-based dispatch. Source: `SignalRs/`
> For server-side hub, session, broadcast see `AyCode.Services.Server/docs/SIGNALR_SERVER.md`.
> For the DataSource collection see `AyCode.Services.Server/docs/SIGNALR_DATASOURCE.md`.
> Server-side hub, session, broadcast: `AyCode.Services.Server/docs/SIGNALR_SERVER.md`
> DataSource collection: `AyCode.Services.Server/docs/SIGNALR_DATASOURCE.md`
## Design Overview
## Design
All communication flows through a **single hub method** with **tag-based dispatch**:
Single hub method, tag-based dispatch:
```
Client ──OnReceiveMessage(tag, bytes, requestId)──► Server
Client ◄──OnReceiveMessage(tag, bytes, requestId)── Server
```
`OnReceiveMessage` is the only transport channel — all calls go through it. The `tag` (int) determines **which server method to invoke**. Projects call any endpoint by specifying the tag; the server dispatches accordingly.
Tag (int) determines server method. All calls go through `OnReceiveMessage`.
```
Client side: Server side:
───────────────────── ─────────────────────
Client: Server:
AcSignalRClientBase AcWebSignalRHubBase<TTags, TLogger>
├─ HubConnection (WebSocket) ├─ Hub<IAcSignalRHubItemServer>
├─ AcBinaryHubProtocol ├─ DynamicMethodRegistry
@ -28,9 +27,7 @@ AcSignalRClientBase AcWebSignalRHubBase<TTags, TLogger>
## Tag System
### Tag Definition
AyCode.Core defines only the base class and built-in infrastructure tags:
Base tags in AyCode.Core:
```csharp
public class AcSignalRTags
@ -41,153 +38,105 @@ public class AcSignalRTags
}
```
**Consuming projects** define their own tags by inheriting `AcSignalRTags`:
Consuming projects inherit and define own tags (plain int constants, must be unique per hub):
```csharp
public abstract class MyProjectTags : AcSignalRTags
{
public const int GetOrders = 100;
public const int GetOrderById = 101;
public const int SaveOrder = 102;
}
```
Tags are plain `int` constants. The project decides the numbering. Tags must be unique across all registered services within a hub.
**Attributes:**
- `[Tag(42)]` — base: maps int tag → method
- `[SignalR(messageTag, sendToOtherClientTag, sendToOtherClientType)]` — server routing + broadcast
- `[SignalRSendToClient(100)]` — client receive dispatch
### Tag Attributes
Three attribute levels:
**SendToClientType:** `None` | `Others` | `Caller` | `All`
**Usage:**
```csharp
// Base: maps an integer tag to a method
[Tag(42)]
// Server method: tag + optional broadcast behavior
[SignalR(messageTag: 100, sendToOtherClientTag: 100, sendToOtherClientType: SendToClientType.Others)]
// Client receive: marks method for server→client dispatch
[SignalRSendToClient(100)]
var orders = await client.PostAsync<List<Order>>(MyTags.GetOrders, companyId);
await client.PostDataAsync(MyTags.SaveOrder, order);
await client.PostDataAsync(MyTags.SaveOrder, order, async response => { ... }); // async callback
```
**SendToClientType:** `None` (no broadcast), `Others` (all except caller), `Caller` (response only), `All` (everyone).
### How Projects Use Tags
Projects call the SignalR transport directly — not only through DataSource:
```csharp
var orders = await signalRClient.PostAsync<List<Order>>(MyTags.GetOrders, companyId);
var order = await signalRClient.GetByIdAsync<Order>(MyTags.GetOrderById, orderId);
await signalRClient.PostDataAsync(MyTags.SaveOrder, order);
// Async callback (non-blocking)
await signalRClient.PostDataAsync(MyTags.SaveOrder, order, async response => { ... });
// Fire-and-forget
await signalRClient.PostDataAsync(MyTags.SaveOrder, order, response => { ... });
```
The CRUD helpers (`PostAsync`, `GetByIdAsync`, `GetAllAsync`, `PostDataAsync`) are all generic transport methods that work with any tag — they are not tied to DataSource.
CRUD helpers (`PostAsync`, `GetByIdAsync`, `GetAllAsync`, `PostDataAsync`) are generic transport, not tied to DataSource.
## Wire Protocol
### AcBinaryHubProtocol
Custom `IHubProtocol` (name: `"acbinary"`), replaces default JSON. Frame format:
Custom `IHubProtocol` (`"acbinary"`), replaces JSON. Zero-copy via `BufferWriterBinaryOutput` standalone mode. `byte[]` args bypass serializer.
```
[4 bytes: payload length, little-endian] [1 byte: message type] [payload...]
```
Message types: Invocation(1), StreamItem(2), Completion(3), Ping(6), Close(7), Ack(8), Sequence(9).
Arguments serialized individually with INT32 length prefix (patched in-place after payload is written).
**Zero-copy write pipeline:**
All writes go through a single `BufferWriterBinaryOutput` in standalone mode (cached chunk pattern, zero virtual dispatch). For argument payloads, the BWO flushes to the pipe via `FlushAndReset()`, then `AcBinarySerializer.Serialize()` writes directly to the `IBufferWriter` (pipe) — zero-copy, no intermediate `byte[]` allocation.
**Raw `byte[]` Fast-Path:**
When an argument is a pure `byte[]`, the size is known upfront. The protocol writes `BinaryTypeCode.ByteArray` (68) marker, `VarUInt` length, and raw bytes entirely through the `BufferWriterBinaryOutput`, completely skipping the `AcBinarySerializer` context, the two-phase scan, and the internal array pools.
> Wire format, argument framing, dual BWO pattern, length prefix patching: `SIGNALR_BINARY_PROTOCOL.md`
### Response Message
`SignalResponseDataMessage` carries:
`SignalResponseDataMessage`:
| Field | Type | Purpose |
|-------|------|---------|
| `MessageTag` | int | Tag identifying the operation |
| `MessageTag` | int | Operation tag |
| `Status` | SignalResponseStatus | Success/Error |
| `ResponseData` | byte[] | Binary-serialized payload (or GZip+JSON fallback) |
| `ResponseData` | byte[] | Serialized payload |
| `DataSerializerType` | AcSerializerType | Binary or Json |
**Binary mode** (default): `AcBinarySerializer.ToBinary(data)` → raw bytes.
**JSON fallback**: `ToJson(data)``GzipHelper.Compress(json)` → bytes.
Binary (default): `AcBinarySerializer.ToBinary(data)`. JSON fallback: `ToJson``GzipHelper.Compress`.
## Request/Response Flow
### Client → Server
```
1. PostAsync<T>(tag, postData) or PostDataAsync(tag, data, callback)
1. PostAsync<T>(tag, postData) / PostDataAsync(tag, data, callback)
2. CreatePostMessage(postData):
├─ Primitives/strings/enums/value types → IdMessage (JSON array of serialized values)
└─ Complex objects → SignalPostJsonDataMessage<T> ⚠️ tech debt: JSON-in-Binary
3. SerializeToBinary(message) wraps in Binary envelope
├─ Primitives/strings/enums/value types → IdMessage
└─ Complex → SignalPostJsonDataMessage<T> ⚠️ JSON-in-Binary tech debt
3. SerializeToBinary(message)
4. HubConnection.SendAsync("OnReceiveMessage", tag, bytes, requestId)
5. AcBinaryHubProtocol frames it on the wire
5. AcBinaryHubProtocol frames on wire
```
### Server → Client
```
13. Client.OnReceiveMessage(tag, bytes, requestId)
14. Has matching requestId in pending ConcurrentDictionary?
├─ YES (response to own request):
│ 15. DeserializeFromBinary<SignalResponseDataMessage>(bytes)
│ 16. Route based on pending request type:
│ ├─ null (sync wait) → set ResponseByRequestId, WaitToAsync completes
│ ├─ Action<SignalResponseDataMessage> → invoke directly
│ └─ Func<SignalResponseDataMessage, Task> → invoke and await
│ 17. GetResponseData<T>():
│ ├─ Binary: ResponseData.BinaryTo<T>()
│ └─ JSON: GzipDecompress → AcJsonDeserializer.Deserialize<T>()
└─ NO (broadcast from another client's action):
18. abstract MessageReceived(tag, bytes).Forget()
└─ Consuming project overrides to handle server push
OnReceiveMessage(tag, bytes, requestId)
├─ Matching requestId in pending dict:
│ ├─ DeserializeFromBinary<SignalResponseDataMessage>(bytes)
│ ├─ Route: null→sync wait, Action→invoke, Func<Task>→await
│ └─ GetResponseData<T>(): Binary→BinaryTo<T>(), JSON→Decompress→Deserialize
└─ No match (broadcast):
└─ abstract MessageReceived(tag, bytes).Forget()
```
**Broadcast receive:** When the server broadcasts to `Others`/`All`, receiving clients have no matching `requestId`. The message falls through to `MessageReceived(int messageTag, byte[] messageBytes)` — an abstract method the consuming project overrides.
**Request model pooling:** `SignalRRequestModel` instances are managed via `SignalRRequestModelPool` (ObjectPool<T> + IResettable) to avoid allocations per request.
Request pooling: `SignalRRequestModel` via `SignalRRequestModelPool` (ObjectPool + IResettable).
## Response Patterns
| Pattern | Method | Blocking |
|---------|--------|----------|
| Sync wait | `PostAsync<T>(tag, data)` | Yes, polls up to `TransportSendTimeout` (60s) |
| Async callback | `PostDataAsync(tag, data, async msg => {...})` | No, `Func<SignalResponseDataMessage, Task>` |
| Fire-and-forget | `PostDataAsync(tag, data, msg => {...})` | No, `Action<SignalResponseDataMessage>` |
| Sync wait | `PostAsync<T>(tag, data)` | Yes (60s timeout) |
| Async callback | `PostDataAsync(tag, data, async msg => {...})` | No |
| Fire-and-forget | `PostDataAsync(tag, data, msg => {...})` | No |
## Connection Lifecycle
## Connection
**Client configuration:**
- Transport: WebSockets only (`SkipNegotiation = true`)
- Buffer: 30MB transport + 30MB application
- Reconnection: `WithAutomaticReconnect()` + `WithStatefulReconnect()`
- Keepalive: 60s interval, 180s server timeout
- WebSockets only (`SkipNegotiation = true`)
- 30MB transport + 30MB application buffer
- `WithAutomaticReconnect()` + `WithStatefulReconnect()`
- Keepalive 60s, server timeout 180s
## Diagnostics
Enable with `AcSignalRClientBase.EnableBinaryDiagnostics = true`.
`AcSignalRClientBase.EnableBinaryDiagnostics = true` — hex dump, header parsing, property enumeration.
Logs: hex dump (500 byte sample), header parsing (version, marker), property count + names via VarUInt reading.
## Tech Debt
## Known Technical Debt
**JSON-in-Binary:** client→server wraps params in JSON inside binary envelope (`SignalPostJsonDataMessage`). Do NOT fix as side effect — requires coordinated cross-project changes.
**JSON-in-Binary request parameters:** Client→server requests currently wrap parameters in JSON inside the binary envelope (`SignalPostJsonDataMessage`). This adds an unnecessary serialization round-trip. Responses are already pure binary. Do NOT attempt to fix as a side effect — requires coordinated changes across all consuming projects.
## Key Source Files
## Source Files
| Component | Path |
|-----------|------|

View File

@ -0,0 +1,106 @@
# SignalR Binary Protocol
`AcBinaryHubProtocol` — custom `IHubProtocol` (name: `"acbinary"`) replacing SignalR JSON+Base64 with `AcBinarySerializer`.
> 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[] → write 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).
## byte[] Fast-Path
When argument is `byte[]`, bypasses serializer:
1. Size upfront: `1 (BinaryTypeCode) + VarUIntSize(length) + length`
2. INT32 prefix written with actual value (no patching)
3. `BinaryTypeCode.ByteArray(68)` + VarUInt length + raw bytes via BWO
Read side mirrors: if `targetType == typeof(byte[])` and first byte is `ByteArray`, deserializer bypassed → direct `SpanReader`.
## Read Path
`SpanReader``ref struct` for sequential `ReadOnlySpan<byte>` reading:
1. Read INT32 length. If `input.Length < total` → false (incomplete).
2. Multi-segment `ReadOnlySequence` → rent contiguous buffer from `ArrayPool`.
3. Parse message type → type-specific parser.
4. Fields via `SpanReader` methods (`ReadByte`, `ReadString`, `ReadVarUInt`, `ReadInt32`, `ReadInt64`, `ReadSpan`).
5. Arguments: INT32 length → slice → `AcBinaryDeserializer.Deserialize(span, targetType)`.
## 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`