AyCode.Core/docs/adr/0003-acbinary-streaming-rec...

30 KiB
Raw Blame History

ADR 0003: AcBinary streaming receive — AsyncPipeReaderInput unified primitive and transport-agnostic helpers

Status

Accepted (2026-05-03), partially executed — Steps 13 + Step 6 delivered; Steps 4 & 5 dropped during execution.

Execution log

Step Topic Original scope Outcome
1 BIN AsyncPipeReaderInput.cs (new sealed class) Delivered (ACCORE-BIN-T-D6H4, Closed 2026-05-02)
2 BIN AsyncPipeReaderInputExtensions.DrainFromAsync Delivered, but moved to test-only assembly during Step 1 follow-up (ACCORE-BIN-T-M2K1, Closed 2026-05-02) — framework stays consumer-implements-transport rather than exposing a public drain helper.
3 BIN AcBinarySerializerPipeParallelTests.cs rewrite — real parallel pipeline test Delivered (ACCORE-BIN-T-V7C9, Closed 2026-05-02)
4 BIN AcBinarySerializerNamedPipeExtensions.cs (NamedPipe helpers) Dropped. Framework decision: stay transport-agnostic, expose only generic PipeWriter / PipeReader primitives. Tests own NamedPipeServerStream / NamedPipeClientStream lifecycles directly. See BINARY_ASYNCPIPE_ISSUES.md#accore-bin-i-t6v2 for the doctrine.
5 BIN AcBinarySerializerFileStreamExtensions.cs (FileStream helpers) Dropped. Same rationale as Step 4. Consumers wrap FileStream with PipeWriter.Create / PipeReader.Create themselves.
6 SBP AcBinaryHubProtocol.cs migration to AsyncPipeReaderInput; SegmentBufferReader.cs + SegmentBufferReaderInput.cs deleted Delivered (ACCORE-SBP-T-G7T2, Closed 2026-05-03). Both legacy types removed from disk; protocol now fully on AsyncPipeReaderInput (multiMessage:false — protocol parses [201]/[202] framing externally, AsyncPipe is a passive byte buffer here).

The body of this ADR below describes the as-designed architecture (Steps 16). The dropped Steps 4 & 5 do not invalidate the unified-primitive consolidation that motivated the ADR — the receive-side primitive and the SignalR migration both delivered cleanly.

Original status entry (historical)

Proposed (2026-04-27)

Context

The AcBinaryHubProtocol (AyCode.Services/SignalRs/AcBinaryHubProtocol.cs) AsyncSegment chunked streaming receive-side currently uses two coupled types in AyCode.Core/Serializers/Binaries/:

  • SegmentBufferReader (sealed class) — thread-safe single-producer/single-consumer byte buffer with sliding-window semantics (write/read positions reset to 0 when consumer catches up). Backed by ArrayPool<byte>. Replaces an earlier System.IO.Pipelines.Pipe-based implementation.
  • SegmentBufferReaderInput (struct) — IBinaryInputBase adapter that reads from a SegmentBufferReader. Holds only a class-reference; state lives in the buffer class.

This receive-side architecture has three structural limitations that block the framework's NuGet positioning and ergonomic standalone use:

1. Type identity coupled to a SignalR-mode name

The "Segment" prefix in both type names refers to AsyncSegment (the SignalR BinaryProtocolMode). For a NuGet consumer using AcBinarySerializer standalone (NamedPipe IPC, file-stream deserialization), the type names suggest "this is a SignalR thing — won't apply to me", even though the underlying primitive (sliding-window buffer with push/pull feed sources) is fully transport-agnostic.

2. Send-/receive-side asymmetry without payoff

The send side has a clean, single-class primitive: AsyncPipeWriterOutput (perf-critical hot path, polished). The receive side has a 2-class adapter pair (SegmentBufferReader + SegmentBufferReaderInput) where the struct is just a thin proxy over the class. The struct/class split was originally motivated by JIT specialization for DeserializeSequence<TInput> — but profiling shows the per-call indirection cost is on the order of 1 µs / MB, which is dominated by serialization work itself.

