AyCode.Core/AyCode.Core/docs/BINARY/BINARY_TODO.md

63 KiB
Raw Blame History

AcBinarySerializer — TODO

This page covers planned work for the binary serializer core (format, SGen, options, deserialization context, buffer writer). Work specific to the streaming I/O layer (AsyncPipeReaderInput + AsyncPipeWriterOutput, multi-message wire framing, sliding-window buffer, producer-consumer synchronization) is tracked separately in BINARY_ASYNCPIPE_TODO.md.

Priority legend

  • P0 blocker · P1 important · P2 nice-to-have · P3 idea

ACCORE-BIN-T-S8P4: Replace JSON-in-Binary request parameters

Priority: P1 · Type: Refactor · Status: Closed (2026-04-26, landed in commits cdd54d3 2026-04-05 + 3b70070 2026-04-06) · Related: ../XCUT/XCUT_ISSUES.md#accore-xcut-i-x8q1 (canonical), AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md

Migrate client→server request parameters from JSON-in-Binary envelope to direct Binary serialization (matching response path). Coordinated change across client, server, and all consuming projects. Do NOT attempt as side-effect of unrelated work.

Acceptance: SignalPostJsonDataMessage<T> replaced by a SignalPostBinaryDataMessage<T> (or equivalent); no JSON round-trip on the wire for request params; benchmarks confirm no regression.

Resolution

  • What: Length-prefixed, per-parameter binary format introduced via SignalRSerializationHelper.SerializeParametersToBinary / DeserializeParametersFromBinary; further unified into SignalParams (single byte[] carrying packed method parameters with SetParameterValues / GetParameterValues).
  • Where: AyCode.Services/SignalRs/AcSignalRClientBase.cs, AcWebSignalRHubBase.cs, ISignalParams.cs (server + client dispatch); IAcSignalRHubClient.cs (legacy wrappers).
  • Equivalent (not literal SignalPostBinaryDataMessage<T>): SignalParams was chosen over a 1:1 binary wrapper class — fewer indirections on the hot path, type-safe pack/unpack, and DataSerializerType field on SignalReceiveParams for response format indication.
  • Wire impact: No JSON round-trip on the wire for request params; this is a breaking change vs. previous JSON-in-Binary clients/servers (see commit message).
  • Legacy types: SignalPostJsonMessage, SignalPostJsonDataMessage<T>, SignalPostMessage<T>, ISignalPostMessage<T> all marked [Obsolete] in IAcSignalRHubClient.cs; deletion tracked separately in AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md#accore-sig-t-s3n8 (gated on consumer migration).

ACCORE-BIN-T-Q2N7: Re-evaluate DiscountProductMapping SGen exclusion

Priority: P3 · Type: Investigation · Related: BINARY_ISSUES.md#accore-bin-i-f1w8

Investigate whether the new int Id shadowing pattern can be handled by SGen (via base-class introspection, property-setter lookup on the base) to eliminate the runtime compiled-expression fallback for this entity class.

ACCORE-BIN-T-W9F1: Generate BinarySerializeTypeMetadata / BinaryDeserializeTypeMetadata at compile time

Priority: P1 · Type: Performance · Related: BINARY_ISSUES.md#accore-bin-i-n6q3

Eliminate the dominant first-call cost (reflection + Expression.Compile in metadata ctor) for SGen types by emitting pre-built metadata from the source generator.

Design outline:

  • TypeMetadataBase / BinarySerializeTypeMetadata / BinaryDeserializeTypeMetadata get a second constructor that accepts pre-computed values (hashes, MinWriteSize, ComplexPropertyCount, flags, IsIId, IdAccessorType, etc.). No reflection executes in this ctor.
  • Source generator keeps its existing s_typeNameHash / s_propertyHashes static fields (hot-path access stays static, zero indirection) and passes the same references to the metadata — single source of truth, no duplicate computation.
  • ModuleInit registers both the writer/reader and the pre-built metadata into a GeneratedMetadataRegistry. GetWrapperSlow consults this registry first, falling back to the reflection-based MetadataFactory for runtime-only types.
  • Lazy RuntimeInit() pattern for Expression.Compile property accessors:
    • TypeMetadataBase gets volatile bool _runtimeInitialized + internal void RuntimeInit() (idempotent, no lock needed).
    • GetWrapperSlow calls metadata.RuntimeInit() only when wrapper.GeneratedWriter == null || !Options.UseGeneratedCode — SGen types skip it entirely (they never touch runtime accessors on their own metadata; non-SGen child types have their own metadata and run the factory path normally).
    • Hybrid mode stays correct: an SGen type on the SGen path never uses its own property accessors; a non-SGen child type's metadata runs the reflection ctor as today.
  • volatile guards the flag; multiple contexts may race into RuntimeInit, second run is a no-op.

Thread safety: GlobalMetadataCache is ConcurrentDictionary; generated metadata is registered once at ModuleInit; wrapper construction is per-context and unchanged.

Acceptance:

  • Cold benchmark: first Serialize<T> of a fresh SGen type shows no reflection / Expression.Compile on the call stack.
  • Runtime fallback (UseGeneratedCode=false) still produces identical wire output and uses the full metadata accessors.
  • Deserialize side has parity (same approach for BinaryDeserializeTypeMetadata).
  • Existing tests pass; wire format unchanged.

ACCORE-BIN-T-T5J8: JIT Tier 1 warmup for generated hot methods

Priority: P2 · Type: Performance · Related: BINARY_ISSUES.md#accore-bin-i-n6q3

