Refactor: centralize SignalR protocol config/options

- Added AcBinaryHubProtocolOptions for unified protocol configuration (serializer, mode, buffer size, flush strategy, timeout, name, logger) with validation and DI support.
- Refactored AcBinaryHubProtocol and AyCodeBinaryHubProtocol to use options object; legacy constructors now delegate to options-based API.
- Added per-chunk flush timeout to AsyncPipeWriterOutput and AcBinarySerializer; throws TimeoutException on slow consumers.
- Improved XML docs and comments for pipeline/backpressure/timeout clarity.
- Updated SIGNALR_BINARY_PROTOCOL.md to document new options and AsyncSegment platform rules.
This commit is contained in:
Loretta 2026-04-20 17:44:37 +02:00
parent 939ce9c39b
commit c6e1fa8efc
6 changed files with 300 additions and 45 deletions

View File

@ -423,15 +423,28 @@ public static partial class AcBinarySerializer
} }
/// <summary> /// <summary>
/// Serialize to PipeWriter with chunked protocol framing via AsyncPipeWriterOutput. /// Serialize to PipeWriter with chunked protocol framing via <see cref="AsyncPipeWriterOutput"/>.
/// Each chunk (including the last) is framed as <c>[201][UINT16 size][data]</c> and committed /// Each chunk (including the last) is framed as <c>[201][UINT16 size][data]</c> and committed
/// to the PipeWriter via Advance (zero-copy). The protocol layer writes a single <c>[202]</c> /// to the PipeWriter via Advance (zero-copy). The protocol layer writes a single <c>[202]</c>
/// byte after to signal end-of-stream. /// byte after to signal end-of-stream.
/// </summary> /// </summary>
/// <param name="value">The value to serialize; <c>null</c> writes a single null marker.</param>
/// <param name="pipeWriter">Target pipe (typically Kestrel's transport output).</param>
/// <param name="options">Serializer options (type wrappers, reference handling, interning, etc.).</param>
/// <param name="waitForFlush">
/// Per-chunk flush synchronization. <c>true</c> (default): maximum pipeline parallelism,
/// guaranteed zero-copy, but slow consumers block the server thread (bounded by <paramref name="flushTimeout"/>).
/// <c>false</c>: adaptive backpressure via memory threshold — safer for mixed consumer speeds.
/// </param>
/// <param name="flushTimeout">
/// Per-flush timeout. <c>null</c> → wait forever (legacy). Positive value: throws
/// <see cref="TimeoutException"/> on stuck consumers.
/// </param>
/// <returns>Total serialized data bytes (excluding framing overhead).</returns> /// <returns>Total serialized data bytes (excluding framing overhead).</returns>
public static int Serialize<T>( public static int Serialize<T>(
T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options,
bool waitForFlush = true) bool waitForFlush = true,
TimeSpan? flushTimeout = null)
{ {
if (value == null) if (value == null)
{ {
@ -444,7 +457,7 @@ public static partial class AcBinarySerializer
var runtimeType = value.GetType(); var runtimeType = value.GetType();
var context = BinarySerializationContextPool<AsyncPipeWriterOutput>.Get(options); var context = BinarySerializationContextPool<AsyncPipeWriterOutput>.Get(options);
context.Output = new AsyncPipeWriterOutput(pipeWriter, options.BufferWriterChunkSize, waitForFlush); context.Output = new AsyncPipeWriterOutput(pipeWriter, options.BufferWriterChunkSize, waitForFlush, flushTimeout);
context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd); context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
try try

View File

@ -9,24 +9,32 @@ using System.Threading.Tasks;
namespace AyCode.Core.Serializers.Binaries; namespace AyCode.Core.Serializers.Binaries;
/// <summary> /// <summary>
/// Binary output that writes to a PipeWriter with per-chunk network flush and self-describing framing. /// Binary output that writes to a PipeWriter with per-chunk self-describing framing.
/// ///
/// Each chunk (including the last) is framed as <c>[201][UINT16 size][data]</c> with a 3-byte header /// Each chunk (including the last) is framed as <c>[201][UINT16 size][data]</c> with a 3-byte header
/// reserved at the start of each buffer. The serializer context writes into the space after the /// reserved at the start of each buffer. The serializer context writes into the space after the
/// reserved bytes; on Grow(), the header is patched and the full chunk is committed via Advance /// reserved bytes; on <see cref="Grow"/>, the header is patched and the full chunk is committed via
/// and flushed to the network. Flush() does the same for the last (partial) chunk — zero-copy /// Advance (zero-copy). <see cref="Flush"/> does the same for the last (partial) chunk.
/// for both intermediate and final chunks.
/// ///
/// The protocol layer writes a single <c>[202]</c> byte after all chunks to signal end-of-stream. /// The protocol layer writes a single <c>[202]</c> byte after all chunks to signal end-of-stream.
/// ///
/// Backpressure modes (controlled by <c>waitForFlush</c> constructor parameter): /// <para><b>Backpressure modes</b> (controlled by <c>waitForFlush</c>):</para>
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>waitForFlush=true</c> (default): Grow() blocks if the previous FlushAsync hasn't completed. /// <item><c>waitForFlush=true</c> (default): Grow() waits for the previous FlushAsync before
/// Bounds memory to ~2 chunks in flight.</item> /// starting a new chunk. <b>Pro:</b> maximum pipeline parallelism, guaranteed end-to-end zero-copy.
/// <item><c>waitForFlush=false</c>: Grow() never blocks. Data accumulates in the PipeWriter's internal /// <b>Con:</b> slow consumer propagates back as server-thread blocking (bounded by <c>flushTimeout</c>).</item>
/// buffer and is sent with the next completed flush. Maximum serialization throughput.</item> /// <item><c>waitForFlush=false</c>: Grow() is fire-and-forget per chunk; only blocks when committed
/// bytes exceed ~60 KB (memory threshold — itself an adaptive backpressure).
/// <b>Pro:</b> no per-chunk waits, safer with mixed consumer speeds.
/// <b>Con:</b> under heavy backpressure may fall back to an owned buffer, losing zero-copy
/// for that chunk.</item>
/// </list> /// </list>
/// ///
/// <para><b>Timeout safety</b>: every synchronous flush-await is bounded by <c>flushTimeout</c>
/// (default <see cref="System.Threading.Timeout.InfiniteTimeSpan"/> when the type is used directly;
/// <see cref="AcBinaryHubProtocol"/> passes 10 s from its options). A <see cref="TimeoutException"/>
/// propagates to the caller, allowing the connection to abort instead of blocking forever.</para>
///
/// Maximum chunk data size: 65535 bytes (UINT16 max). /// Maximum chunk data size: 65535 bytes (UINT16 max).
/// </summary> /// </summary>
public struct AsyncPipeWriterOutput : IBinaryOutputBase public struct AsyncPipeWriterOutput : IBinaryOutputBase
@ -43,12 +51,19 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
private readonly PipeWriter _pipeWriter; private readonly PipeWriter _pipeWriter;
private readonly int _chunkSize; private readonly int _chunkSize;
private readonly bool _waitForFlush; private readonly bool _waitForFlush;
private readonly TimeSpan _flushTimeout;
private int _committedBytes; private int _committedBytes;
private int _currentChunkStart; private int _currentChunkStart;
private bool _ownedBuffer; private bool _ownedBuffer;
private ValueTask<FlushResult> _lastFlush; private ValueTask<FlushResult> _lastFlush;
public AsyncPipeWriterOutput(PipeWriter pipeWriter, int chunkSize = 4096, bool waitForFlush = true) /// <summary>Creates an output bound to the given PipeWriter with self-describing chunked framing.</summary>
/// <param name="pipeWriter">Target pipe (typically Kestrel's transport output for SignalR).</param>
/// <param name="chunkSize">Per-chunk data size (max <see cref="MaxChunkSize"/>). Default 4 KB matches Kestrel's slab size.</param>
/// <param name="waitForFlush">See class summary — pipeline parallelism (true) vs adaptive (false).</param>
/// <param name="flushTimeout">Per-flush timeout. <c>null</c> → <see cref="System.Threading.Timeout.InfiniteTimeSpan"/>
/// (wait forever — legacy behavior). Pass a positive value to fail fast on stuck consumers.</param>
public AsyncPipeWriterOutput(PipeWriter pipeWriter, int chunkSize = 4096, bool waitForFlush = true, TimeSpan? flushTimeout = null)
{ {
if (chunkSize > MaxChunkSize) if (chunkSize > MaxChunkSize)
throw new ArgumentOutOfRangeException(nameof(chunkSize), chunkSize, throw new ArgumentOutOfRangeException(nameof(chunkSize), chunkSize,
@ -57,6 +72,9 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
_pipeWriter = pipeWriter; _pipeWriter = pipeWriter;
_chunkSize = chunkSize; _chunkSize = chunkSize;
_waitForFlush = waitForFlush; _waitForFlush = waitForFlush;
// null → Timeout.InfiniteTimeSpan ("wait forever" — natively supported by Task.Wait as -1ms).
// A positive value enables bounded waiting; on timeout a TimeoutException propagates to the caller.
_flushTimeout = flushTimeout ?? System.Threading.Timeout.InfiniteTimeSpan;
_committedBytes = 0; _committedBytes = 0;
_ownedBuffer = false; _ownedBuffer = false;
_lastFlush = default; _lastFlush = default;
@ -65,13 +83,24 @@ public struct AsyncPipeWriterOutput : IBinaryOutputBase
/// <summary> /// <summary>
/// Synchronously awaits a FlushAsync ValueTask. /// Synchronously awaits a FlushAsync ValueTask.
/// Fast-path: if already completed, returns without Task allocation. /// Fast-path: if already completed, returns without Task allocation.
/// Slow-path: converts to Task for proper blocking (backpressure). /// Slow-path: blocks with <see cref="_flushTimeout"/> — throws <see cref="TimeoutException"/>
/// if the flush does not complete within it (guards against slow/stuck/disconnected consumers).
/// <see cref="System.Threading.Timeout.InfiniteTimeSpan"/> means "wait forever" (natively supported by <c>Task.Wait</c>).
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SyncAwaitFlush(ValueTask<FlushResult> vt) private void SyncAwaitFlush(ValueTask<FlushResult> vt)
{ {
if (!vt.IsCompletedSuccessfully) if (vt.IsCompletedSuccessfully) return;
vt.AsTask().GetAwaiter().GetResult();
var task = vt.AsTask();
if (!task.Wait(_flushTimeout))
throw new TimeoutException(
$"PipeWriter.FlushAsync exceeded {_flushTimeout.TotalSeconds:F1}s — " +
"consumer may be too slow, stuck, or disconnected.");
// Completed within timeout — propagate any faulted exception
task.GetAwaiter().GetResult();
} }
/// <summary> /// <summary>

View File

@ -76,6 +76,17 @@ public class AcBinaryHubProtocol : IHubProtocol
protected readonly BinaryProtocolMode _protocolMode; protected readonly BinaryProtocolMode _protocolMode;
protected readonly ILogger? _logger; protected readonly ILogger? _logger;
/// <summary>
/// AsyncSegment per-chunk flush synchronization — see <see cref="AcBinaryHubProtocolOptions.WaitForFlush"/>.
/// </summary>
protected readonly bool _waitForFlush;
/// <summary>
/// Per-flush wait limit — see <see cref="AcBinaryHubProtocolOptions.FlushTimeout"/>.
/// Guaranteed positive or <see cref="System.Threading.Timeout.InfiniteTimeSpan"/> by <see cref="AcBinaryHubProtocolOptions.Validate"/>.
/// </summary>
protected readonly TimeSpan _flushTimeout;
/// <summary> /// <summary>
/// Per-connection chunk accumulation state. Key is IInvocationBinder (per-connection, GC-friendly). /// Per-connection chunk accumulation state. Key is IInvocationBinder (per-connection, GC-friendly).
/// Always initialized regardless of ProtocolMode — any client can receive chunked data from an AsyncSegment server. /// Always initialized regardless of ProtocolMode — any client can receive chunked data from an AsyncSegment server.
@ -108,10 +119,38 @@ public class AcBinaryHubProtocol : IHubProtocol
public int ChunkFrameBytesConsumed; public int ChunkFrameBytesConsumed;
} }
public AcBinaryHubProtocol() : this(AcBinarySerializerOptions.Default) { } /// <summary>
/// Parameterless constructor — creates the protocol with all-default options
/// (<see cref="BinaryProtocolMode.Bytes"/>, 4 KB buffer, 10 s flush timeout, "acbinary" name).
/// Mainly for tests and simple scenarios. For production, pass an explicit
/// <see cref="AcBinaryHubProtocolOptions"/> or configure via DI.
/// </summary>
public AcBinaryHubProtocol() : this(new AcBinaryHubProtocolOptions()) { }
/// <summary>
/// Legacy constructor — wraps the arguments into <see cref="AcBinaryHubProtocolOptions"/>
/// and delegates to the options-based constructor. Kept for backward compatibility;
/// will be removed in a future version in favor of the options-based API.
/// </summary>
public AcBinaryHubProtocol(AcBinarySerializerOptions options, BinaryProtocolMode protocolMode = BinaryProtocolMode.Bytes, ILogger? logger = null) public AcBinaryHubProtocol(AcBinarySerializerOptions options, BinaryProtocolMode protocolMode = BinaryProtocolMode.Bytes, ILogger? logger = null)
: this(new AcBinaryHubProtocolOptions
{ {
SerializerOptions = options,
ProtocolMode = protocolMode,
Logger = logger,
BufferSize = 4096
// FlushTimeout, WaitForFlush, Name — use options defaults (30s, true, "acbinary")
})
{ }
/// <summary>
/// Primary constructor. All configuration flows through <see cref="AcBinaryHubProtocolOptions"/>.
/// </summary>
public AcBinaryHubProtocol(AcBinaryHubProtocolOptions options)
{
if (options is null) throw new ArgumentNullException(nameof(options));
options.Validate();
// Send-side guard: AsyncSegment uses AsyncPipeWriterOutput whose sync-over-async flush // Send-side guard: AsyncSegment uses AsyncPipeWriterOutput whose sync-over-async flush
// would block the browser's single UI thread. The receive side converts chunked wire // would block the browser's single UI thread. The receive side converts chunked wire
// to a synchronous deserialize on WASM automatically (see TryParseChunkData). // to a synchronous deserialize on WASM automatically (see TryParseChunkData).
@ -119,22 +158,26 @@ public class AcBinaryHubProtocol : IHubProtocol
// TEMP: commented out to test AsyncSegment on both Windows app and WASM without rebuild. // TEMP: commented out to test AsyncSegment on both Windows app and WASM without rebuild.
// Small WASM payloads work; larger ones may deadlock on sync-over-async FlushAsync. // Small WASM payloads work; larger ones may deadlock on sync-over-async FlushAsync.
// Restore once BinaryProtocolMode is runtime-configurable in Program.cs. // Restore once BinaryProtocolMode is runtime-configurable in Program.cs.
//if (IsBrowser && protocolMode == BinaryProtocolMode.AsyncSegment) //if (IsBrowser && options.ProtocolMode == BinaryProtocolMode.AsyncSegment)
// throw new PlatformNotSupportedException( // throw new PlatformNotSupportedException(
// "BinaryProtocolMode.AsyncSegment is not supported on WebAssembly. " + // "BinaryProtocolMode.AsyncSegment is not supported on WebAssembly. " +
// "Use BinaryProtocolMode.Bytes or BinaryProtocolMode.Segment instead."); // "Use BinaryProtocolMode.Bytes or BinaryProtocolMode.Segment instead.");
_options = options; _options = options.SerializerOptions;
_options.BufferWriterChunkSize = 4096; _options.BufferWriterChunkSize = options.BufferSize;
_protocolMode = protocolMode; _protocolMode = options.ProtocolMode;
_logger = logger; _logger = options.Logger;
_waitForFlush = options.WaitForFlush;
_flushTimeout = options.FlushTimeout;
Name = options.Name;
_chunkStates = new ConditionalWeakTable<IInvocationBinder, AsyncChunkState>(); _chunkStates = new ConditionalWeakTable<IInvocationBinder, AsyncChunkState>();
if (_logger != null) if (_logger != null)
{ {
_logger.LogInformation( _logger.LogInformation(
"AcBinaryHubProtocol initialized mode={ProtocolMode} isBrowser={IsBrowser} chunkSize={ChunkSize} initCap={InitCap} useGen={UseGen} wireMode={WireMode} interning={Interning} compression={Compression}", "AcBinaryHubProtocol initialized name={Name} mode={ProtocolMode} isBrowser={IsBrowser} chunkSize={ChunkSize} initCap={InitCap} waitForFlush={WaitForFlush} flushTimeoutMs={FlushTimeoutMs} useGen={UseGen} wireMode={WireMode} interning={Interning} compression={Compression}",
_protocolMode, IsBrowser, _options.BufferWriterChunkSize, _options.InitialBufferCapacity, Name, _protocolMode, IsBrowser, _options.BufferWriterChunkSize, _options.InitialBufferCapacity,
_waitForFlush, _flushTimeout.TotalMilliseconds,
_options.UseGeneratedCode, _options.WireMode, _options.UseStringInterning, _options.UseCompression); _options.UseGeneratedCode, _options.WireMode, _options.UseStringInterning, _options.UseCompression);
} }
} }
@ -149,18 +192,38 @@ public class AcBinaryHubProtocol : IHubProtocol
set => _options = value; set => _options = value;
} }
public string Name => "acbinary"; /// <summary>Protocol name sent in SignalR handshake. Set via <see cref="AcBinaryHubProtocolOptions.Name"/>. Default: <c>"acbinary"</c>.</summary>
public string Name { get; } = "acbinary";
public int Version => 1; public int Version => 1;
public TransferFormat TransferFormat => TransferFormat.Binary; public TransferFormat TransferFormat => TransferFormat.Binary;
/// <summary> /// <summary>
/// Synchronously gets the result of a PipeWriter.FlushAsync ValueTask. /// Synchronously gets the result of a PipeWriter.FlushAsync ValueTask.
/// Fast-path: if already completed (no backpressure), returns directly without Task allocation. /// Fast-path: if already completed (no backpressure), returns directly without Task allocation.
/// Slow-path: converts to Task for proper blocking when pipe backpressure is active. /// Slow-path: blocks with <see cref="_flushTimeout"/> — throws <see cref="TimeoutException"/>
/// if the flush does not complete within the timeout (protects against slow/stuck/disconnected
/// consumers holding the server thread indefinitely).
/// <para>
/// <see cref="AcBinaryHubProtocolOptions.Validate"/> guarantees <c>_flushTimeout</c> is either
/// positive or <see cref="System.Threading.Timeout.InfiniteTimeSpan"/> (which <c>Task.Wait</c>
/// natively treats as "wait forever"), so no explicit zero-check is needed here.
/// </para>
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static FlushResult SyncFlush(ValueTask<FlushResult> vt) private FlushResult SyncFlush(ValueTask<FlushResult> vt)
=> vt.IsCompletedSuccessfully ? vt.Result : vt.AsTask().GetAwaiter().GetResult(); {
if (vt.IsCompletedSuccessfully) return vt.Result;
var task = vt.AsTask();
if (!task.Wait(_flushTimeout))
throw new TimeoutException(
$"PipeWriter.FlushAsync exceeded {_flushTimeout.TotalSeconds:F1}s — " +
"consumer may be too slow, stuck, or disconnected.");
return task.GetAwaiter().GetResult();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsVersionSupported(int version) => version <= Version; public bool IsVersionSupported(int version) => version <= Version;
@ -473,7 +536,7 @@ public class AcBinaryHubProtocol : IHubProtocol
// --- CHUNK_DATA ([201][UINT16 size][data] per chunk, all committed by output) --- // --- CHUNK_DATA ([201][UINT16 size][data] per chunk, all committed by output) ---
if (streamedArg != null) if (streamedArg != null)
{ {
dataBytes = AcBinarySerializer.Serialize(streamedArg, pipeWriter, _options); dataBytes = AcBinarySerializer.Serialize(streamedArg, pipeWriter, _options, _waitForFlush, _flushTimeout);
_logger?.LogDebug("WriteMessageChunked CHUNK_DATA serialized dataBytes={DataBytes}", dataBytes); _logger?.LogDebug("WriteMessageChunked CHUNK_DATA serialized dataBytes={DataBytes}", dataBytes);
} }

View File

@ -0,0 +1,113 @@
using AyCode.Core.Serializers.Binaries;
using Microsoft.Extensions.Logging;
namespace AyCode.Services.SignalRs;
/// <summary>
/// Configuration for <see cref="AcBinaryHubProtocol"/> and derived protocols.
/// Use via <c>services.Configure&lt;AcBinaryHubProtocolOptions&gt;(...)</c> in Program.cs,
/// or pass an instance directly to the protocol constructor.
/// </summary>
public sealed class AcBinaryHubProtocolOptions
{
// --- Serializer (standalone sub-group, also usable without SignalR via ToBinary/BinaryTo) ---
/// <summary>Binary serializer options. Default: <see cref="AcBinarySerializerOptions.Default"/>.</summary>
public AcBinarySerializerOptions SerializerOptions { get; set; } = AcBinarySerializerOptions.Default;
// --- Transport ---
/// <summary>Wire format and pipeline strategy. Default: <see cref="BinaryProtocolMode.Bytes"/>.</summary>
public BinaryProtocolMode ProtocolMode { get; set; } = BinaryProtocolMode.Bytes;
/// <summary>Chunk size for BufferWriterBinaryOutput / AsyncPipeWriterOutput.
/// Default: 4096 (aligns with Kestrel's slab size for optimal latency-to-first-byte).</summary>
public int BufferSize { get; set; } = 4096;
/// <summary>
/// Per-chunk flush synchronization in <see cref="BinaryProtocolMode.AsyncSegment"/> mode.
/// <list type="bullet">
/// <item><c>true</c> (default): every Grow waits for the previous FlushAsync to complete
/// before starting the next chunk. Guarantees end-to-end zero-copy (no owned-buffer fallback)
/// and maximum pipeline parallelism. Best for high-throughput servers with fast consumers.</item>
/// <item><c>false</c>: fire-and-forget flush per chunk; blocks only when committed bytes exceed
/// the memory threshold (~60 KB). Itself an adaptive backpressure mode — a fast consumer
/// never triggers a wait, a slow consumer naturally throttles through buffer pressure.
/// Safer for mixed consumer speeds and memory-sensitive environments.</item>
/// </list>
/// Ignored for Bytes and Segment modes.
/// </summary>
public bool WaitForFlush { get; set; } = true;
/// <summary>
/// Maximum wait for a single synchronous <c>FlushAsync</c> before throwing
/// <see cref="TimeoutException"/>. Protects against slow/stuck/disconnected consumers
/// blocking the server thread indefinitely.
/// <para>
/// Default: 10 seconds. Rationale: AsyncSegment chunks are max 65 KB (UINT16 size field);
/// even GPRS-class connections (~60 Kbit/s) transfer 65 KB in ~9 s. If a flush exceeds 10 s,
/// the consumer is effectively stuck — faster failure detection is preferable to indefinite blocking.
/// </para>
/// <para>
/// Note: complementary to SignalR's connection-level timeouts (<c>ClientTimeoutInterval</c>,
/// <c>KeepAliveInterval</c>). This is a per-operation guard; SignalR timeouts manage the overall
/// connection lifetime. Set <c>FlushTimeout &lt; ClientTimeoutInterval</c> so this guard fires first.
/// </para>
/// Set to <see cref="System.Threading.Timeout.InfiniteTimeSpan"/> to disable.
/// </summary>
public TimeSpan FlushTimeout { get; set; } = TimeSpan.FromSeconds(10);
// --- Identity ---
/// <summary>Protocol name in the SignalR handshake. Client and server must match. Default: "acbinary".</summary>
public string Name { get; set; } = "acbinary";
// --- Diagnostics ---
/// <summary>Optional logger. When null, no protocol-internal logging occurs.</summary>
public ILogger? Logger { get; set; }
// --- Validation ---
/// <summary>
/// Validates option values and platform compatibility.
/// Throws <see cref="ArgumentOutOfRangeException"/> / <see cref="ArgumentException"/> on invalid values,
/// or <see cref="PlatformNotSupportedException"/> for unsupported platform/mode combinations.
/// </summary>
public void Validate()
{
// NOTE: WASM + AsyncSegment send-path guard is currently commented out in the protocol
// constructor for testing. Once BinaryProtocolMode becomes runtime-configurable in
// Program.cs, this validation will be re-enabled here as the primary guard.
//if (OperatingSystem.IsBrowser() && ProtocolMode == BinaryProtocolMode.AsyncSegment)
// throw new PlatformNotSupportedException(
// "BinaryProtocolMode.AsyncSegment is not supported on WebAssembly. " +
// "Use BinaryProtocolMode.Bytes or BinaryProtocolMode.Segment instead.");
if (BufferSize < 256 || BufferSize > AsyncPipeWriterOutput.MaxChunkSize)
throw new ArgumentOutOfRangeException(nameof(BufferSize), BufferSize,
$"BufferSize must be between 256 and {AsyncPipeWriterOutput.MaxChunkSize} (UINT16 max).");
if (FlushTimeout <= TimeSpan.Zero && FlushTimeout != System.Threading.Timeout.InfiniteTimeSpan)
throw new ArgumentOutOfRangeException(nameof(FlushTimeout), FlushTimeout,
"FlushTimeout must be positive, or Timeout.InfiniteTimeSpan to disable.");
if (string.IsNullOrWhiteSpace(Name))
throw new ArgumentException("Name cannot be null or whitespace.", nameof(Name));
}
/// <summary>
/// Creates a shallow copy. Required for DI <c>IOptions&lt;T&gt;</c> integration —
/// the singleton from DI must not be mutated by per-connection <c>configure</c> callbacks.
/// </summary>
public AcBinaryHubProtocolOptions Clone() => new()
{
SerializerOptions = SerializerOptions,
ProtocolMode = ProtocolMode,
BufferSize = BufferSize,
WaitForFlush = WaitForFlush,
FlushTimeout = FlushTimeout,
Name = Name,
Logger = Logger
};
}

View File

@ -24,8 +24,22 @@ namespace AyCode.Services.SignalRs;
/// </summary> /// </summary>
public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol
{ {
public AyCodeBinaryHubProtocol() : this(AcBinarySerializerOptions.Default) { } /// <summary>
public AyCodeBinaryHubProtocol(AcBinarySerializerOptions options, BinaryProtocolMode protocolMode = BinaryProtocolMode.Bytes, ILogger? logger = null) : base(options, protocolMode, logger) { } /// Parameterless constructor — creates the protocol with all-default options. See base class.
/// </summary>
public AyCodeBinaryHubProtocol() : base() { }
/// <summary>
/// Legacy constructor — delegates to the base legacy constructor, which wraps into
/// <see cref="AcBinaryHubProtocolOptions"/>. Kept for backward compatibility.
/// </summary>
public AyCodeBinaryHubProtocol(AcBinarySerializerOptions options, BinaryProtocolMode protocolMode = BinaryProtocolMode.Bytes, ILogger? logger = null)
: base(options, protocolMode, logger) { }
/// <summary>
/// Primary constructor — accepts a fully-configured <see cref="AcBinaryHubProtocolOptions"/>.
/// </summary>
public AyCodeBinaryHubProtocol(AcBinaryHubProtocolOptions options) : base(options) { }
#region Wire header (per-message) #region Wire header (per-message)

File diff suppressed because one or more lines are too long