3. No first-class transport helpers

NamedPipe IPC and large-file streaming are common deserialization scenarios for a binary protocol library. Today, consumers must wire up their own Pipe + Task.Run + manual lifecycle. A self-contained NuGet package should ship turnkey helpers — SerializeToNamedPipeAsync / DeserializeFromNamedPipeAsync / SerializeToFileStreamAsync / DeserializeFromFileStreamAsync — sharing the same chunked AsyncSegment wire format.

Wire format (recap)

The AsyncSegment chunked wire format is the streaming-deserialization format — a transport-agnostic invariant:

[INT32 LE length: 4 byte][message body]
   message body:
     [200] CHUNK_START
     [201][UINT16 size][data]   ← repeats
     [201][UINT16 size][data]
     ...
     [202] CHUNK_END

This format is unchanged by this ADR. The receive-side architecture is the subject; the wire format is the invariant.

Streaming doctrine: peak memory bounded by chunk size

The current SegmentBufferReader.Write resets _writePos and _readPos to 0 whenever _readPos == _writePos > 0 (consumer caught up). This is the central streaming invariant: peak buffer memory is bounded by two-chunks-worth-of-bytes (typically ~8 KB), regardless of total message size. A 1-GB file and a 4-KB SignalR message both run with the same ~8-KB peak buffer. This must be preserved across the redesign.

Cross-project scope

The change touches both projects in this repo:

  • AyCode.Core (Layer 0, framework): AsyncPipeReaderInput, DrainFromAsync extension, NamedPipe + FileStream helpers, deletion of SegmentBufferReader + SegmentBufferReaderInput.
  • AyCode.Services (Layer 1): AcBinaryHubProtocol.TryParseChunkData migration to delegate to the new primitive; AsyncChunkState field type change.

ADR placed at the repo-root docs/adr/ per the multi-project routing rule in .github/skills/adr-author/SKILL.md Step 1 ("cross-cutting decision → highest common ancestor"). The receive-side primitive lives in AyCode.Core but is consumed by transport helpers in the same project AND by AcBinaryHubProtocol in AyCode.Services. The project-scoped sibling AyCode.Services/docs/adr/0001-acbinary-decorator-feature-stack-design.md (decorator feature-stack umbrella) depends on this primitive at the AsyncSegment per-chunk decoration boundary; the cross-reference is one-way (Services-ADR depends on Core-ADR).

The number 0002 in this folder is intentionally skipped — reserved by 0001-user-bearer-token-flow.md's Follow-ups + Related sections for the forthcoming refresh-token flow ADR; preserving that reservation avoids amending an already-Accepted ADR's immutable body.

Decision

Consolidate the receive-side architecture around a single transport-agnostic primitive — AsyncPipeReaderInput — and ship NamedPipe + FileStream helpers using the same chunked AsyncSegment wire format as the SignalR protocol.

1. AsyncPipeReaderInput — single sealed class replaces SegmentBufferReader + SegmentBufferReaderInput

A new sealed class in AyCode.Core/Serializers/Binaries/:

public sealed class AsyncPipeReaderInput : IBinaryInputBase, IDisposable
{
    // Buffer state (sliding window — _readPos/_writePos reset to 0 on consumer catch-up)
    private byte[] _buffer;
    private int _writePos;
    private int _readPos;
    private bool _completed;

    // Synchronization
    private readonly ManualResetEventSlim _dataAvailable;

    // Grow tracking (rare path — old buffers held until Dispose to avoid use-after-free
    // while the consumer thread holds a local reference)
    private byte[][]? _oldBuffers;
    private int _oldBufferCount;

    private readonly ILogger? _logger;

    // Producer API (push) — used by SignalR's TryParseChunkData and DrainFromAsync alike
    public void Feed(ReadOnlySpan<byte> data);
    public void Complete();