After ACCORE-BIN-T-W9F1 lands, JIT of generated WriteProperties / ScanObject / ScanForDuplicates becomes the dominant residual first-call cost for SGen types. Options to evaluate (benchmark before committing):

  • [MethodImpl(MethodImplOptions.AggressiveOptimization)] on the generated hot methods — skips Tier 0, compiles directly at Tier 1. Simple generator change. Trade-off: larger one-time JIT cost in exchange for eliminating the Tier 0→1 recompile step.
  • Background prewarm from ModuleInit: Task.Run(() => RuntimeHelpers.PrepareMethod(handle)) for each registered writer/reader method. Parallelizes JIT with app startup. Keep it opt-in (option flag) to avoid surprising consumers with extra startup threads.
  • ReadyToRun (R2R) in consuming projects' publish config — pre-compiles IL to native at publish time. External to SGen, complementary. Document as a recommended publish setting.
  • Code chunking (split generated methods exceeding a property threshold into sub-methods, e.g. WriteProperties_Part1 / _Part2) — measure first. Only beneficial for unusually large types (20+ properties / nested collections). Call overhead can offset gains; JIT inliner may already handle reasonably-sized methods well.
  • try / finally audit on hot path — On .NET 9 (project's minimum target), JIT silently refuses to inline any method containing an EH region (AggressiveInlining is ignored). [.NET 10 partially lifts this for same-module try-finally — see dotnet/runtime#112998, merged 2025-03-20 — but catch, cross-module, and P/Invoke-stub cases stay blocked. Until project's minimum runtime moves to .NET 10, treat EH as an absolute inlining barrier; even after the upgrade, several sub-cases keep the rule.] Audit scope:
    • Hand-written bridges: WriteValueGenerated / WriteObjectGenerated / WriteStringGenerated / ScanValueGenerated and any helper called from generated WriteProperties for accidental try/finally / using blocks.
    • SGen output template (AcBinarySourceGenerator.cs): generated WriteProperties / ScanObject / ScanForDuplicates / ReadObject / ReadProperties MUST stay straight-line. Future feature additions ([CustomSerializer] / [CustomDeserializer] hooks, OnSerializing / OnDeserialized callbacks, validation attributes, rented-buffer using blocks) are tempting candidates for try/catch/finally — emit them in separate cold helpers, never inline into the generated hot method. A single accidental try block in WriteProperties makes the whole generated method non-inlinable, killing the SGen Root Fast Path benefit.
    • Resource cleanup (Pool/ArrayPool/Dispose) belongs in Serialize<T> entry-frame only, not in per-property helpers or generated hot methods. See BINARY_IMPLEMENTATION.md Rule #3 (Inlining barriers) and BINARY_SGEN.md (SGen Output Constraints).
  • stackalloc size discipline on hot path — On .NET 9, methods containing localloc (any C# stackalloc) historically blocked inlining. Modern .NET allows inlining only for fixed-size stackalloc ≤ 32 bytes outside loops (see dotnet/runtime#7113) — anything larger or loop-nested still blocks. Our typical scratch-buffer patterns (UTF-8 encoding scratch, ArrayPool fallbacks) sit far above 32 bytes (256+), so any helper containing such a stackalloc is non-inlinable. Combined with try/finally for ArrayPool.Return cleanup, the method is doubly non-inlinable on .NET 9. Plan accordingly: keep stackalloc-using helpers as deliberate cold call-frames, not as AggressiveInlining candidates.
  • Native AOT — out of scope for this TODO; separate architectural decision with deployment-model implications.

Acceptance:

  • Benchmark a realistic entity graph (≥ 3 referenced child types) and show first-call time within ~10% of steady-state after ACCORE-BIN-T-W9F1 + chosen mitigation(s).
  • Document which combination is recommended for SignalR hot-path workloads vs. batch serialization.

ACCORE-BIN-T-Z3K8: Replace IId<T> interface dependency with convention/attribute-based Id detection

Priority: P1 · Type: Refactor

The binary serializer currently detects Id-tracking properties via the IId<T> interface (AyCode.Interfaces). This couples the serializer to a framework-specific abstraction and forces consumer types to implement the interface for tracking participation. Move to a POCO-friendly detection scheme:

  • IdDetectionMode.Convention (default) — convention-based; any property named Id is treated as the tracking key. Zero-friction onboarding.
  • IdDetectionMode.Attribute — explicit; only properties marked with a serializer-native [Id] (or similar) attribute are tracked.
  • [IgnoreId] attribute — escape hatch in Convention mode to exclude an Id-named property from tracking when the developer wants explicit opt-out.

Implicit contract for Convention mode: within a single class, the Id property must be type-level unique. Whether it semantically represents a primary key or a sequence number is irrelevant — the tracker keys by (Type, Id), so per-type uniqueness is the only requirement. Violating this invariant typically signals a domain-modelling problem, not a serializer bug. Design rationale discussed in conversation 2026-04-27.

Acceptance:

  • Binary serializer no longer references IId<T> in any execution path (no interface checks, no where T : IId<TKey> constraints in the serializer surface).
  • Wire format unchanged.
  • Existing consumers using IId<T>-implementing types still work transparently in Convention mode (their Id property is detected via convention).
  • New consumers can use plain POCOs with no AyCode.Interfaces dependency.
  • IdDetectionMode exposed on AcBinaryOptions (or successor options class post-rebrand).
  • Default mode = Convention.

ACCORE-BIN-T-N7V1: Replace [JsonIgnore] dependency with serializer-native ignore attribute

Priority: P2 · Type: Refactor

Property exclusion from binary serialization currently relies on [JsonIgnore] (Newtonsoft.Json). This couples the binary serializer to a third-party JSON library's attribute and is conceptually wrong — a binary serializer should not consult a JSON-specific marker for its exclusion semantics.

Define a serializer-native ignore attribute (working name [BinaryIgnore]; final name TBD pending broader rebrand). For backward compatibility during transition, also continue recognizing [JsonIgnore] with a deprecation note.

Possible cross-cutting consideration: if Toon and other future serializers also need property-exclusion, a single shared attribute (e.g., [SerializerIgnore] in a common abstractions package) may be cleaner than per-serializer attributes. Decide before naming finalizes — this may belong in XCUT_TODO.md rather than purely BINARY scope.

Acceptance:

  • Native ignore attribute defined in the binary serializer's namespace (or shared abstractions package, pending the cross-cutting decision above).
  • Both native attribute and [JsonIgnore] recognized during a transitional period; native attribute takes precedence on conflict.
  • [JsonIgnore] recognition flagged for removal in a future major version (track in a follow-up cleanup TODO once consumer projects have migrated).
  • No new code dependency on Newtonsoft.Json for property-exclusion logic.

ACCORE-BIN-T-Y6R2: Implement projection serialization phase 1 (runtime path)

Priority: P1 · Type: Feature · Related: ../adr/0001-binary-projection-serialization.md (canonical)

Implement the phase 1 runtime path of source→target projection serialization per ADR 0001. See the ADR for full context, decision rationale, alternatives, consequences, and acceptance criteria.

Sibling rebrand-prep TODOs: ACCORE-BIN-T-Z3K8 (IId migration), ACCORE-BIN-T-N7V1 (JsonIgnore replacement).

ACCORE-BIN-T-K3W7: Rename BufferWriterChunkSize to reflect actual semantics

Priority: P3 · Type: Refactor · Breaking: Yes (public option API) · Streaming impact: see BINARY_ASYNCPIPE_TODO.md for the streaming-side companion considerations (chunk-on-wire vs internal-buffer semantics)

The property name BufferWriterChunkSize is misleading: across the three output paths it does NOT consistently represent a "chunk".

Output path What BufferWriterChunkSize actually controls Wire-format chunk?
ArrayBinaryOutput (Byte[] API) Initial buffer capacity of the internal byte[] No
BufferWriterBinaryOutput (IBufferWriter overload) Internal buffer size — how much data accumulates before Advance() + new GetMemory() on the underlying writer No
AsyncPipeWriterOutput (streaming) Both internal buffer and wire-format chunk frame size for chunked framing Yes (only here)
Receive side (AsyncPipeReaderInput) Initial receive buffer = BufferWriterChunkSize × 2 No (just sizing hint)

Only the streaming AsyncPipeWriterOutput path has a wire-format "chunk" concept (chunked framing for length-prefixed segments). On the other 75% of paths the property name reads as if the serializer were segmenting the payload, which is not what happens.

Possible directions (decide before implementing):

  1. Single rename, semantic-neutralBufferWriterChunkSizeBufferWriterBufferSize or BufferWriterPageSize. Minimal API surface change, single-property semantics preserved. Downside: still slightly off for the streaming path where there IS chunked framing.
  2. Two-property splitInternalBufferSize (universal: how much data accumulates before Advance/Grow) + StreamingChunkSize (only meaningful for AsyncPipeWriterOutput; separate knob, defaults to InternalBufferSize). Cleanest semantics, most ceremony, slightly more options to document.
  3. Single rename, streaming-honest — Keep as BufferWriterChunkSize but document explicitly that on non-streaming paths the value is repurposed as buffer size. Cheapest change (docs only). Downside: doesn't fix the underlying confusion the field name causes.

Pick one before touching code. Option 2 is the most correct but adds API surface; Option 1 is the pragmatic middle.

Affected callers / docs to update on rename:

  • AcBinarySerializerOptions.cs (definition)
  • AcBinarySerializer.cs × 3 sites (ArrayBinaryOutput ctor, BufferWriterBinaryOutput ctor, AsyncPipeWriterOutput ctor)
  • AcBinaryDeserializer.cs × 1 site (receive-side initial capacity derivation)
  • AsyncPipeReaderInput.cs — XML doc cross-refs
  • BINARY_WRITERS.md, BINARY_TODO.md (this entry), BINARY_ISSUES.md (line 151 — already lists BufferWriterChunkSize among the struct-mutation issue's affected setters)
  • Consumer-side: AyCode.Services/SignalRs/AcBinaryHubProtocol.cs ctor mutates _options.BufferWriterChunkSize = options.BufferSize; — see BINARY_ISSUES.md#accore-bin-i-... (struct-mutation context). Coordinate the rename with the struct-mutation fix to avoid two cross-cutting churn waves on the same property.

Acceptance:

  • Property renamed (or split) per the chosen direction; all internal references updated.
  • XML docs reflect the actual semantics on each output path (initial capacity / advance threshold / chunk frame size — whichever applies).
  • Consumer-side usage in AcBinaryHubProtocol updated; if Option 2 is chosen, the protocol uses StreamingChunkSize (the streaming knob), not the universal one.
  • Wire format unchanged. Default values unchanged (65535 / equivalent).
  • Migration note in CHANGELOG / release notes since this is a breaking change to AcBinarySerializerOptions.

ACCORE-BIN-T-M4D2: Add ReadOnlyMemory<byte> / Memory<byte> deserialize overloads

Priority: P3 · Type: Feature

The public AcBinaryDeserializer.Deserialize surface accepts byte[] (with optional offset/length) and ReadOnlySequence<byte>, but not ReadOnlyMemory<byte> / Memory<byte>. Consumers that hold a ReadOnlyMemory<byte> (cached payloads, message-broker frames, in-memory pipe slices) must call .ToArray() to round-trip through byte[] — unnecessary copy + GC alloc.

Implementation:

  • Deserialize<T>(ReadOnlyMemory<byte> data, AcBinarySerializerOptions options) and the non-generic Type-based variant.
  • Body: MemoryMarshal.TryGetArray(data, out var seg) → array-backed path delegates to Deserialize<T>(seg.Array!, seg.Offset, seg.Count, options) (zero-copy). Non-array-backed fallback (rare — custom MemoryManager<T> with native memory) copies into a pooled byte[].
  • Memory<byte> overload trivially delegates to the ReadOnlyMemory<byte> one (Memory<byte> is implicitly convertible).
  • No new input-strategy struct needed — reuses existing ArrayBinaryInput.

Acceptance:

  • Both overloads compile and pass round-trip tests against byte[]-equivalent input.
  • Array-backed path measurably zero-alloc (BenchmarkDotNet allocation diagnoser).
  • Non-array-backed path documented as fallback (separate using var pooled = MemoryPool<byte>.Shared.Rent(...) style copy).
  • API doc-strings cross-reference the existing byte[] and ReadOnlySequence<byte> overloads.

ACCORE-BIN-T-S7X3: Add ReadOnlySpan<byte> deserialize overload

Priority: P2 · Type: Feature · Related: ACCORE-BIN-T-M4D2

The MemoryPack-style Deserialize<T>(ReadOnlySpan<byte>) API enables direct deserialization from stack-allocated buffers (stackalloc byte[256]), pinned native memory (fixed blocks), and ReadOnlyMemory<byte>.Span slices without round-tripping through a heap-allocated byte[]. The current AcBinary surface lacks this entry point.

Design tension: the existing IBinaryInputBase.Initialize(out byte[] buffer, ...) contract returns a byte[] — a ReadOnlySpan<byte> cannot be stored in a regular struct field, only in a ref struct field. Two implementation paths to evaluate:

  1. ref struct SpanBinaryInput + interface bump to support ref byte buffer / int length fields. Pure zero-copy from any span. Cost: BinaryDeserializationContext<TInput> and IBinaryInputBase need a parallel ref-struct-friendly track (the existing pooled context cannot hold a ref struct). Major surgery on the deser core.
  2. MemoryMarshal.CreateReadOnlySpanFromNullTerminated-style hack — accept ReadOnlySpan<byte>, use Unsafe.AsRef/MemoryMarshal.GetReference to obtain a ref byte, then copy into a pooled byte[] before deserialization. Not zero-copy, defeats the purpose. Reject.
  3. Pinned-buffer trampoline — accept ReadOnlySpan<byte>, allocate a Memory<byte> view via a MemoryManager<byte>-like wrapper, delegate to ReadOnlyMemory<byte> overload. Awkward, allocations per call. Reject.

Recommendation: option (1) is the only correct path, but it's a substantial refactor — measure first whether real consumer demand justifies the surgery. The current byte[]-based pool-pattern outperforms MemoryPack on the dominant use-cases per existing benchmarks; this overload addresses an API-surface gap, not a perf gap.

Acceptance:

  • Deserialize<T>(ReadOnlySpan<byte> data, AcBinarySerializerOptions options) compiles and round-trips against byte[]-equivalent input.
  • Zero-alloc path verified for stackalloc-source spans (BenchmarkDotNet allocation diagnoser).
  • IBinaryInputBase (or successor interface) refactor preserves backward compatibility for existing ArrayBinaryInput / SequenceBinaryInput / AsyncPipeReaderInputAdapter consumers.
  • Doc-strings cross-reference the byte[] / ReadOnlyMemory<byte> (ACCORE-BIN-T-M4D2) / ReadOnlySequence<byte> overloads with use-case guidance.

ACCORE-BIN-T-T8K3: Add SerializeAsync(Stream, T) async overloads with mode-driven output strategy

Priority: P1 · Type: Feature · Related: ACCORE-BIN-T-N9G6 (Type-based coordination)

The mainstream serializer ecosystem (System.Text.Json, MessagePack, Newtonsoft.Json, MemoryPack) all expose SerializeAsync(Stream, T) as a primary entry point — async file I/O, network response body, log streaming. AcBinary's public API surface MUST include this overload regardless of what we do internally; consumers expect a Stream parameter and don't navigate PipeWriter.Create(stream) workarounds. Market-entry-blocking otherwise.

Mode-driven output strategy — three lanes for three workload shapes

AcBinary already models the three output strategies in BinaryProtocolMode (AyCode.Services/SignalRs/BinaryProtocolMode.cs) for the SignalR side. The same three-lane shape applies to the public SerializeAsync(Stream) API. Promote the concept to AcBinary core scope (e.g. AcBinaryOutputMode in AyCode.Core/Serializers/Binaries/) and let the SignalR BinaryProtocolMode either alias it or migrate to it. Migration timing: the existing BinaryProtocolMode keeps shipping until the new public API is stabilized; both names live for one major version, then BinaryProtocolMode becomes a using-alias.

Mode Output strategy Peak memory Pipeline parallelism Use when
Bytes (default) Serialize(T) → byte[] + stream.WriteAsync(bytes) Full payload in byte[] (pooled) No Typical payloads (<10 MB), throughput-focus
Segment BufferWriterBinaryOutputPipeWriter, single closing flush PipeWriter pause-threshold-bounded (~64 KB Kestrel default) No Mid-size payloads, zero-copy desired
AsyncSegment SerializeChunked(PipeWriter), per-chunk async flush Chunk-size-bounded (~8 KB at default BufferWriterChunkSize) Yes (on parallel-capable PipeWriter — Kestrel / Pipe) Very large payloads (>10 MB), memory-tight hosts, parallel-capable transport

Honest performance positioning vs. MemoryPack — three real axes

MemoryPack's SerializeAsync(Stream) is pseudo-streaming — serializes the entire payload into a pool-allocated linked-list buffer first (ReusableLinkedArrayBufferWriter), then writes the completed buffer to the stream in a single closing fence. Peak memory ≈ payload size; no pipeline parallelism. AcBinary's Bytes mode is architecturally similar (single pooled contiguous byte[] vs. MemoryPack's linked-list) — comparable peak-memory cost, often faster on the wire due to one contiguous WriteAsync call.

AcBinary's AsyncSegment mode is architecturally different in three real ways MemoryPack cannot match:

Axis Bytes mode (default) AsyncSegment mode MemoryPack SerializeAsync
Heap allocation per call Pooled byte[] rent (peak ≈ payload size) Truly zeroArrayPool + pooled context + MemoryMarshal.TryGetArray direct-buffer-write into the transport's own byte[] Pool-allocated linked-list buffer per call (peak ≈ payload size)
Peak managed memory ≈ payload size ≈ chunk size (BufferWriterChunkSize, e.g. 4-8 KB) ≈ payload size
GC pressure Touches GC pool on every call Never touches GC for the serialize itself Touches GC pool on every call
Pipeline parallelism No Yes on parallel-capable PipeWriter (Kestrel transport, new Pipe()) No
GB-scale payload OOM risk on memory-tight hosts Works OOM risk

The AsyncSegment zero-alloc claim is literal, not "almost zero": AsyncPipeWriterOutput.AcquireChunk calls _pipeWriter.GetMemory(chunkSize) and uses MemoryMarshal.TryGetArray(memory, out segment) to obtain the transport's own internal byte[] — the serializer writes directly into it. With chunkSize aligned to the transport's internal buffer (e.g. NamedPipe-server pipe-buffer-size), one chunk is one kernel-level transfer; no managed-side double-fragmentation.

Throughput nuance — AsyncSegment cost on Stream-backed transports

AsyncSegment IS slightly slower than Bytes on StreamPipeWriter-backed transports (NamedPipe / FileStream / NetworkStream), but not for the reason that initially seems obvious:

  • The cost is NOT "managed-side double-fragmentation on top of OS-level fragmentation" — that's not what happens. MemoryMarshal.TryGetArray zero-copy direct-buffer-writes mean the managed chunking is the same chunking the kernel does anyway, not redundant.
  • The cost IS the per-chunk async-await round-trip (SyncAwaitFlush(_lastFlush) blocks until the kernel acknowledges the write), forced sequential by the StreamPipeWriter._tailMemory reset race (ACCORE-BIN-I-...). N async cycles vs 1 in Bytes mode.
  • Empirically the gap is roughly 1.2-1.5x on NamedPipe — not 2-5x. The dominant cost on these transports is the transport itself (Windows IRP / Linux FIFO syscall overhead), independent of the serializer mode.

When AsyncSegment wins outright:

  • GC-sensitive hot-paths (server hubs, real-time game tick loops, mobile UI thread, embedded targets): zero-alloc + zero-GC-pressure beats a 1.2x throughput edge every time.
  • Memory-tight hosts (mobile, WASM, container-trimmed, embedded): chunk-bounded peak memory is the only option.
  • GB-scale payloads: Bytes OOMs; AsyncSegment works.
  • Kestrel transport / parallel-capable Pipe: pipeline parallelism makes AsyncSegment faster than Bytes for medium-to-large payloads.

When Bytes wins outright:

  • Tipikus NuGet workload (small-to-medium payload, throughput priority, GC-tolerant): one async cycle vs N is the simpler, faster path.
  • MemoryStream (in-memory): one large byte[] copy decisively beats N managed chunks.

Marketing claim — three-way honest comparison

"AcBinary offers a real choice. Bytes mode for typical throughput-priority workloads (matches MemoryPack's pseudo-streaming, often faster on the wire). AsyncSegment mode for the workloads MemoryPack cannot serve: zero-alloc serialize for GC-sensitive hot-paths, chunk-bounded peak memory for tight-budget hosts, GB-scale payloads, and pipeline parallelism on parallel-capable transports. You pick the mode; MemoryPack picks for you."

This is honest — does not overclaim universal speed, does not hide the small AsyncSegment cost on Stream-backed transports, AND clearly surfaces the three differentiator axes (alloc / memory / parallelism) where AcBinary architecturally beats MemoryPack.

Implementation outline:

  • New enum AcBinaryOutputMode { Bytes = 0, Segment = 1, AsyncSegment = 2 } in AyCode.Core/Serializers/Binaries/. Default Bytes.
  • New mode field on AcBinarySerializerOptions: AcBinaryOutputMode OutputMode { get; set; } = AcBinaryOutputMode.Bytes;. (Note: subject to ACCORE-BIN-I-L8N5 thread-safety treatment — defensive copy / immutable refactor coordination.)
  • public static ValueTask SerializeAsync<T>(T value, Stream stream, AcBinarySerializerOptions? options = null, bool leaveOpen = false, CancellationToken ct = default):
    • Switch on options.OutputMode:
      • Bytesvar bytes = Serialize(value, options); await stream.WriteAsync(bytes, ct); ArrayPool.Return(bytes);
      • Segmentvar pw = PipeWriter.Create(stream, new(leaveOpen: leaveOpen)); Serialize(value, pw, options); await pw.CompleteAsync();
      • AsyncSegmentvar pw = PipeWriter.Create(stream, new(leaveOpen: leaveOpen)); SerializeChunked(value, pw, options); await pw.CompleteAsync();
  • public static ValueTask SerializeAsync(object? value, Type type, Stream stream, ...) — non-generic, same dispatch (coordinated with ACCORE-BIN-T-N9G6).
  • leaveOpen parameter standard for stream-async serializers (System.Text.Json, MessagePack convention).
  • The Bytes mode uses a pooled byte[] from ArrayBinaryOutput to keep alloc cost amortized.

SignalR migration coordination: the existing BinaryProtocolMode enum (in AyCode.Services) keeps shipping unchanged until the new public API is stabilized. After stabilization, BinaryProtocolMode becomes a deprecated alias of AcBinaryOutputMode, eventually removed in a major-bump. No SignalR-side churn during this TODO's implementation.

Acceptance:

  • SerializeAsync<T> round-trips against Deserialize<T>(byte[]) via MemoryStream in all three modes.
  • Cancellation propagates correctly (OperationCanceledException on cancelled token mid-stream).
  • Throughput matrix benchmark: 4 transports (MemoryStream, FileStream, NamedPipeStream, NetworkStream) × 3 modes × 3 payload sizes (small ~1 KB / medium ~100 KB / large ~10 MB). Results documented in Test_Benchmark_Results/Benchmark/SerializeAsync_Stream_Modes.LLM (or similar) and surfaced as a doc-string table for consumer guidance.
  • Memory-bounded benchmark: 100 MB payload to FileStream in AsyncSegment mode → peak managed-heap delta ≤ 1 MB throughout. Same payload in Bytes mode → peak ~100 MB (expected, documented).
  • API doc-string contains a "When to use which mode?" decision matrix; explicitly compares with MemoryPack's pseudo-streaming.
  • leaveOpen parameter behaves per the System.Text.Json / MessagePack convention across all three modes.

ACCORE-BIN-T-D7K4: Add DeserializeAsync(Stream, T) async overloads with mode-driven input strategy

Priority: P1 · Type: Feature · Related: ACCORE-BIN-T-T8K3 (companion write-side overload), ACCORE-BIN-T-N9G6 (non-generic Type-based dispatch)

Companion to T8K3 on the receive side. The mainstream serializer ecosystem (System.Text.Json, MessagePack, Newtonsoft.Json, MemoryPack) all expose DeserializeAsync<T>(Stream) — the symmetric counterpart of SerializeAsync(Stream, T). AcBinary's public API surface MUST include this overload for parity; consumers expect a Stream parameter for receive paths (file load, HTTP response body, network stream) and don't navigate PipeReader.Create(stream) workarounds. Market-entry-blocking otherwise.

Implementation: zero new IBinaryInputBase impl needed

The existing receive-side primitives cover the full strategy space via BCL PipeReader.Create(stream):

Mode Input strategy Peak memory Pipeline parallelism Use when
Bytes (default) await stream.CopyToAsync(MemoryStream)Deserialize<T>(byte[]) (existing overload) Full payload as byte[] (pooled) No Typical payloads (<10 MB), throughput-focus
Segment await PipeReader.Create(stream).ReadAsync()Deserialize<T>(ReadOnlySequence<byte>) (existing overload) PipeReader pause-threshold-bounded (~64 KB) No Mid-size payloads, no full byte[] alloc desired
AsyncSegment AsyncPipeReaderInput + DrainFromAsync(PipeReader.Create(stream)) + Deserialize<T>(input) (existing overload) Chunk-size-bounded (~8 KB) Yes (producer drain Task in parallel with deser Task) Very large payloads (>10 MB), memory-tight hosts

The AcBinaryOutputMode enum (introduced by T8K3) is symmetric — it controls deser-input strategy as well. The same enum value picks the matching read path. No new IBinaryInputBase implementation needed — the trio of existing inputs (ArrayBinaryInput, SequenceBinaryInput, AsyncPipeReaderInput) already cover all three modes; the new overload is a thin shim that wraps the Stream and routes to the right existing overload.

Public API shape

public static ValueTask<T?> DeserializeAsync<T>(
    Stream stream,
    AcBinarySerializerOptions? options = null,
    bool leaveOpen = false,
    CancellationToken ct = default);

// Non-generic Type-based variant (coordinated with N9G6):
public static ValueTask<object?> DeserializeAsync(
    Stream stream,
    Type targetType,
    AcBinarySerializerOptions? options = null,
    bool leaveOpen = false,
    CancellationToken ct = default);

Implementation outline (per mode)

// Bytes mode (default — simplest path, sub-LOH-friendly fast path):
public static async ValueTask<T?> DeserializeAsync_Bytes<T>(Stream stream, ..., CancellationToken ct)
{
    var rented = ArrayPool<byte>.Shared.Rent((int)Math.Min(stream.CanSeek ? stream.Length : 4096, int.MaxValue));
    try
    {
        var totalRead = 0;
        int read;
        while ((read = await stream.ReadAsync(rented.AsMemory(totalRead), ct)) > 0)
        {
            totalRead += read;
            if (totalRead == rented.Length) { /* grow rented */ }
        }
        return Deserialize<T>(rented, 0, totalRead, options);
    }
    finally { ArrayPool<byte>.Shared.Return(rented); }
}

// Segment mode (PipeReader.Create wrapping, then drain to ReadOnlySequence):
public static async ValueTask<T?> DeserializeAsync_Segment<T>(Stream stream, ..., CancellationToken ct)
{
    var pipeReader = PipeReader.Create(stream, new(leaveOpen: leaveOpen));
    var result = await pipeReader.ReadAtLeastAsync(int.MaxValue, ct);   // drain whole stream
    var seq = result.Buffer;
    var obj = Deserialize<T>(seq, options);
    pipeReader.AdvanceTo(seq.End);
    await pipeReader.CompleteAsync();
    return obj;
}

// AsyncSegment mode (chunked streaming pipeline, parallel drain + deser):
public static async ValueTask<T?> DeserializeAsync_AsyncSegment<T>(Stream stream, ..., CancellationToken ct)
{
    using var input = new AsyncPipeReaderInput(options.BufferWriterChunkSize * 2, multiMessage: false);
    var pipeReader = PipeReader.Create(stream, new(leaveOpen: leaveOpen));
    var deserTask = Task.Run(() => Deserialize<T>(input, options), ct);
    await input.DrainFromAsync(pipeReader, ct);
    await pipeReader.CompleteAsync();
    return await deserTask;
}

Honest performance positioning

Symmetric to T8K3's analysis:

  • Bytes mode: simplest, single contiguous byte[] (pooled) → Deserialize<T>(byte[]). Comparable to MemoryPack's DeserializeAsync (which does similar full-buffer-then-deser). Best for typical workloads.
  • Segment mode: zero-copy from PipeReader's natural ReadOnlySequence<byte> — no extra byte[] allocation. Best for mid-size payloads where allocation matters but pipeline overlap doesn't.
  • AsyncSegment mode: producer-drain Task and consumer-deser Task in parallel via AsyncPipeReaderInput. Wall-clock = max(network-drain, deser-CPU) + small overlap-cost. Best for large payloads + slow transports (network, mobile, satellite — where transit dominates and overlap pays).

Acceptance

  • DeserializeAsync<T> round-trips against SerializeAsync(Stream, T) (T8K3) via MemoryStream in all three modes.
  • Cancellation propagates correctly (OperationCanceledException on cancelled token mid-stream); partial-buffer state cleaned up; pooled byte[] returned even on cancellation.
  • Throughput matrix benchmark (mirror of T8K3): 4 transports (MemoryStream, FileStream, NamedPipeStream, NetworkStream) × 3 modes × 3 payload sizes. Results documented in Test_Benchmark_Results/Benchmark/DeserializeAsync_Stream_Modes.LLM.
  • Memory-bounded benchmark: 100 MB payload from FileStream in AsyncSegment mode → peak managed-heap delta ≤ 1 MB throughout. Same payload in Bytes mode → peak ~100 MB (expected, documented).
  • API doc-string contains a "When to use which mode?" decision matrix; cross-references T8K3's symmetric write-side guidance.
  • leaveOpen parameter behaves per the System.Text.Json / MessagePack convention across all three modes.

ACCORE-BIN-T-N9G6: Add non-generic Type-based Serialize(object, Type, ...) overloads

Priority: P2 · Type: Feature · Related: ACCORE-BIN-T-T8K3

Plugin frameworks, ASP.NET ModelBinding, DI middleware, and DataContractSerializer-style "generic-API container" use-cases need to serialize an object whose type is known only at runtime. Current AcBinary surface forces a reflection trampoline through the generic Serialize<T>:

// Today's workaround (slow + noisy):
typeof(AcBinarySerializer).GetMethod("Serialize", new[] { type, typeof(AcBinarySerializerOptions) })
    .MakeGenericMethod(type).Invoke(null, new[] { value, options });

Implementation outline:

  • public static byte[] Serialize(object? value, Type type, AcBinarySerializerOptions? options = null)
  • public static int Serialize(object? value, Type type, IBufferWriter<byte> writer, AcBinarySerializerOptions? options = null)
  • public static int SerializeChunked(object? value, Type type, PipeWriter writer, AcBinarySerializerOptions? options = null) and Pipe overload
  • public static int SerializeChunkedFramed(object? value, Type type, PipeWriter writer, AcBinarySerializerOptions? options = null) and Pipe overload
  • public static ValueTask SerializeAsync(object? value, Type type, Stream stream, ...) — coordinated with ACCORE-BIN-T-T8K3
  • Internal dispatch: value.GetType() is the runtime type; the Type type parameter constrains the declared type for polymorphism handling (ObjectWithTypeName write decision).

Acceptance:

  • All non-generic overloads round-trip via the generic deserializer's Deserialize(byte[], Type) overload.
  • Plugin-style scenario: serialize IList<dynamic> of mixed-type elements → all elements correctly typed in the wire output.
  • API doc-strings call out the performance characteristics (slightly slower than generic due to runtime Type lookup but without the reflection trampoline cost).

ACCORE-BIN-T-R4P2: Expose low-level ref Writer-style API for custom formatters

Priority: P3 · Type: Feature

The MemoryPack-style Serialize<T>(ref MemoryPackWriter writer, in T value) low-level API enables:

  • Custom formatters that compose write primitives without the full Serialize entry-point overhead.
  • Nested-into-existing-stream scenarios where the caller already owns a writer-style cursor.
  • Test harnesses that exercise specific wire-format paths in isolation.

Today's BufferWriterBinaryOutput standalone-mode partly fills this gap — exposing WriteByte, WriteVarUInt, WriteStringUtf8, etc. — but it is not a ref struct, not a documented low-level public API for external custom formatters, and the relationship with BinarySerializationContext<TOutput> is unclear from the consumer's perspective.

Design tension (decide before implementing):

  1. Promote BufferWriterBinaryOutput to documented public surface — add doc, examples, supported usage patterns. Cheapest, but the standalone-mode is currently a side-feature, not a primary API; documenting it commits to its current shape.
  2. New ref struct AcBinaryWriter wrapper around BufferWriterBinaryOutput (or a dedicated impl) — explicit "this is the low-level writer" signal. More API surface but clearer mental model. Aesthetic alignment with MemoryPack.
  3. Skip entirely — the IBufferWriter<byte> overload is already lower-level than most consumers need; custom formatters can write to an ArrayBufferWriter<byte> and use IBufferWriter-style primitives. This is what BufferWriterBinaryOutput already does internally.

Recommendation: option 3 is honest — the existing IBufferWriter<byte> overload covers the use case, and adding a ref struct AcBinaryWriter is mostly aesthetic alignment with MemoryPack. Re-evaluate when there's a concrete custom-formatter request that the current API can't accommodate.

Acceptance (if implemented):

  • AcBinaryWriter ref struct (or equivalent) compiles, supports the same write primitives as BufferWriterBinaryOutput standalone-mode.
  • At least one example custom formatter ships in tests (e.g., a Vector3 struct formatter).
  • Doc-string clearly distinguishes when to use the low-level writer vs. the high-level Serialize<T> entry-point.

ACCORE-BIN-T-U6Y8: Attribute-driven polymorphism via [AcBinaryUnion] + SGen (opt-in, AOT-friendly)

Priority: P1 (if AOT target required) / P2 (non-AOT only) · Type: Feature

Design philosophy alignment: AcBinary's market positioning is "JSON-style flexibility with MessagePack-class speed" — attributes are opt-in optimization, never required. The runtime polymorphism path (AQN-based, today's default) stays the default and continues to work for arbitrary unattributed types. This TODO adds a fast/AOT path alongside it, never replaces it.

AcBinary today handles polymorphism at runtime: the wire writes ObjectWithTypeName(72) + AQN string, and the deserializer calls Type.GetType(aqn) to resolve. This is flexible (no upfront declaration), but has three significant drawbacks for some consumers:

  • AOT-incompatibleType.GetType(AQN) requires reflection metadata that the Native AOT trimmer strips by default. The runtime polymorphism path does not work at all under Native AOT. Hard blocker for AOT-targeting consumers (Blazor WASM, MAUI mobile, container-trimmed deployments).
  • Slower — AQN string parse + reflection lookup vs. a closed switch (tag) in code-gen.
  • Larger wire format — full AQN string (often 100+ bytes) vs. a single-byte tag.

Design — three coordinated pieces:

1. New 5th bool parameter on [AcBinarySerializable]: EnablePolymorphismFeature

Mirrors the existing EnableMetadataFeature / EnableIdTrackingFeature / EnableRefHandlingFeature / EnableInternStringFeature pattern. Per-type opt-out / opt-in via attribute parameter.

public AcBinarySerializableAttribute(
    bool enableMetadataFeature,
    bool enableIdTrackingFeature,
    bool enableRefHandlingFeature,
    bool enableInternStringFeature,
    bool enablePolymorphismFeature)   // ← ÚJ, default: true

Three behavior modes per type:

  • EnablePolymorphismFeature = falsedisabled. SGen never emits polymorphism dispatch for this type; runtime path also short-circuits — runtime type ≠ declared type is silently treated as declared (or throws, decision TBD). Use for hot-path closed types where polymorphism is impossible-by-design and the perf/AOT cost is unwanted.
  • EnablePolymorphismFeature = true (default), no [AcBinaryUnion]runtime options control. Behaves per AcBinarySerializerOptions.PolymorphismMode (Runtime/AQN today). This preserves the JSON-style flexibility for unattributed bases.
  • EnablePolymorphismFeature = true + [AcBinaryUnion(...)] declared → union-switch dispatch. SGen emits a closed switch (tag) dispatch using the declared subtype set. Fast + AOT-friendly. Overrides the options-level default for this type.

2. New [AcBinaryUnion(byte tag, Type subtype)] attribute

Multiple instances per base class / interface declare the closed polymorphism set:

[AcBinarySerializable]   // EnablePolymorphismFeature defaults to true
[AcBinaryUnion(0, typeof(Cat))]
[AcBinaryUnion(1, typeof(Dog))]
public abstract partial class Animal { ... }

SGen detects [AcBinaryUnion] on abstract / base type → emits the switch-based write/read dispatch instead of falling through to runtime AQN.

3. New PolymorphismMode enum on AcBinarySerializerOptions

Options-level default for unattributed polymorphism (i.e. the case where EnablePolymorphismFeature = true but no [AcBinaryUnion] is declared):

  • Runtime (today's default) — AQN-based. Flexible, AOT-incompatible.
  • Throw — fail fast on any polymorphic write that lacks a [AcBinaryUnion] attribute. AOT-friendly diagnostic mode for migration scenarios.

Note: there is no UnionAttribute-only mode — declaration is per-type via the attribute, not options-global. The options-level mode only governs the fallback when no [AcBinaryUnion] is present.

Wire-format addition:

New marker (e.g. UnionTagBase = <TBD>) + [byte tag][inner Object], parallel to existing ObjectWithTypeName(72). Slot number to be assigned avoiding clashes with existing 64134 / 192255 ranges.

Implementation outline:

  • AcBinarySerializableAttribute — new ctor parameter enablePolymorphismFeature, all existing ctors default it to true (backward compatible).
  • AcBinaryUnionAttribute — new attribute, AttributeUsage(AttributeTargets.Class | Interface, AllowMultiple = true).
  • Source generator — emit WriteUnion<TBase>(value, ctx, depth) and ReadUnion<TBase>(ctx, depth) static methods on the union-base type's generated writer/reader. Skipped entirely when EnablePolymorphismFeature = false.
  • Wire-format new marker + [byte tag][inner Object] body.
  • Runtime path: WriteValueNonPrimitive checks the wrapper's PolymorphismFeatureEnabled flag; when false, skips the value.GetType() != declaredType polymorphism branch entirely.

Acceptance:

  • EnablePolymorphismFeature = false: SGen-emitted dispatch contains zero is-typeof / GetType branches; runtime path also short-circuits. Verify in JIT disassembly.
  • EnablePolymorphismFeature = true, no union: runtime AQN polymorphism works as today (full backward compat); preserved JSON-style flexibility for unattributed bases.
  • EnablePolymorphismFeature = true + [AcBinaryUnion]: AOT-test (Native AOT publish) compiles and round-trips a polymorphic graph — Type.GetType() is never invoked on this path.
  • Benchmark: union-switch polymorphism measurably faster than AQN polymorphism on deser side (typed switch vs. reflection lookup).
  • Wire format documented in BINARY_FORMAT.md; BINARY_FEATURES.md cross-references the attribute pattern; BINARY_OPTIONS.md documents PolymorphismMode. AcBinarySerializableAttribute doc-string explains all three behavior modes.

ACCORE-BIN-T-B7H4: Implement AcBinarySerializerOptions thread-safety fix

Priority: P2 · Type: Refactor · Related: BINARY_ISSUES.md#accore-bin-i-l8n5 (canonical issue)

The latent thread-safety problem documented in ACCORE-BIN-I-L8N5 — mutable set; properties on AcBinarySerializerOptions shared across concurrent serialize/deserialize calls — needs a fix before AcBinary ships as a NuGet package. The package cannot constrain how consumers scope their options instances; defensive contract is needed in the serializer itself.

Three candidate fix directions (decide before implementing):

  1. Defensive copy on ingress — add AcBinarySerializerOptions Clone() method (member-wise copy). Every API entry point that retains an options instance clones it on entry. External mutation to the original becomes invisible to the holder.

    • Pro: non-breaking. Existing consumer code unchanged. No major version bump required.
    • Pro: API surface change limited to one new Clone() method.
    • Con: per-call clone overhead (small, but non-zero). Cache keyed on options-identity becomes invalid for downstream code using reference equality.
    • Con: doesn't fix the underlying mutability — internal code can still race-mutate the cloned snapshot if a method retains both the snapshot and modifies it concurrently.
  2. Immutable record refactorset;init; on all configuration properties. Mutation requires with-expression which produces a new instance.

    • Pro: type-system-strong guarantee. Race becomes a compile error, not a runtime corruption risk.
    • Pro: zero runtime overhead (init-only is compile-time check; record class semantics are unchanged at runtime).
    • Con: breaking change for any consumer doing opts.UseGeneratedCode = false after construction. Major version bump.
    • Con: source-generator coordination needed if SGen emits options-builder code that mutates properties.
  3. Read-only flag pattern (à la JsonSerializerOptions.MakeReadOnly()) — mutable by default, holder calls MakeReadOnly() on entry; subsequent property setters throw InvalidOperationException.

    • Pro: BCL-precedent — Microsoft adopted it for JsonSerializerOptions in .NET 7 (dotnet/runtime#74431) for exactly this problem. Familiar pattern for consumers.
    • Pro: minimal API surface change (one new method + IsReadOnly flag property).
    • Pro: per-call overhead = single bool check per setter call. Negligible.
    • Con: opt-in by the holder — if a custom consumer-side wrapper forgets to call MakeReadOnly(), the safety hole stays open for that wrapper's clients. Documentation-driven safety, not type-system-driven.
    • Con: bypasses static-analysis tooling (the setter signature stays public; the throw is runtime). IDE doesn't surface "this property is currently read-only" in autocomplete.

Recommendation: Option 3 (MakeReadOnly pattern) is the BCL-precedent, lowest-friction migration path. Microsoft adopted it for JsonSerializerOptions in .NET 7 to solve the same problem; AcBinary should follow the same pattern for consistency with consumers' mental model and zero migration cost.

Coordination with the existing AcBinaryHubProtocol setter side-effect (the second risk surface in ACCORE-BIN-I-L8N5): the protocol ctor currently mutates the caller-provided options reference (_options.BufferWriterChunkSize = options.BufferSize). After the fix:

  • Option 1 (Clone): ctor mutates the cloned snapshot → no side-channel to the caller. Fix transparent.
  • Option 2 (Immutable): ctor cannot mutate; needs to construct a new options via with-expression. Breaking change in the ctor's options-handling.
  • Option 3 (MakeReadOnly): ctor mutates before calling MakeReadOnly() — same as today, but explicit "frozen" point afterwards. Caller-side mutation post-ctor is now a runtime throw.

Implementation outline (Option 3 path):

  1. AcBinarySerializerOptions.IsReadOnly { get; } — public bool property.
  2. AcBinarySerializerOptions.MakeReadOnly() — sets the flag; idempotent (no-op if already set).
  3. All set; accessors guard: if (IsReadOnly) throw new InvalidOperationException("AcBinarySerializerOptions has been made read-only and can no longer be mutated. Construct a new options instance instead.");.
  4. AcBinarySerializer.Serialize<T> entry (and all sibling entries — Deserialize<T>, SerializeChunked, etc.): options.MakeReadOnly() before any property read.
  5. AcBinaryHubProtocol ctor: complete the BufferWriterChunkSize mutation before calling options.MakeReadOnly(). After ctor returns, the options instance is frozen for that protocol's lifetime.
  6. Doc-string update on AcBinarySerializerOptions class header: explicit "thread-safety contract" section explaining the freeze-on-first-use semantics.

Acceptance:

  • Concurrent stress test (16 threads × 1000 iterations) on a shared AcBinarySerializerOptions instance with property-mutation-attempts mid-iteration — all mutations after MakeReadOnly() throw InvalidOperationException; no silent corruption observed.
  • Existing tests pass unchanged (the MakeReadOnly is opt-in for the serializer entries; tests that build options + use them once continue to work transparently).
  • BINARY_ISSUES.md#accore-bin-i-l8n5 Status updated to Closed (YYYY-MM-DD) with a ### Resolution sub-section pointing to this TODO + the implementing commit.
  • Doc-string on AcBinarySerializerOptions documents the freeze-on-first-use contract; BINARY_FEATURES.md or BINARY_OPTIONS.md cross-references the BCL-precedent (JsonSerializerOptions.MakeReadOnly).

ACCORE-BIN-T-F8N3: Switch source-generator type-name hashing from simple-name to fully-qualified-name

Priority: P3 · Type: Refactor · Related: ACCORE-BIN-T-I3P8 (override mechanism for residual collisions)

The source generator's ComputeFnvHash(typeSymbol.Name) uses the simple name only (e.g. "User", not "MyApp.A.User"). Cross-namespace types with the same simple name silently collide on s_typeNameHash. The hash is currently only consumed by the WireMode=Metadata inline metadata-write path (cross-version property compat) — the framework explicitly does NOT add wire-format type-id (per CLAUDE.md Rule #7: type-dispatch is consumer responsibility, see BINARY_ASYNCPIPE_ISSUES.md#accore-bin-i-t6v2). Within UseMetadata, the simple-name collision can still cause silent property-set mismatches between two types with the same short name in different namespaces — this TODO fixes that.

Change scope (AcBinarySourceGenerator.cs) — 4 call sites: ComputeFnvHash(typeSymbol.Name)ComputeFnvHash(typeSymbol.ToDisplayString()):

  • Self type-name hash (~line 358)
  • Child type-name hash (~line 157)
  • Element type-name hash (~line 254)
  • Dict-value type-name hash (~line 311)

No runtime code changes; output regenerates with new constants on next build.

Breaking change scope: any saved binary stream that uses WireMode=Metadata and was produced by an older version embeds the old simple-name hash; consumers reading those streams with the new hash compute would mismatch and throw. Pre-1.0: acceptable. Post-1.0 would require a WireMode=Metadata format-version bump.

Acceptance:

  • All *_GeneratedWriter.g.cs files regenerate with FQN-based s_typeNameHash values.
  • Existing tests pass (auto-regen propagates; no manual hash literals in tests).
  • Wire format identical for WireMode=Compact (no metadata embedded).
  • UseMetadata=true paths produce different hashes — explicitly tested via round-trip.

ACCORE-BIN-T-I3P8: [AcBinaryTypeId(...)] attribute — explicit type-id override

Priority: P3 · Type: Feature · Related: ACCORE-BIN-T-F8N3 (FQN base hash being overridden)

Once ACCORE-BIN-T-F8N3 reduces collision frequency by switching to FQN, residual FQN-hash collisions are still possible (32-bit hash space, birthday paradox). Currently the only consumer of s_typeNameHash is the WireMode=Metadata inline metadata-write path — a residual collision there causes a silent property-set mismatch.

[AcBinaryTypeId(0x12345)] attribute on a class:

  • Source generator emits s_typeNameHash = 0x12345 instead of computing FNV.
  • Two types with the same [AcBinaryTypeId(...)] value → compile-time / first-use error.

Useful for:

  • Resolving rare FQN-hash collisions deterministically (within WireMode=Metadata).
  • Pinning a stable type-id across class renames (wire-compat across versions in Metadata mode).
  • Future-proofing: if a Layer 1 consumer (hypothetically) builds a type-dispatch above AcBinary using s_typeNameHash, the same override mechanism applies.

Acceptance:

  • New attribute class shipped alongside [AcBinarySerializable].
  • Generator honours the override (emits explicit constant instead of FNV result).
  • Tests: rename a class with [AcBinaryTypeId]s_typeNameHash unchanged.

ACCORE-BIN-T-X2M5: Evaluate xxHash3 vs FNV-1a for type-name hashes

Priority: P3 · Type: Investigation · Related: ACCORE-BIN-T-F8N3

FNV-1a is currently used for both s_typeNameHash and s_propertyHashes. For compile-time hashing, performance is irrelevant. For collision resistance:

  • FNV-1a 32-bit: ~50% collision at ~77K types (birthday paradox). Adequate for small/medium projects, marginal for large ones with many auto-generated types.
  • xxHash3 32-bit: comparable mathematical properties to FNV-1a (both non-cryptographic).
  • xxHash3 64-bit: dramatically better collision resistance (~50% at ~5B entries), at the cost of 8 wire bytes instead of 4.

Trigger: real collisions observed (1000+ types per assembly + cross-assembly aggregation), or community feedback indicating collision pain.

Investigation questions (no code change without a triggering pain signal):

  1. Switch to xxHash3 32-bit (incremental improvement) — but doubles the change scope (touch property hashes too if uniformity desired).
  2. Switch to xxHash3 64-bit (8 wire bytes instead of 4) — meaningful collision resistance, modest wire cost.
  3. Stay on FNV-1a + force [AcBinaryTypeId] for collisions — minimal change, devops burden.

Investigation only — defer until pain signal arrives.

ACCORE-BIN-T-K9E4: [RequiresDynamicCode] + [RequiresUnreferencedCode] on Runtime-only methods

Priority: P3 · Type: Refactor · Related: BINARY_FEATURES.md#nativeaot-compatibility

The Runtime path (factories in AcSerializerCommon + wrapper-based deserialize fallback in AcBinaryDeserializer) currently works under NativeAOT thanks to DAMs propagation + RuntimeFeature.IsDynamicCodeSupported guards, but the trimmer still emits warnings for the well-known blind spots (polymorphism via obj.GetType(), nested-type chain via generic argument extraction). The library suppresses these with [UnconditionalSuppressMessage] and documented justification.

A complementary signal would be to mark the Runtime entry points (or the factories themselves) with [RequiresDynamicCode("AcBinary Runtime path uses Reflection.Emit / closed-generic instantiation; use [AcBinarySerializable] + SGen for NativeAOT.")] and [RequiresUnreferencedCode("...")]. Effect:

  • AOT publish in consumer's project surfaces a warning at the call site → consumer chooses SGen or accepts the Runtime cost
  • Mirrors the System.Text.Json reflection-mode pattern ([RequiresDynamicCode] on JsonSerializer.Serialize<T> overloads)
  • One-codebase, no NuGet split needed
  • Cheap implementation — attribute placement only

Coordination: [RequiresDynamicCode] is contagious; every caller must either propagate it or suppress with [UnconditionalSuppressMessage]. Scope:

  • Public Serialize<T> / Deserialize<T> entry points stay attribute-free (consumer-facing)
  • Runtime fallback methods get the attribute (contained inside the library)
  • The DAMs annotations we already have stay — they're orthogonal (one prevents trim, the other warns about JIT-only behavior)

Acceptance:

  • Consumer's AOT publish surfaces a IL2026/IL3050 warning when UseGeneratedCode=false is set or an unattributed type is deserialized
  • SGen path is warning-free
  • Library compiles 0 warnings (suppressions added at the propagation barrier)
  • BINARY_FEATURES.md NativeAOT Compatibility section updated to mention the explicit warning signal

ACCORE-BIN-T-A2J7: Optional AyCode.Core.Aot NuGet variant (SGen-only build)

Priority: P3 · Type: Feature · Related: BINARY_FEATURES.md#nativeaot-compatibility, ACCORE-BIN-T-K9E4

Binary-size-sensitive AOT consumers (Blazor WASM, MAUI mobile, embedded, container-trimmed) benefit from a smaller library variant that strips the Runtime fallback path entirely. Estimated savings: ~80-150 KB of native code (~25-60 KB compressed wire size for WASM publish).

Strippable code in the .Aot variant:

Component LOC Purpose Removable in Aot?
AcSerializerCommon.Create* (7 factory methods + Expression-tree code) ~150 Runtime delegate compilation Yes
TypeMetadataBase runtime metadata path (CompiledConstructor, IdGetters via Expression.Compile) ~300 Reflection-based metadata Yes
AcBinaryDeserializer wrapper-based runtime fallback (PopulateObjectPropertiesIndexed, ReadObjectCoreWithWrapper non-SGen branches, CreateInstance(type) Activator-fallback) ~500 Runtime polymorphic dispatch Yes
Property accessor runtime delegate fields (_dynamicGetter, typed getter/setter caches outside SGen) ~150 Boxed property access Yes
System.Linq.Expressions transitive dependency Expression-tree IL emission Yes (when nothing else in graph uses it)

Implementation sketch (avoid #if-erdő via file-level split):

AyCode.Core/Serializers/
  AcSerializerCommon.cs              // SGen-safe shared parts
  AcSerializerCommon.Runtime.cs      // 7 Create* factory methods only here
  AcBinaryDeserializer.cs            // SGen path
  AcBinaryDeserializer.Runtime.cs    // wrapper-based runtime fallback path
  TypeMetadataBase.cs                // SGen-safe metadata
  TypeMetadataBase.Runtime.cs        // Expression.Compile-based ctor + accessor wiring

Two .csproj files:

  • AyCode.Core.csproj — full package (current); includes all files
  • AyCode.Core.Aot.csproj<Compile Remove="**/*.Runtime.cs" />; sets <PackageId>AyCode.Core.Aot</PackageId>; same version as full

Trade-offs:

  • No #if directives in business code — physically separate file groups
  • Source mostly shared via SDK include/exclude semantics
  • DAMs annotations and trim-suppressions only land in the full package; .Aot variant is genuinely trim-clean by construction
  • "Strict SGen" semantics in .Aot: a non-SGen type at deser time throws clearly instead of silently falling back. Marketing positioning: "guaranteed SGen path, no hidden slow lane".
  • ⚠️ Two NuGet IDs, two changelogs, version sync (CI-automatable)
  • ⚠️ Consumer must pick the right package — wrong choice = breaking switch later

Coordination:

  • Land ACCORE-BIN-T-K9E4 first ([RequiresDynamicCode] attributes) — if that pattern handles the consumer-side scenarios well, .Aot may not be needed
  • The current Runtime fallback code is already well-isolated (mostly in AcSerializerCommon factories + AcBinaryDeserializer wrapper-based methods), so the file-split refactor is mechanically straightforward
  • Marketing decision: is binary size a central pillar? If yes, .Aot is a NuGet differentiator; if not, K9E4 alone is enough

Acceptance:

  • AyCode.Core.Aot.csproj produces a NuGet ~25-60 KB smaller than AyCode.Core after compression
  • .Aot build emits zero IL/AOT trim warnings (no suppressions needed because the Runtime path code is physically removed)
  • Round-trip tests pass on .Aot for all SGen types
  • .Aot throws a clear InvalidOperationException (not MissingMethodException) when a non-[AcBinarySerializable] type is encountered at deser time
  • BINARY_FEATURES.md NativeAOT Compatibility section documents both packages and when to choose which