using System.Collections.Concurrent; namespace AyCode.Services.SignalRs; /// /// Dedicated off-pool executor for the AsyncSegment streaming deserialization (see /// SIGNALR_BINARY_PROTOCOL_ISSUES.md#accore-sbp-i-t3w9). /// /// The streaming deser blocks in AsyncPipeReaderInput.TryAdvanceSegment → /// ManualResetEventSlim.Wait() between network-delivered chunks. Running that blocking /// wait on a .NET thread-pool thread (plain Task.Run) starves the pool: the signal that /// unblocks it (the producer Feed()+Set()) runs on the SignalR receive-continuation, /// which ALSO needs a pool thread → circular dependency → deadlock under concurrent load + real /// network latency. /// /// Design — never-queue, grow-on-demand, reuse-forever: every /// immediately hands the work to an idle worker (reuse) or spawns a new dedicated background /// thread (grow). It never queues. Consequences: /// /// The deser always starts immediately on its own off-pool thread → the [202] /// GetResult() always waits on a running deser (no queue-coupling). /// The blocking Wait() never consumes a .NET thread-pool thread → the producer's /// receive-continuations always get pool threads → no circular dependency, no starvation. /// Threads are reused (no per-message churn). Thread count is bounded in practice by peak /// concurrent chunked receives — one in-flight deser per connection (the [202] /// GetResult drains it before the next message on that connection). /// /// /// This is TaskCreationOptions.LongRunning's unconditional safety (never-queue, /// off-pool) plus thread reuse. Workers live forever — no idle-timeout, hence no idle-vs-assign /// race (the simplest provably-correct variant). An idle-timeout reclaim can be added later if the /// held-thread memory at peak concurrency becomes a concern; it must use a CAS idle→dead transition. /// /// Rejected: a bounded-queue executor reintroduces the GetResult-on-queued-deser /// coupling and drops requests on overload. /// internal static class AcBinaryDeserExecutor { private static readonly ConcurrentStack _idle = new(); // Monotonic id for distinct worker-thread names — eases dump / profiler / debug correlation. // Incremented only on grow (new thread), never per message. private static int _threadId; /// /// Runs on a dedicated off-pool thread (reused idle worker, or a new /// one) — never queues. Returns a task completing with the work's result. /// public static Task Run(Func work) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (_idle.TryPop(out var worker)) worker.Assign(work, tcs); // reuse an idle worker else new Worker().Start(work, tcs); // grow: spawn a new dedicated thread return tcs.Task; } private sealed class Worker { // Single-permit park signal. Released exactly once per park (Assign), so the count // never exceeds 1 → no SemaphoreFullException. Release/Wait provide the memory barrier // that publishes the _work/_tcs writes to the worker thread. private readonly SemaphoreSlim _signal = new(0, 1); private Func? _work; private TaskCompletionSource? _tcs; public void Start(Func work, TaskCompletionSource tcs) { _work = work; _tcs = tcs; new Thread(Loop) { IsBackground = true, Name = $"AcBinaryDeser-{Interlocked.Increment(ref _threadId)}" }.Start(); } // Called by Run only when this worker is parked in _signal.Wait() (it was popped from // _idle, which it entered only AFTER finishing the previous work). So _work/_tcs are // written here strictly while the worker is idle — no race with the worker's active read. public void Assign(Func work, TaskCompletionSource tcs) { _work = work; _tcs = tcs; _signal.Release(); } private void Loop() { while (true) { var work = _work ?? throw new InvalidOperationException("AcBinaryDeserExecutor worker woke without work — invariant violated"); var tcs = _tcs ?? throw new InvalidOperationException("AcBinaryDeserExecutor worker woke without tcs — invariant violated"); _work = null; _tcs = null; try { tcs.SetResult(work()); } catch (Exception ex) { tcs.SetException(ex); } _idle.Push(this); // become reusable _signal.Wait(); // park until the next Assign } } } }