    // IBinaryInputBase
    public void Initialize(out byte[] buffer, out int position, out int bufferLength);
    public bool TryAdvanceSegment(ref byte[] buffer, ref int position, ref int bufferLength, int needed);
    public void Release();

    public void Dispose();
}

Naming convention rationale: AsyncPipeReaderInput is symmetric with the existing AsyncPipeWriterOutput. The Async prefix follows .NET BCL precedent for async-oriented types (AsyncEnumerable, IAsyncDisposable, AsyncLocal<T>, AsyncCallback, IAsyncResult); the BCL convention puts Async as suffix on methods (returning Task / ValueTask) but as prefix on types. The Async prefix here also differentiates from the BCL System.IO.Pipelines.PipeReader type at first glance.

sealed permits JIT inlining + dynamic PGO devirtualization at IBinaryInputBase interface call sites in shared generic code.

Class vs struct rationale: the struct/class split in today's design exists to keep DeserializeSequence<TInput> JIT-specialized over a small struct (the SegmentBufferReaderInput proxy). Empirically, the inner deserialization hot path reads from local stack variables (buffer, position, bufferLength) — not through input.X — so the struct specialization win is concentrated at the rare-path TryAdvanceSegment call (~1 call per chunk worth of bytes). The class-based design pays one extra vtable dispatch on that rare call (~5 ns) in exchange for removing one indirection through the inner buffer reference (~1 ns saved); net delta is sub-microsecond per MB. A pure-struct alternative (with internal buffer state) was rejected — see Alternatives.

2. Feed-API for push-mode (SignalR pattern)

Feed(ReadOnlySpan<byte> data) is the producer-thread API for transports that push chunk data inline (the SignalR receive loop). The protocol calls Feed from TryParseChunkData whenever a [201] CHUNK_DATA frame's payload arrives. Internal logic:

  1. If consumer has caught up (_readPos == _writePos > 0), reset both to 0 (sliding-window cycling).
  2. If _writePos + data.Length > _buffer.Length, grow (last-resort path — under typical chunk-aligned write patterns this never fires).
  3. Copy bytes to the buffer, advance _writePos (volatile write), signal _dataAvailable.

Synchronization model preserved verbatim from today's SegmentBufferReader: _writePos is volatile-written by producer / volatile-read by consumer; _readPos is volatile-written by consumer / volatile-read by producer; reset-to-0 happens only when both equal (consumer not actively reading).

3. DrainFromAsync extension for pull-mode (NamedPipe / FileStream pattern)

Transports backed by System.IO.Pipelines.PipeReader (NamedPipe and FileStream-via-PipeReader) pull data through PipeReader.ReadAsync(). An extension method handles this:

public static class AsyncPipeReaderInputExtensions
{
    public static async Task DrainFromAsync(
        this AsyncPipeReaderInput input,
        PipeReader reader,
        CancellationToken cancellationToken = default)
    {
        while (true)
        {
            var result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
            foreach (var segment in result.Buffer)
                input.Feed(segment.Span);
            reader.AdvanceTo(result.Buffer.End);
            if (result.IsCompleted) break;
        }
        input.Complete();
    }
}

The extension lives in a separate file (AsyncPipeReaderInputExtensions.cs). This keeps the core class free of System.IO.Pipelines import surface in its primary contract and makes the optional pull-mode visible at use-sites.

4. Initial buffer capacity — BufferWriterChunkSize × 2 heuristic

Constructor parameter initialCapacity recommended value: options.BufferWriterChunkSize × 2.

Context Chunk size Initial capacity
SignalR (Kestrel slab-aligned) 4 KB 8 KB
Standalone (default) 64 KB 128 KB

Two-chunks-worth of headroom lets the producer write the next chunk while the consumer is still reading the previous one — without growth. Reset-to-0 cycling reuses the same buffer for the message's lifetime regardless of total payload size.

