From 9150df6982f220643d689f560bc66ad8a86fa4a2 Mon Sep 17 00:00:00 2001 From: Loretta Date: Sat, 4 Apr 2026 09:27:36 +0200 Subject: [PATCH] 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. --- .github/copilot-instructions.md | 3 +- AyCode.Core/Serializers/Binaries/README.md | 101 ++++++------ AyCode.Core/docs/BINARY_IMPLEMENTATION.md | 74 ++++----- AyCode.Core/docs/BINARY_WRITERS.md | 88 ++++++++++ AyCode.Services/SignalRs/README.md | 1 + AyCode.Services/docs/SIGNALR.md | 151 ++++++------------ .../docs/SIGNALR_BINARY_PROTOCOL.md | 106 ++++++++++++ 7 files changed, 330 insertions(+), 194 deletions(-) create mode 100644 AyCode.Core/docs/BINARY_WRITERS.md create mode 100644 AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5dd7248..0025ea2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,7 @@ You are operating in a multi-repo, documentation-first architecture. You MUST ST - Do not answer the user's core question until the `[LOADED_DOCS]` list is populated with the base architecture files. - **CRITICAL EXCEPTION:** Do **NOT** re-read `.md` files that are already mapped in your context or `LOADED_DOCS` list (strictly maintain rule 20). - **CROSS-REPO HARD-GATE:** When navigating to an external repo (via `own-dep-repos` paths), read that repo's `docs/` and `README.md` BEFORE searching its source code. The hard-gate applies to EVERY repo you enter, not just your own. + - **PER-QUESTION DOC-FIRST:** Before searching source code for any user question, check whether there is a relevant `.md` file (folder `README.md`, other repo `docs/`, etc.) that has NOT yet been loaded. Read it first — it tells you where to look in the code, saving searches and tokens. Only after loading relevant docs should you search/read source files. 3. **STRICT NO-RE-READ POLICY (ANTI-LOOP):** You are PHYSICALLY FORBIDDEN from calling `get_file` or `file_search` on any `.md` file that is already listed in your `[LOADED_DOCS]` prefix. @@ -29,7 +30,7 @@ You are operating in a multi-repo, documentation-first architecture. You MUST ST - `docs/` (in this repository root) 5. **EXPLICIT CONSENT FOR MODIFICATIONS:** - NEVER rewrite, create, or delete code/files without the user's explicit permission. If the user does not specifically request a code modification (e.g., using phrases like "we are just thinking," "what do you think," "let's plan"), you MUST ONLY provide text-based analysis and planning. You are FORBIDDEN from using file-modifying tools (`replace_string_in_file`, `edit_file`, `create_file`, etc.) until the user explicitly says "ok", "go ahead", "implement", or a similar unambiguous approval. + NEVER rewrite, create, or delete any file (code, documentation, configuration, memory, or otherwise) without the user's explicit permission. If the user does not specifically request a code modification (e.g., using phrases like "we are just thinking," "what do you think," "let's plan"), you MUST ONLY provide text-based analysis and planning. You are FORBIDDEN from using file-modifying tools (`replace_string_in_file`, `edit_file`, `create_file`, etc.) until the user explicitly says "ok", "go ahead", "implement", or a similar unambiguous approval. ## Workspace Dependencies diff --git a/AyCode.Core/Serializers/Binaries/README.md b/AyCode.Core/Serializers/Binaries/README.md index f554278..77b1c2b 100644 --- a/AyCode.Core/Serializers/Binaries/README.md +++ b/AyCode.Core/Serializers/Binaries/README.md @@ -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 | |---|---| -| 0–63 | **FixObj** — Compiled type slot indices | -| 64–71 | **Complex types** — Object, ObjectRef, Array, Dictionary, ByteArray (with/without metadata) | -| 72–75 | **Polymorphic** — ObjectWithTypeName, ObjectWithTypeIndex (with/without RefFirst) | +| 0–63 | **FixObj** — compiled type slot indices | +| 64–71 | **Complex** — Object, ObjectRef, Array, Dictionary, ByteArray | +| 72–75 | **Polymorphic** — ObjectWithTypeName/TypeIndex (±RefFirst) | | 76–90 | **Primitives** — Null, Bool, Int8–64, Float32–64, Decimal, Char | | 91–94 | **Strings** — String, StringInterned, StringEmpty, StringInternFirst | | 95–98 | **Date/Time** — DateTime, DateTimeOffset, TimeSpan, Guid | -| 99–102 | **Other** — Enum, Legacy headers, PropertySkip | -| 103–134 | **FixStr** — Short strings with length encoded in marker | -| 144+ | **Headers** — Metadata, RefHandling, CacheCount flags | -| 192–255 | **Tiny ints** — Single-byte encoding for values -16 to 47 | +| 99–102 | **Other** — Enum, Legacy, PropertySkip | +| 103–134 | **FixStr** — short strings (length in marker) | +| 144+ | **Headers** — Metadata, RefHandling, CacheCount | +| 192–255 | **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`-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` 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) diff --git a/AyCode.Core/docs/BINARY_IMPLEMENTATION.md b/AyCode.Core/docs/BINARY_IMPLEMENTATION.md index cc35574..d5ebbf9 100644 --- a/AyCode.Core/docs/BINARY_IMPLEMENTATION.md +++ b/AyCode.Core/docs/BINARY_IMPLEMENTATION.md @@ -1,15 +1,16 @@ # Binaries Implementation Details -This document covers the low-level technical decisions, memory management strategies, and internal structure of the `AcBinary` serializer. These details are intended for framework developers modifying or extending the serialization pipeline. +Low-level technical decisions, memory management, internal structure of `AcBinarySerializer`. For framework developers modifying the serialization pipeline. -For format specifications, see `BINARY_FORMAT.md`. For options and presets, see `BINARY_OPTIONS.md`. For features, see `BINARY_FEATURES.md`. +> Format spec: `BINARY_FORMAT.md` | Options/presets: `BINARY_OPTIONS.md` | Features: `BINARY_FEATURES.md` | Output writers: `BINARY_WRITERS.md` ## Zero-Allocation Buffer Management -The core design philosophy of `AcBinarySerializer` is **Zero Virtual Dispatch** and **Zero Direct Allocation** on the hot path. +Core philosophy: **zero virtual dispatch**, **zero direct allocation** on hot path. ### Context-Owned Buffer State -Instead of passing an `IBinaryWriter` interface down the object graph (which forces a virtual method call for every single byte or integer written), the `BinarySerializationContext` strictly owns the buffer state: + +`BinarySerializationContext` owns buffer state directly (no `IBinaryWriter` interface dispatch per byte): ```csharp internal byte[] _buffer = null!; @@ -17,59 +18,54 @@ internal int _position; internal int _bufferEnd; ``` -All write methods (`WriteByte`, `WriteVarUInt`, `WriteStringUtf8`, etc.) are declared directly on the sealed context class and aggressively inlined. +All write methods on the sealed context class, aggressively inlined. -### No Temporary Buffers for Strings -When writing strings, the serializer **never** allocates an intermediate `byte[]` to perform UTF-8 encoding. It writes directly to the context's pinned `_buffer` using `System.Span` and `Ascii.FromUtf16` / `Utf8NoBom.GetBytes`. +### No Temporary String Buffers -**Speculative ASCII Fast Path:** -1. Assume the string is purely ASCII (byte length = char length). -2. Ensure capacity for `value.Length`. -3. Try `Ascii.FromUtf16` directly into the final buffer. -4. If it hits a non-ASCII character, it safely aborts. The serializer then rewinds the `position`, calculates true UTF-8 byte length, and uses `Utf8.GetBytes`. +String → UTF-8 directly into context `_buffer`, no intermediate `byte[]`. -### Abstracting the Output (The `TOutput` Strategy) -To support both `byte[]` returns and streaming models (via `IBufferWriter`), the context is generic over `TOutput : struct, IBinaryOutputBase`. +**Speculative ASCII fast path:** +1. Assume ASCII (byte length = char length), ensure capacity +2. `Ascii.FromUtf16` directly into buffer +3. Non-ASCII hit → rewind, calculate true UTF-8 length, `Utf8.GetBytes` -The `TOutput` struct is **only** invoked on the cold path (when `_position >= _bufferEnd`). -- `ArrayBinaryOutput`: Rents a newly doubled array from `ArrayPool`, copies existing data across, and returns the old array to the pool. When serialization finishes, a final buffer slice is returned (often avoiding `ToArray()` allocations altogether if wrapped in a `BinarySerializationResult`). -- `BufferWriterBinaryOutput`: Two usage modes. **Context mode** (Initialize/Grow/Flush): used by `BinarySerializationContext` for the serialization pipeline. **Standalone mode** (WriteByte/WriteVarUInt/WriteStringUtf8/etc.): direct write methods for use outside the serialization pipeline (e.g. `AcBinaryHubProtocol` frame headers). Both modes use a cached chunk pattern: acquires a large chunk once via `GetMemory` + `MemoryMarshal.TryGetArray`, extracts the backing array, and writes with direct indexing. If the `IBufferWriter` isn't backed by an array (e.g. native memory), it falls back to renting a temporary buffer from `ArrayPool`. `FlushAndReset()` commits pending bytes and invalidates the chunk, allowing the underlying `IBufferWriter` to be used directly by another writer (e.g. the serializer) before the BWO re-acquires a fresh chunk on the next write. +### TOutput Strategy -Because `TOutput` is a generic struct constraint, the JIT completely devirtualizes the `Grow()` calls. +Generic `TOutput : struct, IBinaryOutputBase` → JIT devirtualizes `Grow()`. Output invoked only on cold path (buffer exhaustion). + +> `ArrayBinaryOutput`, `BufferWriterBinaryOutput`, chunk sizing, dual buffer state: `BINARY_WRITERS.md` ## Direct Object Write (IsDirectObjectWrite) -When `UseMetadata = false`, there is no need to track inline property name hashes. The [Source Generator] (SGen) can completely bypass the generic `WriteObject` loop. - -When SGen code executes, it checks `IsDirectObjectWrite`. If true, it writes the `Object(64)` marker and immediately outputs properties sequentially without any framework-level `foreach` or reflection. This reduces the overhead of an object write effectively to the time it takes to write raw bytes to an array. +When `UseMetadata = false`: no inline property name hashes needed. SGen bypasses generic `WriteObject` loop entirely — writes `Object(64)` marker then sequential properties. Overhead ≈ raw byte writes. ## Property State Buffering -During the `UseMetadata=true` cross-type deserialization phase, properties might arrive in a different order than expected. The deserializer maintains a temporary state buffer (rented from `ArrayPool`) to track which properties have been seen and which have been populated, allowing it to efficiently map wire hashes to local property setters. +`UseMetadata=true` cross-type deserialization: properties may arrive in different order. Deserializer rents temp buffer from `ArrayPool` to map wire hashes → local property setters. -## High-Performance Coding Guidelines (LLM / Contributor Rules) +## High-Performance Coding Rules -The following patterns are strictly enforced within the serialization pipeline. AI agents and developers modifying this layer MUST adhere to these rules to maintain zero-allocation and high-throughput characteristics. +Strictly enforced within serialization pipeline. AI agents and developers MUST follow. ### 1. The "Write Plan" (O(1) Reference Tracking) -**Rule:** Never use `Dictionary.ContainsKey`, `HashSet.Contains`, or similar lookups during the serialization hot path. -**Implementation:** -Reference tracking and string interning use a two-phase approach: -1. **Scan Pass:** Walks the object graph and populates an array-based `WriteDuplicateEntry[]` "Write Plan" representing occurrences of duplicates. -2. **Serialize Pass:** Iterates sequentially. The framework advances an integer cursor (`WriteVisitIndex`) and compares it to a pre-calculated index via `TryConsumeWritePlanEntry()`. This provides `O(1)` duplicate detection without any hashing overhead during the actual byte-writing phase. +**Rule:** Never use `Dictionary.ContainsKey`/`HashSet.Contains` on hot path. + +Two-phase: +1. **Scan:** walks graph → array-based `WriteDuplicateEntry[]` plan +2. **Serialize:** sequential cursor (`WriteVisitIndex`) vs pre-calculated index via `TryConsumeWritePlanEntry()` → O(1) duplicate detection, zero hashing ### 2. Unsafe and SIMD Memory Access -**Rule:** Do not use `BitConverter` or manual bit-shifting for primitive unmanaged types or block copying. -**Implementation:** -- For structs and unmanaged types (e.g., `Guid`, `DateTime`, `decimal`), use `Unsafe.WriteUnaligned` and `MemoryMarshal.AsBytes()` to write directly to the buffer. -- For contiguous block memory copies (e.g., `byte[]`), use `System.Span.CopyTo` or SIMD hardware acceleration (e.g., `Vector` and `Vector.IsHardwareAccelerated` as seen in `WriteBytesSimd`). +**Rule:** No `BitConverter` or manual bit-shifting for unmanaged types. -### 3. Hot-Path vs. Cold-Path Inlining (VarInt/VarUInt) -**Rule:** Ensure the JIT can inline the common cases by explicitly isolating the slow paths. +- Structs/unmanaged (`Guid`, `DateTime`, `decimal`): `Unsafe.WriteUnaligned`, `MemoryMarshal.AsBytes()` +- Block copies (`byte[]`): `Span.CopyTo` or SIMD (`Vector`, `Vector.IsHardwareAccelerated` → `WriteBytesSimd`) -**Implementation:** -Methods called in tight loops are small and decorated with `[MethodImpl(MethodImplOptions.AggressiveInlining)]`. Heavy branching or loop logic (which prevents inlining) is extracted into separate methods: -- **Hot path:** Checks if the value fits in a single byte (e.g., `value < 0x80`). Aggressively inlined. -- **Cold path:** Multi-byte encoding logic is placed in a secondary method (e.g., `WriteVarUIntMultiByteUnsafe`) decorated with `[MethodImpl(MethodImplOptions.NoInlining)]`. This keeps the calling method's IL size extremely small and processor cache-friendly. +### 3. Hot/Cold Path Inlining + +**Rule:** Keep hot-path IL small for JIT inlining. + +- **Hot:** single-byte check (e.g. `value < 0x80`), `AggressiveInlining` +- **Cold:** multi-byte logic in separate `NoInlining` method (e.g. `WriteVarUIntMultiByteUnsafe`) +- Keeps caller IL small, cache-friendly diff --git a/AyCode.Core/docs/BINARY_WRITERS.md b/AyCode.Core/docs/BINARY_WRITERS.md new file mode 100644 index 0000000..9d02311 --- /dev/null +++ b/AyCode.Core/docs/BINARY_WRITERS.md @@ -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` 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` 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` (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` +- `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` (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. diff --git a/AyCode.Services/SignalRs/README.md b/AyCode.Services/SignalRs/README.md index 378a81e..4cc49e3 100644 --- a/AyCode.Services/SignalRs/README.md +++ b/AyCode.Services/SignalRs/README.md @@ -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 diff --git a/AyCode.Services/docs/SIGNALR.md b/AyCode.Services/docs/SIGNALR.md index f07e792..7f6eee0 100644 --- a/AyCode.Services/docs/SIGNALR.md +++ b/AyCode.Services/docs/SIGNALR.md @@ -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 ├─ HubConnection (WebSocket) ├─ Hub ├─ AcBinaryHubProtocol ├─ DynamicMethodRegistry @@ -28,9 +27,7 @@ AcSignalRClientBase AcWebSignalRHubBase ## 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>(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>(MyTags.GetOrders, companyId); -var order = await signalRClient.GetByIdAsync(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(tag, postData) or PostDataAsync(tag, data, callback) +1. PostAsync(tag, postData) / PostDataAsync(tag, data, callback) 2. CreatePostMessage(postData): - ├─ Primitives/strings/enums/value types → IdMessage (JSON array of serialized values) - └─ Complex objects → SignalPostJsonDataMessage ⚠️ tech debt: JSON-in-Binary -3. SerializeToBinary(message) wraps in Binary envelope + ├─ Primitives/strings/enums/value types → IdMessage + └─ Complex → SignalPostJsonDataMessage ⚠️ 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(bytes) - │ 16. Route based on pending request type: - │ ├─ null (sync wait) → set ResponseByRequestId, WaitToAsync completes - │ ├─ Action → invoke directly - │ └─ Func → invoke and await - │ 17. GetResponseData(): - │ ├─ Binary: ResponseData.BinaryTo() - │ └─ JSON: GzipDecompress → AcJsonDeserializer.Deserialize() - │ - └─ 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(bytes) +│ ├─ Route: null→sync wait, Action→invoke, Func→await +│ └─ GetResponseData(): Binary→BinaryTo(), 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 + IResettable) to avoid allocations per request. +Request pooling: `SignalRRequestModel` via `SignalRRequestModelPool` (ObjectPool + IResettable). ## Response Patterns | Pattern | Method | Blocking | |---------|--------|----------| -| Sync wait | `PostAsync(tag, data)` | Yes, polls up to `TransportSendTimeout` (60s) | -| Async callback | `PostDataAsync(tag, data, async msg => {...})` | No, `Func` | -| Fire-and-forget | `PostDataAsync(tag, data, msg => {...})` | No, `Action` | +| Sync wait | `PostAsync(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 | |-----------|------| diff --git a/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md b/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md new file mode 100644 index 0000000..fbab7a0 --- /dev/null +++ b/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL.md @@ -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`, 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[] → 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` 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`