Explicitly NOT file-size-aware: an earlier draft considered pre-allocating to file size for DeserializeFromFileStreamAsync. Rejected — would defeat the streaming purpose (peak memory ∝ file size, no zerocopy/zeroalloc). The fixed ~8-KB peak (or ~128-KB at the standalone default) is the streaming-doctrine invariant — see Alternatives.

5. Reset-to-0 sliding-window cycling preserved

The peak-memory-bounded streaming invariant from today's SegmentBufferReader.Write is preserved verbatim: when _readPos > 0 && _readPos == _writePos, both fields reset to 0. Consumer's local position re-reads from _readPos inside TryAdvanceSegment after wake-from-Wait. This is the architectural cornerstone — without it, peak memory grows linearly with message size and the transport-agnostic FileStream helper becomes useless on multi-GB files.

6. AsyncPipeWriterOutput unchanged

The send side stays as-is. It's perf-critical and well-tested. This ADR addresses only the receive side. Renaming AsyncPipeWriterOutput for full naming uniformity was considered and rejected (breaking change for early NuGet consumers — see Alternatives).

7. NamedPipe helpers — full lifecycle, one-shot

public static class AcBinarySerializerNamedPipeExtensions
{
    public static async Task SerializeToNamedPipeAsync<T>(
        this AcBinarySerializerOptions options,
        T value,
        string pipeName,
        CancellationToken cancellationToken = default);

    public static async Task<T?> DeserializeFromNamedPipeAsync<T>(
        this AcBinarySerializerOptions options,
        string pipeName,
        CancellationToken cancellationToken = default);
}

Send-side flow: NamedPipeServerStreamPipeWriter.Create(stream)AsyncPipeWriterOutput → AcBinary chunked write → flush → close.

Receive-side flow: NamedPipeClientStreamPipeReader.Create(stream)AsyncPipeReaderInput.DrainFromAsync(pipeReader) (background task) → AcBinaryDeserializer.DeserializeAsync<T>(input) → result.

Cross-platform: NamedPipeServerStream / NamedPipeClientStream work on Windows + Linux (Unix domain sockets under the hood). WASM throws PlatformNotSupportedException per BCL contract.

8. FileStream helpers — streaming, fixed peak memory

public static class AcBinarySerializerFileStreamExtensions
{
    public static async Task SerializeToFileStreamAsync<T>(
        this AcBinarySerializerOptions options,
        T value,
        string filePath,
        CancellationToken cancellationToken = default);

    public static async Task<T?> DeserializeFromFileStreamAsync<T>(
        this AcBinarySerializerOptions options,
        string filePath,
        CancellationToken cancellationToken = default);
}

Send-side flow: FileStream.Create(path)PipeWriter.Create(fileStream)AsyncPipeWriterOutput → AcBinary chunked write → flush → close.

Receive-side flow: FileStream.OpenRead(path)PipeReader.Create(fileStream) → same DrainFromAsync pattern as NamedPipe.

A 1-GB file deserializes with ~8-KB peak buffer (per the streaming-doctrine invariant — Decision #5). Random-access file reads are not used; the file is a one-pass forward stream from the deserializer's perspective.

9. Unified AsyncSegment chunked wire format across all transports

NamedPipe, FileStream, and SignalR all use the same chunked wire format ([INT32 length][200 CHUNK_START]+[201][UINT16 size][data]+[202 CHUNK_END]). This is the cornerstone of the unification — one AsyncPipeReaderInput implementation handles all three because the wire bytes are identical.

Implication: a file written by SerializeToFileStreamAsync can be drained by a NamedPipe consumer (with appropriate transport adapter) and produce the same deserialized object. The wire format is the contract; transports are interchangeable.

10. AcBinaryHubProtocol.TryParseChunkData delegates to AsyncPipeReaderInput.Feed

The existing chunked path in AcBinaryHubProtocol.TryParseChunkData (currently uses state.Buffer.Write(span) on a SegmentBufferReader) migrates to state.Input.Feed(span) on an AsyncPipeReaderInput. Behavioral equivalence — only the type changes.

State storage in AsyncChunkState:

// Before:
public SegmentBufferReader Buffer = null!;

// After:
public AsyncPipeReaderInput Input = null!;

The cutover preserves the existing protocol mode dispatch, lazy Task.Run deser-task start (after first chunk), and CHUNK_END lifecycle (Complete() + Dispose() + _chunkStates.Remove). Wire format is unchanged.

11. UINT16 65535-byte chunk-size invariant — transport-agnostic

The [201][UINT16 size][data] chunk frame caps wire-chunks at 65535 bytes. This is a transport-agnostic invariant — applies to NamedPipe and FileStream chunks, not just SignalR. Consumers configuring options.BufferWriterChunkSize > 65535 will fail at write-time validation (the existing chunking logic enforces this via the wire format).

This ADR codifies the limit as a wire-protocol invariant, not a SignalR-/Kestrel-derived constraint. (See AyCode.Services/docs/adr/0001-acbinary-decorator-feature-stack-design.md "UINT16 size prefix design intent" for the original rationale; this ADR generalizes the scope.)

12. Standalone NuGet positioning — AcBinarySerializer self-contained

The AcBinarySerializer NuGet package (AyCode.Core assembly) ships with:

  • AsyncPipeReaderInput — transport-agnostic streaming primitive
  • DrainFromAsync extension — System.IO.Pipelines consumer
  • SerializeToNamedPipeAsync / DeserializeFromNamedPipeAsync — NamedPipe IPC
  • SerializeToFileStreamAsync / DeserializeFromFileStreamAsync — file streaming

No transitive Microsoft.AspNetCore.SignalR dependency. The SignalR integration (AcBinaryHubProtocol) lives in the separate AyCode.Services assembly and references AyCode.Core for the streaming primitive — not the other way around.

Consequences

Positive

  • Single primitive across transports: SignalR / NamedPipe / FileStream use the same AsyncPipeReaderInput — one mental model, one set of bug fixes propagates everywhere.
  • NuGet-clean self-contained package: AcBinarySerializer ships turnkey transport helpers without forcing consumers to wire Pipe + Task.Run + lifecycle by hand.
  • Streaming-doctrine preserved: ~8-KB peak buffer regardless of message / file size — same as today.
  • Symmetric send/receive naming: AsyncPipeWriterOutputAsyncPipeReaderInput reads as a pair in the API; the Async prefix follows .NET BCL convention for type-level naming.
  • Type identity decoupled from SignalR: "Segment" prefix removed; "AsyncPipeReader" describes the class's actual capability (async pipe-style reader), not a SignalR mode.
  • Easier to test in isolation: AsyncPipeReaderInput can be unit-tested without SignalR scaffolding (which is what the new "real parallel pipeline test" exercises in Step 3 of the migration plan).
  • Send-side untouched: AsyncPipeWriterOutput (perf-critical, well-tested) stays as-is — risk concentrated on the receive side only.

Negative

  • ~1 µs / MB perf delta vs current struct+class architecture: virtual call dispatch on the IBinaryInputBase interface in shared generic code path (vs JIT-specialized struct dispatch today). Negligible at typical message sizes (sub-millisecond cost on a 1-GB stream); accepted for the architectural simplicity gain. Verified in Step 3's parallel pipeline test as part of the migration acceptance criteria.
  • Allocation parity, not improvement: same 1 class allocation per deserialize (today: SegmentBufferReader; after: AsyncPipeReaderInput). No regression, no gain.
  • Migration touches both AyCode.Core and AyCode.Services: AsyncPipeReaderInput lands in Core; AcBinaryHubProtocol migration lands in Services. Two-assembly rollout sequenced via the 6-step migration plan below.
  • Brief temporary duplication during transition: between Step 1 (new class lands) and Step 6 (old types deleted), both SegmentBufferReader + SegmentBufferReaderInput AND AsyncPipeReaderInput exist. Bounded by the migration window; mechanical delete at the end.

Migration plan (6 steps, each commit-reviewable)

Execution outcome (2026-05-03): see the Execution log at the top of this ADR for what actually shipped. Steps 13 + Step 6 delivered as designed; Steps 4 & 5 (NamedPipe / FileStream helpers) dropped — the framework stays consumer-implements-transport. The table below is preserved as the original migration plan for historical context.

Step Topic Files Review checkpoint
1 BIN AsyncPipeReaderInput.cs (NEW); existing SegmentBufferReader.cs + SegmentBufferReaderInput.cs unchanged New class compiles, unit-tested in isolation; SignalR path still on old types
2 BIN AsyncPipeReaderInputExtensions.cs (NEW — DrainFromAsync) Extension drains an in-memory Pipe end-to-end in a unit test
3 BIN AcBinarySerializerPipeParallelTests.cs (REWRITE — replaces today's misleading test with a real parallel pipeline test) Real ser+deser overlap measured on a thread pair; ~1 µs / MB delta confirmed
4 BIN AcBinarySerializerNamedPipeExtensions.cs (NEW) Cross-platform NamedPipe IPC roundtrip test passes
5 BIN AcBinarySerializerFileStreamExtensions.cs (NEW) Large-file (≥ 100 MB) roundtrip test passes with fixed ~8-KB peak buffer
6 SBP AcBinaryHubProtocol.cs migrated to AsyncPipeReaderInput; SegmentBufferReader.cs + SegmentBufferReaderInput.cs DELETED Existing SignalR tests pass; wire format unchanged; code-search finds 0 references to deleted types

Steps 15 are tracked under the BIN topic (AyCode.Core/docs/BINARY/BINARY_TODO.md). Step 6 is tracked under the SBP topic (AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md).

The migration is abortable between Step 5 and Step 6: if Step 3's parallel pipeline test or Step 4/5's roundtrip tests reveal an unfixable perf regression, the migration is paused; SignalR stays on the old types; the new helpers ship as-is on AsyncPipeReaderInput for NamedPipe / FileStream consumers; old types live on indefinitely. Step 6 (delete old types) is the final irreversible step.

Follow-ups required

  1. TODO entries — 6 freshly-generated ACCORE-BIN-T-<RAND> and ACCORE-SBP-T-<RAND> IDs for the 6 migration steps, added to AyCode.Core/docs/BINARY/BINARY_TODO.md (Steps 15) and AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md (Step 6) per the docs-check skill Step 5 procedure. Tracked as a separate mehet round.
  2. AyCode.Core/docs/adr/README.md index update — append an 0003 row to the index table (Status: Proposed (2026-04-27)). Tracked as a separate mehet round.
  3. ADR-0001 (Services) cross-referenceAyCode.Services/docs/adr/0001-acbinary-decorator-feature-stack-design.md "Related" section can optionally cross-reference ADR-0003 (the per-chunk decoration constraint in AsyncSegment mode benefits from the unified AsyncPipeReaderInput primitive). Tracked as a separate mehet round.
  4. Topic-folder cross-referencesAyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/README.md ## Related ADRs section gets a row for ADR-0003. AyCode.Core/docs/BINARY/README.md gets a cross-ref to ADR-0003 for the streaming primitive. Tracked as a separate mehet round.
  5. Promotion to Accepted — once all 6 migration steps land, Status changes from Proposed (2026-04-27) to Accepted (YYYY-MM-DD). Old types deleted, NuGet package self-containment verified by a no-SignalR-reference build check.

Alternatives considered

  • Rename existing types in place, preserve 2-class split (rejected): keep SegmentBufferReader + SegmentBufferReaderInput as 2 types, just rename to AsyncPipeReaderBuffer + AsyncPipeReaderInput. Solves the naming-decoupling goal but doesn't solve the proxy split. The struct-as-thin-proxy-over-class indirection survives; users see two types where one would do.

  • Struct merger with internal buffer state (rejected): struct AsyncPipeReaderInput carrying byte[], _writePos, _readPos directly. JIT struct specialization in DeserializeSequence<TInput> preserved. Rejected — struct copy semantics break producer/consumer state sharing: the producer (SignalR receive thread) writes to a struct copy, the consumer (deserialization thread) reads from a different copy. Would require all interface methods to take ref this or all call sites to pass the struct by ref — sprawling API ergonomics issue, sinks into ref-everywhere boilerplate. The class-based design takes the ~1 µs / MB perf hit in exchange for clean copy-by-reference semantics.

  • PipelineReaderInput naming (no Async prefix) (rejected): cleaner standalone, no BCL PipeReader overlap concern, namespace-neutral. Rejected — breaks symmetry with the existing AsyncPipeWriterOutput send-side primitive, where the Async prefix is established project convention. The asymmetric naming pair would obscure the send/receive symmetry to API readers.

  • Rename AsyncPipeWriterOutput to match a non-Async receive-side name (rejected): full uniformity at the cost of a public-API breaking change. Rejected — AsyncPipeWriterOutput is part of the existing public surface; renaming would break all early consumers and predates the NuGet positioning effort. The new receive-side name should adopt the established convention, not invert it.

  • Async as suffix instead of prefix (rejected): PipeReaderInputAsync / PipeWriterOutputAsync matching the .NET method-naming convention (ReadAsync, WriteAsync). Rejected — the Async suffix convention applies to methods returning Task / ValueTask, not to types. .NET BCL types use Async as prefix (AsyncEnumerable, IAsyncDisposable, AsyncLocal<T>, AsyncCallback, AsyncTaskMethodBuilder, IAsyncResult); no BCL type uses an Async suffix. A type-suffix Async would read as a method name to .NET developers.

  • File-size-aware initialCapacity for FileStream helpers (rejected): DeserializeFromFileStreamAsync could Stat the file and pre-allocate the buffer to file size, achieving "zero growth, single allocation" for the receive buffer. Rejected — defeats the streaming purpose: peak memory becomes O(file size) instead of O(chunk size). A 1-GB file would allocate 1 GB on the heap, eliminating the streaming win. The BufferWriterChunkSize × 2 heuristic preserves O(chunk size) regardless of file size; the rare grow-path handles the unusual case where chunk-size is misconfigured.

  • Composition: AsyncPipeReaderInput wrapping a kept SegmentBufferReader (rejected): preserve SegmentBufferReader (sealed class) as the buffer primitive, build AsyncPipeReaderInput as a thin wrapper that adds Feed/DrainFromAsync API + IBinaryInputBase. Rejected — only marginally simpler than today's struct+class split; doesn't realize the "single primitive" goal; users see two types where one suffices. The pure merger is conceptually cleaner.

  • PipeReader-inheriting type (rejected): make AsyncPipeReaderInput derive from System.IO.Pipelines.PipeReader. Rejected — PipeReader is an abstract async-buffer reader contract; implementing it commits to all of ReadAsync / AdvanceTo / CancelPendingRead / Complete semantics, only ~30% of which apply to the AcBinary streaming use-case. The IBinaryInputBase contract is purpose-built for AcBinary deserialization (Initialize / TryAdvanceSegment / Release); inheriting PipeReader would force impedance-mismatched API surface for marginal pattern-match value.

  • Place ADR at AyCode.Services/docs/adr/ (rejected): an earlier draft placed this ADR in the project-scoped folder as ADR-0002 (sibling of the decorator-feature-stack ADR). Rejected — the change touches both AyCode.Core (primary, primitive + helpers) and AyCode.Services (protocol delegation). Per the multi-project routing rule, cross-cutting ADRs live at the highest common ancestor — repo-root docs/adr/. The project-scoped folder remains for project-internal decisions.