From e6c6cd53575d14fc0085d1d16893e9ce557b8ebe Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:07:29 +0800 Subject: [PATCH 001/228] refactor(server): prototype two-phase call reservations --- .../ServerCallCapacityGovernor.cs | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 src/SharpLink.Server/ServerCallCapacityGovernor.cs diff --git a/src/SharpLink.Server/ServerCallCapacityGovernor.cs b/src/SharpLink.Server/ServerCallCapacityGovernor.cs new file mode 100644 index 000000000..4b9d6f858 --- /dev/null +++ b/src/SharpLink.Server/ServerCallCapacityGovernor.cs @@ -0,0 +1,189 @@ +namespace SharpLink.Server; + +/// +/// Allocation-free Phase 0 primitive for the #273 two-phase call lifecycle. +/// A reservation consumes call capacity immediately and remains capacity-owning +/// when it is activated; activation only changes lifecycle accounting. +/// +internal sealed class ServerCallCapacityGovernor +{ + // High 32 bits: reserved calls. Low 32 bits: active calls. + // Keeping both counters in one atomic word makes every stable snapshot satisfy + // reserved + active <= capacity without a request-path lock. + private long _state; + private readonly int _capacity; + + internal ServerCallCapacityGovernor(int capacity) + { + ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); + _capacity = capacity; + } + + internal int Capacity => _capacity; + + internal bool TryReserve(out ServerCallReservation reservation) + { + while (true) + { + var observed = Volatile.Read(ref _state); + var reserved = GetReserved(observed); + var active = GetActive(observed); + if ((long)reserved + active >= _capacity) + { + reservation = default; + return false; + } + + var updated = Pack(reserved + 1, active); + if (Interlocked.CompareExchange(ref _state, updated, observed) != observed) + continue; + + reservation = new ServerCallReservation(this); + return true; + } + } + + internal ServerCallCapacitySnapshot CaptureSnapshot() + { + var state = Volatile.Read(ref _state); + return new ServerCallCapacitySnapshot( + GetReserved(state), + GetActive(state), + _capacity); + } + + internal void AssertInvariant() + { + var snapshot = CaptureSnapshot(); + if (snapshot.ReservedCalls < 0 || snapshot.ActiveCalls < 0) + throw new InvalidOperationException("Server call capacity accounting became negative."); + if ((long)snapshot.ReservedCalls + snapshot.ActiveCalls > snapshot.Capacity) + { + throw new InvalidOperationException( + "Server call capacity invariant violated: reserved + active exceeds capacity."); + } + } + + private void ActivateReservation() + { + while (true) + { + var observed = Volatile.Read(ref _state); + var reserved = GetReserved(observed); + var active = GetActive(observed); + if (reserved == 0) + throw new InvalidOperationException("No reserved call is available to activate."); + + var updated = Pack(reserved - 1, checked(active + 1)); + if (Interlocked.CompareExchange(ref _state, updated, observed) == observed) + return; + } + } + + private void ReleaseReservation() + { + while (true) + { + var observed = Volatile.Read(ref _state); + var reserved = GetReserved(observed); + var active = GetActive(observed); + if (reserved == 0) + throw new InvalidOperationException("Server reserved call count underflowed."); + + var updated = Pack(reserved - 1, active); + if (Interlocked.CompareExchange(ref _state, updated, observed) == observed) + return; + } + } + + private void ReleaseActiveCall() + { + while (true) + { + var observed = Volatile.Read(ref _state); + var reserved = GetReserved(observed); + var active = GetActive(observed); + if (active == 0) + throw new InvalidOperationException("Server active call count underflowed."); + + var updated = Pack(reserved, active - 1); + if (Interlocked.CompareExchange(ref _state, updated, observed) == observed) + return; + } + } + + private static int GetReserved(long state) => unchecked((int)(uint)(state >> 32)); + + private static int GetActive(long state) => unchecked((int)(uint)state); + + private static long Pack(int reserved, int active) + => ((long)(uint)reserved << 32) | (uint)active; + + /// + /// Single-owner value representing one capacity slot. The request path must not + /// copy this value after acquisition; ownership is transferred by ref until it is + /// either activated and eventually disposed, or disposed while still reserved. + /// + internal struct ServerCallReservation : IDisposable + { + private ServerCallCapacityGovernor? _owner; + private ReservationState _state; + + internal ServerCallReservation(ServerCallCapacityGovernor owner) + { + _owner = owner; + _state = ReservationState.Reserved; + } + + internal bool IsReserved => _owner is not null && _state == ReservationState.Reserved; + + internal bool IsActive => _owner is not null && _state == ReservationState.Active; + + internal void Activate() + { + var owner = _owner ?? throw new ObjectDisposedException(nameof(ServerCallReservation)); + if (_state != ReservationState.Reserved) + throw new InvalidOperationException("Only a reserved call can be activated."); + + owner.ActivateReservation(); + _state = ReservationState.Active; + } + + public void Dispose() + { + var owner = _owner; + if (owner is null) + return; + + switch (_state) + { + case ReservationState.Reserved: + owner.ReleaseReservation(); + break; + case ReservationState.Active: + owner.ReleaseActiveCall(); + break; + default: + throw new InvalidOperationException("Unknown server call reservation state."); + } + + _state = ReservationState.None; + _owner = null; + } + + private enum ReservationState : byte + { + None, + Reserved, + Active + } + } +} + +internal readonly record struct ServerCallCapacitySnapshot( + int ReservedCalls, + int ActiveCalls, + int Capacity) +{ + internal int OccupiedCalls => checked(ReservedCalls + ActiveCalls); +} From 10d68591bc15c7d026d5dec1954b5b3575e27891 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:07:46 +0800 Subject: [PATCH 002/228] test(server): cover call reservation lifecycle --- .../Server/ServerCallCapacityGovernorTests.cs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs diff --git a/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs b/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs new file mode 100644 index 000000000..3a912535b --- /dev/null +++ b/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs @@ -0,0 +1,157 @@ +using SharpLink.Server; + +namespace SharpLink.UnitTests.Server; + +public class ServerCallCapacityGovernorTests +{ + [Test] + public async Task ReservationConsumesCapacityBeforeActivation() + { + var governor = new ServerCallCapacityGovernor(1); + + Ensure(governor.TryReserve(out var reservation), "first reservation must acquire capacity"); + try + { + var reserved = governor.CaptureSnapshot(); + await Assert.That(reserved.ReservedCalls).IsEqualTo(1); + await Assert.That(reserved.ActiveCalls).IsEqualTo(0); + await Assert.That(reserved.OccupiedCalls).IsEqualTo(1); + await Assert.That(governor.TryReserve(out _)).IsFalse(); + + reservation.Activate(); + + var active = governor.CaptureSnapshot(); + await Assert.That(active.ReservedCalls).IsEqualTo(0); + await Assert.That(active.ActiveCalls).IsEqualTo(1); + await Assert.That(active.OccupiedCalls).IsEqualTo(1); + governor.AssertInvariant(); + } + finally + { + reservation.Dispose(); + } + + await Assert.That(governor.CaptureSnapshot().OccupiedCalls).IsEqualTo(0); + } + + [Test] + public async Task ReservedAndActiveCallsShareTheSameCapacityBoundary() + { + var governor = new ServerCallCapacityGovernor(2); + Ensure(governor.TryReserve(out var active), "first reservation must acquire capacity"); + Ensure(governor.TryReserve(out var reserved), "second reservation must acquire capacity"); + try + { + active.Activate(); + + var snapshot = governor.CaptureSnapshot(); + await Assert.That(snapshot.ReservedCalls).IsEqualTo(1); + await Assert.That(snapshot.ActiveCalls).IsEqualTo(1); + await Assert.That(snapshot.OccupiedCalls).IsEqualTo(2); + await Assert.That(governor.TryReserve(out _)).IsFalse(); + governor.AssertInvariant(); + } + finally + { + reserved.Dispose(); + active.Dispose(); + } + } + + [Test] + public async Task DisposingUnactivatedReservationReturnsCapacity() + { + var governor = new ServerCallCapacityGovernor(1); + Ensure(governor.TryReserve(out var reservation), "reservation must acquire capacity"); + + reservation.Dispose(); + + var released = governor.CaptureSnapshot(); + await Assert.That(released.ReservedCalls).IsEqualTo(0); + await Assert.That(released.ActiveCalls).IsEqualTo(0); + Ensure(governor.TryReserve(out var replacement), "released capacity must be reusable"); + replacement.Dispose(); + } + + [Test] + public async Task DisposeIsExactlyOnceForOneReservationOwner() + { + var governor = new ServerCallCapacityGovernor(1); + Ensure(governor.TryReserve(out var reservation), "reservation must acquire capacity"); + reservation.Activate(); + + reservation.Dispose(); + reservation.Dispose(); + + var snapshot = governor.CaptureSnapshot(); + await Assert.That(snapshot.ReservedCalls).IsEqualTo(0); + await Assert.That(snapshot.ActiveCalls).IsEqualTo(0); + governor.AssertInvariant(); + } + + [Test] + public async Task ActivationDoesNotPermitAnAdditionalCall() + { + var governor = new ServerCallCapacityGovernor(1); + Ensure(governor.TryReserve(out var reservation), "reservation must acquire capacity"); + try + { + reservation.Activate(); + await Assert.That(governor.TryReserve(out _)).IsFalse(); + } + finally + { + reservation.Dispose(); + } + } + + [Test] + public async Task ConcurrentReservationChurnPreservesCapacityInvariant() + { + const int capacity = 16; + const int iterations = 100_000; + var governor = new ServerCallCapacityGovernor(capacity); + var invariantFailures = 0; + + Parallel.For(0, iterations, index => + { + if (!governor.TryReserve(out var reservation)) + return; + + try + { + if ((index & 1) == 0) + reservation.Activate(); + + var snapshot = governor.CaptureSnapshot(); + if (snapshot.ReservedCalls < 0 || + snapshot.ActiveCalls < 0 || + snapshot.OccupiedCalls > capacity) + { + Interlocked.Increment(ref invariantFailures); + } + } + finally + { + reservation.Dispose(); + } + }); + + await Assert.That(invariantFailures).IsEqualTo(0); + await Assert.That(governor.CaptureSnapshot().OccupiedCalls).IsEqualTo(0); + governor.AssertInvariant(); + } + + [Test] + public async Task InvalidCapacityIsRejected() + { + await Assert.That(() => new ServerCallCapacityGovernor(0)) + .Throws(); + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} From ceaab18d4ffdb68b327923d9a742cb72a7a370fb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:10:11 +0800 Subject: [PATCH 003/228] test(server): import threading primitives --- .../Server/ServerCallCapacityGovernorTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs b/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs index 3a912535b..775c75b8b 100644 --- a/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs @@ -1,3 +1,4 @@ +using System.Threading; using SharpLink.Server; namespace SharpLink.UnitTests.Server; From 55dfd0d6a370456093a700413a541f68f4bb498e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:33:55 +0800 Subject: [PATCH 004/228] fix(server): make call reservation ownership identity-safe --- .../ServerCallCapacityGovernor.cs | 96 +++++++++++-------- 1 file changed, 58 insertions(+), 38 deletions(-) diff --git a/src/SharpLink.Server/ServerCallCapacityGovernor.cs b/src/SharpLink.Server/ServerCallCapacityGovernor.cs index 4b9d6f858..41d019fb2 100644 --- a/src/SharpLink.Server/ServerCallCapacityGovernor.cs +++ b/src/SharpLink.Server/ServerCallCapacityGovernor.cs @@ -1,7 +1,7 @@ namespace SharpLink.Server; /// -/// Allocation-free Phase 0 primitive for the #273 two-phase call lifecycle. +/// Phase 0 primitive for the #273 two-phase call lifecycle. /// A reservation consumes call capacity immediately and remains capacity-owning /// when it is activated; activation only changes lifecycle accounting. /// @@ -30,7 +30,7 @@ internal bool TryReserve(out ServerCallReservation reservation) var active = GetActive(observed); if ((long)reserved + active >= _capacity) { - reservation = default; + reservation = null!; return false; } @@ -120,62 +120,82 @@ private static long Pack(int reserved, int active) => ((long)(uint)reserved << 32) | (uint)active; /// - /// Single-owner value representing one capacity slot. The request path must not - /// copy this value after acquisition; ownership is transferred by ref until it is - /// either activated and eventually disposed, or disposed while still reserved. + /// Identity-bearing Phase 0 reservation for one capacity slot. Aliases refer to + /// the same lifecycle state, so a stale reference cannot release a later lease. + /// Production wiring should fold this identity/state into the unique request permit + /// or request context instead of treating this standalone allocation as the target shape. /// - internal struct ServerCallReservation : IDisposable + internal sealed class ServerCallReservation : IDisposable { - private ServerCallCapacityGovernor? _owner; - private ReservationState _state; + private const int Reserved = 0; + private const int Activating = 1; + private const int Active = 2; + private const int Disposed = 3; + + private readonly ServerCallCapacityGovernor _owner; + private int _state = Reserved; internal ServerCallReservation(ServerCallCapacityGovernor owner) { _owner = owner; - _state = ReservationState.Reserved; } - internal bool IsReserved => _owner is not null && _state == ReservationState.Reserved; + internal bool IsReserved => Volatile.Read(ref _state) == Reserved; - internal bool IsActive => _owner is not null && _state == ReservationState.Active; + internal bool IsActive => Volatile.Read(ref _state) == Active; internal void Activate() { - var owner = _owner ?? throw new ObjectDisposedException(nameof(ServerCallReservation)); - if (_state != ReservationState.Reserved) + var observed = Interlocked.CompareExchange(ref _state, Activating, Reserved); + if (observed != Reserved) + { + if (observed == Disposed) + throw new ObjectDisposedException(nameof(ServerCallReservation)); + throw new InvalidOperationException("Only a reserved call can be activated."); + } - owner.ActivateReservation(); - _state = ReservationState.Active; + try + { + _owner.ActivateReservation(); + Volatile.Write(ref _state, Active); + } + catch + { + Volatile.Write(ref _state, Reserved); + throw; + } } public void Dispose() { - var owner = _owner; - if (owner is null) - return; - - switch (_state) + var spinner = new SpinWait(); + while (true) { - case ReservationState.Reserved: - owner.ReleaseReservation(); - break; - case ReservationState.Active: - owner.ReleaseActiveCall(); - break; - default: - throw new InvalidOperationException("Unknown server call reservation state."); + var observed = Volatile.Read(ref _state); + switch (observed) + { + case Reserved: + if (Interlocked.CompareExchange(ref _state, Disposed, Reserved) != Reserved) + continue; + + _owner.ReleaseReservation(); + return; + case Activating: + spinner.SpinOnce(); + continue; + case Active: + if (Interlocked.CompareExchange(ref _state, Disposed, Active) != Active) + continue; + + _owner.ReleaseActiveCall(); + return; + case Disposed: + return; + default: + throw new InvalidOperationException("Unknown server call reservation state."); + } } - - _state = ReservationState.None; - _owner = null; - } - - private enum ReservationState : byte - { - None, - Reserved, - Active } } } From d0f9e6b3799155237eaba804fe094512cbb57551 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:34:11 +0800 Subject: [PATCH 005/228] test(server): cover stale reservation aliases --- .../Server/ServerCallCapacityGovernorTests.cs | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs b/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs index 775c75b8b..a6656f398 100644 --- a/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs @@ -75,14 +75,15 @@ public async Task DisposingUnactivatedReservationReturnsCapacity() } [Test] - public async Task DisposeIsExactlyOnceForOneReservationOwner() + public async Task DisposeIsExactlyOnceAcrossAliases() { var governor = new ServerCallCapacityGovernor(1); Ensure(governor.TryReserve(out var reservation), "reservation must acquire capacity"); + var alias = reservation; reservation.Activate(); reservation.Dispose(); - reservation.Dispose(); + alias.Dispose(); var snapshot = governor.CaptureSnapshot(); await Assert.That(snapshot.ReservedCalls).IsEqualTo(0); @@ -90,6 +91,51 @@ public async Task DisposeIsExactlyOnceForOneReservationOwner() governor.AssertInvariant(); } + [Test] + public async Task StaleAliasCannotReleaseAReplacementReservation() + { + var governor = new ServerCallCapacityGovernor(1); + Ensure(governor.TryReserve(out var first), "first reservation must acquire capacity"); + var stale = first; + + first.Activate(); + first.Dispose(); + + Ensure(governor.TryReserve(out var current), "replacement reservation must acquire capacity"); + try + { + stale.Dispose(); + + var snapshot = governor.CaptureSnapshot(); + await Assert.That(snapshot.ReservedCalls).IsEqualTo(1); + await Assert.That(snapshot.ActiveCalls).IsEqualTo(0); + await Assert.That(snapshot.OccupiedCalls).IsEqualTo(1); + await Assert.That(governor.TryReserve(out _)).IsFalse(); + governor.AssertInvariant(); + } + finally + { + current.Dispose(); + } + } + + [Test] + public async Task ConcurrentAliasDisposalReleasesCapacityExactlyOnce() + { + var governor = new ServerCallCapacityGovernor(1); + Ensure(governor.TryReserve(out var reservation), "reservation must acquire capacity"); + reservation.Activate(); + + Parallel.For(0, 10_000, _ => reservation.Dispose()); + + var snapshot = governor.CaptureSnapshot(); + await Assert.That(snapshot.ReservedCalls).IsEqualTo(0); + await Assert.That(snapshot.ActiveCalls).IsEqualTo(0); + Ensure(governor.TryReserve(out var replacement), "capacity must be reusable after concurrent disposal"); + replacement.Dispose(); + governor.AssertInvariant(); + } + [Test] public async Task ActivationDoesNotPermitAnAdditionalCall() { From 82cf247ee584c207ec96fd02d125cdd9b6dfbf57 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:06:15 +0800 Subject: [PATCH 006/228] fix(server): harden reservation contracts for review --- .../ServerCallCapacityGovernor.cs | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/SharpLink.Server/ServerCallCapacityGovernor.cs b/src/SharpLink.Server/ServerCallCapacityGovernor.cs index 41d019fb2..fe5372e89 100644 --- a/src/SharpLink.Server/ServerCallCapacityGovernor.cs +++ b/src/SharpLink.Server/ServerCallCapacityGovernor.cs @@ -12,16 +12,21 @@ internal sealed class ServerCallCapacityGovernor // reserved + active <= capacity without a request-path lock. private long _state; private readonly int _capacity; + private readonly ServerCallCapacityGovernorTestHooks? _testHooks; - internal ServerCallCapacityGovernor(int capacity) + internal ServerCallCapacityGovernor( + int capacity, + ServerCallCapacityGovernorTestHooks? testHooks = null) { ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); _capacity = capacity; + _testHooks = testHooks; } internal int Capacity => _capacity; - internal bool TryReserve(out ServerCallReservation reservation) + internal bool TryReserve( + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ServerCallReservation? reservation) { while (true) { @@ -30,7 +35,7 @@ internal bool TryReserve(out ServerCallReservation reservation) var active = GetActive(observed); if ((long)reserved + active >= _capacity) { - reservation = null!; + reservation = null; return false; } @@ -112,6 +117,12 @@ private void ReleaseActiveCall() } } + private void NotifyReservationEnteredActivatingForTest() + => _testHooks?.ReservationEnteredActivating?.Invoke(); + + private void NotifyDisposeObservedActivatingForTest() + => _testHooks?.DisposeObservedActivating?.Invoke(); + private static int GetReserved(long state) => unchecked((int)(uint)(state >> 32)); private static int GetActive(long state) => unchecked((int)(uint)state); @@ -157,6 +168,7 @@ internal void Activate() try { + _owner.NotifyReservationEnteredActivatingForTest(); _owner.ActivateReservation(); Volatile.Write(ref _state, Active); } @@ -182,6 +194,7 @@ public void Dispose() _owner.ReleaseReservation(); return; case Activating: + _owner.NotifyDisposeObservedActivatingForTest(); spinner.SpinOnce(); continue; case Active: @@ -200,6 +213,13 @@ public void Dispose() } } +internal sealed class ServerCallCapacityGovernorTestHooks +{ + internal Action? ReservationEnteredActivating { get; init; } + + internal Action? DisposeObservedActivating { get; init; } +} + internal readonly record struct ServerCallCapacitySnapshot( int ReservedCalls, int ActiveCalls, From 8153c86c6957af092c1b47debbace2d53cea8a25 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:07:01 +0800 Subject: [PATCH 007/228] test(server): cover activation disposal transition --- .../Server/ServerCallCapacityGovernorTests.cs | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs b/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs index a6656f398..af4fc798c 100644 --- a/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerCallCapacityGovernorTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Threading; using SharpLink.Server; @@ -136,6 +137,63 @@ public async Task ConcurrentAliasDisposalReleasesCapacityExactlyOnce() governor.AssertInvariant(); } + [Test] + public async Task DisposeDuringActivationReleasesCapacityExactlyOnce() + { + using var activationEntered = new ManualResetEventSlim(); + using var releaseActivation = new ManualResetEventSlim(); + using var disposeObservedActivating = new ManualResetEventSlim(); + + var hooks = new ServerCallCapacityGovernorTestHooks + { + ReservationEnteredActivating = () => + { + activationEntered.Set(); + if (!releaseActivation.Wait(TimeSpan.FromSeconds(10))) + throw new TimeoutException("Timed out waiting to release activation transition."); + }, + DisposeObservedActivating = () => disposeObservedActivating.Set(), + }; + var governor = new ServerCallCapacityGovernor(1, hooks); + Ensure(governor.TryReserve(out var reservation), "reservation must acquire capacity"); + + var activateTask = Task.Run(reservation.Activate); + Task? disposeTask = null; + try + { + Ensure( + activationEntered.Wait(TimeSpan.FromSeconds(10)), + "activation must pause after Reserved -> Activating"); + + disposeTask = Task.Run(reservation.Dispose); + Ensure( + disposeObservedActivating.Wait(TimeSpan.FromSeconds(10)), + "dispose must observe the Activating state before activation is released"); + + var inFlight = governor.CaptureSnapshot(); + await Assert.That(inFlight.ReservedCalls).IsEqualTo(1); + await Assert.That(inFlight.ActiveCalls).IsEqualTo(0); + await Assert.That(inFlight.OccupiedCalls).IsEqualTo(1); + await Assert.That(governor.TryReserve(out _)).IsFalse(); + } + finally + { + releaseActivation.Set(); + } + + await activateTask; + if (disposeTask is not null) + await disposeTask; + + var released = governor.CaptureSnapshot(); + await Assert.That(released.ReservedCalls).IsEqualTo(0); + await Assert.That(released.ActiveCalls).IsEqualTo(0); + await Assert.That(released.OccupiedCalls).IsEqualTo(0); + Ensure(governor.TryReserve(out var replacement), "capacity must be reusable after activation/dispose race"); + replacement.Dispose(); + governor.AssertInvariant(); + } + [Test] public async Task ActivationDoesNotPermitAnAdditionalCall() { @@ -196,7 +254,7 @@ public async Task InvalidCapacityIsRejected() .Throws(); } - private static void Ensure(bool condition, string message) + private static void Ensure([DoesNotReturnIf(false)] bool condition, string message) { if (!condition) throw new InvalidOperationException(message); From 6fc7d79758d1cfa010b82c2d71b3fc0ae584c181 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:46:52 +0800 Subject: [PATCH 008/228] perf(server): add phase0 decode execution evidence runner --- .../DecodeExecutionPhase0EvidenceRunner.cs | 1181 +++++++++++++++++ 1 file changed, 1181 insertions(+) create mode 100644 test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs diff --git a/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs new file mode 100644 index 000000000..4f170e8b8 --- /dev/null +++ b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs @@ -0,0 +1,1181 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading.Channels; +using SharpLink.Abstractions; +using SharpLink.Runtime; +using SharpLink.Server; + +namespace SharpLink.Benchmarks; + +/// +/// Benchmark-only Phase 0 evidence for #273. This runner intentionally does not wire any +/// decode strategy into the production request loop. It compares execution/scheduling +/// shapes around the reviewed two-phase call reservation primitive. +/// +internal static class DecodeExecutionPhase0EvidenceRunner +{ + private const uint IntegrityMagic = 0x31504353; + private const int IntegrityTrailerBytes = sizeof(uint) + sizeof(uint); + private static readonly DecodeStrategy[] s_strategies = + [ + DecodeStrategy.ThreadPoolHandoff, + DecodeStrategy.InlineProvider, + DecodeStrategy.CooperativeQuantum, + DecodeStrategy.PersistentExecutor + ]; + private static readonly AdmissionMode[] s_admissionModes = + [ + AdmissionMode.Off, + AdmissionMode.Immediate, + AdmissionMode.Queued + ]; + private static readonly int[] s_concurrency = [1, 16, 128]; + + internal static async Task RunAsync(string[] args) + { + var outputPath = GetOption(args, "--output") ?? + Path.Combine("artifacts", "performance", "current", "phase0-decode-execution.json"); + var payloadSizes = GetPayloadSizes(args); + var compressibility = GetCompressibility(args); + var repetitions = GetPositiveInt(args, "--repetitions", 3); + var quantumBytes = GetPositiveInt(args, "--quantum-bytes", 64 * 1024); + var results = new List(); + var lifecycle = new List(); + + foreach (var payloadSize in payloadSizes) + { + foreach (var compressible in compressibility) + { + var fixture = DecodeFixture.Create(payloadSize, compressible); + foreach (var remoteCancellable in new[] { false, true }) + { + foreach (var capacityMode in new[] { CapacityMode.Available, CapacityMode.Full }) + { + foreach (var admissionMode in s_admissionModes) + { + foreach (var concurrency in s_concurrency) + { + for (var repetition = 1; repetition <= repetitions; repetition++) + { + foreach (var strategy in GetStrategyOrder(repetition)) + { + var result = await MeasureCaseAsync( + fixture, + strategy, + admissionMode, + capacityMode, + remoteCancellable, + concurrency, + repetition, + quantumBytes); + results.Add(result); + } + } + } + } + } + } + + foreach (var strategy in s_strategies) + { + lifecycle.Add(await MeasureLifecycleAsync( + fixture, + strategy, + quantumBytes)); + } + } + } + + var summary = BuildSummary(results, lifecycle); + var evidence = new DecodeExecutionEvidenceDocument( + DateTimeOffset.UtcNow, + RuntimeInformation.FrameworkDescription, + RuntimeInformation.OSDescription, + Environment.ProcessorCount, + quantumBytes, + results, + lifecycle, + summary); + + var fullPath = Path.GetFullPath(outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await File.WriteAllTextAsync(fullPath, JsonSerializer.Serialize(evidence, new JsonSerializerOptions + { + WriteIndented = true + })); + + Console.WriteLine($"Phase 0 decode execution evidence: {fullPath}"); + foreach (var item in summary) + { + Console.WriteLine( + $"PHASE0_SUMMARY strategy={item.Strategy} qpsRatio={item.MedianQpsRatioToInline:F3} " + + $"cpuRatio={item.MedianCpuRatioToInline:F3} p99Ratio={item.MedianP99RatioToInline:F3} " + + $"allocBop={item.MedianAllocatedBytesPerOperation:F1} schedulerP99Us={item.MedianSchedulerP99Microseconds:F2} " + + $"cancelObserved={item.CancelObservedProbes}/{item.CancelProbeCount} " + + $"cancelMedianUs={(item.MedianCancelObservationMicroseconds?.ToString("F2") ?? "n/a")} " + + $"drainMedianUs={item.MedianStopDrainMicroseconds:F2} rejectedInvariantFailures={item.RejectedInvariantFailures}"); + } + } + + private static async Task MeasureCaseAsync( + DecodeFixture fixture, + DecodeStrategy strategy, + AdmissionMode admissionMode, + CapacityMode capacityMode, + bool remoteCancellable, + int concurrency, + int repetition, + int quantumBytes) + { + await using var runtime = new DecodeCaseRuntime( + fixture, + strategy, + admissionMode, + capacityMode, + concurrency, + quantumBytes); + + // Warm the provider, ArrayPool buckets, ThreadPool/executor path and async state machines. + var warmupCount = Math.Min(concurrency, 4); + for (var index = 0; index < warmupCount; index++) + _ = await runtime.ExecuteAsync(remoteCancellable ? runtime.NonCancelledRemoteToken : CancellationToken.None); + runtime.ResetMetrics(); + + var operations = GetOperationsPerCase(fixture.PayloadSize, concurrency); + var latencies = new double[operations]; + var schedulerDelays = new double[operations]; + var accepted = 0; + var rejected = 0; + var next = -1; + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + using var process = Process.GetCurrentProcess(); + var cpuBefore = process.TotalProcessorTime; + var started = Stopwatch.GetTimestamp(); + + var workers = new Task[concurrency]; + for (var worker = 0; worker < workers.Length; worker++) + { + workers[worker] = Task.Run(async () => + { + while (true) + { + var index = Interlocked.Increment(ref next); + if (index >= operations) + return; + + var requestStarted = Stopwatch.GetTimestamp(); + var request = await runtime.ExecuteAsync( + remoteCancellable ? runtime.NonCancelledRemoteToken : CancellationToken.None); + latencies[index] = ElapsedMicroseconds(requestStarted); + schedulerDelays[index] = request.SchedulerDelayMicroseconds; + if (request.Accepted) + Interlocked.Increment(ref accepted); + else + Interlocked.Increment(ref rejected); + } + }); + } + await Task.WhenAll(workers); + + var elapsed = Stopwatch.GetElapsedTime(started); + var cpu = process.TotalProcessorTime - cpuBefore; + var allocated = GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore; + var metrics = runtime.CaptureMetrics(); + var snapshot = runtime.CaptureCapacitySnapshot(); + + if (capacityMode == CapacityMode.Available) + { + if (rejected != 0 || snapshot.OccupiedCalls != 0) + throw new InvalidOperationException("Available-capacity evidence unexpectedly rejected or leaked a call reservation."); + } + else + { + if (accepted != 0 || rejected != operations) + throw new InvalidOperationException("Full-capacity evidence did not reject every request."); + if (metrics.DecompressCalls != 0 || metrics.DecodedRentCount != 0 || metrics.RetainedRentCount != 0) + { + throw new InvalidOperationException( + "Full-capacity compressed evidence violated #244: rejection performed decode or payload retention/rent."); + } + if (snapshot.OccupiedCalls != 1) + throw new InvalidOperationException("The synthetic full-capacity holder was not preserved."); + } + + double? cancelObservationMicroseconds = null; + bool? cancelObserved = null; + if (remoteCancellable && capacityMode == CapacityMode.Available) + { + var cancel = await MeasureCancellationAsync( + fixture, + strategy, + admissionMode, + concurrency, + quantumBytes); + cancelObservationMicroseconds = cancel.ObservationMicroseconds; + cancelObserved = cancel.Observed; + } + + return new DecodeExecutionEvidenceResult( + strategy.ToString(), + admissionMode.ToString(), + capacityMode.ToString(), + remoteCancellable, + concurrency, + repetition, + fixture.PayloadSize, + fixture.Compressible, + fixture.Compressed.Length, + fixture.Compressed.Length / (double)fixture.PayloadSize, + operations, + accepted, + rejected, + elapsed.TotalSeconds, + operations / elapsed.TotalSeconds, + cpu.TotalNanoseconds / operations, + Percentile(latencies, 0.50), + Percentile(latencies, 0.99), + allocated / (double)operations, + rejected == 0 ? null : metrics.DecompressCalls / (double)rejected, + rejected == 0 ? null : metrics.DecodedBytesRented / (double)rejected, + metrics.PeakRetainedBytes, + metrics.PeakDecodedBytes, + metrics.PeakDecodeQueueDepth, + Percentile(schedulerDelays, 0.50), + Percentile(schedulerDelays, 0.99), + cancelObserved, + cancelObservationMicroseconds); + } + + private static async Task MeasureCancellationAsync( + DecodeFixture fixture, + DecodeStrategy strategy, + AdmissionMode admissionMode, + int concurrency, + int quantumBytes) + { + await using var runtime = new DecodeCaseRuntime( + fixture, + strategy, + admissionMode, + CapacityMode.Available, + Math.Max(1, concurrency), + quantumBytes); + using var started = new ManualResetEventSlim(false); + using var cts = new CancellationTokenSource(); + + var request = Task.Run(async () => + await runtime.ExecuteAsync(cts.Token, () => started.Set())); + if (!started.Wait(TimeSpan.FromSeconds(5))) + throw new TimeoutException("Cancellation probe did not reach decode execution."); + + var cancellationStarted = Stopwatch.GetTimestamp(); + cts.Cancel(); + try + { + _ = await request; + return new CancelProbeResult(false, null); + } + catch (OperationCanceledException) + { + return new CancelProbeResult(true, ElapsedMicroseconds(cancellationStarted)); + } + } + + private static async Task MeasureLifecycleAsync( + DecodeFixture fixture, + DecodeStrategy strategy, + int quantumBytes) + { + const int concurrency = 16; + await using var runtime = new DecodeCaseRuntime( + fixture, + strategy, + AdmissionMode.Off, + CapacityMode.Available, + concurrency, + quantumBytes); + + var tasks = new Task[concurrency]; + for (var index = 0; index < tasks.Length; index++) + { + tasks[index] = Task.Run(async () => + _ = await runtime.ExecuteAsync(CancellationToken.None)); + } + + // Let the burst publish work before measuring the drain boundary. + await Task.Yield(); + var started = Stopwatch.GetTimestamp(); + await Task.WhenAll(tasks); + await runtime.StopExecutorAsync(); + var elapsed = ElapsedMicroseconds(started); + var snapshot = runtime.CaptureCapacitySnapshot(); + if (snapshot.OccupiedCalls != 0) + throw new InvalidOperationException("Lifecycle probe leaked call capacity."); + + return new DecodeLifecycleEvidenceResult( + strategy.ToString(), + fixture.PayloadSize, + fixture.Compressible, + concurrency, + elapsed); + } + + private static IReadOnlyList BuildSummary( + IReadOnlyList results, + IReadOnlyList lifecycle) + { + var inline = new Dictionary(); + foreach (var result in results) + { + if (result.Strategy == DecodeStrategy.InlineProvider.ToString()) + inline[new CaseKey(result)] = result; + } + + var summaries = new List(); + foreach (var strategy in s_strategies) + { + var name = strategy.ToString(); + var qpsRatios = new List(); + var cpuRatios = new List(); + var p99Ratios = new List(); + var allocations = new List(); + var schedulerP99 = new List(); + var cancelLatency = new List(); + var cancelProbes = 0; + var cancelObserved = 0; + var rejectedInvariantFailures = 0; + + foreach (var result in results) + { + if (result.Strategy != name) + continue; + allocations.Add(result.AllocatedBytesPerOperation); + schedulerP99.Add(result.SchedulerDelayP99Microseconds); + if (result.CapacityMode == CapacityMode.Full.ToString() && + ((result.DecompressCallsPerRejectedRequest ?? 0) != 0 || + (result.DecodedBytesRentedPerRejectedRequest ?? 0) != 0)) + rejectedInvariantFailures++; + if (result.CancelObserved.HasValue) + { + cancelProbes++; + if (result.CancelObserved.Value) + { + cancelObserved++; + if (result.CancelObservationMicroseconds.HasValue) + cancelLatency.Add(result.CancelObservationMicroseconds.Value); + } + } + if (result.CapacityMode != CapacityMode.Available.ToString()) + continue; + var baseline = inline[new CaseKey(result)]; + qpsRatios.Add(result.Qps / baseline.Qps); + cpuRatios.Add(result.CpuNanosecondsPerOperation / baseline.CpuNanosecondsPerOperation); + p99Ratios.Add(result.P99Microseconds / baseline.P99Microseconds); + } + + var drain = new List(); + foreach (var probe in lifecycle) + { + if (probe.Strategy == name) + drain.Add(probe.StopDrainMicroseconds); + } + + summaries.Add(new DecodeExecutionSummary( + name, + Median(qpsRatios), + Median(cpuRatios), + Median(p99Ratios), + Median(allocations), + Median(schedulerP99), + cancelObserved, + cancelProbes, + cancelLatency.Count == 0 ? null : Median(cancelLatency), + Median(drain), + rejectedInvariantFailures)); + } + return summaries; + } + + private static DecodeStrategy[] GetStrategyOrder(int repetition) + => repetition % 2 == 0 + ? [DecodeStrategy.PersistentExecutor, DecodeStrategy.CooperativeQuantum, DecodeStrategy.InlineProvider, DecodeStrategy.ThreadPoolHandoff] + : s_strategies; + + private static int GetOperationsPerCase(int payloadSize, int concurrency) + { + var baseline = payloadSize switch + { + <= 1024 => 4096, + <= 65_536 => 768, + _ => 96 + }; + return Math.Max(baseline, concurrency); + } + + private static double Percentile(double[] values, double percentile) + { + if (values.Length == 0) + return 0; + var copy = (double[])values.Clone(); + Array.Sort(copy); + var index = Math.Clamp((int)Math.Ceiling(percentile * copy.Length) - 1, 0, copy.Length - 1); + return copy[index]; + } + + private static double Median(List values) + { + if (values.Count == 0) + return 0; + values.Sort(); + var middle = values.Count / 2; + return values.Count % 2 == 0 + ? (values[middle - 1] + values[middle]) / 2 + : values[middle]; + } + + private static double ElapsedMicroseconds(long started) + => Stopwatch.GetElapsedTime(started).TotalNanoseconds / 1000d; + + private static string? GetOption(string[] args, string name) + { + for (var index = 0; index < args.Length - 1; index++) + { + if (string.Equals(args[index], name, StringComparison.Ordinal)) + return args[index + 1]; + } + return null; + } + + private static int GetPositiveInt(string[] args, string name, int defaultValue) + { + var option = GetOption(args, name); + if (option is null) + return defaultValue; + if (!int.TryParse(option, out var value) || value <= 0) + throw new ArgumentOutOfRangeException(name, "Expected a positive integer."); + return value; + } + + private static IReadOnlyList GetPayloadSizes(string[] args) + { + var option = GetOption(args, "--payload-size"); + if (option is null || string.Equals(option, "all", StringComparison.OrdinalIgnoreCase)) + return [1024, 65_536, 1_048_576]; + if (!int.TryParse(option, out var size) || size is not (1024 or 65_536 or 1_048_576)) + throw new ArgumentOutOfRangeException(nameof(args), "Payload size must be 1024, 65536, 1048576, or all."); + return [size]; + } + + private static IReadOnlyList GetCompressibility(string[] args) + { + var option = GetOption(args, "--compressibility"); + return option?.ToLowerInvariant() switch + { + null or "all" => [true, false], + "high" => [true], + "low" => [false], + _ => throw new ArgumentOutOfRangeException(nameof(args), "Compressibility must be high, low, or all.") + }; + } + + private enum DecodeStrategy + { + ThreadPoolHandoff, + InlineProvider, + CooperativeQuantum, + PersistentExecutor + } + + private enum AdmissionMode + { + Off, + Immediate, + Queued + } + + private enum CapacityMode + { + Available, + Full + } + + private sealed class DecodeFixture + { + private DecodeFixture(int payloadSize, bool compressible, byte[] compressed) + { + PayloadSize = payloadSize; + Compressible = compressible; + Compressed = compressed; + } + + internal int PayloadSize { get; } + internal bool Compressible { get; } + internal byte[] Compressed { get; } + + internal static DecodeFixture Create(int payloadSize, bool compressible) + { + var payload = new byte[payloadSize]; + if (compressible) + Array.Fill(payload, (byte)0x2a); + else + new Random(42).NextBytes(payload); + var provider = CompressionProviderBenchmarks.CreateProvider("fastest"); + var output = new ArrayBufferWriter(payloadSize * 2 + 1024); + var result = provider.Compress( + new ReadOnlySequence(payload), + output, + payloadSize * 2 + 1024); + if (result.ConsumedBytes != payloadSize || result.WrittenBytes != output.WrittenCount) + throw new InvalidOperationException("Compression fixture creation returned inconsistent counts."); + return new DecodeFixture(payloadSize, compressible, output.WrittenSpan.ToArray()); + } + } + + private sealed class DecodeCaseRuntime : IAsyncDisposable + { + private readonly DecodeFixture _fixture; + private readonly DecodeStrategy _strategy; + private readonly AdmissionMode _admissionMode; + private readonly int _quantumBytes; + private readonly ISharpLinkCompressionProvider _provider; + private readonly ServerCallCapacityGovernor _governor; + private readonly ServerCallCapacityGovernor.ServerCallReservation? _fullCapacityHolder; + private readonly PersistentDecodeExecutor? _executor; + private readonly CancellationTokenSource _remoteTokenSource = new(); + private readonly DecodeMetrics _metrics = new(); + private bool _executorStopped; + + internal DecodeCaseRuntime( + DecodeFixture fixture, + DecodeStrategy strategy, + AdmissionMode admissionMode, + CapacityMode capacityMode, + int concurrency, + int quantumBytes) + { + _fixture = fixture; + _strategy = strategy; + _admissionMode = admissionMode; + _quantumBytes = quantumBytes; + _provider = CompressionProviderBenchmarks.CreateProvider("fastest"); + _governor = new ServerCallCapacityGovernor( + capacityMode == CapacityMode.Full ? 1 : Math.Max(1, concurrency)); + if (capacityMode == CapacityMode.Full) + { + if (!_governor.TryReserve(out _fullCapacityHolder)) + throw new InvalidOperationException("Failed to establish the full-capacity evidence fixture."); + } + if (strategy == DecodeStrategy.PersistentExecutor) + { + _executor = new PersistentDecodeExecutor( + Math.Clamp(Environment.ProcessorCount, 1, 4), + Math.Max(32, concurrency * 2), + _metrics); + } + } + + internal CancellationToken NonCancelledRemoteToken => _remoteTokenSource.Token; + + internal async ValueTask ExecuteAsync( + CancellationToken cancellationToken, + Action? onDecodeStart = null) + { + await ApplyAdmissionAsync(); + if (!_governor.TryReserve(out var reservation)) + return new DecodeRequestResult(false, 0); + + using (reservation) + { + var requiresRetention = _strategy is not DecodeStrategy.InlineProvider; + using var retained = requiresRetention + ? RetainedPayload.Rent(_fixture.Compressed, _metrics) + : default; + var compressed = requiresRetention + ? retained.Memory + : _fixture.Compressed.AsMemory(); + using var output = new PooledOutput(_fixture.PayloadSize, _metrics); + + double schedulerDelay; + switch (_strategy) + { + case DecodeStrategy.ThreadPoolHandoff: + schedulerDelay = await RunThreadPoolHandoffAsync( + _provider, + compressed, + output, + _fixture.PayloadSize, + cancellationToken, + onDecodeStart, + _metrics); + break; + case DecodeStrategy.InlineProvider: + onDecodeStart?.Invoke(); + _metrics.OnDecompress(); + ValidateProviderResult( + _provider.Decompress( + new ReadOnlySequence(compressed), + output, + _fixture.PayloadSize, + cancellationToken), + compressed.Length, + _fixture.PayloadSize); + schedulerDelay = 0; + break; + case DecodeStrategy.CooperativeQuantum: + onDecodeStart?.Invoke(); + _metrics.OnDecompress(); + schedulerDelay = await DecompressCooperativelyAsync( + compressed, + output, + _fixture.PayloadSize, + _quantumBytes, + cancellationToken); + break; + case DecodeStrategy.PersistentExecutor: + schedulerDelay = await _executor!.EnqueueAsync( + _provider, + compressed, + output, + _fixture.PayloadSize, + cancellationToken, + onDecodeStart); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + reservation.Activate(); + return new DecodeRequestResult(true, schedulerDelay); + } + } + + internal void ResetMetrics() => _metrics.Reset(); + + internal DecodeMetricsSnapshot CaptureMetrics() => _metrics.Capture(); + + internal ServerCallCapacitySnapshot CaptureCapacitySnapshot() => _governor.CaptureSnapshot(); + + internal async ValueTask StopExecutorAsync() + { + if (_executorStopped) + return; + _executorStopped = true; + if (_executor is not null) + await _executor.DisposeAsync(); + } + + public async ValueTask DisposeAsync() + { + await StopExecutorAsync(); + _fullCapacityHolder?.Dispose(); + _remoteTokenSource.Dispose(); + _governor.AssertInvariant(); + } + + private ValueTask ApplyAdmissionAsync() + { + switch (_admissionMode) + { + case AdmissionMode.Off: + return ValueTask.CompletedTask; + case AdmissionMode.Immediate: + Thread.SpinWait(32); + return ValueTask.CompletedTask; + case AdmissionMode.Queued: + return YieldAdmissionAsync(); + default: + throw new ArgumentOutOfRangeException(); + } + } + + private static async ValueTask YieldAdmissionAsync() + => await Task.Yield(); + } + + private static async ValueTask RunThreadPoolHandoffAsync( + ISharpLinkCompressionProvider provider, + ReadOnlyMemory compressed, + PooledOutput output, + int originalLength, + CancellationToken cancellationToken, + Action? onDecodeStart, + DecodeMetrics metrics) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var queuedAt = Stopwatch.GetTimestamp(); + metrics.OnDecodeQueued(); + var work = new DecodeWorkItem( + provider, + compressed, + output, + originalLength, + cancellationToken, + onDecodeStart, + completion, + queuedAt, + metrics); + if (!ThreadPool.UnsafeQueueUserWorkItem(static item => item.Run(), work, preferLocal: false)) + { + metrics.OnDecodeDequeued(); + throw new InvalidOperationException("ThreadPool rejected Phase 0 decode work."); + } + return await completion.Task; + } + + private readonly record struct DecodeWorkItem( + ISharpLinkCompressionProvider Provider, + ReadOnlyMemory Compressed, + PooledOutput Output, + int OriginalLength, + CancellationToken CancellationToken, + Action? OnDecodeStart, + TaskCompletionSource Completion, + long QueuedAt, + DecodeMetrics Metrics) + { + internal void Run() + { + Metrics.OnDecodeDequeued(); + var schedulerDelay = ElapsedMicroseconds(QueuedAt); + try + { + OnDecodeStart?.Invoke(); + Metrics.OnDecompress(); + ValidateProviderResult( + Provider.Decompress( + new ReadOnlySequence(Compressed), + Output, + OriginalLength, + CancellationToken), + Compressed.Length, + OriginalLength); + Completion.TrySetResult(schedulerDelay); + } + catch (Exception exception) + { + Completion.TrySetException(exception); + } + } + } + + private sealed class PersistentDecodeExecutor : IAsyncDisposable + { + private readonly Channel _channel; + private readonly Task[] _workers; + private readonly DecodeMetrics _metrics; + + internal PersistentDecodeExecutor(int workers, int capacity, DecodeMetrics metrics) + { + _metrics = metrics; + _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = workers == 1, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + _workers = new Task[workers]; + for (var index = 0; index < workers; index++) + _workers[index] = Task.Run(WorkerAsync); + } + + internal async ValueTask EnqueueAsync( + ISharpLinkCompressionProvider provider, + ReadOnlyMemory compressed, + PooledOutput output, + int originalLength, + CancellationToken cancellationToken, + Action? onDecodeStart) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var queuedAt = Stopwatch.GetTimestamp(); + _metrics.OnDecodeQueued(); + try + { + await _channel.Writer.WriteAsync(new DecodeWorkItem( + provider, + compressed, + output, + originalLength, + cancellationToken, + onDecodeStart, + completion, + queuedAt, + _metrics), cancellationToken); + } + catch + { + _metrics.OnDecodeDequeued(); + throw; + } + return await completion.Task; + } + + public async ValueTask DisposeAsync() + { + _channel.Writer.TryComplete(); + await Task.WhenAll(_workers); + } + + private async Task WorkerAsync() + { + await foreach (var work in _channel.Reader.ReadAllAsync()) + work.Run(); + } + } + + private static async ValueTask DecompressCooperativelyAsync( + ReadOnlyMemory input, + PooledOutput output, + int maxOutputBytes, + int quantumBytes, + CancellationToken cancellationToken) + { + if (input.Length <= IntegrityTrailerBytes) + throw new InvalidDataException("Compressed payload integrity trailer is truncated."); + var trailer = input.Span[^IntegrityTrailerBytes..]; + if (BinaryPrimitives.ReadUInt32LittleEndian(trailer) != IntegrityMagic) + throw new InvalidDataException("Compressed payload integrity trailer is missing."); + var compressedPayload = input[..^IntegrityTrailerBytes]; + var expectedChecksum = BinaryPrimitives.ReadUInt32LittleEndian(trailer[sizeof(uint)..]); + if (Crc32Accumulator.Compute(new ReadOnlySequence(compressedPayload)) != expectedChecksum) + throw new InvalidDataException("Compressed payload integrity checksum does not match."); + + using var decoder = new BrotliDecoder(); + var consumed = 0; + var written = 0; + var quantumWritten = 0; + var schedulerDelay = 0d; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + OperationStatus status; + int consumedNow; + int writtenNow; + if (written < maxOutputBytes) + { + var capacity = Math.Min(8192, maxOutputBytes - written); + var destination = output.GetSpan(capacity)[..capacity]; + status = decoder.Decompress( + compressedPayload.Span[consumed..], + destination, + out consumedNow, + out writtenNow); + output.Advance(writtenNow); + written += writtenNow; + quantumWritten += writtenNow; + } + else + { + Span outputLimitProbe = stackalloc byte[1]; + status = decoder.Decompress( + compressedPayload.Span[consumed..], + outputLimitProbe, + out consumedNow, + out writtenNow); + if (writtenNow != 0) + throw new SharpLinkCompressionOutputLimitException(maxOutputBytes); + } + consumed += consumedNow; + + switch (status) + { + case OperationStatus.Done: + if (consumed != compressedPayload.Length) + throw new InvalidDataException("Compressed payload contains trailing data."); + if (written != maxOutputBytes) + throw new InvalidDataException("Phase 0 cooperative decode produced an unexpected output size."); + return schedulerDelay; + case OperationStatus.InvalidData: + throw new InvalidDataException("Brotli payload is invalid."); + case OperationStatus.NeedMoreData when consumed == compressedPayload.Length: + throw new InvalidDataException("Brotli payload is truncated."); + } + if (consumedNow == 0 && writtenNow == 0) + throw new InvalidDataException("Brotli decoder made no progress."); + + if (quantumWritten >= quantumBytes) + { + var yieldStarted = Stopwatch.GetTimestamp(); + await Task.Yield(); + schedulerDelay += ElapsedMicroseconds(yieldStarted); + quantumWritten = 0; + } + } + } + + private static void ValidateProviderResult( + SharpLinkCompressionResult result, + int compressedLength, + int originalLength) + { + if (result.ConsumedBytes != compressedLength || result.WrittenBytes != originalLength) + throw new InvalidOperationException("Phase 0 decode evidence returned inconsistent provider counts."); + } + + private readonly struct RetainedPayload : IDisposable + { + private readonly byte[]? _buffer; + private readonly int _length; + private readonly DecodeMetrics? _metrics; + + private RetainedPayload(byte[] buffer, int length, DecodeMetrics metrics) + { + _buffer = buffer; + _length = length; + _metrics = metrics; + } + + internal ReadOnlyMemory Memory + => _buffer is null ? default : _buffer.AsMemory(0, _length); + + internal static RetainedPayload Rent(byte[] source, DecodeMetrics metrics) + { + var buffer = ArrayPool.Shared.Rent(source.Length); + source.CopyTo(buffer, 0); + metrics.OnRetainedRent(buffer.Length); + return new RetainedPayload(buffer, source.Length, metrics); + } + + public void Dispose() + { + if (_buffer is null) + return; + _metrics!.OnRetainedReturn(_buffer.Length); + ArrayPool.Shared.Return(_buffer); + } + } + + private sealed class PooledOutput : IBufferWriter, IDisposable + { + private readonly byte[] _buffer; + private readonly int _limit; + private readonly DecodeMetrics _metrics; + private int _written; + + internal PooledOutput(int limit, DecodeMetrics metrics) + { + _buffer = ArrayPool.Shared.Rent(limit); + _limit = limit; + _metrics = metrics; + _metrics.OnDecodedRent(_buffer.Length); + } + + public void Advance(int count) + { + if (count < 0 || count > _limit - _written) + throw new ArgumentOutOfRangeException(nameof(count)); + _written += count; + } + + public Memory GetMemory(int sizeHint = 0) + => _buffer.AsMemory(_written, GetRemainingLength(sizeHint)); + + public Span GetSpan(int sizeHint = 0) + => _buffer.AsSpan(_written, GetRemainingLength(sizeHint)); + + public void Dispose() + { + _metrics.OnDecodedReturn(_buffer.Length); + ArrayPool.Shared.Return(_buffer); + } + + private int GetRemainingLength(int sizeHint) + { + ArgumentOutOfRangeException.ThrowIfNegative(sizeHint); + var remaining = _limit - _written; + if (sizeHint > remaining) + throw new SharpLinkCompressionOutputLimitException(_limit); + return remaining; + } + } + + private sealed class DecodeMetrics + { + private long _decompressCalls; + private long _decodedRentCount; + private long _decodedBytesRented; + private long _retainedRentCount; + private long _retainedBytes; + private long _peakRetainedBytes; + private long _decodedBytes; + private long _peakDecodedBytes; + private long _decodeQueueDepth; + private long _peakDecodeQueueDepth; + + internal void OnDecompress() => Interlocked.Increment(ref _decompressCalls); + + internal void OnDecodedRent(int bytes) + { + Interlocked.Increment(ref _decodedRentCount); + Interlocked.Add(ref _decodedBytesRented, bytes); + var current = Interlocked.Add(ref _decodedBytes, bytes); + UpdatePeak(ref _peakDecodedBytes, current); + } + + internal void OnDecodedReturn(int bytes) => Interlocked.Add(ref _decodedBytes, -bytes); + + internal void OnRetainedRent(int bytes) + { + Interlocked.Increment(ref _retainedRentCount); + var current = Interlocked.Add(ref _retainedBytes, bytes); + UpdatePeak(ref _peakRetainedBytes, current); + } + + internal void OnRetainedReturn(int bytes) => Interlocked.Add(ref _retainedBytes, -bytes); + + internal void OnDecodeQueued() + { + var current = Interlocked.Increment(ref _decodeQueueDepth); + UpdatePeak(ref _peakDecodeQueueDepth, current); + } + + internal void OnDecodeDequeued() => Interlocked.Decrement(ref _decodeQueueDepth); + + internal void Reset() + { + if (Volatile.Read(ref _retainedBytes) != 0 || + Volatile.Read(ref _decodedBytes) != 0 || + Volatile.Read(ref _decodeQueueDepth) != 0) + throw new InvalidOperationException("Cannot reset Phase 0 metrics while resources are in flight."); + Interlocked.Exchange(ref _decompressCalls, 0); + Interlocked.Exchange(ref _decodedRentCount, 0); + Interlocked.Exchange(ref _decodedBytesRented, 0); + Interlocked.Exchange(ref _retainedRentCount, 0); + Interlocked.Exchange(ref _peakRetainedBytes, 0); + Interlocked.Exchange(ref _peakDecodedBytes, 0); + Interlocked.Exchange(ref _peakDecodeQueueDepth, 0); + } + + internal DecodeMetricsSnapshot Capture() + => new( + Volatile.Read(ref _decompressCalls), + Volatile.Read(ref _decodedRentCount), + Volatile.Read(ref _decodedBytesRented), + Volatile.Read(ref _retainedRentCount), + Volatile.Read(ref _peakRetainedBytes), + Volatile.Read(ref _peakDecodedBytes), + Volatile.Read(ref _peakDecodeQueueDepth)); + + private static void UpdatePeak(ref long target, long value) + { + while (true) + { + var observed = Volatile.Read(ref target); + if (observed >= value) + return; + if (Interlocked.CompareExchange(ref target, value, observed) == observed) + return; + } + } + } + + private readonly record struct DecodeMetricsSnapshot( + long DecompressCalls, + long DecodedRentCount, + long DecodedBytesRented, + long RetainedRentCount, + long PeakRetainedBytes, + long PeakDecodedBytes, + long PeakDecodeQueueDepth); + + private readonly record struct DecodeRequestResult( + bool Accepted, + double SchedulerDelayMicroseconds); + + private readonly record struct CancelProbeResult( + bool Observed, + double? ObservationMicroseconds); + + private readonly record struct CaseKey( + string AdmissionMode, + string CapacityMode, + bool RemoteCancellable, + int Concurrency, + int Repetition, + int PayloadSize, + bool Compressible) + { + internal CaseKey(DecodeExecutionEvidenceResult result) + : this( + result.AdmissionMode, + result.CapacityMode, + result.RemoteCancellable, + result.Concurrency, + result.Repetition, + result.PayloadSize, + result.Compressible) + { + } + } +} + +internal sealed record DecodeExecutionEvidenceDocument( + DateTimeOffset CapturedAtUtc, + string Runtime, + string OperatingSystem, + int ProcessorCount, + int CooperativeQuantumBytes, + IReadOnlyList Results, + IReadOnlyList Lifecycle, + IReadOnlyList Summary); + +internal sealed record DecodeExecutionEvidenceResult( + string Strategy, + string AdmissionMode, + string CapacityMode, + bool RemoteCancellable, + int Concurrency, + int Repetition, + int PayloadSize, + bool Compressible, + int CompressedBytes, + double CompressionRatio, + int Operations, + int Accepted, + int Rejected, + double ElapsedSeconds, + double Qps, + double CpuNanosecondsPerOperation, + double P50Microseconds, + double P99Microseconds, + double AllocatedBytesPerOperation, + double? DecompressCallsPerRejectedRequest, + double? DecodedBytesRentedPerRejectedRequest, + long PeakRetainedCompressedBytes, + long PeakDecodedBytes, + long PeakDecodeQueueDepth, + double SchedulerDelayP50Microseconds, + double SchedulerDelayP99Microseconds, + bool? CancelObserved, + double? CancelObservationMicroseconds); + +internal sealed record DecodeLifecycleEvidenceResult( + string Strategy, + int PayloadSize, + bool Compressible, + int Concurrency, + double StopDrainMicroseconds); + +internal sealed record DecodeExecutionSummary( + string Strategy, + double MedianQpsRatioToInline, + double MedianCpuRatioToInline, + double MedianP99RatioToInline, + double MedianAllocatedBytesPerOperation, + double MedianSchedulerP99Microseconds, + int CancelObservedProbes, + int CancelProbeCount, + double? MedianCancelObservationMicroseconds, + double MedianStopDrainMicroseconds, + int RejectedInvariantFailures); From f3b795215f1bae2fe29455d90f8d6b3a86bbf007 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:47:09 +0800 Subject: [PATCH 009/228] perf(server): expose phase0 decode evidence command --- test/SharpLink.Benchmarks/Program.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/SharpLink.Benchmarks/Program.cs b/test/SharpLink.Benchmarks/Program.cs index 8cc058115..4cc7eb60c 100644 --- a/test/SharpLink.Benchmarks/Program.cs +++ b/test/SharpLink.Benchmarks/Program.cs @@ -50,6 +50,12 @@ public static async Task Main(string[] args) await CompressionEvidenceRunner.RunAsync(args[1..]); return; } + if (args.Length > 0 && string.Equals( + args[0], "--phase0-decode-evidence", StringComparison.Ordinal)) + { + await DecodeExecutionPhase0EvidenceRunner.RunAsync(args[1..]); + return; + } if (args.Length > 0 && string.Equals( args[0], "--buffer-writer-growth-evidence", StringComparison.Ordinal)) { From f4faaaf2b08c0d9f8b8d2e25e79ce93edeb36a49 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:47:22 +0800 Subject: [PATCH 010/228] ci(perf): run phase0 decode strategy matrix --- .../workflows/phase0-decode-performance.yml | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/phase0-decode-performance.yml diff --git a/.github/workflows/phase0-decode-performance.yml b/.github/workflows/phase0-decode-performance.yml new file mode 100644 index 000000000..db665c258 --- /dev/null +++ b/.github/workflows/phase0-decode-performance.yml @@ -0,0 +1,68 @@ +name: Phase 0 Decode Strategy Evidence + +on: + pull_request: + branches: + - issue-273-call-reservation-phase0 + paths: + - 'test/SharpLink.Benchmarks/**' + - '.github/workflows/phase0-decode-performance.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + evidence: + name: phase0-${{ matrix.payload }}-${{ matrix.compressibility }} + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + payload: [1024, 65536, 1048576] + compressibility: [high, low] + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + TESTINGPLATFORM_TELEMETRY_OPTOUT: '1' + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Record environment + shell: bash + run: | + output="artifacts/performance/phase0/${{ matrix.payload }}-${{ matrix.compressibility }}" + mkdir -p "$output" + dotnet --info > "$output/dotnet-info.txt" + uname -a > "$output/uname.txt" + lscpu > "$output/cpu.txt" + + - name: Build benchmark evidence runner + run: dotnet build test/SharpLink.Benchmarks/SharpLink.Benchmarks.csproj -c Release -v minimal + + - name: Run Phase 0 decode execution matrix + shell: bash + run: | + output="artifacts/performance/phase0/${{ matrix.payload }}-${{ matrix.compressibility }}" + dotnet run -c Release --no-build --project test/SharpLink.Benchmarks/SharpLink.Benchmarks.csproj -- \ + --phase0-decode-evidence \ + --payload-size ${{ matrix.payload }} \ + --compressibility ${{ matrix.compressibility }} \ + --repetitions 3 \ + --quantum-bytes 65536 \ + --output "$output/evidence.json" + + - name: Upload raw evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: phase0-decode-${{ matrix.payload }}-${{ matrix.compressibility }}-${{ github.sha }} + path: artifacts/performance/phase0/${{ matrix.payload }}-${{ matrix.compressibility }} + if-no-files-found: error + retention-days: 30 From 05ff549cf29bd10886166d1511fa979c7e4625c3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:47:39 +0800 Subject: [PATCH 011/228] docs(perf): document phase0 decode matrix methodology --- docs/phase0-decode-performance.md | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/phase0-decode-performance.md diff --git a/docs/phase0-decode-performance.md b/docs/phase0-decode-performance.md new file mode 100644 index 000000000..76682e121 --- /dev/null +++ b/docs/phase0-decode-performance.md @@ -0,0 +1,50 @@ +# #273 Phase 0 decode execution evidence + +This slice is benchmark-only and is stacked on the reviewed call-reservation primitive from #276. It does not wire a decode strategy into the production request loop. + +## Candidate execution models + +- **A — ThreadPoolHandoff**: one per-request ThreadPool handoff before synchronous provider decode. This is the #261-style scheduling baseline. +- **B — InlineProvider**: reserve, call the existing synchronous compression provider inline, then activate. The built-in Brotli provider already decodes in bounded 8 KiB output chunks and checks cancellation in its loop. +- **C — CooperativeQuantum**: benchmark-only Brotli decoder that preserves SharpLink integrity-trailer/CRC validation, decodes in the same 8 KiB chunks, and reschedules after a bounded 64 KiB output quantum. +- **D — PersistentExecutor**: bounded channel plus a fixed persistent worker set (1..4 workers based on runner CPU count), with bounded queue depth and explicit ownership transfer. + +The C implementation is intentionally local to the benchmark project. It is not a proposed public provider API or production implementation. + +## Matrix + +Each payload/compressibility shard runs all four strategies across: + +- payload: 1 KiB / 64 KiB / 1 MiB; +- compression ratio proxy: high-compressibility / low-compressibility deterministic payloads; +- remote-cancellable token: off / on; +- call capacity: available / full; +- admission shape: off / immediate cheap policy / queued continuation; +- concurrency: 1 / 16 / 128; +- repetitions: 3, with alternating strategy order to reduce systematic drift. + +The queued-admission shape is deliberately one scheduler continuation, not a production `AdmissionProgram` implementation. It isolates how an already-asynchronous admission continuation interacts with the decode execution model without prematurely coupling the benchmark to #264 production wiring. + +## Evidence collected + +Per matrix case: + +- QPS; +- process CPU ns/op; +- request P50/P99; +- process allocated bytes/op; +- decompression calls per rejected request; +- decoded bytes rented per rejected request; +- peak retained compressed bytes in flight; +- peak decoded bytes in flight; +- peak explicit decode queue depth; +- scheduler/worker delay P50/P99; +- remote cancellation observation probe when applicable. + +A separate burst probe records Stop/Drain latency for each strategy. + +Capacity-full cases are executable correctness assertions: any decompression call, decoded-buffer rent, or compressed-payload retention fails the evidence run. This preserves the #244 requirement while comparing execution models. + +## Interpretation boundary + +This runner is intended to select the Phase 0 execution model before production plumbing. It does not establish the final `RequestPermit`, Stop/Drain integration, decode byte budgets, dynamic policy generation, or RequestLoop ownership model. Those remain production follow-up after the execution strategy is selected. From 8a7a1a7833e1adda4f335a2e77d6f0d2364f7420 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:50:05 +0800 Subject: [PATCH 012/228] fix(perf): import threading primitives for decode evidence --- test/SharpLink.Benchmarks/GlobalUsings.cs | 1 + 1 file changed, 1 insertion(+) create mode 100644 test/SharpLink.Benchmarks/GlobalUsings.cs diff --git a/test/SharpLink.Benchmarks/GlobalUsings.cs b/test/SharpLink.Benchmarks/GlobalUsings.cs new file mode 100644 index 000000000..4ffeb9530 --- /dev/null +++ b/test/SharpLink.Benchmarks/GlobalUsings.cs @@ -0,0 +1 @@ +global using System.Threading; From 10b33c25914f3d6762904b6ead7202cb72a1d781 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:51:25 +0800 Subject: [PATCH 013/228] fix(perf): scope stackalloc analyzer exception to phase0 runner --- test/SharpLink.Benchmarks/.editorconfig | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 test/SharpLink.Benchmarks/.editorconfig diff --git a/test/SharpLink.Benchmarks/.editorconfig b/test/SharpLink.Benchmarks/.editorconfig new file mode 100644 index 000000000..434a0e3ab --- /dev/null +++ b/test/SharpLink.Benchmarks/.editorconfig @@ -0,0 +1,5 @@ +[DecodeExecutionPhase0EvidenceRunner.cs] +# The one-byte output-limit probe mirrors the production Brotli decoder guard and +# can execute only after the bounded output is already full. Keep the exception +# local to this benchmark-only prototype rather than weakening repository rules. +dotnet_diagnostic.CA2014.severity = none From 0744415a4ab2abf04a8b1ea04d34f3a64df583c6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:55:14 +0800 Subject: [PATCH 014/228] chore(perf): document relative hosted-runner evidence --- test/SharpLink.Benchmarks/.editorconfig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/SharpLink.Benchmarks/.editorconfig b/test/SharpLink.Benchmarks/.editorconfig index 434a0e3ab..803f2c87e 100644 --- a/test/SharpLink.Benchmarks/.editorconfig +++ b/test/SharpLink.Benchmarks/.editorconfig @@ -2,4 +2,6 @@ # The one-byte output-limit probe mirrors the production Brotli decoder guard and # can execute only after the bounded output is already full. Keep the exception # local to this benchmark-only prototype rather than weakening repository rules. +# Hosted-runner performance conclusions use within-shard relative ratios; independent +# workflow repetitions are used to check that the selected shape survives VM noise. dotnet_diagnostic.CA2014.severity = none From 4032216e86204ee2dc388d7ccad7c33a6207cc27 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:01:44 +0800 Subject: [PATCH 015/228] docs(perf): select adaptive decode execution model --- docs/phase0-decode-performance.md | 96 +++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/docs/phase0-decode-performance.md b/docs/phase0-decode-performance.md index 76682e121..ac96e46c1 100644 --- a/docs/phase0-decode-performance.md +++ b/docs/phase0-decode-performance.md @@ -7,7 +7,7 @@ This slice is benchmark-only and is stacked on the reviewed call-reservation pri - **A — ThreadPoolHandoff**: one per-request ThreadPool handoff before synchronous provider decode. This is the #261-style scheduling baseline. - **B — InlineProvider**: reserve, call the existing synchronous compression provider inline, then activate. The built-in Brotli provider already decodes in bounded 8 KiB output chunks and checks cancellation in its loop. - **C — CooperativeQuantum**: benchmark-only Brotli decoder that preserves SharpLink integrity-trailer/CRC validation, decodes in the same 8 KiB chunks, and reschedules after a bounded 64 KiB output quantum. -- **D — PersistentExecutor**: bounded channel plus a fixed persistent worker set (1..4 workers based on runner CPU count), with bounded queue depth and explicit ownership transfer. +- **D — PersistentExecutor**: bounded channel plus a fixed persistent worker set (1..4 workers based on runner CPU count), with explicit ownership transfer. The C implementation is intentionally local to the benchmark project. It is not a proposed public provider API or production implementation. @@ -25,6 +25,13 @@ Each payload/compressibility shard runs all four strategies across: The queued-admission shape is deliberately one scheduler continuation, not a production `AdmissionProgram` implementation. It isolates how an already-asynchronous admission continuation interacts with the decode execution model without prematurely coupling the benchmark to #264 production wiring. +Two independent hosted-runner workflow executions were used for the decision. Relative ratios are calculated only against B inside the same payload/compressibility shard; absolute QPS is not compared across hosted VMs. + +- workflow run `32568724302`, benchmark head `10b33c25914f3d6762904b6ead7202cb72a1d781`; +- workflow run `32568891389`, benchmark-equivalent head `0744415a4ab2abf04a8b1ea04d34f3a64df583c6`. + +Both runs used 4 logical CPUs, but GitHub assigned different AMD EPYC models across shards/runs. The strategy ordering below remained stable despite that reassignment. + ## Evidence collected Per matrix case: @@ -41,10 +48,91 @@ Per matrix case: - scheduler/worker delay P50/P99; - remote cancellation observation probe when applicable. -A separate burst probe records Stop/Drain latency for each strategy. +A separate burst probe records synthetic drain-completion latency for each strategy. It is useful for relative executor supervision cost but is not a substitute for the production Stop/Drain integration suite. + +Capacity-full cases are executable correctness assertions: any decompression call, decoded-buffer rent, or compressed-payload retention fails the evidence run. Across both independent runs, all 2,592 capacity-full matrix rows passed, covering 4,294,656 rejected requests with: + +- accepted requests: `0`; +- decompression calls / rejected request: `0`; +- decoded bytes rented / rejected request: `0`; +- peak retained compressed bytes: `0`; +- peak decoded bytes: `0`. + +This preserves the #244 requirement while comparing execution models. + +## Results + +B (`InlineProvider`) is the within-shard baseline (`1.000`). The ranges below are the two independent workflow medians. + +| Payload / compressibility | A QPS / CPU | C QPS / CPU | D QPS / CPU | Interpretation | +| --- | --- | --- | --- | --- | +| 1 KiB / high | `0.772–0.773` / `1.300–1.407` | `0.993–1.009` / `0.997–1.003` | `0.645–0.690` / `1.547–1.556` | scheduling dominates; B/C are effectively equivalent | +| 1 KiB / low | `0.666–0.789` / `1.257–1.560` | `0.981–1.003` / `0.991–1.016` | `0.589–0.700` / `1.418–1.759` | per-request handoff/executor is too expensive | +| 64 KiB / high | `0.974–0.979` / `1.022–1.023` | `1.000–1.000` / `0.999–0.999` | `0.976–0.978` / `1.024–1.024` | 64 KiB quantum does not materially yield; B/C remain best | +| 64 KiB / low | `0.952–0.964` / `1.039–1.050` | `0.984–0.986` / `1.016–1.019` | `0.945–0.954` / `1.049–1.062` | B remains the cheapest execution shape | +| 1 MiB / high | `0.972–0.976` / `1.034–1.036` | `0.914–0.938` / `1.120–1.149` | `0.971–0.974` / `1.035–1.043` | D reaches A-like throughput/CPU without per-request ThreadPool ownership | +| 1 MiB / low | `0.953–0.963` / `1.044–1.051` | `0.939–0.948` / `1.068–1.071` | `0.967–0.974` / `1.044–1.044` | D is the best bounded-offload candidate; C pays repeated-yield cost | + +P99 follows the same small-payload conclusion: A/D add substantial scheduler tails at 1 KiB, while C is essentially B until the quantum is crossed. At 1 MiB and high offered concurrency, A/D queueing can create large request-latency tails. That is not an argument for an unbounded inline reader loop; it is evidence that production D must combine bounded worker concurrency with explicit queue/retained/decoded resource budgets and admission/backpressure. + +The cancellation probe directly cancels the decode token after decode begins. It verifies provider/executor token observation, but it does **not** model the key network property that an inline RequestLoop cannot consume a later remote Cancel frame while it is synchronously decoding. Therefore B's best CPU/QPS result cannot by itself justify using B for arbitrarily expensive remote-cancellable decode. + +For 1 MiB probes, cancellation was observed in essentially every case in both runs, and median observation time was similar between B/A/C/D for the same compressibility. This means D does not introduce a material cancellation-token reaction penalty once work has begun; its main cost is queue/scheduler latency. + +### Resource-budget observation + +The benchmark intentionally records resource amplification before the production ResourceGovernor byte budgets exist. At concurrency 128 with low-compressibility 1 MiB payloads, deferred strategies can accumulate large retained/decoded in-flight totals. This is a useful negative result: the production executor must **not** simply copy the benchmark queue/rent sequence. + +Production D must acquire or account for, in the RequestPermit/ResourceGovernor ownership model: + +1. call reservation; +2. bounded decode queue/concurrency credit; +3. retained compressed-byte budget before long-lived retention; +4. decoded-byte budget before the large decoded rent; +5. exactly-once transfer/release across queue, worker, activation, failure, cancellation, and Stop/Drain. + +The executor queue must be fixed/bounded independently of offered request concurrency, and production scheduling must add the per-connection fairness / anti-monopoly behavior required by #273. + +## ADR — selected Phase 0 execution model + +**Decision: select an adaptive B + D production model.** + +1. **Use B / inline provider decode for the cheap path.** + - Non-remote-cancellable accepted requests should decode inline after all required permits are held. + - Remote-cancellable requests whose validated estimated decode cost is within the inline budget should also decode inline. + - The Phase 0 evidence supports **64 KiB of declared/original output as the initial inline-budget candidate**, because B/C remain effectively equivalent through that point while A/D pay unnecessary scheduling cost. + +2. **Use D / persistent bounded DecodeExecutor for expensive remote-cancellable decode.** + - Above the inline budget, keep the reader/control-plane path free to process Cancel/deadline/close/Stop while decode is supervised by a small persistent worker set. + - The 1 MiB evidence shows D at roughly `0.967–0.974x` QPS and `1.035–1.044x` CPU versus inline in the tested shards, materially better than C's repeated-yield cost while avoiding A's per-request ThreadPool scheduling model. + - The exact production threshold should remain an internal policy input and can be tuned with later end-to-end evidence; Phase 0 selects the execution **shape**, not a new public configuration API. + +3. **Do not productionize A.** + - A remains the #261 comparison baseline. + - At large payloads it can approach D's throughput, but it provides no durable bounded/fair executor ownership model and is especially expensive for small payloads. + +4. **Do not productionize C as a separate execution model.** + - Up to 64 KiB, C mostly behaves like B because the quantum is not crossed. + - At 1 MiB, repeated cooperative yields consistently cost more CPU/QPS than D. + - Keeping a second provider-specific decode state machine would add ownership and maintenance complexity without winning the measured large-payload tradeoff. + +## Production follow-up implied by this ADR + +The next production slice should implement only the selected adaptive model, not all Phase 0 prototypes: + +`Request/frame -> cheap validation -> optional AdmissionProgram -> ResourceGovernor/RequestPermit -> CallReservation -> (inline B | bounded D) -> ActivateCall -> invoke -> exactly-once release` + +Required gates before calling that slice complete: -Capacity-full cases are executable correctness assertions: any decompression call, decoded-buffer rent, or compressed-payload retention fails the evidence run. This preserves the #244 requirement while comparing execution models. +- compression safety is always-on and independent of `_admissionController != null`; +- capacity/policy rejected compressed requests keep `Decompress=0` and decoded rent `=0`; +- D is supervised, bounded, fair across connections, and has no detached per-request workers; +- retained/decoded byte budgets are enforced before retention/rent; +- remote Cancel/deadline/close/Stop are exercised during executor decode; +- generation capture for #262/#264 remains stable across awaits and does not reset ResourceGovernor state; +- uncompressed/default fast path is re-measured after production wiring; +- final end-to-end performance gate re-runs the relevant payload/concurrency matrix against the selected production implementation. ## Interpretation boundary -This runner is intended to select the Phase 0 execution model before production plumbing. It does not establish the final `RequestPermit`, Stop/Drain integration, decode byte budgets, dynamic policy generation, or RequestLoop ownership model. Those remain production follow-up after the execution strategy is selected. +This evidence selects the execution model before production plumbing. It does not establish the final `RequestPermit`, Stop/Drain implementation, decode byte-budget values, dynamic policy generation, fairness algorithm, or public configuration surface. Those remain production work under #273, with the selected adaptive B + D model as the constraint. From 42664bb724c7e74cf091cf13c1420b57dcb56feb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:06:19 +0800 Subject: [PATCH 016/228] perf(server): add bounded decode executor saturation evidence --- ...ecodeExecutorBackpressureEvidenceRunner.cs | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs diff --git a/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs new file mode 100644 index 000000000..f76e6f0a1 --- /dev/null +++ b/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs @@ -0,0 +1,394 @@ +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json; +using System.Threading.Channels; +using SharpLink.Abstractions; + +namespace SharpLink.Benchmarks; + +/// +/// Explicit saturation probe for the Phase 0 persistent decode executor candidate. +/// Unlike the comparative A/B/C/D matrix, this probe fixes queue capacity independently +/// of offered concurrency and deliberately holds workers until bounded-channel backpressure +/// is observed. +/// +internal static class DecodeExecutorBackpressureEvidenceRunner +{ + private const int DefaultQueueCapacity = 8; + private const int DefaultConcurrency = 128; + private const int DefaultOperations = 256; + + internal static async Task RunAsync(string[] args) + { + var outputPath = GetOption(args, "--output") ?? + Path.Combine("artifacts", "performance", "current", "phase0-decode-backpressure.json"); + var payloadSize = GetPayloadSize(args); + var compressible = GetCompressibility(args); + var queueCapacity = GetPositiveInt(args, "--queue-capacity", DefaultQueueCapacity); + var concurrency = GetPositiveInt(args, "--concurrency", DefaultConcurrency); + var operations = GetPositiveInt(args, "--operations", DefaultOperations); + var workerCount = Math.Clamp(Environment.ProcessorCount, 1, 4); + if (concurrency <= queueCapacity) + { + throw new ArgumentOutOfRangeException( + nameof(args), + "Backpressure evidence requires concurrency greater than queue capacity."); + } + + var provider = CompressionProviderBenchmarks.CreateProvider("fastest"); + var compressed = CreateCompressedFixture(provider, payloadSize, compressible); + using var metrics = new BackpressureMetrics(); + await using var executor = new SaturatedDecodeExecutor( + workerCount, + queueCapacity, + metrics); + + var producerGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var next = -1; + var producers = new Task[concurrency]; + var started = Stopwatch.GetTimestamp(); + for (var producer = 0; producer < producers.Length; producer++) + { + producers[producer] = Task.Run(async () => + { + await producerGate.Task; + while (true) + { + var index = Interlocked.Increment(ref next); + if (index >= operations) + return; + await executor.EnqueueAsync( + provider, + compressed, + payloadSize, + CancellationToken.None); + } + }); + } + + producerGate.TrySetResult(); + if (!metrics.BackpressureObserved.Wait(TimeSpan.FromSeconds(5))) + { + throw new InvalidOperationException( + "Fixed-capacity Phase 0 executor did not exercise bounded-channel backpressure."); + } + + executor.ReleaseWorkers(); + await Task.WhenAll(producers); + await executor.StopAsync(); + var elapsed = Stopwatch.GetElapsedTime(started); + var snapshot = metrics.Capture(); + if (snapshot.BackpressureWaitCount == 0 || snapshot.PeakPendingWriters == 0) + throw new InvalidOperationException("Backpressure metrics did not record a blocked writer."); + if (snapshot.CompletedWorkItems != operations) + throw new InvalidOperationException("Backpressure probe did not complete every submitted decode."); + + var result = new DecodeExecutorBackpressureEvidenceResult( + DateTimeOffset.UtcNow, + payloadSize, + compressible, + compressed.Length, + workerCount, + queueCapacity, + concurrency, + operations, + elapsed.TotalSeconds, + operations / elapsed.TotalSeconds, + snapshot.BackpressureWaitCount, + snapshot.PeakPendingWriters, + snapshot.MedianWaitMicroseconds, + snapshot.P99WaitMicroseconds, + snapshot.CompletedWorkItems); + + var fullPath = Path.GetFullPath(outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await File.WriteAllTextAsync(fullPath, JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = true + })); + + Console.WriteLine($"Phase 0 decode executor backpressure evidence: {fullPath}"); + Console.WriteLine( + $"PHASE0_BACKPRESSURE payload={payloadSize} compressible={compressible} workers={workerCount} " + + $"queueCapacity={queueCapacity} concurrency={concurrency} operations={operations} " + + $"waitCount={snapshot.BackpressureWaitCount} peakPendingWriters={snapshot.PeakPendingWriters} " + + $"waitP50Us={snapshot.MedianWaitMicroseconds:F2} waitP99Us={snapshot.P99WaitMicroseconds:F2}"); + } + + private static byte[] CreateCompressedFixture( + ISharpLinkCompressionProvider provider, + int payloadSize, + bool compressible) + { + var payload = new byte[payloadSize]; + if (compressible) + Array.Fill(payload, (byte)0x2a); + else + new Random(42).NextBytes(payload); + var output = new ArrayBufferWriter(payloadSize * 2 + 1024); + var result = provider.Compress( + new ReadOnlySequence(payload), + output, + payloadSize * 2 + 1024); + if (result.ConsumedBytes != payloadSize || result.WrittenBytes != output.WrittenCount) + throw new InvalidOperationException("Backpressure fixture compression returned inconsistent counts."); + return output.WrittenSpan.ToArray(); + } + + private static int GetPayloadSize(string[] args) + { + var option = GetOption(args, "--payload-size"); + if (!int.TryParse(option, out var payloadSize) || + payloadSize is not (1024 or 65_536 or 1_048_576)) + { + throw new ArgumentOutOfRangeException( + nameof(args), + "Payload size must be 1024, 65536, or 1048576."); + } + return payloadSize; + } + + private static bool GetCompressibility(string[] args) + => GetOption(args, "--compressibility")?.ToLowerInvariant() switch + { + "high" => true, + "low" => false, + _ => throw new ArgumentOutOfRangeException( + nameof(args), + "Compressibility must be high or low.") + }; + + private static int GetPositiveInt(string[] args, string name, int defaultValue) + { + var option = GetOption(args, name); + if (option is null) + return defaultValue; + if (!int.TryParse(option, out var value) || value <= 0) + throw new ArgumentOutOfRangeException(name, "Expected a positive integer."); + return value; + } + + private static string? GetOption(string[] args, string name) + { + for (var index = 0; index < args.Length - 1; index++) + { + if (string.Equals(args[index], name, StringComparison.Ordinal)) + return args[index + 1]; + } + return null; + } + + private sealed class SaturatedDecodeExecutor : IAsyncDisposable + { + private readonly Channel _channel; + private readonly Task[] _workers; + private readonly TaskCompletionSource _workerGate = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly BackpressureMetrics _metrics; + private bool _stopped; + + internal SaturatedDecodeExecutor( + int workerCount, + int queueCapacity, + BackpressureMetrics metrics) + { + _metrics = metrics; + _channel = Channel.CreateBounded(new BoundedChannelOptions(queueCapacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = workerCount == 1, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + _workers = new Task[workerCount]; + for (var index = 0; index < _workers.Length; index++) + _workers[index] = Task.Run(WorkerAsync); + } + + internal async ValueTask EnqueueAsync( + ISharpLinkCompressionProvider provider, + ReadOnlyMemory compressed, + int originalLength, + CancellationToken cancellationToken) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var work = new DecodeWorkItem( + provider, + compressed, + originalLength, + cancellationToken, + completion, + _metrics); + var writeStarted = Stopwatch.GetTimestamp(); + var write = _channel.Writer.WriteAsync(work, cancellationToken); + if (!write.IsCompletedSuccessfully) + { + _metrics.OnBackpressureWaitStarted(); + try + { + await write; + } + finally + { + _metrics.OnBackpressureWaitCompleted( + Stopwatch.GetElapsedTime(writeStarted).TotalNanoseconds / 1000d); + } + } + else + { + await write; + } + await completion.Task; + } + + internal void ReleaseWorkers() => _workerGate.TrySetResult(); + + internal async ValueTask StopAsync() + { + if (_stopped) + return; + _stopped = true; + ReleaseWorkers(); + _channel.Writer.TryComplete(); + await Task.WhenAll(_workers); + } + + public async ValueTask DisposeAsync() => await StopAsync(); + + private async Task WorkerAsync() + { + await _workerGate.Task; + await foreach (var work in _channel.Reader.ReadAllAsync()) + work.Run(); + } + } + + private readonly record struct DecodeWorkItem( + ISharpLinkCompressionProvider Provider, + ReadOnlyMemory Compressed, + int OriginalLength, + CancellationToken CancellationToken, + TaskCompletionSource Completion, + BackpressureMetrics Metrics) + { + internal void Run() + { + try + { + CancellationToken.ThrowIfCancellationRequested(); + var output = new ArrayBufferWriter(OriginalLength); + var result = Provider.Decompress( + new ReadOnlySequence(Compressed), + output, + OriginalLength, + CancellationToken); + if (result.ConsumedBytes != Compressed.Length || + result.WrittenBytes != OriginalLength || + output.WrittenCount != OriginalLength) + { + throw new InvalidOperationException( + "Backpressure decode returned inconsistent provider counts."); + } + Metrics.OnWorkCompleted(); + Completion.TrySetResult(); + } + catch (Exception exception) + { + Completion.TrySetException(exception); + } + } + } + + private sealed class BackpressureMetrics : IDisposable + { + private readonly object _gate = new(); + private readonly List _waitMicroseconds = []; + private long _backpressureWaitCount; + private long _pendingWriters; + private long _peakPendingWriters; + private long _completedWorkItems; + + internal ManualResetEventSlim BackpressureObserved { get; } = new(false); + + internal void OnBackpressureWaitStarted() + { + Interlocked.Increment(ref _backpressureWaitCount); + var pending = Interlocked.Increment(ref _pendingWriters); + UpdatePeak(ref _peakPendingWriters, pending); + BackpressureObserved.Set(); + } + + internal void OnBackpressureWaitCompleted(double microseconds) + { + Interlocked.Decrement(ref _pendingWriters); + lock (_gate) + _waitMicroseconds.Add(microseconds); + } + + internal void OnWorkCompleted() => Interlocked.Increment(ref _completedWorkItems); + + internal BackpressureMetricsSnapshot Capture() + { + double[] waits; + lock (_gate) + waits = [.. _waitMicroseconds]; + Array.Sort(waits); + return new BackpressureMetricsSnapshot( + Volatile.Read(ref _backpressureWaitCount), + Volatile.Read(ref _peakPendingWriters), + Percentile(waits, 0.50), + Percentile(waits, 0.99), + Volatile.Read(ref _completedWorkItems)); + } + + public void Dispose() => BackpressureObserved.Dispose(); + + private static double Percentile(double[] values, double percentile) + { + if (values.Length == 0) + return 0; + var index = Math.Clamp( + (int)Math.Ceiling(percentile * values.Length) - 1, + 0, + values.Length - 1); + return values[index]; + } + + private static void UpdatePeak(ref long target, long value) + { + while (true) + { + var observed = Volatile.Read(ref target); + if (observed >= value) + return; + if (Interlocked.CompareExchange(ref target, value, observed) == observed) + return; + } + } + } + + private readonly record struct BackpressureMetricsSnapshot( + long BackpressureWaitCount, + long PeakPendingWriters, + double MedianWaitMicroseconds, + double P99WaitMicroseconds, + long CompletedWorkItems); +} + +internal sealed record DecodeExecutorBackpressureEvidenceResult( + DateTimeOffset CapturedAtUtc, + int PayloadSize, + bool Compressible, + int CompressedBytes, + int WorkerCount, + int QueueCapacity, + int Concurrency, + int Operations, + double ElapsedSeconds, + double Qps, + long BackpressureWaitCount, + long PeakPendingWriters, + double BackpressureWaitP50Microseconds, + double BackpressureWaitP99Microseconds, + long CompletedWorkItems); From b550c72c6c3e5a38813c08e54ff216e86514b068 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:06:37 +0800 Subject: [PATCH 017/228] perf(server): wire executor backpressure evidence runner --- test/SharpLink.Benchmarks/Program.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/SharpLink.Benchmarks/Program.cs b/test/SharpLink.Benchmarks/Program.cs index 4cc7eb60c..f4d1f56f8 100644 --- a/test/SharpLink.Benchmarks/Program.cs +++ b/test/SharpLink.Benchmarks/Program.cs @@ -56,6 +56,12 @@ public static async Task Main(string[] args) await DecodeExecutionPhase0EvidenceRunner.RunAsync(args[1..]); return; } + if (args.Length > 0 && string.Equals( + args[0], "--phase0-decode-backpressure-evidence", StringComparison.Ordinal)) + { + await DecodeExecutorBackpressureEvidenceRunner.RunAsync(args[1..]); + return; + } if (args.Length > 0 && string.Equals( args[0], "--buffer-writer-growth-evidence", StringComparison.Ordinal)) { From 9f288f882c61ebab951cbe3985ca88c475091a06 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:06:47 +0800 Subject: [PATCH 018/228] perf(server): exercise bounded executor backpressure --- .github/workflows/phase0-decode-performance.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/phase0-decode-performance.yml b/.github/workflows/phase0-decode-performance.yml index db665c258..43674d4cb 100644 --- a/.github/workflows/phase0-decode-performance.yml +++ b/.github/workflows/phase0-decode-performance.yml @@ -58,6 +58,19 @@ jobs: --quantum-bytes 65536 \ --output "$output/evidence.json" + - name: Run fixed-capacity executor backpressure probe + shell: bash + run: | + output="artifacts/performance/phase0/${{ matrix.payload }}-${{ matrix.compressibility }}" + dotnet run -c Release --no-build --project test/SharpLink.Benchmarks/SharpLink.Benchmarks.csproj -- \ + --phase0-decode-backpressure-evidence \ + --payload-size ${{ matrix.payload }} \ + --compressibility ${{ matrix.compressibility }} \ + --queue-capacity 8 \ + --concurrency 128 \ + --operations 256 \ + --output "$output/backpressure.json" + - name: Upload raw evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From 8db2ef646d2f42dc1d7aa178bd6bf39d4eac3bd9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:07:54 +0800 Subject: [PATCH 019/228] docs(server): narrow Phase 0 decode conclusions after review --- docs/phase0-decode-performance.md | 43 ++++++++++++++++++------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/phase0-decode-performance.md b/docs/phase0-decode-performance.md index ac96e46c1..297e7b3b2 100644 --- a/docs/phase0-decode-performance.md +++ b/docs/phase0-decode-performance.md @@ -7,7 +7,7 @@ This slice is benchmark-only and is stacked on the reviewed call-reservation pri - **A — ThreadPoolHandoff**: one per-request ThreadPool handoff before synchronous provider decode. This is the #261-style scheduling baseline. - **B — InlineProvider**: reserve, call the existing synchronous compression provider inline, then activate. The built-in Brotli provider already decodes in bounded 8 KiB output chunks and checks cancellation in its loop. - **C — CooperativeQuantum**: benchmark-only Brotli decoder that preserves SharpLink integrity-trailer/CRC validation, decodes in the same 8 KiB chunks, and reschedules after a bounded 64 KiB output quantum. -- **D — PersistentExecutor**: bounded channel plus a fixed persistent worker set (1..4 workers based on runner CPU count), with explicit ownership transfer. +- **D — PersistentExecutor**: persistent fixed workers with explicit queued-work ownership. The comparative A/B/C/D matrix measures this executor with an unsaturated queue; a separate fixed-capacity saturation probe exercises bounded-channel backpressure explicitly. The C implementation is intentionally local to the benchmark project. It is not a proposed public provider API or production implementation. @@ -25,7 +25,7 @@ Each payload/compressibility shard runs all four strategies across: The queued-admission shape is deliberately one scheduler continuation, not a production `AdmissionProgram` implementation. It isolates how an already-asynchronous admission continuation interacts with the decode execution model without prematurely coupling the benchmark to #264 production wiring. -Two independent hosted-runner workflow executions were used for the decision. Relative ratios are calculated only against B inside the same payload/compressibility shard; absolute QPS is not compared across hosted VMs. +Two independent hosted-runner workflow executions were used for the initial execution-shape comparison. Relative ratios are calculated only against B inside the same payload/compressibility shard; absolute QPS is not compared across hosted VMs. - workflow run `32568724302`, benchmark head `10b33c25914f3d6762904b6ead7202cb72a1d781`; - workflow run `32568891389`, benchmark-equivalent head `0744415a4ab2abf04a8b1ea04d34f3a64df583c6`. @@ -34,7 +34,7 @@ Both runs used 4 logical CPUs, but GitHub assigned different AMD EPYC models acr ## Evidence collected -Per matrix case: +Per comparative matrix case: - QPS; - process CPU ns/op; @@ -46,7 +46,7 @@ Per matrix case: - peak decoded bytes in flight; - peak explicit decode queue depth; - scheduler/worker delay P50/P99; -- remote cancellation observation probe when applicable. +- local cancellation-token observation probe when applicable. A separate burst probe records synthetic drain-completion latency for each strategy. It is useful for relative executor supervision cost but is not a substitute for the production Stop/Drain integration suite. @@ -62,7 +62,7 @@ This preserves the #244 requirement while comparing execution models. ## Results -B (`InlineProvider`) is the within-shard baseline (`1.000`). The ranges below are the two independent workflow medians. +B (`InlineProvider`) is the within-shard baseline (`1.000`). The ranges below are the two independent workflow medians. These D ratios describe **fixed-worker overhead with an unsaturated executor queue**; they do not, by themselves, prove bounded-queue saturation behavior. | Payload / compressibility | A QPS / CPU | C QPS / CPU | D QPS / CPU | Interpretation | | --- | --- | --- | --- | --- | @@ -70,14 +70,20 @@ B (`InlineProvider`) is the within-shard baseline (`1.000`). The ranges below ar | 1 KiB / low | `0.666–0.789` / `1.257–1.560` | `0.981–1.003` / `0.991–1.016` | `0.589–0.700` / `1.418–1.759` | per-request handoff/executor is too expensive | | 64 KiB / high | `0.974–0.979` / `1.022–1.023` | `1.000–1.000` / `0.999–0.999` | `0.976–0.978` / `1.024–1.024` | 64 KiB quantum does not materially yield; B/C remain best | | 64 KiB / low | `0.952–0.964` / `1.039–1.050` | `0.984–0.986` / `1.016–1.019` | `0.945–0.954` / `1.049–1.062` | B remains the cheapest execution shape | -| 1 MiB / high | `0.972–0.976` / `1.034–1.036` | `0.914–0.938` / `1.120–1.149` | `0.971–0.974` / `1.035–1.043` | D reaches A-like throughput/CPU without per-request ThreadPool ownership | -| 1 MiB / low | `0.953–0.963` / `1.044–1.051` | `0.939–0.948` / `1.068–1.071` | `0.967–0.974` / `1.044–1.044` | D is the best bounded-offload candidate; C pays repeated-yield cost | +| 1 MiB / high | `0.972–0.976` / `1.034–1.036` | `0.914–0.938` / `1.120–1.149` | `0.971–0.974` / `1.035–1.043` | D reaches A-like fixed-worker throughput/CPU without per-request ThreadPool ownership | +| 1 MiB / low | `0.953–0.963` / `1.044–1.051` | `0.939–0.948` / `1.068–1.071` | `0.967–0.974` / `1.044–1.044` | D is the best fixed-worker offload candidate; C pays repeated-yield cost | P99 follows the same small-payload conclusion: A/D add substantial scheduler tails at 1 KiB, while C is essentially B until the quantum is crossed. At 1 MiB and high offered concurrency, A/D queueing can create large request-latency tails. That is not an argument for an unbounded inline reader loop; it is evidence that production D must combine bounded worker concurrency with explicit queue/retained/decoded resource budgets and admission/backpressure. -The cancellation probe directly cancels the decode token after decode begins. It verifies provider/executor token observation, but it does **not** model the key network property that an inline RequestLoop cannot consume a later remote Cancel frame while it is synchronously decoding. Therefore B's best CPU/QPS result cannot by itself justify using B for arbitrarily expensive remote-cancellable decode. +The cancellation probe directly cancels the decode token after decode begins. It verifies provider/executor token observation, but it does **not** model the key network property that an inline RequestLoop cannot consume a later remote Cancel/close/Stop frame while it is synchronously decoding. It therefore cannot establish a safe remote-cancellable inline threshold or bound reader-loop/control-plane stall. -For 1 MiB probes, cancellation was observed in essentially every case in both runs, and median observation time was similar between B/A/C/D for the same compressibility. This means D does not introduce a material cancellation-token reaction penalty once work has begun; its main cost is queue/scheduler latency. +For 1 MiB probes, cancellation was observed in essentially every case in both runs, and median local token-observation time was similar between B/A/C/D for the same compressibility. This means D does not introduce a material cancellation-token reaction penalty once work has begun; it does not prove anything about how quickly a remote control frame is read when B is running inline. + +### Fixed-capacity executor saturation probe + +A separate probe fixes queue capacity independently of offered concurrency (`queue capacity = 8`, `concurrency = 128`, `operations = 256`). Executor workers are deliberately held behind a gate until at least one `ChannelWriter.WriteAsync` is observed to complete asynchronously. The probe fails if no blocked writer is recorded or if submitted decode work does not complete after workers are released. + +This probe exists specifically to validate the bounded/backpressure path that the original comparative D queue (`max(32, concurrency * 2)`) could not saturate. Its blocked-writer counts and wait distributions are stored separately from the A/B/C/D throughput ratios; saturation evidence must not be blended into the unsaturated comparative QPS table. ### Resource-budget observation @@ -95,25 +101,25 @@ The executor queue must be fixed/bounded independently of offered request concur ## ADR — selected Phase 0 execution model -**Decision: select an adaptive B + D production model.** +**Decision: select an adaptive B + D production model, with the inline threshold left unresolved until production RequestLoop control-plane evidence exists.** 1. **Use B / inline provider decode for the cheap path.** - Non-remote-cancellable accepted requests should decode inline after all required permits are held. - - Remote-cancellable requests whose validated estimated decode cost is within the inline budget should also decode inline. - - The Phase 0 evidence supports **64 KiB of declared/original output as the initial inline-budget candidate**, because B/C remain effectively equivalent through that point while A/D pay unnecessary scheduling cost. + - Remote-cancellable requests may decode inline only when a production RequestLoop experiment shows that the chosen cost budget keeps remote Cancel/close/Stop observation within an explicit control-plane stall budget. + - **64 KiB declared/original output is only the first threshold hypothesis to test**, because B/C have similar CPU/QPS through that size. Phase 0 does not establish 64 KiB as a safe remote-cancellable inline budget. 2. **Use D / persistent bounded DecodeExecutor for expensive remote-cancellable decode.** - - Above the inline budget, keep the reader/control-plane path free to process Cancel/deadline/close/Stop while decode is supervised by a small persistent worker set. - - The 1 MiB evidence shows D at roughly `0.967–0.974x` QPS and `1.035–1.044x` CPU versus inline in the tested shards, materially better than C's repeated-yield cost while avoiding A's per-request ThreadPool scheduling model. - - The exact production threshold should remain an internal policy input and can be tuned with later end-to-end evidence; Phase 0 selects the execution **shape**, not a new public configuration API. + - Keep the reader/control-plane path free to process Cancel/deadline/close/Stop while decode is supervised by a small persistent worker set. + - The comparative 1 MiB evidence shows that fixed-worker D has substantially lower repeated-yield cost than C. The separate saturation probe validates the fixed-capacity bounded-channel/backpressure mechanism rather than inferring it from the unsaturated throughput matrix. + - The exact production threshold remains an internal policy decision that must be validated end-to-end; Phase 0 selects the execution **shape**, not a threshold value or a new public configuration API. 3. **Do not productionize A.** - A remains the #261 comparison baseline. - - At large payloads it can approach D's throughput, but it provides no durable bounded/fair executor ownership model and is especially expensive for small payloads. + - At large payloads it can approach D's unsaturated fixed-worker throughput, but it provides no durable bounded/fair executor ownership model and is especially expensive for small payloads. 4. **Do not productionize C as a separate execution model.** - Up to 64 KiB, C mostly behaves like B because the quantum is not crossed. - - At 1 MiB, repeated cooperative yields consistently cost more CPU/QPS than D. + - At 1 MiB, repeated cooperative yields consistently cost more CPU/QPS than D's fixed-worker path in the comparative matrix. - Keeping a second provider-specific decode state machine would add ownership and maintenance complexity without winning the measured large-payload tradeoff. ## Production follow-up implied by this ADR @@ -129,10 +135,11 @@ Required gates before calling that slice complete: - D is supervised, bounded, fair across connections, and has no detached per-request workers; - retained/decoded byte budgets are enforced before retention/rent; - remote Cancel/deadline/close/Stop are exercised during executor decode; +- a real RequestLoop remote-control-frame probe measures Cancel/close/Stop observation while testing any proposed inline threshold, starting with the 64 KiB hypothesis; - generation capture for #262/#264 remains stable across awaits and does not reset ResourceGovernor state; - uncompressed/default fast path is re-measured after production wiring; - final end-to-end performance gate re-runs the relevant payload/concurrency matrix against the selected production implementation. ## Interpretation boundary -This evidence selects the execution model before production plumbing. It does not establish the final `RequestPermit`, Stop/Drain implementation, decode byte-budget values, dynamic policy generation, fairness algorithm, or public configuration surface. Those remain production work under #273, with the selected adaptive B + D model as the constraint. +This evidence selects the execution shape before production plumbing. It does not establish the final `RequestPermit`, Stop/Drain implementation, decode byte-budget values, remote-cancellable inline threshold, dynamic policy generation, fairness algorithm, or public configuration surface. Those remain production work under #273, with adaptive B + D as the selected shape. From 3f7f0661662a24234ec8a4b308a1ae74f9a75d23 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:08:40 +0800 Subject: [PATCH 020/228] fix(perf): reference runtime compression provider contract --- .../DecodeExecutorBackpressureEvidenceRunner.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs index f76e6f0a1..779147924 100644 --- a/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs +++ b/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Threading.Channels; using SharpLink.Abstractions; +using SharpLink.Runtime; namespace SharpLink.Benchmarks; From 86e72a114b831eafa1c1c97a84663c1849908339 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:09:43 +0800 Subject: [PATCH 021/228] fix(perf): add IO global using for evidence runners --- test/SharpLink.Benchmarks/GlobalUsings.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.Benchmarks/GlobalUsings.cs b/test/SharpLink.Benchmarks/GlobalUsings.cs index 4ffeb9530..a8b9f4720 100644 --- a/test/SharpLink.Benchmarks/GlobalUsings.cs +++ b/test/SharpLink.Benchmarks/GlobalUsings.cs @@ -1 +1,2 @@ +global using System.IO; global using System.Threading; From dd72db2dbd42782d0d0d1779d3d95d8820dc16e5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:19:52 +0800 Subject: [PATCH 022/228] test(perf): cover queued decode cancellation --- ...ecodeExecutorBackpressureEvidenceRunner.cs | 379 +++++++++++++++--- 1 file changed, 323 insertions(+), 56 deletions(-) diff --git a/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs index 779147924..aec51de69 100644 --- a/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs +++ b/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs @@ -9,10 +9,10 @@ namespace SharpLink.Benchmarks; /// -/// Explicit saturation probe for the Phase 0 persistent decode executor candidate. -/// Unlike the comparative A/B/C/D matrix, this probe fixes queue capacity independently -/// of offered concurrency and deliberately holds workers until bounded-channel backpressure -/// is observed. +/// Explicit saturation and queued-cancellation probes for the Phase 0 persistent decode +/// executor candidate. Unlike the comparative A/B/C/D matrix, these probes fix queue +/// capacity independently of offered concurrency and deliberately hold workers so queue +/// ownership can be observed before provider execution begins. /// internal static class DecodeExecutorBackpressureEvidenceRunner { @@ -39,7 +39,72 @@ internal static async Task RunAsync(string[] args) var provider = CompressionProviderBenchmarks.CreateProvider("fastest"); var compressed = CreateCompressedFixture(provider, payloadSize, compressible); - using var metrics = new BackpressureMetrics(); + var saturation = await MeasureSaturationAsync( + provider, + compressed, + payloadSize, + workerCount, + queueCapacity, + concurrency, + operations); + var queuedCancellation = await MeasureQueuedCancellationAsync( + provider, + compressed, + payloadSize, + workerCount, + queueCapacity); + + var result = new DecodeExecutorBackpressureEvidenceResult( + DateTimeOffset.UtcNow, + payloadSize, + compressible, + compressed.Length, + workerCount, + queueCapacity, + concurrency, + operations, + saturation.ElapsedSeconds, + saturation.Qps, + saturation.BackpressureWaitCount, + saturation.PeakPendingWriters, + saturation.BackpressureWaitP50Microseconds, + saturation.BackpressureWaitP99Microseconds, + saturation.CompletedWorkItems, + queuedCancellation); + + var fullPath = Path.GetFullPath(outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await File.WriteAllTextAsync(fullPath, JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = true + })); + + Console.WriteLine($"Phase 0 decode executor backpressure evidence: {fullPath}"); + Console.WriteLine( + $"PHASE0_BACKPRESSURE payload={payloadSize} compressible={compressible} workers={workerCount} " + + $"queueCapacity={queueCapacity} concurrency={concurrency} operations={operations} " + + $"waitCount={saturation.BackpressureWaitCount} peakPendingWriters={saturation.PeakPendingWriters} " + + $"waitP50Us={saturation.BackpressureWaitP50Microseconds:F2} " + + $"waitP99Us={saturation.BackpressureWaitP99Microseconds:F2}"); + Console.WriteLine( + $"PHASE0_QUEUED_CANCEL payload={payloadSize} compressible={compressible} workers={workerCount} " + + $"queueCapacity={queueCapacity} cancelled={queuedCancellation.CancelledRequests} " + + $"providerStarts={queuedCancellation.ProviderStarts} " + + $"skippedBeforeProvider={queuedCancellation.SkippedBeforeProvider} " + + $"ownershipReleasedBeforeWorkerStart={queuedCancellation.OwnershipReleasedBeforeWorkerStart} " + + $"cancelCompletionUs={queuedCancellation.CancellationCompletionMicroseconds:F2}"); + } + + private static async Task MeasureSaturationAsync( + ISharpLinkCompressionProvider provider, + ReadOnlyMemory compressed, + int payloadSize, + int workerCount, + int queueCapacity, + int concurrency, + int operations) + { + using var metrics = new BackpressureMetrics(queueCapacity); await using var executor = new SaturatedDecodeExecutor( workerCount, queueCapacity, @@ -84,16 +149,10 @@ await executor.EnqueueAsync( throw new InvalidOperationException("Backpressure metrics did not record a blocked writer."); if (snapshot.CompletedWorkItems != operations) throw new InvalidOperationException("Backpressure probe did not complete every submitted decode."); + if (snapshot.CurrentQueuedWorkItems != 0) + throw new InvalidOperationException("Backpressure probe left queued work after executor drain."); - var result = new DecodeExecutorBackpressureEvidenceResult( - DateTimeOffset.UtcNow, - payloadSize, - compressible, - compressed.Length, - workerCount, - queueCapacity, - concurrency, - operations, + return new SaturationEvidenceResult( elapsed.TotalSeconds, operations / elapsed.TotalSeconds, snapshot.BackpressureWaitCount, @@ -101,20 +160,107 @@ await executor.EnqueueAsync( snapshot.MedianWaitMicroseconds, snapshot.P99WaitMicroseconds, snapshot.CompletedWorkItems); + } - var fullPath = Path.GetFullPath(outputPath); - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - await File.WriteAllTextAsync(fullPath, JsonSerializer.Serialize(result, new JsonSerializerOptions + private static async Task MeasureQueuedCancellationAsync( + ISharpLinkCompressionProvider provider, + ReadOnlyMemory compressed, + int payloadSize, + int workerCount, + int queueCapacity) + { + using var metrics = new BackpressureMetrics(queueCapacity); + await using var executor = new SaturatedDecodeExecutor( + workerCount, + queueCapacity, + metrics); + var ownershipInFlight = 0; + var unexpectedCompletions = 0; + var cancellationSources = new CancellationTokenSource[queueCapacity]; + var requests = new Task[queueCapacity]; + + try { - WriteIndented = true - })); + for (var index = 0; index < requests.Length; index++) + { + var cancellation = new CancellationTokenSource(); + cancellationSources[index] = cancellation; + Interlocked.Increment(ref ownershipInFlight); + requests[index] = Task.Run(async () => + { + try + { + await executor.EnqueueAsync( + provider, + compressed, + payloadSize, + cancellation.Token); + Interlocked.Increment(ref unexpectedCompletions); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + finally + { + Interlocked.Decrement(ref ownershipInFlight); + } + }); + } - Console.WriteLine($"Phase 0 decode executor backpressure evidence: {fullPath}"); - Console.WriteLine( - $"PHASE0_BACKPRESSURE payload={payloadSize} compressible={compressible} workers={workerCount} " + - $"queueCapacity={queueCapacity} concurrency={concurrency} operations={operations} " + - $"waitCount={snapshot.BackpressureWaitCount} peakPendingWriters={snapshot.PeakPendingWriters} " + - $"waitP50Us={snapshot.MedianWaitMicroseconds:F2} waitP99Us={snapshot.P99WaitMicroseconds:F2}"); + if (!metrics.QueueFilled.Wait(TimeSpan.FromSeconds(5))) + throw new TimeoutException("Queued-cancellation probe did not fill the fixed executor queue."); + var beforeCancel = metrics.Capture(); + if (beforeCancel.ProviderStartCount != 0) + throw new InvalidOperationException("Queued-cancellation probe started provider work before worker release."); + if (beforeCancel.CurrentQueuedWorkItems != queueCapacity) + throw new InvalidOperationException("Queued-cancellation probe did not hold every request in queue ownership."); + + var cancellationStarted = Stopwatch.GetTimestamp(); + foreach (var cancellation in cancellationSources) + cancellation.Cancel(); + await Task.WhenAll(requests).WaitAsync(TimeSpan.FromSeconds(5)); + var cancellationCompletionMicroseconds = + Stopwatch.GetElapsedTime(cancellationStarted).TotalNanoseconds / 1000d; + + var beforeWorkerRelease = metrics.Capture(); + if (Volatile.Read(ref ownershipInFlight) != 0) + { + throw new InvalidOperationException( + "Queued cancellation did not release caller reservation/buffer ownership before worker service."); + } + if (Volatile.Read(ref unexpectedCompletions) != 0) + throw new InvalidOperationException("Queued-cancellation probe unexpectedly completed decode work."); + if (beforeWorkerRelease.ProviderStartCount != 0) + throw new InvalidOperationException("Queued cancellation entered provider work before worker service."); + if (beforeWorkerRelease.QueuedCancellationCount != queueCapacity) + throw new InvalidOperationException("Queued-cancellation probe did not cancel every queued work item."); + + executor.ReleaseWorkers(); + await executor.StopAsync(); + var afterDrain = metrics.Capture(); + if (afterDrain.ProviderStartCount != 0) + { + throw new InvalidOperationException( + "A request cancelled while queued entered provider decode after worker release."); + } + if (afterDrain.SkippedCancelledWorkItems != queueCapacity) + throw new InvalidOperationException("Workers did not skip every queued-cancelled work item."); + if (afterDrain.CurrentQueuedWorkItems != 0) + throw new InvalidOperationException("Queued-cancellation probe left cancelled work in the executor queue."); + + return new QueuedCancellationEvidenceResult( + queueCapacity, + afterDrain.ProviderStartCount, + afterDrain.SkippedCancelledWorkItems, + Volatile.Read(ref ownershipInFlight) == 0, + cancellationCompletionMicroseconds); + } + finally + { + executor.ReleaseWorkers(); + foreach (var cancellation in cancellationSources) + cancellation?.Dispose(); + } } private static byte[] CreateCompressedFixture( @@ -223,23 +369,33 @@ internal async ValueTask EnqueueAsync( _metrics); var writeStarted = Stopwatch.GetTimestamp(); var write = _channel.Writer.WriteAsync(work, cancellationToken); - if (!write.IsCompletedSuccessfully) + try { - _metrics.OnBackpressureWaitStarted(); - try + if (!write.IsCompletedSuccessfully) { - await write; + _metrics.OnBackpressureWaitStarted(); + try + { + await write; + } + finally + { + _metrics.OnBackpressureWaitCompleted( + Stopwatch.GetElapsedTime(writeStarted).TotalNanoseconds / 1000d); + } } - finally + else { - _metrics.OnBackpressureWaitCompleted( - Stopwatch.GetElapsedTime(writeStarted).TotalNanoseconds / 1000d); + await write; } } - else + catch { - await write; + throw; } + + work.EnableQueuedCancellation(); + _metrics.OnWorkEnqueued(); await completion.Task; } @@ -265,52 +421,119 @@ private async Task WorkerAsync() } } - private readonly record struct DecodeWorkItem( - ISharpLinkCompressionProvider Provider, - ReadOnlyMemory Compressed, - int OriginalLength, - CancellationToken CancellationToken, - TaskCompletionSource Completion, - BackpressureMetrics Metrics) + private sealed class DecodeWorkItem { + private const int Queued = 0; + private const int Running = 1; + private const int CancelledBeforeStart = 2; + private readonly ISharpLinkCompressionProvider _provider; + private readonly ReadOnlyMemory _compressed; + private readonly int _originalLength; + private readonly CancellationToken _cancellationToken; + private readonly TaskCompletionSource _completion; + private readonly BackpressureMetrics _metrics; + private CancellationTokenRegistration _cancellationRegistration; + private int _state; + + internal DecodeWorkItem( + ISharpLinkCompressionProvider provider, + ReadOnlyMemory compressed, + int originalLength, + CancellationToken cancellationToken, + TaskCompletionSource completion, + BackpressureMetrics metrics) + { + _provider = provider; + _compressed = compressed; + _originalLength = originalLength; + _cancellationToken = cancellationToken; + _completion = completion; + _metrics = metrics; + } + + internal void EnableQueuedCancellation() + { + if (!_cancellationToken.CanBeCanceled) + return; + _cancellationRegistration = _cancellationToken.Register( + static state => ((DecodeWorkItem)state!).CancelBeforeStart(), + this); + } + internal void Run() { + _metrics.OnWorkDequeued(); + if (Interlocked.CompareExchange(ref _state, Running, Queued) != Queued) + { + _cancellationRegistration.Dispose(); + _metrics.OnCancelledWorkSkipped(); + return; + } + + _cancellationRegistration.Dispose(); try { - CancellationToken.ThrowIfCancellationRequested(); - var output = new ArrayBufferWriter(OriginalLength); - var result = Provider.Decompress( - new ReadOnlySequence(Compressed), + // Cancellation that wins while queue ownership is still held completes the + // caller before worker service. A cancellation racing after ownership transfer + // is checked here before any provider-side CRC/decompression work begins. + _cancellationToken.ThrowIfCancellationRequested(); + _metrics.OnProviderStarted(); + var output = new ArrayBufferWriter(_originalLength); + var result = _provider.Decompress( + new ReadOnlySequence(_compressed), output, - OriginalLength, - CancellationToken); - if (result.ConsumedBytes != Compressed.Length || - result.WrittenBytes != OriginalLength || - output.WrittenCount != OriginalLength) + _originalLength, + _cancellationToken); + if (result.ConsumedBytes != _compressed.Length || + result.WrittenBytes != _originalLength || + output.WrittenCount != _originalLength) { throw new InvalidOperationException( "Backpressure decode returned inconsistent provider counts."); } - Metrics.OnWorkCompleted(); - Completion.TrySetResult(); + _metrics.OnWorkCompleted(); + _completion.TrySetResult(); + } + catch (OperationCanceledException) when (_cancellationToken.IsCancellationRequested) + { + _completion.TrySetCanceled(_cancellationToken); } catch (Exception exception) { - Completion.TrySetException(exception); + _completion.TrySetException(exception); } } + + private void CancelBeforeStart() + { + if (Interlocked.CompareExchange(ref _state, CancelledBeforeStart, Queued) != Queued) + return; + _metrics.OnQueuedCancellation(); + _completion.TrySetCanceled(_cancellationToken); + } } private sealed class BackpressureMetrics : IDisposable { private readonly object _gate = new(); private readonly List _waitMicroseconds = []; + private readonly int _queueCapacity; private long _backpressureWaitCount; private long _pendingWriters; private long _peakPendingWriters; private long _completedWorkItems; + private long _queuedWorkItems; + private long _providerStartCount; + private long _queuedCancellationCount; + private long _skippedCancelledWorkItems; + + internal BackpressureMetrics(int queueCapacity) + { + _queueCapacity = queueCapacity; + } internal ManualResetEventSlim BackpressureObserved { get; } = new(false); + internal ManualResetEventSlim QueueFilled { get; } = new(false); internal void OnBackpressureWaitStarted() { @@ -327,6 +550,21 @@ internal void OnBackpressureWaitCompleted(double microseconds) _waitMicroseconds.Add(microseconds); } + internal void OnWorkEnqueued() + { + var queued = Interlocked.Increment(ref _queuedWorkItems); + if (queued >= _queueCapacity) + QueueFilled.Set(); + } + + internal void OnWorkDequeued() => Interlocked.Decrement(ref _queuedWorkItems); + + internal void OnProviderStarted() => Interlocked.Increment(ref _providerStartCount); + + internal void OnQueuedCancellation() => Interlocked.Increment(ref _queuedCancellationCount); + + internal void OnCancelledWorkSkipped() => Interlocked.Increment(ref _skippedCancelledWorkItems); + internal void OnWorkCompleted() => Interlocked.Increment(ref _completedWorkItems); internal BackpressureMetricsSnapshot Capture() @@ -340,10 +578,18 @@ internal BackpressureMetricsSnapshot Capture() Volatile.Read(ref _peakPendingWriters), Percentile(waits, 0.50), Percentile(waits, 0.99), - Volatile.Read(ref _completedWorkItems)); + Volatile.Read(ref _completedWorkItems), + Volatile.Read(ref _queuedWorkItems), + Volatile.Read(ref _providerStartCount), + Volatile.Read(ref _queuedCancellationCount), + Volatile.Read(ref _skippedCancelledWorkItems)); } - public void Dispose() => BackpressureObserved.Dispose(); + public void Dispose() + { + BackpressureObserved.Dispose(); + QueueFilled.Dispose(); + } private static double Percentile(double[] values, double percentile) { @@ -374,9 +620,29 @@ private readonly record struct BackpressureMetricsSnapshot( long PeakPendingWriters, double MedianWaitMicroseconds, double P99WaitMicroseconds, + long CompletedWorkItems, + long CurrentQueuedWorkItems, + long ProviderStartCount, + long QueuedCancellationCount, + long SkippedCancelledWorkItems); + + private readonly record struct SaturationEvidenceResult( + double ElapsedSeconds, + double Qps, + long BackpressureWaitCount, + long PeakPendingWriters, + double BackpressureWaitP50Microseconds, + double BackpressureWaitP99Microseconds, long CompletedWorkItems); } +internal sealed record QueuedCancellationEvidenceResult( + int CancelledRequests, + long ProviderStarts, + long SkippedBeforeProvider, + bool OwnershipReleasedBeforeWorkerStart, + double CancellationCompletionMicroseconds); + internal sealed record DecodeExecutorBackpressureEvidenceResult( DateTimeOffset CapturedAtUtc, int PayloadSize, @@ -392,4 +658,5 @@ internal sealed record DecodeExecutorBackpressureEvidenceResult( long PeakPendingWriters, double BackpressureWaitP50Microseconds, double BackpressureWaitP99Microseconds, - long CompletedWorkItems); + long CompletedWorkItems, + QueuedCancellationEvidenceResult QueuedCancellation); From 6936dbbc3f2b3ddab6b0b5e7bc477d7cc75b49ca Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:20:42 +0800 Subject: [PATCH 023/228] docs(perf): narrow cooperative decode conclusion --- docs/phase0-decode-performance.md | 46 ++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/docs/phase0-decode-performance.md b/docs/phase0-decode-performance.md index 297e7b3b2..fbe1b526a 100644 --- a/docs/phase0-decode-performance.md +++ b/docs/phase0-decode-performance.md @@ -5,11 +5,11 @@ This slice is benchmark-only and is stacked on the reviewed call-reservation pri ## Candidate execution models - **A — ThreadPoolHandoff**: one per-request ThreadPool handoff before synchronous provider decode. This is the #261-style scheduling baseline. -- **B — InlineProvider**: reserve, call the existing synchronous compression provider inline, then activate. The built-in Brotli provider already decodes in bounded 8 KiB output chunks and checks cancellation in its loop. -- **C — CooperativeQuantum**: benchmark-only Brotli decoder that preserves SharpLink integrity-trailer/CRC validation, decodes in the same 8 KiB chunks, and reschedules after a bounded 64 KiB output quantum. -- **D — PersistentExecutor**: persistent fixed workers with explicit queued-work ownership. The comparative A/B/C/D matrix measures this executor with an unsaturated queue; a separate fixed-capacity saturation probe exercises bounded-channel backpressure explicitly. +- **B — InlineProvider**: reserve, call the existing synchronous compression provider inline, then activate. The built-in Brotli provider already decodes in bounded 8 KiB output chunks and checks cancellation in its decode loop. +- **C — CooperativeQuantum**: benchmark-only Brotli-loop prototype that preserves SharpLink integrity-trailer/CRC validation, decodes in the same 8 KiB chunks, and reschedules after a bounded 64 KiB output quantum. The integrity CRC is still a whole-input synchronous scan before the first cancellation check/yield, so C is **not** an end-to-end bounded cooperative decode pipeline. +- **D — PersistentExecutor**: persistent fixed workers with explicit queued-work ownership. The comparative A/B/C/D matrix measures this executor with an unsaturated queue; separate fixed-capacity probes exercise bounded-channel backpressure and cancellation while work is still queue-owned. -The C implementation is intentionally local to the benchmark project. It is not a proposed public provider API or production implementation. +The C implementation is intentionally local to the benchmark project. It is not a proposed public provider API or production implementation, and its results apply only to this Brotli-loop `Task.Yield` shape rather than cooperative decode in general. ## Matrix @@ -68,23 +68,35 @@ B (`InlineProvider`) is the within-shard baseline (`1.000`). The ranges below ar | --- | --- | --- | --- | --- | | 1 KiB / high | `0.772–0.773` / `1.300–1.407` | `0.993–1.009` / `0.997–1.003` | `0.645–0.690` / `1.547–1.556` | scheduling dominates; B/C are effectively equivalent | | 1 KiB / low | `0.666–0.789` / `1.257–1.560` | `0.981–1.003` / `0.991–1.016` | `0.589–0.700` / `1.418–1.759` | per-request handoff/executor is too expensive | -| 64 KiB / high | `0.974–0.979` / `1.022–1.023` | `1.000–1.000` / `0.999–0.999` | `0.976–0.978` / `1.024–1.024` | 64 KiB quantum does not materially yield; B/C remain best | -| 64 KiB / low | `0.952–0.964` / `1.039–1.050` | `0.984–0.986` / `1.016–1.019` | `0.945–0.954` / `1.049–1.062` | B remains the cheapest execution shape | -| 1 MiB / high | `0.972–0.976` / `1.034–1.036` | `0.914–0.938` / `1.120–1.149` | `0.971–0.974` / `1.035–1.043` | D reaches A-like fixed-worker throughput/CPU without per-request ThreadPool ownership | -| 1 MiB / low | `0.953–0.963` / `1.044–1.051` | `0.939–0.948` / `1.068–1.071` | `0.967–0.974` / `1.044–1.044` | D is the best fixed-worker offload candidate; C pays repeated-yield cost | +| 64 KiB / high | `0.974–0.979` / `1.022–1.023` | `1.000–1.000` / `0.999–0.999` | `0.976–0.978` / `1.024–1.024` | 64 KiB Brotli-loop quantum does not materially yield; B/C remain best in this prototype | +| 64 KiB / low | `0.952–0.964` / `1.039–1.050` | `0.984–0.986` / `1.016–1.019` | `0.945–0.954` / `1.049–1.062` | B remains the cheapest measured execution shape | +| 1 MiB / high | `0.972–0.976` / `1.034–1.036` | `0.914–0.938` / `1.120–1.149` | `0.971–0.974` / `1.035–1.043` | D reaches A-like fixed-worker throughput/CPU; this C prototype pays repeated Brotli-loop yields | +| 1 MiB / low | `0.953–0.963` / `1.044–1.051` | `0.939–0.948` / `1.068–1.071` | `0.967–0.974` / `1.044–1.044` | D is the best measured fixed-worker offload candidate; this C prototype pays repeated-yield cost | -P99 follows the same small-payload conclusion: A/D add substantial scheduler tails at 1 KiB, while C is essentially B until the quantum is crossed. At 1 MiB and high offered concurrency, A/D queueing can create large request-latency tails. That is not an argument for an unbounded inline reader loop; it is evidence that production D must combine bounded worker concurrency with explicit queue/retained/decoded resource budgets and admission/backpressure. +P99 follows the same small-payload conclusion: A/D add substantial scheduler tails at 1 KiB, while C is essentially B until the output quantum is crossed. At 1 MiB and high offered concurrency, A/D queueing can create large request-latency tails. That is not an argument for an unbounded inline reader loop; it is evidence that production D must combine bounded worker concurrency with explicit queue/retained/decoded resource budgets and admission/backpressure. The cancellation probe directly cancels the decode token after decode begins. It verifies provider/executor token observation, but it does **not** model the key network property that an inline RequestLoop cannot consume a later remote Cancel/close/Stop frame while it is synchronously decoding. It therefore cannot establish a safe remote-cancellable inline threshold or bound reader-loop/control-plane stall. For 1 MiB probes, cancellation was observed in essentially every case in both runs, and median local token-observation time was similar between B/A/C/D for the same compressibility. This means D does not introduce a material cancellation-token reaction penalty once work has begun; it does not prove anything about how quickly a remote control frame is read when B is running inline. -### Fixed-capacity executor saturation probe +C's cancellation/yield evidence also has a specific boundary: `Crc32Accumulator.Compute` scans the complete compressed payload synchronously before the Brotli loop starts. For low-compressibility 1 MiB inputs that can mean nearly the whole compressed input is traversed before C reaches its first cancellation check or output-quantum yield. The measurements therefore compare B against a **Brotli-loop-only cooperative prototype**; they do not establish the cost or viability of a design that also makes integrity validation cooperative. + +### Fixed-capacity executor saturation and queued-cancellation probes A separate probe fixes queue capacity independently of offered concurrency (`queue capacity = 8`, `concurrency = 128`, `operations = 256`). Executor workers are deliberately held behind a gate until at least one `ChannelWriter.WriteAsync` is observed to complete asynchronously. The probe fails if no blocked writer is recorded or if submitted decode work does not complete after workers are released. This probe exists specifically to validate the bounded/backpressure path that the original comparative D queue (`max(32, concurrency * 2)`) could not saturate. Its blocked-writer counts and wait distributions are stored separately from the A/B/C/D throughput ratios; saturation evidence must not be blended into the unsaturated comparative QPS table. +The same fixed-capacity runner also executes a **queued-cancellation ownership probe**. It fills the queue while all workers remain gated, cancels every queue-owned request, and requires all of the following before workers are released: + +- caller-side reservation/buffer ownership completes/releases promptly; +- provider-start count remains `0`; +- every queued work item transitions atomically from queue ownership to cancelled-before-start ownership. + +After worker release/drain, the probe requires every cancelled item to be skipped before provider execution and still requires provider-start count `= 0`. The candidate work item performs an explicit cancellation check immediately after a worker wins the queue-to-running ownership transition and before any provider/CRC work, covering the race where cancellation arrives as ownership transfers to a worker. + +This is the required semantic shape for production D: cancellation may complete caller ownership early only if cancellation wins while the item is still queue-owned. If a worker has already won ownership, the caller must continue to await that worker so retained/decoded buffers cannot be returned while provider code may still access them. + ### Resource-budget observation The benchmark intentionally records resource amplification before the production ResourceGovernor byte budgets exist. At concurrency 128 with low-compressibility 1 MiB payloads, deferred strategies can accumulate large retained/decoded in-flight totals. This is a useful negative result: the production executor must **not** simply copy the benchmark queue/rent sequence. @@ -110,17 +122,17 @@ The executor queue must be fixed/bounded independently of offered request concur 2. **Use D / persistent bounded DecodeExecutor for expensive remote-cancellable decode.** - Keep the reader/control-plane path free to process Cancel/deadline/close/Stop while decode is supervised by a small persistent worker set. - - The comparative 1 MiB evidence shows that fixed-worker D has substantially lower repeated-yield cost than C. The separate saturation probe validates the fixed-capacity bounded-channel/backpressure mechanism rather than inferring it from the unsaturated throughput matrix. + - The comparative 1 MiB evidence shows that fixed-worker D avoids the repeated Brotli-loop-yield cost paid by this C prototype. The separate saturation probe validates the fixed-capacity bounded-channel/backpressure mechanism, and the queued-cancellation probe validates cancellation before provider start without prematurely releasing worker-owned buffers. - The exact production threshold remains an internal policy decision that must be validated end-to-end; Phase 0 selects the execution **shape**, not a threshold value or a new public configuration API. 3. **Do not productionize A.** - A remains the #261 comparison baseline. - At large payloads it can approach D's unsaturated fixed-worker throughput, but it provides no durable bounded/fair executor ownership model and is especially expensive for small payloads. -4. **Do not productionize C as a separate execution model.** - - Up to 64 KiB, C mostly behaves like B because the quantum is not crossed. - - At 1 MiB, repeated cooperative yields consistently cost more CPU/QPS than D's fixed-worker path in the comparative matrix. - - Keeping a second provider-specific decode state machine would add ownership and maintenance complexity without winning the measured large-payload tradeoff. +4. **Do not productionize this C prototype as a separate execution model.** + - Up to 64 KiB output, it mostly behaves like B because the Brotli output quantum is not crossed. + - At 1 MiB, its repeated Brotli-loop yields consistently cost more CPU/QPS than D's fixed-worker path in the comparative matrix. + - Its synchronous whole-input CRC means Phase 0 has **not** evaluated a fully cooperative integrity+decode pipeline. The data therefore does not rule out such a design in general; it only shows that carrying this provider-specific Brotli-loop `Task.Yield` prototype alongside B + D is not justified by the measured tradeoff. ## Production follow-up implied by this ADR @@ -133,6 +145,8 @@ Required gates before calling that slice complete: - compression safety is always-on and independent of `_admissionController != null`; - capacity/policy rejected compressed requests keep `Decompress=0` and decoded rent `=0`; - D is supervised, bounded, fair across connections, and has no detached per-request workers; +- queued D cancellation before worker start skips provider/CRC work and releases caller ownership without waiting for worker service; +- cancellation racing queue-to-worker ownership performs a pre-provider token check, while worker-owned work prevents early buffer return; - retained/decoded byte budgets are enforced before retention/rent; - remote Cancel/deadline/close/Stop are exercised during executor decode; - a real RequestLoop remote-control-frame probe measures Cancel/close/Stop observation while testing any proposed inline threshold, starting with the 64 KiB hypothesis; @@ -142,4 +156,4 @@ Required gates before calling that slice complete: ## Interpretation boundary -This evidence selects the execution shape before production plumbing. It does not establish the final `RequestPermit`, Stop/Drain implementation, decode byte-budget values, remote-cancellable inline threshold, dynamic policy generation, fairness algorithm, or public configuration surface. Those remain production work under #273, with adaptive B + D as the selected shape. +This evidence selects the execution shape before production plumbing. It does not establish the final `RequestPermit`, Stop/Drain implementation, decode byte-budget values, remote-cancellable inline threshold, dynamic policy generation, fairness algorithm, or public configuration surface. It also does not benchmark a fully cooperative integrity+decode implementation. Those remain production/research work under #273, with adaptive B + D as the selected shape. From 83f5a0e010d31aabd2409ce1f030f4c12e1684f9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:24:12 +0800 Subject: [PATCH 024/228] fix(perf): cancel queued persistent decode before provider --- .../DecodeExecutionPhase0EvidenceRunner.cs | 129 +++++++++++++----- 1 file changed, 96 insertions(+), 33 deletions(-) diff --git a/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs index 4f170e8b8..d9fe444fb 100644 --- a/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs +++ b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs @@ -732,40 +732,100 @@ private static async ValueTask RunThreadPoolHandoffAsync( return await completion.Task; } - private readonly record struct DecodeWorkItem( - ISharpLinkCompressionProvider Provider, - ReadOnlyMemory Compressed, - PooledOutput Output, - int OriginalLength, - CancellationToken CancellationToken, - Action? OnDecodeStart, - TaskCompletionSource Completion, - long QueuedAt, - DecodeMetrics Metrics) + private sealed class DecodeWorkItem { + private const int Queued = 0; + private const int Running = 1; + private const int CancelledBeforeStart = 2; + private readonly ISharpLinkCompressionProvider _provider; + private readonly ReadOnlyMemory _compressed; + private readonly PooledOutput _output; + private readonly int _originalLength; + private readonly CancellationToken _cancellationToken; + private readonly Action? _onDecodeStart; + private readonly TaskCompletionSource _completion; + private readonly long _queuedAt; + private readonly DecodeMetrics _metrics; + private CancellationTokenRegistration _cancellationRegistration; + private int _state; + + internal DecodeWorkItem( + ISharpLinkCompressionProvider provider, + ReadOnlyMemory compressed, + PooledOutput output, + int originalLength, + CancellationToken cancellationToken, + Action? onDecodeStart, + TaskCompletionSource completion, + long queuedAt, + DecodeMetrics metrics) + { + _provider = provider; + _compressed = compressed; + _output = output; + _originalLength = originalLength; + _cancellationToken = cancellationToken; + _onDecodeStart = onDecodeStart; + _completion = completion; + _queuedAt = queuedAt; + _metrics = metrics; + } + + internal void EnableQueuedCancellation() + { + if (!_cancellationToken.CanBeCanceled) + return; + _cancellationRegistration = _cancellationToken.Register( + static state => ((DecodeWorkItem)state!).CancelBeforeStart(), + this); + } + + internal void DisposeQueuedCancellation() => _cancellationRegistration.Dispose(); + internal void Run() { - Metrics.OnDecodeDequeued(); - var schedulerDelay = ElapsedMicroseconds(QueuedAt); + _metrics.OnDecodeDequeued(); + if (Interlocked.CompareExchange(ref _state, Running, Queued) != Queued) + { + _cancellationRegistration.Dispose(); + return; + } + + _cancellationRegistration.Dispose(); + var schedulerDelay = ElapsedMicroseconds(_queuedAt); try { - OnDecodeStart?.Invoke(); - Metrics.OnDecompress(); + // Queue-owned cancellation may complete the caller early; after worker + // ownership wins, check the token before any provider-side CRC/decode work. + _cancellationToken.ThrowIfCancellationRequested(); + _onDecodeStart?.Invoke(); + _metrics.OnDecompress(); ValidateProviderResult( - Provider.Decompress( - new ReadOnlySequence(Compressed), - Output, - OriginalLength, - CancellationToken), - Compressed.Length, - OriginalLength); - Completion.TrySetResult(schedulerDelay); + _provider.Decompress( + new ReadOnlySequence(_compressed), + _output, + _originalLength, + _cancellationToken), + _compressed.Length, + _originalLength); + _completion.TrySetResult(schedulerDelay); + } + catch (OperationCanceledException) when (_cancellationToken.IsCancellationRequested) + { + _completion.TrySetCanceled(_cancellationToken); } catch (Exception exception) { - Completion.TrySetException(exception); + _completion.TrySetException(exception); } } + + private void CancelBeforeStart() + { + if (Interlocked.CompareExchange(ref _state, CancelledBeforeStart, Queued) != Queued) + return; + _completion.TrySetCanceled(_cancellationToken); + } } private sealed class PersistentDecodeExecutor : IAsyncDisposable @@ -799,22 +859,25 @@ internal async ValueTask EnqueueAsync( { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var queuedAt = Stopwatch.GetTimestamp(); + var work = new DecodeWorkItem( + provider, + compressed, + output, + originalLength, + cancellationToken, + onDecodeStart, + completion, + queuedAt, + _metrics); + work.EnableQueuedCancellation(); _metrics.OnDecodeQueued(); try { - await _channel.Writer.WriteAsync(new DecodeWorkItem( - provider, - compressed, - output, - originalLength, - cancellationToken, - onDecodeStart, - completion, - queuedAt, - _metrics), cancellationToken); + await _channel.Writer.WriteAsync(work, cancellationToken); } catch { + work.DisposeQueuedCancellation(); _metrics.OnDecodeDequeued(); throw; } From 2efbd3abcfa0fc2adea2a4e1096e1824e74edc2a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:28:06 +0800 Subject: [PATCH 025/228] fix(perf): isolate persistent queue cancellation from A baseline --- .../DecodeExecutionPhase0EvidenceRunner.cs | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs index d9fe444fb..8b6db4ba8 100644 --- a/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs +++ b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs @@ -714,7 +714,7 @@ private static async ValueTask RunThreadPoolHandoffAsync( var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var queuedAt = Stopwatch.GetTimestamp(); metrics.OnDecodeQueued(); - var work = new DecodeWorkItem( + var work = new ThreadPoolDecodeWorkItem( provider, compressed, output, @@ -732,7 +732,43 @@ private static async ValueTask RunThreadPoolHandoffAsync( return await completion.Task; } - private sealed class DecodeWorkItem + private readonly record struct ThreadPoolDecodeWorkItem( + ISharpLinkCompressionProvider Provider, + ReadOnlyMemory Compressed, + PooledOutput Output, + int OriginalLength, + CancellationToken CancellationToken, + Action? OnDecodeStart, + TaskCompletionSource Completion, + long QueuedAt, + DecodeMetrics Metrics) + { + internal void Run() + { + Metrics.OnDecodeDequeued(); + var schedulerDelay = ElapsedMicroseconds(QueuedAt); + try + { + OnDecodeStart?.Invoke(); + Metrics.OnDecompress(); + ValidateProviderResult( + Provider.Decompress( + new ReadOnlySequence(Compressed), + Output, + OriginalLength, + CancellationToken), + Compressed.Length, + OriginalLength); + Completion.TrySetResult(schedulerDelay); + } + catch (Exception exception) + { + Completion.TrySetException(exception); + } + } + } + + private sealed class PersistentDecodeWorkItem { private const int Queued = 0; private const int Running = 1; @@ -749,7 +785,7 @@ private sealed class DecodeWorkItem private CancellationTokenRegistration _cancellationRegistration; private int _state; - internal DecodeWorkItem( + internal PersistentDecodeWorkItem( ISharpLinkCompressionProvider provider, ReadOnlyMemory compressed, PooledOutput output, @@ -776,7 +812,7 @@ internal void EnableQueuedCancellation() if (!_cancellationToken.CanBeCanceled) return; _cancellationRegistration = _cancellationToken.Register( - static state => ((DecodeWorkItem)state!).CancelBeforeStart(), + static state => ((PersistentDecodeWorkItem)state!).CancelBeforeStart(), this); } @@ -799,6 +835,7 @@ internal void Run() // ownership wins, check the token before any provider-side CRC/decode work. _cancellationToken.ThrowIfCancellationRequested(); _onDecodeStart?.Invoke(); + _cancellationToken.ThrowIfCancellationRequested(); _metrics.OnDecompress(); ValidateProviderResult( _provider.Decompress( @@ -830,14 +867,14 @@ private void CancelBeforeStart() private sealed class PersistentDecodeExecutor : IAsyncDisposable { - private readonly Channel _channel; + private readonly Channel _channel; private readonly Task[] _workers; private readonly DecodeMetrics _metrics; internal PersistentDecodeExecutor(int workers, int capacity, DecodeMetrics metrics) { _metrics = metrics; - _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) + _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait, SingleReader = workers == 1, @@ -859,7 +896,7 @@ internal async ValueTask EnqueueAsync( { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var queuedAt = Stopwatch.GetTimestamp(); - var work = new DecodeWorkItem( + var work = new PersistentDecodeWorkItem( provider, compressed, output, From b2a9dbc2527857e3b3b674513a67d0e6c3ed2f6d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:49:39 +0800 Subject: [PATCH 026/228] test(bench): expose actual D cancellation seam --- .../DecodeExecutionPhase0EvidenceRunner.cs | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs index 8b6db4ba8..9209ac03b 100644 --- a/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs +++ b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs @@ -488,7 +488,7 @@ private static IReadOnlyList GetCompressibility(string[] args) }; } - private enum DecodeStrategy + internal enum DecodeStrategy { ThreadPoolHandoff, InlineProvider, @@ -496,20 +496,20 @@ private enum DecodeStrategy PersistentExecutor } - private enum AdmissionMode + internal enum AdmissionMode { Off, Immediate, Queued } - private enum CapacityMode + internal enum CapacityMode { Available, Full } - private sealed class DecodeFixture + internal sealed class DecodeFixture { private DecodeFixture(int payloadSize, bool compressible, byte[] compressed) { @@ -541,7 +541,7 @@ internal static DecodeFixture Create(int payloadSize, bool compressible) } } - private sealed class DecodeCaseRuntime : IAsyncDisposable + internal sealed class DecodeCaseRuntime : IAsyncDisposable { private readonly DecodeFixture _fixture; private readonly DecodeStrategy _strategy; @@ -561,7 +561,10 @@ internal DecodeCaseRuntime( AdmissionMode admissionMode, CapacityMode capacityMode, int concurrency, - int quantumBytes) + int quantumBytes, + int? executorQueueCapacity = null, + Task? executorWorkerGate = null, + Action? onExecutorWorkPublished = null) { _fixture = fixture; _strategy = strategy; @@ -579,8 +582,10 @@ internal DecodeCaseRuntime( { _executor = new PersistentDecodeExecutor( Math.Clamp(Environment.ProcessorCount, 1, 4), - Math.Max(32, concurrency * 2), - _metrics); + executorQueueCapacity ?? Math.Max(32, concurrency * 2), + _metrics, + executorWorkerGate, + onExecutorWorkPublished); } } @@ -824,6 +829,7 @@ internal void Run() if (Interlocked.CompareExchange(ref _state, Running, Queued) != Queued) { _cancellationRegistration.Dispose(); + _metrics.OnCancelledWorkSkipped(); return; } @@ -870,10 +876,19 @@ private sealed class PersistentDecodeExecutor : IAsyncDisposable private readonly Channel _channel; private readonly Task[] _workers; private readonly DecodeMetrics _metrics; - - internal PersistentDecodeExecutor(int workers, int capacity, DecodeMetrics metrics) + private readonly Task? _workerGate; + private readonly Action? _onWorkPublished; + + internal PersistentDecodeExecutor( + int workers, + int capacity, + DecodeMetrics metrics, + Task? workerGate = null, + Action? onWorkPublished = null) { _metrics = metrics; + _workerGate = workerGate; + _onWorkPublished = onWorkPublished; _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait, @@ -911,6 +926,7 @@ internal async ValueTask EnqueueAsync( try { await _channel.Writer.WriteAsync(work, cancellationToken); + _onWorkPublished?.Invoke(); } catch { @@ -929,6 +945,8 @@ public async ValueTask DisposeAsync() private async Task WorkerAsync() { + if (_workerGate is not null) + await _workerGate; await foreach (var work in _channel.Reader.ReadAllAsync()) work.Run(); } @@ -1112,6 +1130,7 @@ private sealed class DecodeMetrics private long _peakDecodedBytes; private long _decodeQueueDepth; private long _peakDecodeQueueDepth; + private long _skippedCancelledWorkItems; internal void OnDecompress() => Interlocked.Increment(ref _decompressCalls); @@ -1142,6 +1161,8 @@ internal void OnDecodeQueued() internal void OnDecodeDequeued() => Interlocked.Decrement(ref _decodeQueueDepth); + internal void OnCancelledWorkSkipped() => Interlocked.Increment(ref _skippedCancelledWorkItems); + internal void Reset() { if (Volatile.Read(ref _retainedBytes) != 0 || @@ -1155,6 +1176,7 @@ internal void Reset() Interlocked.Exchange(ref _peakRetainedBytes, 0); Interlocked.Exchange(ref _peakDecodedBytes, 0); Interlocked.Exchange(ref _peakDecodeQueueDepth, 0); + Interlocked.Exchange(ref _skippedCancelledWorkItems, 0); } internal DecodeMetricsSnapshot Capture() @@ -1163,9 +1185,13 @@ internal DecodeMetricsSnapshot Capture() Volatile.Read(ref _decodedRentCount), Volatile.Read(ref _decodedBytesRented), Volatile.Read(ref _retainedRentCount), + Volatile.Read(ref _retainedBytes), Volatile.Read(ref _peakRetainedBytes), + Volatile.Read(ref _decodedBytes), Volatile.Read(ref _peakDecodedBytes), - Volatile.Read(ref _peakDecodeQueueDepth)); + Volatile.Read(ref _decodeQueueDepth), + Volatile.Read(ref _peakDecodeQueueDepth), + Volatile.Read(ref _skippedCancelledWorkItems)); private static void UpdatePeak(ref long target, long value) { @@ -1180,14 +1206,18 @@ private static void UpdatePeak(ref long target, long value) } } - private readonly record struct DecodeMetricsSnapshot( + internal readonly record struct DecodeMetricsSnapshot( long DecompressCalls, long DecodedRentCount, long DecodedBytesRented, long RetainedRentCount, + long CurrentRetainedBytes, long PeakRetainedBytes, + long CurrentDecodedBytes, long PeakDecodedBytes, - long PeakDecodeQueueDepth); + long CurrentDecodeQueueDepth, + long PeakDecodeQueueDepth, + long SkippedCancelledWorkItems); private readonly record struct DecodeRequestResult( bool Accepted, From 5a7c84fc6aa74c7e2b718b56e878fce6629d4ba8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:53:20 +0800 Subject: [PATCH 027/228] test(bench): probe queued cancellation through actual D --- ...ecodeExecutorBackpressureEvidenceRunner.cs | 347 +++++++----------- 1 file changed, 127 insertions(+), 220 deletions(-) diff --git a/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs index aec51de69..ed35dab50 100644 --- a/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs +++ b/test/SharpLink.Benchmarks/DecodeExecutorBackpressureEvidenceRunner.cs @@ -10,15 +10,16 @@ namespace SharpLink.Benchmarks; /// /// Explicit saturation and queued-cancellation probes for the Phase 0 persistent decode -/// executor candidate. Unlike the comparative A/B/C/D matrix, these probes fix queue -/// capacity independently of offered concurrency and deliberately hold workers so queue -/// ownership can be observed before provider execution begins. +/// executor candidate. Saturation uses a minimal local fixed-capacity channel harness; +/// queued cancellation deliberately drives the exact D runtime/work-item/lease path used by +/// the comparative matrix so ownership ordering cannot diverge between the probe and D. /// internal static class DecodeExecutorBackpressureEvidenceRunner { private const int DefaultQueueCapacity = 8; private const int DefaultConcurrency = 128; private const int DefaultOperations = 256; + private const int DefaultQuantumBytes = 64 * 1024; internal static async Task RunAsync(string[] args) { @@ -37,28 +38,25 @@ internal static async Task RunAsync(string[] args) "Backpressure evidence requires concurrency greater than queue capacity."); } + var fixture = DecodeExecutionPhase0EvidenceRunner.DecodeFixture.Create(payloadSize, compressible); var provider = CompressionProviderBenchmarks.CreateProvider("fastest"); - var compressed = CreateCompressedFixture(provider, payloadSize, compressible); var saturation = await MeasureSaturationAsync( provider, - compressed, + fixture.Compressed, payloadSize, workerCount, queueCapacity, concurrency, operations); var queuedCancellation = await MeasureQueuedCancellationAsync( - provider, - compressed, - payloadSize, - workerCount, + fixture, queueCapacity); var result = new DecodeExecutorBackpressureEvidenceResult( DateTimeOffset.UtcNow, payloadSize, compressible, - compressed.Length, + fixture.Compressed.Length, workerCount, queueCapacity, concurrency, @@ -92,6 +90,9 @@ internal static async Task RunAsync(string[] args) $"providerStarts={queuedCancellation.ProviderStarts} " + $"skippedBeforeProvider={queuedCancellation.SkippedBeforeProvider} " + $"ownershipReleasedBeforeWorkerStart={queuedCancellation.OwnershipReleasedBeforeWorkerStart} " + + $"reservationReleased={queuedCancellation.ReservationReleasedBeforeWorkerStart} " + + $"retainedLeaseReleased={queuedCancellation.RetainedLeaseReleasedBeforeWorkerStart} " + + $"decodedLeaseReleased={queuedCancellation.DecodedLeaseReleasedBeforeWorkerStart} " + $"cancelCompletionUs={queuedCancellation.CancellationCompletionMicroseconds:F2}"); } @@ -104,7 +105,7 @@ private static async Task MeasureSaturationAsync( int concurrency, int operations) { - using var metrics = new BackpressureMetrics(queueCapacity); + using var metrics = new BackpressureMetrics(); await using var executor = new SaturatedDecodeExecutor( workerCount, queueCapacity, @@ -124,11 +125,7 @@ private static async Task MeasureSaturationAsync( var index = Interlocked.Increment(ref next); if (index >= operations) return; - await executor.EnqueueAsync( - provider, - compressed, - payloadSize, - CancellationToken.None); + await executor.EnqueueAsync(provider, compressed, payloadSize); } }); } @@ -163,57 +160,63 @@ await executor.EnqueueAsync( } private static async Task MeasureQueuedCancellationAsync( - ISharpLinkCompressionProvider provider, - ReadOnlyMemory compressed, - int payloadSize, - int workerCount, + DecodeExecutionPhase0EvidenceRunner.DecodeFixture fixture, int queueCapacity) { - using var metrics = new BackpressureMetrics(queueCapacity); - await using var executor = new SaturatedDecodeExecutor( - workerCount, - queueCapacity, - metrics); - var ownershipInFlight = 0; + var workerGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var allWorkPublished = new ManualResetEventSlim(false); + var publishedCount = 0; var unexpectedCompletions = 0; var cancellationSources = new CancellationTokenSource[queueCapacity]; var requests = new Task[queueCapacity]; + await using var runtime = new DecodeExecutionPhase0EvidenceRunner.DecodeCaseRuntime( + fixture, + DecodeExecutionPhase0EvidenceRunner.DecodeStrategy.PersistentExecutor, + DecodeExecutionPhase0EvidenceRunner.AdmissionMode.Off, + DecodeExecutionPhase0EvidenceRunner.CapacityMode.Available, + queueCapacity, + DefaultQuantumBytes, + executorQueueCapacity: queueCapacity, + executorWorkerGate: workerGate.Task, + onExecutorWorkPublished: () => + { + if (Interlocked.Increment(ref publishedCount) == queueCapacity) + allWorkPublished.Set(); + }); + try { for (var index = 0; index < requests.Length; index++) { var cancellation = new CancellationTokenSource(); cancellationSources[index] = cancellation; - Interlocked.Increment(ref ownershipInFlight); requests[index] = Task.Run(async () => { try { - await executor.EnqueueAsync( - provider, - compressed, - payloadSize, - cancellation.Token); + _ = await runtime.ExecuteAsync(cancellation.Token); Interlocked.Increment(ref unexpectedCompletions); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } - finally - { - Interlocked.Decrement(ref ownershipInFlight); - } }); } - if (!metrics.QueueFilled.Wait(TimeSpan.FromSeconds(5))) - throw new TimeoutException("Queued-cancellation probe did not fill the fixed executor queue."); - var beforeCancel = metrics.Capture(); - if (beforeCancel.ProviderStartCount != 0) - throw new InvalidOperationException("Queued-cancellation probe started provider work before worker release."); - if (beforeCancel.CurrentQueuedWorkItems != queueCapacity) - throw new InvalidOperationException("Queued-cancellation probe did not hold every request in queue ownership."); + if (!allWorkPublished.Wait(TimeSpan.FromSeconds(5))) + throw new TimeoutException("Actual-D queued-cancellation probe did not publish every work item."); + + var beforeCancelCapacity = runtime.CaptureCapacitySnapshot(); + var beforeCancelMetrics = runtime.CaptureMetrics(); + if (beforeCancelCapacity.OccupiedCalls != queueCapacity) + throw new InvalidOperationException("Actual-D probe did not hold every call reservation while queued."); + if (beforeCancelMetrics.CurrentDecodeQueueDepth != queueCapacity) + throw new InvalidOperationException("Actual-D probe did not hold every work item in the real D queue."); + if (beforeCancelMetrics.CurrentRetainedBytes <= 0 || beforeCancelMetrics.CurrentDecodedBytes <= 0) + throw new InvalidOperationException("Actual-D probe did not hold the real retained/decoded leases while queued."); + if (beforeCancelMetrics.DecompressCalls != 0) + throw new InvalidOperationException("Actual-D probe entered provider work before workers were released."); var cancellationStarted = Stopwatch.GetTimestamp(); foreach (var cancellation in cancellationSources) @@ -222,67 +225,66 @@ await executor.EnqueueAsync( var cancellationCompletionMicroseconds = Stopwatch.GetElapsedTime(cancellationStarted).TotalNanoseconds / 1000d; - var beforeWorkerRelease = metrics.Capture(); - if (Volatile.Read(ref ownershipInFlight) != 0) + var beforeWorkerCapacity = runtime.CaptureCapacitySnapshot(); + var beforeWorkerMetrics = runtime.CaptureMetrics(); + var reservationReleased = beforeWorkerCapacity.OccupiedCalls == 0; + var retainedLeaseReleased = beforeWorkerMetrics.CurrentRetainedBytes == 0; + var decodedLeaseReleased = beforeWorkerMetrics.CurrentDecodedBytes == 0; + var ownershipReleased = reservationReleased && retainedLeaseReleased && decodedLeaseReleased; + + if (Volatile.Read(ref unexpectedCompletions) != 0) + throw new InvalidOperationException("Actual-D queued-cancellation probe unexpectedly completed decode work."); + if (!ownershipReleased) { throw new InvalidOperationException( - "Queued cancellation did not release caller reservation/buffer ownership before worker service."); + "Actual-D queued cancellation did not release reservation/retained/decoded ownership before worker service."); } - if (Volatile.Read(ref unexpectedCompletions) != 0) - throw new InvalidOperationException("Queued-cancellation probe unexpectedly completed decode work."); - if (beforeWorkerRelease.ProviderStartCount != 0) - throw new InvalidOperationException("Queued cancellation entered provider work before worker service."); - if (beforeWorkerRelease.QueuedCancellationCount != queueCapacity) - throw new InvalidOperationException("Queued-cancellation probe did not cancel every queued work item."); - - executor.ReleaseWorkers(); - await executor.StopAsync(); - var afterDrain = metrics.Capture(); - if (afterDrain.ProviderStartCount != 0) + if (beforeWorkerMetrics.DecompressCalls != 0) + throw new InvalidOperationException("Actual-D queued cancellation entered provider work before worker service."); + if (beforeWorkerMetrics.CurrentDecodeQueueDepth != queueCapacity) { throw new InvalidOperationException( - "A request cancelled while queued entered provider decode after worker release."); + "Actual-D queued cancellation dequeued work before the deterministic worker gate was released."); + } + + workerGate.TrySetResult(); + await runtime.StopExecutorAsync(); + var afterDrainCapacity = runtime.CaptureCapacitySnapshot(); + var afterDrainMetrics = runtime.CaptureMetrics(); + if (afterDrainMetrics.DecompressCalls != 0) + { + throw new InvalidOperationException( + "A request cancelled while queued entered the actual D provider after worker release."); + } + if (afterDrainMetrics.SkippedCancelledWorkItems != queueCapacity) + throw new InvalidOperationException("Actual D did not skip every queued-cancelled work item."); + if (afterDrainMetrics.CurrentDecodeQueueDepth != 0) + throw new InvalidOperationException("Actual-D queued-cancellation probe left work in the executor queue."); + if (afterDrainCapacity.OccupiedCalls != 0 || + afterDrainMetrics.CurrentRetainedBytes != 0 || + afterDrainMetrics.CurrentDecodedBytes != 0) + { + throw new InvalidOperationException("Actual-D queued-cancellation probe leaked request ownership after drain."); } - if (afterDrain.SkippedCancelledWorkItems != queueCapacity) - throw new InvalidOperationException("Workers did not skip every queued-cancelled work item."); - if (afterDrain.CurrentQueuedWorkItems != 0) - throw new InvalidOperationException("Queued-cancellation probe left cancelled work in the executor queue."); return new QueuedCancellationEvidenceResult( queueCapacity, - afterDrain.ProviderStartCount, - afterDrain.SkippedCancelledWorkItems, - Volatile.Read(ref ownershipInFlight) == 0, + afterDrainMetrics.DecompressCalls, + afterDrainMetrics.SkippedCancelledWorkItems, + ownershipReleased, + reservationReleased, + retainedLeaseReleased, + decodedLeaseReleased, cancellationCompletionMicroseconds); } finally { - executor.ReleaseWorkers(); + workerGate.TrySetResult(); foreach (var cancellation in cancellationSources) cancellation?.Dispose(); } } - private static byte[] CreateCompressedFixture( - ISharpLinkCompressionProvider provider, - int payloadSize, - bool compressible) - { - var payload = new byte[payloadSize]; - if (compressible) - Array.Fill(payload, (byte)0x2a); - else - new Random(42).NextBytes(payload); - var output = new ArrayBufferWriter(payloadSize * 2 + 1024); - var result = provider.Compress( - new ReadOnlySequence(payload), - output, - payloadSize * 2 + 1024); - if (result.ConsumedBytes != payloadSize || result.WrittenBytes != output.WrittenCount) - throw new InvalidOperationException("Backpressure fixture compression returned inconsistent counts."); - return output.WrittenSpan.ToArray(); - } - private static int GetPayloadSize(string[] args) { var option = GetOption(args, "--payload-size"); @@ -328,7 +330,7 @@ private static int GetPositiveInt(string[] args, string name, int defaultValue) private sealed class SaturatedDecodeExecutor : IAsyncDisposable { - private readonly Channel _channel; + private readonly Channel _channel; private readonly Task[] _workers; private readonly TaskCompletionSource _workerGate = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -341,7 +343,7 @@ internal SaturatedDecodeExecutor( BackpressureMetrics metrics) { _metrics = metrics; - _channel = Channel.CreateBounded(new BoundedChannelOptions(queueCapacity) + _channel = Channel.CreateBounded(new BoundedChannelOptions(queueCapacity) { FullMode = BoundedChannelFullMode.Wait, SingleReader = workerCount == 1, @@ -356,45 +358,35 @@ internal SaturatedDecodeExecutor( internal async ValueTask EnqueueAsync( ISharpLinkCompressionProvider provider, ReadOnlyMemory compressed, - int originalLength, - CancellationToken cancellationToken) + int originalLength) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var work = new DecodeWorkItem( + var work = new SaturationDecodeWorkItem( provider, compressed, originalLength, - cancellationToken, completion, _metrics); var writeStarted = Stopwatch.GetTimestamp(); - var write = _channel.Writer.WriteAsync(work, cancellationToken); - try + var write = _channel.Writer.WriteAsync(work); + if (!write.IsCompletedSuccessfully) { - if (!write.IsCompletedSuccessfully) + _metrics.OnBackpressureWaitStarted(); + try { - _metrics.OnBackpressureWaitStarted(); - try - { - await write; - } - finally - { - _metrics.OnBackpressureWaitCompleted( - Stopwatch.GetElapsedTime(writeStarted).TotalNanoseconds / 1000d); - } + await write; } - else + finally { - await write; + _metrics.OnBackpressureWaitCompleted( + Stopwatch.GetElapsedTime(writeStarted).TotalNanoseconds / 1000d); } } - catch + else { - throw; + await write; } - work.EnableQueuedCancellation(); _metrics.OnWorkEnqueued(); await completion.Task; } @@ -421,119 +413,52 @@ private async Task WorkerAsync() } } - private sealed class DecodeWorkItem + private readonly record struct SaturationDecodeWorkItem( + ISharpLinkCompressionProvider Provider, + ReadOnlyMemory Compressed, + int OriginalLength, + TaskCompletionSource Completion, + BackpressureMetrics Metrics) { - private const int Queued = 0; - private const int Running = 1; - private const int CancelledBeforeStart = 2; - private readonly ISharpLinkCompressionProvider _provider; - private readonly ReadOnlyMemory _compressed; - private readonly int _originalLength; - private readonly CancellationToken _cancellationToken; - private readonly TaskCompletionSource _completion; - private readonly BackpressureMetrics _metrics; - private CancellationTokenRegistration _cancellationRegistration; - private int _state; - - internal DecodeWorkItem( - ISharpLinkCompressionProvider provider, - ReadOnlyMemory compressed, - int originalLength, - CancellationToken cancellationToken, - TaskCompletionSource completion, - BackpressureMetrics metrics) - { - _provider = provider; - _compressed = compressed; - _originalLength = originalLength; - _cancellationToken = cancellationToken; - _completion = completion; - _metrics = metrics; - } - - internal void EnableQueuedCancellation() - { - if (!_cancellationToken.CanBeCanceled) - return; - _cancellationRegistration = _cancellationToken.Register( - static state => ((DecodeWorkItem)state!).CancelBeforeStart(), - this); - } - internal void Run() { - _metrics.OnWorkDequeued(); - if (Interlocked.CompareExchange(ref _state, Running, Queued) != Queued) - { - _cancellationRegistration.Dispose(); - _metrics.OnCancelledWorkSkipped(); - return; - } - - _cancellationRegistration.Dispose(); + Metrics.OnWorkDequeued(); try { - // Cancellation that wins while queue ownership is still held completes the - // caller before worker service. A cancellation racing after ownership transfer - // is checked here before any provider-side CRC/decompression work begins. - _cancellationToken.ThrowIfCancellationRequested(); - _metrics.OnProviderStarted(); - var output = new ArrayBufferWriter(_originalLength); - var result = _provider.Decompress( - new ReadOnlySequence(_compressed), + var output = new ArrayBufferWriter(OriginalLength); + var result = Provider.Decompress( + new ReadOnlySequence(Compressed), output, - _originalLength, - _cancellationToken); - if (result.ConsumedBytes != _compressed.Length || - result.WrittenBytes != _originalLength || - output.WrittenCount != _originalLength) + OriginalLength, + CancellationToken.None); + if (result.ConsumedBytes != Compressed.Length || + result.WrittenBytes != OriginalLength || + output.WrittenCount != OriginalLength) { throw new InvalidOperationException( "Backpressure decode returned inconsistent provider counts."); } - _metrics.OnWorkCompleted(); - _completion.TrySetResult(); - } - catch (OperationCanceledException) when (_cancellationToken.IsCancellationRequested) - { - _completion.TrySetCanceled(_cancellationToken); + Metrics.OnWorkCompleted(); + Completion.TrySetResult(); } catch (Exception exception) { - _completion.TrySetException(exception); + Completion.TrySetException(exception); } } - - private void CancelBeforeStart() - { - if (Interlocked.CompareExchange(ref _state, CancelledBeforeStart, Queued) != Queued) - return; - _metrics.OnQueuedCancellation(); - _completion.TrySetCanceled(_cancellationToken); - } } private sealed class BackpressureMetrics : IDisposable { private readonly object _gate = new(); private readonly List _waitMicroseconds = []; - private readonly int _queueCapacity; private long _backpressureWaitCount; private long _pendingWriters; private long _peakPendingWriters; private long _completedWorkItems; private long _queuedWorkItems; - private long _providerStartCount; - private long _queuedCancellationCount; - private long _skippedCancelledWorkItems; - - internal BackpressureMetrics(int queueCapacity) - { - _queueCapacity = queueCapacity; - } internal ManualResetEventSlim BackpressureObserved { get; } = new(false); - internal ManualResetEventSlim QueueFilled { get; } = new(false); internal void OnBackpressureWaitStarted() { @@ -550,21 +475,10 @@ internal void OnBackpressureWaitCompleted(double microseconds) _waitMicroseconds.Add(microseconds); } - internal void OnWorkEnqueued() - { - var queued = Interlocked.Increment(ref _queuedWorkItems); - if (queued >= _queueCapacity) - QueueFilled.Set(); - } + internal void OnWorkEnqueued() => Interlocked.Increment(ref _queuedWorkItems); internal void OnWorkDequeued() => Interlocked.Decrement(ref _queuedWorkItems); - internal void OnProviderStarted() => Interlocked.Increment(ref _providerStartCount); - - internal void OnQueuedCancellation() => Interlocked.Increment(ref _queuedCancellationCount); - - internal void OnCancelledWorkSkipped() => Interlocked.Increment(ref _skippedCancelledWorkItems); - internal void OnWorkCompleted() => Interlocked.Increment(ref _completedWorkItems); internal BackpressureMetricsSnapshot Capture() @@ -579,17 +493,10 @@ internal BackpressureMetricsSnapshot Capture() Percentile(waits, 0.50), Percentile(waits, 0.99), Volatile.Read(ref _completedWorkItems), - Volatile.Read(ref _queuedWorkItems), - Volatile.Read(ref _providerStartCount), - Volatile.Read(ref _queuedCancellationCount), - Volatile.Read(ref _skippedCancelledWorkItems)); + Volatile.Read(ref _queuedWorkItems)); } - public void Dispose() - { - BackpressureObserved.Dispose(); - QueueFilled.Dispose(); - } + public void Dispose() => BackpressureObserved.Dispose(); private static double Percentile(double[] values, double percentile) { @@ -621,10 +528,7 @@ private readonly record struct BackpressureMetricsSnapshot( double MedianWaitMicroseconds, double P99WaitMicroseconds, long CompletedWorkItems, - long CurrentQueuedWorkItems, - long ProviderStartCount, - long QueuedCancellationCount, - long SkippedCancelledWorkItems); + long CurrentQueuedWorkItems); private readonly record struct SaturationEvidenceResult( double ElapsedSeconds, @@ -641,6 +545,9 @@ internal sealed record QueuedCancellationEvidenceResult( long ProviderStarts, long SkippedBeforeProvider, bool OwnershipReleasedBeforeWorkerStart, + bool ReservationReleasedBeforeWorkerStart, + bool RetainedLeaseReleasedBeforeWorkerStart, + bool DecodedLeaseReleasedBeforeWorkerStart, double CancellationCompletionMicroseconds); internal sealed record DecodeExecutorBackpressureEvidenceResult( From b19eaec9657735ad42d769e0571cbe4e11e84a97 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:55:26 +0800 Subject: [PATCH 028/228] fix(bench): align D probe seam accessibility --- .../SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs index 9209ac03b..11731d76f 100644 --- a/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs +++ b/test/SharpLink.Benchmarks/DecodeExecutionPhase0EvidenceRunner.cs @@ -1219,7 +1219,7 @@ internal readonly record struct DecodeMetricsSnapshot( long PeakDecodeQueueDepth, long SkippedCancelledWorkItems); - private readonly record struct DecodeRequestResult( + internal readonly record struct DecodeRequestResult( bool Accepted, double SchedulerDelayMicroseconds); From 2e7a56049ccd069f7cd8f2b9f1fde81f5e2bb5ea Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:57:40 +0800 Subject: [PATCH 029/228] docs(bench): require post-cancellation-safe-D evidence --- docs/phase0-decode-performance.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/phase0-decode-performance.md b/docs/phase0-decode-performance.md index fbe1b526a..fed9983b0 100644 --- a/docs/phase0-decode-performance.md +++ b/docs/phase0-decode-performance.md @@ -30,6 +30,8 @@ Two independent hosted-runner workflow executions were used for the initial exec - workflow run `32568724302`, benchmark head `10b33c25914f3d6762904b6ead7202cb72a1d781`; - workflow run `32568891389`, benchmark-equivalent head `0744415a4ab2abf04a8b1ea04d34f3a64df583c6`. +Those runs predate D's queue-owned cancellation work-item/registration path and are historical evidence only. Any quantitative D range used by the final ADR must be regenerated from two independent hosted-runner executions of the cancellation-safe D hot path. + Both runs used 4 logical CPUs, but GitHub assigned different AMD EPYC models across shards/runs. The strategy ordering below remained stable despite that reassignment. ## Evidence collected @@ -62,7 +64,7 @@ This preserves the #244 requirement while comparing execution models. ## Results -B (`InlineProvider`) is the within-shard baseline (`1.000`). The ranges below are the two independent workflow medians. These D ratios describe **fixed-worker overhead with an unsaturated executor queue**; they do not, by themselves, prove bounded-queue saturation behavior. +B (`InlineProvider`) is the within-shard baseline (`1.000`). The ranges below are the two independent **pre-cancellation-safe-D** workflow medians and are retained temporarily as historical comparison only; they are not quantitative support for the current D implementation. These D ratios describe fixed-worker overhead with an unsaturated executor queue and also predate the queued-cancellation work-item/registration cost. | Payload / compressibility | A QPS / CPU | C QPS / CPU | D QPS / CPU | Interpretation | | --- | --- | --- | --- | --- | @@ -87,13 +89,9 @@ A separate probe fixes queue capacity independently of offered concurrency (`que This probe exists specifically to validate the bounded/backpressure path that the original comparative D queue (`max(32, concurrency * 2)`) could not saturate. Its blocked-writer counts and wait distributions are stored separately from the A/B/C/D throughput ratios; saturation evidence must not be blended into the unsaturated comparative QPS table. -The same fixed-capacity runner also executes a **queued-cancellation ownership probe**. It fills the queue while all workers remain gated, cancels every queue-owned request, and requires all of the following before workers are released: - -- caller-side reservation/buffer ownership completes/releases promptly; -- provider-start count remains `0`; -- every queued work item transitions atomically from queue ownership to cancelled-before-start ownership. +Queued cancellation is now exercised through the **same `DecodeCaseRuntime -> PersistentDecodeExecutor -> PersistentDecodeWorkItem` path used by comparative D**, not through a parallel executor/state-machine implementation. A deterministic worker gate holds actual D work in queue ownership. Before worker release, cancellation must complete the real caller path and the probe asserts that the real call reservation, retained-compressed lease, and decoded-output lease are all released while provider/decompress count remains `0` and the cancelled work items remain queued. -After worker release/drain, the probe requires every cancelled item to be skipped before provider execution and still requires provider-start count `= 0`. The candidate work item performs an explicit cancellation check immediately after a worker wins the queue-to-running ownership transition and before any provider/CRC work, covering the race where cancellation arrives as ownership transfers to a worker. +After worker release/drain, the actual D work items must all take the `CancelledBeforeStart` skip path: queue depth reaches `0`, skipped-cancel count equals the number of cancelled requests, provider/decompress count remains `0`, and no request ownership is reacquired or leaked. This directly protects the ordering on which safe early return of the pooled retained/decoded buffers depends. This is the required semantic shape for production D: cancellation may complete caller ownership early only if cancellation wins while the item is still queue-owned. If a worker has already won ownership, the caller must continue to await that worker so retained/decoded buffers cannot be returned while provider code may still access them. @@ -122,7 +120,7 @@ The executor queue must be fixed/bounded independently of offered request concur 2. **Use D / persistent bounded DecodeExecutor for expensive remote-cancellable decode.** - Keep the reader/control-plane path free to process Cancel/deadline/close/Stop while decode is supervised by a small persistent worker set. - - The comparative 1 MiB evidence shows that fixed-worker D avoids the repeated Brotli-loop-yield cost paid by this C prototype. The separate saturation probe validates the fixed-capacity bounded-channel/backpressure mechanism, and the queued-cancellation probe validates cancellation before provider start without prematurely releasing worker-owned buffers. + - The comparative 1 MiB evidence shows that fixed-worker D avoids the repeated Brotli-loop-yield cost paid by this C prototype. The separate saturation probe validates the fixed-capacity bounded-channel/backpressure mechanism, and the actual-D queued-cancellation probe validates cancellation before provider start together with real reservation/pooled-lease release ordering. - The exact production threshold remains an internal policy decision that must be validated end-to-end; Phase 0 selects the execution **shape**, not a threshold value or a new public configuration API. 3. **Do not productionize A.** From 6428e2cc5aa7775bcc66e97275b0d7ae474f7483 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:02:01 +0800 Subject: [PATCH 030/228] docs(bench): refresh current-D performance evidence --- docs/phase0-decode-performance.md | 59 +++++++++++++++++-------------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/docs/phase0-decode-performance.md b/docs/phase0-decode-performance.md index fed9983b0..8ca7783b9 100644 --- a/docs/phase0-decode-performance.md +++ b/docs/phase0-decode-performance.md @@ -7,7 +7,7 @@ This slice is benchmark-only and is stacked on the reviewed call-reservation pri - **A — ThreadPoolHandoff**: one per-request ThreadPool handoff before synchronous provider decode. This is the #261-style scheduling baseline. - **B — InlineProvider**: reserve, call the existing synchronous compression provider inline, then activate. The built-in Brotli provider already decodes in bounded 8 KiB output chunks and checks cancellation in its decode loop. - **C — CooperativeQuantum**: benchmark-only Brotli-loop prototype that preserves SharpLink integrity-trailer/CRC validation, decodes in the same 8 KiB chunks, and reschedules after a bounded 64 KiB output quantum. The integrity CRC is still a whole-input synchronous scan before the first cancellation check/yield, so C is **not** an end-to-end bounded cooperative decode pipeline. -- **D — PersistentExecutor**: persistent fixed workers with explicit queued-work ownership. The comparative A/B/C/D matrix measures this executor with an unsaturated queue; separate fixed-capacity probes exercise bounded-channel backpressure and cancellation while work is still queue-owned. +- **D — PersistentExecutor**: persistent fixed workers with explicit queued-work ownership and queue-owned cancellation. The comparative A/B/C/D matrix measures this executor with an unsaturated queue; separate fixed-capacity probes exercise bounded-channel backpressure and cancellation while work is still queue-owned. The C implementation is intentionally local to the benchmark project. It is not a proposed public provider API or production implementation, and its results apply only to this Brotli-loop `Task.Yield` shape rather than cooperative decode in general. @@ -25,14 +25,12 @@ Each payload/compressibility shard runs all four strategies across: The queued-admission shape is deliberately one scheduler continuation, not a production `AdmissionProgram` implementation. It isolates how an already-asynchronous admission continuation interacts with the decode execution model without prematurely coupling the benchmark to #264 production wiring. -Two independent hosted-runner workflow executions were used for the initial execution-shape comparison. Relative ratios are calculated only against B inside the same payload/compressibility shard; absolute QPS is not compared across hosted VMs. +Two independent hosted-runner workflow executions were run **after** D gained its cancellation-aware `PersistentDecodeWorkItem` and `CancellationToken.Register` hot path. Relative ratios are calculated only against B inside the same payload/compressibility shard; absolute QPS is not compared across hosted VMs. -- workflow run `32568724302`, benchmark head `10b33c25914f3d6762904b6ead7202cb72a1d781`; -- workflow run `32568891389`, benchmark-equivalent head `0744415a4ab2abf04a8b1ea04d34f3a64df583c6`. +- workflow run `32580143013`, benchmark head `b19eaec9657735ad42d769e0571cbe4e11e84a97`; +- workflow run `32580252570`, benchmark-equivalent head `2e7a56049ccd069f7cd8f2b9f1fde81f5e2bb5ea` (documentation-only change after the first run). -Those runs predate D's queue-owned cancellation work-item/registration path and are historical evidence only. Any quantitative D range used by the final ADR must be regenerated from two independent hosted-runner executions of the cancellation-safe D hot path. - -Both runs used 4 logical CPUs, but GitHub assigned different AMD EPYC models across shards/runs. The strategy ordering below remained stable despite that reassignment. +Earlier pre-cancellation-safe-D runs are historical evidence only and are no longer used as quantitative support for D. The ranges below come exclusively from these two current-D executions. ## Evidence collected @@ -52,7 +50,7 @@ Per comparative matrix case: A separate burst probe records synthetic drain-completion latency for each strategy. It is useful for relative executor supervision cost but is not a substitute for the production Stop/Drain integration suite. -Capacity-full cases are executable correctness assertions: any decompression call, decoded-buffer rent, or compressed-payload retention fails the evidence run. Across both independent runs, all 2,592 capacity-full matrix rows passed, covering 4,294,656 rejected requests with: +Capacity-full cases are executable correctness assertions: any decompression call, decoded-buffer rent, or compressed-payload retention fails the evidence run. Across the two refreshed runs, all 2,592 capacity-full matrix rows passed, covering 4,294,656 rejected requests with: - accepted requests: `0`; - decompression calls / rejected request: `0`; @@ -60,38 +58,47 @@ Capacity-full cases are executable correctness assertions: any decompression cal - peak retained compressed bytes: `0`; - peak decoded bytes: `0`. -This preserves the #244 requirement while comparing execution models. +This preserves the #244 requirement while comparing the current execution models. ## Results -B (`InlineProvider`) is the within-shard baseline (`1.000`). The ranges below are the two independent **pre-cancellation-safe-D** workflow medians and are retained temporarily as historical comparison only; they are not quantitative support for the current D implementation. These D ratios describe fixed-worker overhead with an unsaturated executor queue and also predate the queued-cancellation work-item/registration cost. +B (`InlineProvider`) is the within-shard baseline (`1.000`). The ranges below are the two independent **cancellation-safe-D** workflow medians. They include D's per-request work-item allocation, queued-cancellation registration, and ownership transition overhead. D is still measured with an unsaturated comparison queue; saturation/backpressure is validated separately. | Payload / compressibility | A QPS / CPU | C QPS / CPU | D QPS / CPU | Interpretation | | --- | --- | --- | --- | --- | -| 1 KiB / high | `0.772–0.773` / `1.300–1.407` | `0.993–1.009` / `0.997–1.003` | `0.645–0.690` / `1.547–1.556` | scheduling dominates; B/C are effectively equivalent | -| 1 KiB / low | `0.666–0.789` / `1.257–1.560` | `0.981–1.003` / `0.991–1.016` | `0.589–0.700` / `1.418–1.759` | per-request handoff/executor is too expensive | -| 64 KiB / high | `0.974–0.979` / `1.022–1.023` | `1.000–1.000` / `0.999–0.999` | `0.976–0.978` / `1.024–1.024` | 64 KiB Brotli-loop quantum does not materially yield; B/C remain best in this prototype | -| 64 KiB / low | `0.952–0.964` / `1.039–1.050` | `0.984–0.986` / `1.016–1.019` | `0.945–0.954` / `1.049–1.062` | B remains the cheapest measured execution shape | -| 1 MiB / high | `0.972–0.976` / `1.034–1.036` | `0.914–0.938` / `1.120–1.149` | `0.971–0.974` / `1.035–1.043` | D reaches A-like fixed-worker throughput/CPU; this C prototype pays repeated Brotli-loop yields | -| 1 MiB / low | `0.953–0.963` / `1.044–1.051` | `0.939–0.948` / `1.068–1.071` | `0.967–0.974` / `1.044–1.044` | D is the best measured fixed-worker offload candidate; this C prototype pays repeated-yield cost | +| 1 KiB / high | `0.746–0.760` / `1.313–1.341` | `0.998–1.011` / `0.988–1.002` | `0.681–0.707` / `1.383–1.505` | scheduling and D cancellation ownership dominate; B/C are effectively equivalent | +| 1 KiB / low | `0.758–0.788` / `1.266–1.304` | `0.993–0.994` / `1.006–1.010` | `0.690–0.742` / `1.393–1.499` | B remains decisively cheaper than either offload shape | +| 64 KiB / high | `0.943–0.965` / `1.036–1.060` | `0.999–1.001` / `1.000–1.002` | `0.922–0.931` / `1.061–1.074` | B/C remain best; current D's cancellation-safe fixed-worker overhead is measurable | +| 64 KiB / low | `0.944–0.947` / `1.056–1.057` | `0.986–0.989` / `1.012–1.016` | `0.916–0.942` / `1.073–1.099` | B remains the cheapest measured execution shape | +| 1 MiB / high | `0.981–0.985` / `1.032–1.033` | `0.935–0.943` / `1.112–1.135` | `0.974–0.976` / `1.034–1.043` | current D retains A-like fixed-worker throughput/CPU; this C prototype pays repeated Brotli-loop yields | +| 1 MiB / low | `0.946–0.967` / `1.043–1.061` | `0.932–0.941` / `1.064–1.078` | `0.965–0.974` / `1.040–1.051` | current D remains the best measured fixed-worker offload candidate; this C prototype pays repeated-yield cost | + +The refreshed data strengthens the adaptive split rather than weakening it: D's queue-owned cancellation machinery has a visible fixed cost at 1 KiB and 64 KiB, while at 1 MiB its QPS/CPU remains close to A and ahead of this C prototype. That supports B for cheap work and D only once preserving reader/control-plane availability justifies the fixed-worker ownership cost. -P99 follows the same small-payload conclusion: A/D add substantial scheduler tails at 1 KiB, while C is essentially B until the output quantum is crossed. At 1 MiB and high offered concurrency, A/D queueing can create large request-latency tails. That is not an argument for an unbounded inline reader loop; it is evidence that production D must combine bounded worker concurrency with explicit queue/retained/decoded resource budgets and admission/backpressure. +P99 follows the same small-payload conclusion: A/D add scheduler tails at 1 KiB, while C is essentially B until the output quantum is crossed. At 1 MiB and high offered concurrency, A/D queueing can create large request-latency tails. That is not an argument for an unbounded inline reader loop; it is evidence that production D must combine bounded worker concurrency with explicit queue/retained/decoded resource budgets and admission/backpressure. The cancellation probe directly cancels the decode token after decode begins. It verifies provider/executor token observation, but it does **not** model the key network property that an inline RequestLoop cannot consume a later remote Cancel/close/Stop frame while it is synchronously decoding. It therefore cannot establish a safe remote-cancellable inline threshold or bound reader-loop/control-plane stall. -For 1 MiB probes, cancellation was observed in essentially every case in both runs, and median local token-observation time was similar between B/A/C/D for the same compressibility. This means D does not introduce a material cancellation-token reaction penalty once work has begun; it does not prove anything about how quickly a remote control frame is read when B is running inline. +For 1 MiB probes, cancellation was observed in essentially every case in both refreshed runs, and median local token-observation time remained similar between B/A/C/D for the same compressibility. This means D does not introduce a material cancellation-token reaction penalty once work has begun; it does not prove anything about how quickly a remote control frame is read when B is running inline. C's cancellation/yield evidence also has a specific boundary: `Crc32Accumulator.Compute` scans the complete compressed payload synchronously before the Brotli loop starts. For low-compressibility 1 MiB inputs that can mean nearly the whole compressed input is traversed before C reaches its first cancellation check or output-quantum yield. The measurements therefore compare B against a **Brotli-loop-only cooperative prototype**; they do not establish the cost or viability of a design that also makes integrity validation cooperative. -### Fixed-capacity executor saturation and queued-cancellation probes +### Fixed-capacity executor saturation and actual-D queued-cancellation probes + +A separate saturation probe fixes queue capacity independently of offered concurrency (`queue capacity = 8`, `concurrency = 128`, `operations = 256`). Its minimal local channel harness deliberately holds workers until bounded-channel backpressure is observed, and it fails if no blocked writer is recorded or if submitted decode work does not complete after release. That harness exists only to measure channel saturation; it no longer carries a second queued-cancellation state machine. -A separate probe fixes queue capacity independently of offered concurrency (`queue capacity = 8`, `concurrency = 128`, `operations = 256`). Executor workers are deliberately held behind a gate until at least one `ChannelWriter.WriteAsync` is observed to complete asynchronously. The probe fails if no blocked writer is recorded or if submitted decode work does not complete after workers are released. +Queued cancellation is exercised through the **same `DecodeCaseRuntime -> PersistentDecodeExecutor -> PersistentDecodeWorkItem` path used by comparative D**. A deterministic worker gate holds actual D work in queue ownership. Before cancellation the probe requires all 8 real call reservations, retained-compressed leases, decoded-output leases, and D queue entries to be in flight while `DecompressCalls=0`. -This probe exists specifically to validate the bounded/backpressure path that the original comparative D queue (`max(32, concurrency * 2)`) could not saturate. Its blocked-writer counts and wait distributions are stored separately from the A/B/C/D throughput ratios; saturation evidence must not be blended into the unsaturated comparative QPS table. +After cancellation completes but before worker release, the probe requires the real call reservations, retained-compressed bytes, and decoded-output bytes all to be released (`0`) while the 8 cancelled work items remain in the gated D queue and `DecompressCalls=0`. Across all six payload/compressibility shards in both refreshed runs, every queued-cancellation probe reported: -Queued cancellation is now exercised through the **same `DecodeCaseRuntime -> PersistentDecodeExecutor -> PersistentDecodeWorkItem` path used by comparative D**, not through a parallel executor/state-machine implementation. A deterministic worker gate holds actual D work in queue ownership. Before worker release, cancellation must complete the real caller path and the probe asserts that the real call reservation, retained-compressed lease, and decoded-output lease are all released while provider/decompress count remains `0` and the cancelled work items remain queued. +- `cancelled=8`; +- `providerStarts=0`; +- `skippedBeforeProvider=8` after drain; +- `reservationReleased=True` before worker service; +- `retainedLeaseReleased=True` before worker service; +- `decodedLeaseReleased=True` before worker service. -After worker release/drain, the actual D work items must all take the `CancelledBeforeStart` skip path: queue depth reaches `0`, skipped-cancel count equals the number of cancelled requests, provider/decompress count remains `0`, and no request ownership is reacquired or leaked. This directly protects the ordering on which safe early return of the pooled retained/decoded buffers depends. +After worker release/drain, the actual D work items must all take the `CancelledBeforeStart` skip path: queue depth reaches `0`, skipped-cancel count equals 8, provider/decompress count remains `0`, and no request ownership is reacquired or leaked. The work item checks its ownership state before reading the retained/output fields, so the deterministic probe exercises the exact ordering on which safe early return of those pooled buffers depends. This is the required semantic shape for production D: cancellation may complete caller ownership early only if cancellation wins while the item is still queue-owned. If a worker has already won ownership, the caller must continue to await that worker so retained/decoded buffers cannot be returned while provider code may still access them. @@ -116,11 +123,11 @@ The executor queue must be fixed/bounded independently of offered request concur 1. **Use B / inline provider decode for the cheap path.** - Non-remote-cancellable accepted requests should decode inline after all required permits are held. - Remote-cancellable requests may decode inline only when a production RequestLoop experiment shows that the chosen cost budget keeps remote Cancel/close/Stop observation within an explicit control-plane stall budget. - - **64 KiB declared/original output is only the first threshold hypothesis to test**, because B/C have similar CPU/QPS through that size. Phase 0 does not establish 64 KiB as a safe remote-cancellable inline budget. + - **64 KiB declared/original output is only the first threshold hypothesis to test**, because B/C have similar CPU/QPS through that size while cancellation-safe D has visible fixed-worker ownership cost. Phase 0 does not establish 64 KiB as a safe remote-cancellable inline budget. 2. **Use D / persistent bounded DecodeExecutor for expensive remote-cancellable decode.** - Keep the reader/control-plane path free to process Cancel/deadline/close/Stop while decode is supervised by a small persistent worker set. - - The comparative 1 MiB evidence shows that fixed-worker D avoids the repeated Brotli-loop-yield cost paid by this C prototype. The separate saturation probe validates the fixed-capacity bounded-channel/backpressure mechanism, and the actual-D queued-cancellation probe validates cancellation before provider start together with real reservation/pooled-lease release ordering. + - The refreshed 1 MiB evidence includes D's cancellation-aware work-item/registration cost and still shows fixed-worker D avoiding the repeated Brotli-loop-yield cost paid by this C prototype. The separate saturation probe validates bounded-channel backpressure, and the actual-D queued-cancellation probe validates cancellation before provider start together with real reservation/pooled-lease release ordering. - The exact production threshold remains an internal policy decision that must be validated end-to-end; Phase 0 selects the execution **shape**, not a threshold value or a new public configuration API. 3. **Do not productionize A.** @@ -129,7 +136,7 @@ The executor queue must be fixed/bounded independently of offered request concur 4. **Do not productionize this C prototype as a separate execution model.** - Up to 64 KiB output, it mostly behaves like B because the Brotli output quantum is not crossed. - - At 1 MiB, its repeated Brotli-loop yields consistently cost more CPU/QPS than D's fixed-worker path in the comparative matrix. + - At 1 MiB, its repeated Brotli-loop yields cost more CPU/QPS than cancellation-safe D in both refreshed runs. - Its synchronous whole-input CRC means Phase 0 has **not** evaluated a fully cooperative integrity+decode pipeline. The data therefore does not rule out such a design in general; it only shows that carrying this provider-specific Brotli-loop `Task.Yield` prototype alongside B + D is not justified by the measured tradeoff. ## Production follow-up implied by this ADR From 317a5e57150c7b24b198b8b7e93119fb5c4ae78c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:35:44 +0800 Subject: [PATCH 031/228] test(perf): cover actual D blocked-writer cancellation --- ...BlockedWriterCancellationEvidenceRunner.cs | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 test/SharpLink.Benchmarks/DecodeExecutorBlockedWriterCancellationEvidenceRunner.cs diff --git a/test/SharpLink.Benchmarks/DecodeExecutorBlockedWriterCancellationEvidenceRunner.cs b/test/SharpLink.Benchmarks/DecodeExecutorBlockedWriterCancellationEvidenceRunner.cs new file mode 100644 index 000000000..484884de1 --- /dev/null +++ b/test/SharpLink.Benchmarks/DecodeExecutorBlockedWriterCancellationEvidenceRunner.cs @@ -0,0 +1,312 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace SharpLink.Benchmarks; + +/// +/// Exercises cancellation at the actual persistent executor boundary where the bounded +/// channel is full and an additional request is waiting in ChannelWriter.WriteAsync. +/// The probe uses the same DecodeCaseRuntime/PersistentDecodeExecutor/PersistentDecodeWorkItem +/// and real reservation/retained/output leases as comparative strategy D. +/// +internal static class DecodeExecutorBlockedWriterCancellationEvidenceRunner +{ + private const int DefaultQueueCapacity = 8; + private const int DefaultQuantumBytes = 64 * 1024; + + internal static async Task RunAsync(string[] args) + { + var outputPath = GetOption(args, "--output") ?? + Path.Combine("artifacts", "performance", "current", "phase0-decode-blocked-writer-cancel.json"); + var payloadSize = GetPayloadSize(args); + var compressible = GetCompressibility(args); + var queueCapacity = GetPositiveInt(args, "--queue-capacity", DefaultQueueCapacity); + var fixture = DecodeExecutionPhase0EvidenceRunner.DecodeFixture.Create(payloadSize, compressible); + var result = await MeasureAsync(fixture, queueCapacity); + + var fullPath = Path.GetFullPath(outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await File.WriteAllTextAsync(fullPath, JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = true + })); + + Console.WriteLine($"Phase 0 actual-D blocked-writer cancellation evidence: {fullPath}"); + Console.WriteLine( + $"PHASE0_BLOCKED_WRITER_CANCEL payload={payloadSize} compressible={compressible} " + + $"queueCapacity={queueCapacity} publishedBeforeCancel={result.PublishedBeforeCancel} " + + $"occupiedWhileBlocked={result.OccupiedCallsWhileBlocked} " + + $"queueDepthWhileBlocked={result.DecodeQueueDepthWhileBlocked} " + + $"blockedReservationReleased={result.BlockedReservationReleased} " + + $"blockedRetainedLeaseReleased={result.BlockedRetainedLeaseReleased} " + + $"blockedDecodedLeaseReleased={result.BlockedDecodedLeaseReleased} " + + $"providerStarts={result.ProviderStarts} skippedQueued={result.SkippedQueuedWorkItems} " + + $"cancelCompletionUs={result.BlockedCancellationCompletionMicroseconds:F2}"); + } + + private static async Task MeasureAsync( + DecodeExecutionPhase0EvidenceRunner.DecodeFixture fixture, + int queueCapacity) + { + var workerGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var queueFilled = new ManualResetEventSlim(false); + var publishedCount = 0; + var unexpectedCompletions = 0; + var queuedCancellations = new CancellationTokenSource[queueCapacity]; + var queuedRequests = new Task[queueCapacity]; + using var blockedCancellation = new CancellationTokenSource(); + + await using var runtime = new DecodeExecutionPhase0EvidenceRunner.DecodeCaseRuntime( + fixture, + DecodeExecutionPhase0EvidenceRunner.DecodeStrategy.PersistentExecutor, + DecodeExecutionPhase0EvidenceRunner.AdmissionMode.Off, + DecodeExecutionPhase0EvidenceRunner.CapacityMode.Available, + queueCapacity + 1, + DefaultQuantumBytes, + executorQueueCapacity: queueCapacity, + executorWorkerGate: workerGate.Task, + onExecutorWorkPublished: () => + { + if (Interlocked.Increment(ref publishedCount) == queueCapacity) + queueFilled.Set(); + }); + + try + { + for (var index = 0; index < queuedRequests.Length; index++) + { + var cancellation = new CancellationTokenSource(); + queuedCancellations[index] = cancellation; + queuedRequests[index] = RunRequestAsync(runtime, cancellation, () => + Interlocked.Increment(ref unexpectedCompletions)); + } + + if (!queueFilled.Wait(TimeSpan.FromSeconds(5))) + throw new TimeoutException("Actual-D blocked-writer probe did not fill the executor queue."); + + var fullCapacity = runtime.CaptureCapacitySnapshot(); + var fullMetrics = runtime.CaptureMetrics(); + if (Volatile.Read(ref publishedCount) != queueCapacity) + throw new InvalidOperationException("Actual-D blocked-writer probe did not publish exactly one full queue."); + if (fullCapacity.OccupiedCalls != queueCapacity) + throw new InvalidOperationException("Actual-D blocked-writer probe did not hold the full queue's reservations."); + if (fullMetrics.CurrentDecodeQueueDepth != queueCapacity) + throw new InvalidOperationException("Actual-D blocked-writer probe did not fill the real D queue."); + if (fullMetrics.CurrentRetainedBytes <= 0 || fullMetrics.CurrentDecodedBytes <= 0) + throw new InvalidOperationException("Actual-D blocked-writer probe did not hold real pooled leases for the full queue."); + if (fullMetrics.DecompressCalls != 0) + throw new InvalidOperationException("Actual-D blocked-writer probe entered provider work while workers were gated."); + + var blockedRequest = RunRequestAsync(runtime, blockedCancellation, () => + Interlocked.Increment(ref unexpectedCompletions)); + + await WaitUntilAsync( + () => + { + var capacity = runtime.CaptureCapacitySnapshot(); + var metrics = runtime.CaptureMetrics(); + return capacity.OccupiedCalls == queueCapacity + 1 && + metrics.CurrentDecodeQueueDepth == queueCapacity + 1 && + metrics.CurrentRetainedBytes > fullMetrics.CurrentRetainedBytes && + metrics.CurrentDecodedBytes > fullMetrics.CurrentDecodedBytes; + }, + "The extra actual-D request did not reach the blocked-writer ownership state."); + + var blockedCapacity = runtime.CaptureCapacitySnapshot(); + var blockedMetrics = runtime.CaptureMetrics(); + if (Volatile.Read(ref publishedCount) != queueCapacity) + { + throw new InvalidOperationException( + "The extra actual-D request published despite a full channel and gated workers."); + } + if (blockedRequest.IsCompleted) + throw new InvalidOperationException("The extra actual-D request completed before blocked-writer cancellation."); + if (blockedMetrics.DecompressCalls != 0) + throw new InvalidOperationException("The blocked actual-D writer entered provider work."); + + // With workers gated, all queueCapacity slots are already published and no reader can + // free a slot. The ninth request has incremented D's queue-attempt metric and holds its + // real reservation/retained/output leases, while the publish callback remains at eight; + // it is therefore waiting before publication in the real ChannelWriter.WriteAsync path. + var cancellationStarted = Stopwatch.GetTimestamp(); + blockedCancellation.Cancel(); + await blockedRequest.WaitAsync(TimeSpan.FromSeconds(5)); + var cancellationCompletionMicroseconds = + Stopwatch.GetElapsedTime(cancellationStarted).TotalNanoseconds / 1000d; + + var afterBlockedCancelCapacity = runtime.CaptureCapacitySnapshot(); + var afterBlockedCancelMetrics = runtime.CaptureMetrics(); + var blockedReservationReleased = afterBlockedCancelCapacity.OccupiedCalls == queueCapacity; + var blockedRetainedReleased = + afterBlockedCancelMetrics.CurrentRetainedBytes == fullMetrics.CurrentRetainedBytes; + var blockedDecodedReleased = + afterBlockedCancelMetrics.CurrentDecodedBytes == fullMetrics.CurrentDecodedBytes; + + if (!blockedReservationReleased || !blockedRetainedReleased || !blockedDecodedReleased) + { + throw new InvalidOperationException( + "Cancelling the actual-D blocked writer did not restore reservation/retained/output ownership to the full-queue baseline."); + } + if (afterBlockedCancelMetrics.CurrentDecodeQueueDepth != queueCapacity) + { + throw new InvalidOperationException( + "Cancelling the actual-D blocked writer did not remove the unpublished enqueue attempt."); + } + if (Volatile.Read(ref publishedCount) != queueCapacity) + throw new InvalidOperationException("The cancelled blocked writer was published into the actual D queue."); + if (afterBlockedCancelMetrics.DecompressCalls != 0) + throw new InvalidOperationException("The cancelled blocked writer entered provider work."); + + foreach (var cancellation in queuedCancellations) + cancellation.Cancel(); + await Task.WhenAll(queuedRequests).WaitAsync(TimeSpan.FromSeconds(5)); + + var beforeWorkerCapacity = runtime.CaptureCapacitySnapshot(); + var beforeWorkerMetrics = runtime.CaptureMetrics(); + if (beforeWorkerCapacity.OccupiedCalls != 0 || + beforeWorkerMetrics.CurrentRetainedBytes != 0 || + beforeWorkerMetrics.CurrentDecodedBytes != 0) + { + throw new InvalidOperationException( + "Actual-D blocked-writer probe did not release all caller ownership before worker service."); + } + if (beforeWorkerMetrics.CurrentDecodeQueueDepth != queueCapacity) + throw new InvalidOperationException("Queued cancellation unexpectedly removed published items before worker release."); + if (beforeWorkerMetrics.DecompressCalls != 0) + throw new InvalidOperationException("Actual-D blocked-writer probe entered provider work before worker release."); + + workerGate.TrySetResult(); + await runtime.StopExecutorAsync(); + var afterDrainCapacity = runtime.CaptureCapacitySnapshot(); + var afterDrainMetrics = runtime.CaptureMetrics(); + if (Volatile.Read(ref unexpectedCompletions) != 0) + throw new InvalidOperationException("Actual-D blocked-writer probe unexpectedly completed decode work."); + if (afterDrainMetrics.DecompressCalls != 0) + throw new InvalidOperationException("A cancelled actual-D request entered provider work after worker release."); + if (afterDrainMetrics.SkippedCancelledWorkItems != queueCapacity) + throw new InvalidOperationException("Actual D did not skip every published queued-cancelled work item."); + if (afterDrainMetrics.CurrentDecodeQueueDepth != 0) + throw new InvalidOperationException("Actual-D blocked-writer probe left queue attempts after drain."); + if (afterDrainCapacity.OccupiedCalls != 0 || + afterDrainMetrics.CurrentRetainedBytes != 0 || + afterDrainMetrics.CurrentDecodedBytes != 0) + { + throw new InvalidOperationException("Actual-D blocked-writer probe leaked ownership after drain."); + } + + return new BlockedWriterCancellationEvidenceResult( + DateTimeOffset.UtcNow, + fixture.PayloadSize, + fixture.Compressible, + fixture.Compressed.Length, + queueCapacity, + Volatile.Read(ref publishedCount), + blockedCapacity.OccupiedCalls, + blockedMetrics.CurrentDecodeQueueDepth, + blockedReservationReleased, + blockedRetainedReleased, + blockedDecodedReleased, + afterDrainMetrics.DecompressCalls, + afterDrainMetrics.SkippedCancelledWorkItems, + cancellationCompletionMicroseconds); + } + finally + { + workerGate.TrySetResult(); + blockedCancellation.Cancel(); + foreach (var cancellation in queuedCancellations) + { + if (cancellation is null) + continue; + cancellation.Cancel(); + cancellation.Dispose(); + } + } + } + + private static Task RunRequestAsync( + DecodeExecutionPhase0EvidenceRunner.DecodeCaseRuntime runtime, + CancellationTokenSource cancellation, + Action onUnexpectedCompletion) + => Task.Run(async () => + { + try + { + _ = await runtime.ExecuteAsync(cancellation.Token); + onUnexpectedCompletion(); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + }); + + private static async Task WaitUntilAsync(Func condition, string failureMessage) + { + var deadline = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * 5d); + while (!condition()) + { + if (Stopwatch.GetTimestamp() >= deadline) + throw new TimeoutException(failureMessage); + await Task.Delay(1); + } + } + + private static int GetPayloadSize(string[] args) + { + var option = GetOption(args, "--payload-size"); + if (!int.TryParse(option, out var payloadSize) || + payloadSize is not (1024 or 65_536 or 1_048_576)) + { + throw new ArgumentOutOfRangeException( + nameof(args), + "Payload size must be 1024, 65536, or 1048576."); + } + return payloadSize; + } + + private static bool GetCompressibility(string[] args) + => GetOption(args, "--compressibility")?.ToLowerInvariant() switch + { + "high" => true, + "low" => false, + _ => throw new ArgumentOutOfRangeException( + nameof(args), + "Compressibility must be high or low.") + }; + + private static int GetPositiveInt(string[] args, string name, int defaultValue) + { + var option = GetOption(args, name); + if (option is null) + return defaultValue; + if (!int.TryParse(option, out var value) || value <= 0) + throw new ArgumentOutOfRangeException(name, "Expected a positive integer."); + return value; + } + + private static string? GetOption(string[] args, string name) + { + for (var index = 0; index < args.Length - 1; index++) + { + if (string.Equals(args[index], name, StringComparison.Ordinal)) + return args[index + 1]; + } + return null; + } +} + +internal sealed record BlockedWriterCancellationEvidenceResult( + DateTimeOffset CapturedAtUtc, + int PayloadSize, + bool Compressible, + int CompressedBytes, + int QueueCapacity, + long PublishedBeforeCancel, + long OccupiedCallsWhileBlocked, + long DecodeQueueDepthWhileBlocked, + bool BlockedReservationReleased, + bool BlockedRetainedLeaseReleased, + bool BlockedDecodedLeaseReleased, + long ProviderStarts, + long SkippedQueuedWorkItems, + double BlockedCancellationCompletionMicroseconds); From 00f79e3c062599f3c9f5d707d4c70fa6cc548980 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:36:04 +0800 Subject: [PATCH 032/228] test(perf): expose blocked-writer cancellation probe --- test/SharpLink.Benchmarks/Program.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/SharpLink.Benchmarks/Program.cs b/test/SharpLink.Benchmarks/Program.cs index f4d1f56f8..006ba41bb 100644 --- a/test/SharpLink.Benchmarks/Program.cs +++ b/test/SharpLink.Benchmarks/Program.cs @@ -62,6 +62,12 @@ public static async Task Main(string[] args) await DecodeExecutorBackpressureEvidenceRunner.RunAsync(args[1..]); return; } + if (args.Length > 0 && string.Equals( + args[0], "--phase0-decode-blocked-writer-cancel-evidence", StringComparison.Ordinal)) + { + await DecodeExecutorBlockedWriterCancellationEvidenceRunner.RunAsync(args[1..]); + return; + } if (args.Length > 0 && string.Equals( args[0], "--buffer-writer-growth-evidence", StringComparison.Ordinal)) { From 9d8c77252d548dc9cd414b2ec34f1b0bcd62bfb3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:36:20 +0800 Subject: [PATCH 033/228] ci(perf): run actual D blocked-writer cancellation probe --- .github/workflows/phase0-decode-performance.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/phase0-decode-performance.yml b/.github/workflows/phase0-decode-performance.yml index 43674d4cb..eb3075b7f 100644 --- a/.github/workflows/phase0-decode-performance.yml +++ b/.github/workflows/phase0-decode-performance.yml @@ -71,6 +71,17 @@ jobs: --operations 256 \ --output "$output/backpressure.json" + - name: Run actual D blocked-writer cancellation probe + shell: bash + run: | + output="artifacts/performance/phase0/${{ matrix.payload }}-${{ matrix.compressibility }}" + dotnet run -c Release --no-build --project test/SharpLink.Benchmarks/SharpLink.Benchmarks.csproj -- \ + --phase0-decode-blocked-writer-cancel-evidence \ + --payload-size ${{ matrix.payload }} \ + --compressibility ${{ matrix.compressibility }} \ + --queue-capacity 8 \ + --output "$output/blocked-writer-cancel.json" + - name: Upload raw evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From 2d41c14cd531737b5437c650e5ba69e967578c6a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:54:54 +0800 Subject: [PATCH 034/228] refactor(server): add production call permit owner for #273 --- .../SharpLinkServer.CallPermit.cs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/SharpLink.Server/SharpLinkServer.CallPermit.cs diff --git a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs new file mode 100644 index 000000000..6ab6404b5 --- /dev/null +++ b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs @@ -0,0 +1,109 @@ +namespace SharpLink.Server; + +internal sealed partial class SharpLinkServer +{ + /// + /// Transitional production owner for one accepted call-capacity slot. + /// + /// The backing local/global accounting intentionally remains the existing + /// Stop/Drain-hardened call accounting in this slice: reserving the permit + /// consumes both capacity slots immediately, so a Reserved permit is still + /// visible to drain as occupied work. Activation is therefore an ownership + /// phase transition only; a later #273 slice can move decode between Reserve + /// and Activate without first reopening the local-to-global drain race. + /// + internal ServerCallAdmissionResult TryReserveCall( + ServerConnectionState connection, + out ServerRequestPermit? permit) + { + ArgumentNullException.ThrowIfNull(connection); + var admission = TryAcquireCall(connection); + if (admission != ServerCallAdmissionResult.Acquired) + { + permit = null; + return admission; + } + + try + { + permit = new ServerRequestPermit(this, connection); + return ServerCallAdmissionResult.Acquired; + } + catch + { + // The existing accounting is already capacity-owning at this point. + // If permit materialization fails, roll both slots back synchronously. + ReleaseCall(connection); + throw; + } + } + + internal sealed class ServerRequestPermit : IDisposable + { + private const int Reserved = 0; + private const int Activating = 1; + private const int Active = 2; + private const int Disposed = 3; + + private readonly SharpLinkServer _server; + private readonly ServerConnectionState _connection; + private int _state = Reserved; + + internal ServerRequestPermit( + SharpLinkServer server, + ServerConnectionState connection) + { + _server = server; + _connection = connection; + } + + internal bool IsReserved => Volatile.Read(ref _state) == Reserved; + + internal bool IsActive => Volatile.Read(ref _state) == Active; + + internal void Activate() + { + var observed = Interlocked.CompareExchange(ref _state, Activating, Reserved); + if (observed != Reserved) + { + if (observed == Disposed) + throw new ObjectDisposedException(nameof(ServerRequestPermit)); + throw new InvalidOperationException("Only a reserved call permit can be activated."); + } + + // Capacity was deliberately acquired during TryReserveCall. There is + // no counter transfer here yet: this slice introduces the unique owner + // while preserving the existing Stop/Drain linearization unchanged. + Volatile.Write(ref _state, Active); + } + + public void Dispose() + { + var spinner = new SpinWait(); + while (true) + { + var observed = Volatile.Read(ref _state); + switch (observed) + { + case Reserved: + if (Interlocked.CompareExchange(ref _state, Disposed, Reserved) != Reserved) + continue; + _server.ReleaseCall(_connection); + return; + case Activating: + spinner.SpinOnce(); + continue; + case Active: + if (Interlocked.CompareExchange(ref _state, Disposed, Active) != Active) + continue; + _server.ReleaseCall(_connection); + return; + case Disposed: + return; + default: + throw new InvalidOperationException("Unknown server request permit state."); + } + } + } + } +} From 86f9297172d2d0c92ebc017f5cbee26f37e6bc1b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:55:29 +0800 Subject: [PATCH 035/228] test(server): cover production call permit lifecycle for #273 --- .../Server/ServerRequestPermitTests.cs | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs diff --git a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs new file mode 100644 index 000000000..8ec684130 --- /dev/null +++ b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs @@ -0,0 +1,167 @@ +using System.IO.Pipelines; +using System.Reflection; +using SharpLink.Server; +using SharpLink.UnitTests.Runtime; + +namespace SharpLink.UnitTests.Server; + +public class ServerRequestPermitTests +{ + [Test] + public async Task ReservedPermitShouldHoldCapacityAndReleaseExactlyOnce() + { + await using var server = (SharpLinkServer)SharpLinkServerBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) + .DisableAutomaticServiceRegistration() + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 1; + options.FlowControl.MaxConcurrentCallsPerServer = 1; + }) + .UseTransport(new IdleListener()) + .Build(); + await using var session = CreateSession("permit-capacity"); + var connection = CreateConnection(session); + Ensure(connection.MarkReady(null), "connection ready"); + SetServerState(server, 2); // Running + + try + { + var admission = server.TryReserveCall(connection, out var permit); + Ensure(admission == SharpLinkServer.ServerCallAdmissionResult.Acquired && permit is not null, + "first permit must reserve call capacity"); + Ensure(permit.IsReserved && !permit.IsActive, + "new permit must start in Reserved state"); + Ensure(server.PendingCallAdmissionsForDiagnostics == 0 && + server.ActiveCallCountForDiagnostics == 1 && + connection.ActiveCalls == 1, + "Reserved permit must remain visible to the existing drain-safe capacity accounting"); + + var rejected = server.TryReserveCall(connection, out var rejectedPermit); + Ensure(rejected == SharpLinkServer.ServerCallAdmissionResult.PerConnectionCapacityExhausted && + rejectedPermit is null, + "a Reserved permit must consume the configured connection capacity before activation"); + + var alias = permit; + permit.Activate(); + Ensure(permit.IsActive && !permit.IsReserved, + "Activate must move the unique permit to Active without changing occupied capacity"); + Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, + "activation must not acquire a second call slot"); + + alias.Dispose(); + permit.Dispose(); + Ensure(server.PendingCallAdmissionsForDiagnostics == 0 && + server.ActiveCallCountForDiagnostics == 0 && + connection.ActiveCalls == 0, + "aliases must release the backing local/global capacity exactly once"); + + var recovered = server.TryReserveCall(connection, out var recoveredPermit); + Ensure(recovered == SharpLinkServer.ServerCallAdmissionResult.Acquired && + recoveredPermit is not null, + "capacity must be reusable after permit disposal"); + recoveredPermit.Dispose(); + Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, + "disposing a still-Reserved permit must roll capacity back without activation"); + } + finally + { + SetServerState(server, 3); // Draining + await connection.CloseAsync(); + await connection.ServiceCleanupTask; + } + } + + [Test] + public async Task ReservedPermitShouldKeepServerDrainOpenUntilDisposed() + { + await using var server = (SharpLinkServer)SharpLinkServerBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) + .DisableAutomaticServiceRegistration() + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 1; + options.FlowControl.MaxConcurrentCallsPerServer = 1; + }) + .UseTransport(new IdleListener()) + .Build(); + await using var session = CreateSession("permit-drain"); + var connection = CreateConnection(session); + Ensure(connection.MarkReady(null), "connection ready"); + SetServerState(server, 2); // Running + + var admission = server.TryReserveCall(connection, out var permit); + Ensure(admission == SharpLinkServer.ServerCallAdmissionResult.Acquired && permit is not null, + "permit reservation"); + Ensure(permit.IsReserved, "permit must remain Reserved for the drain-boundary probe"); + + SetServerState(server, 3); // Draining + InvokeTrySignalCallsDrained(server); + Ensure(!server.CallsDrainedForDiagnostics.IsCompleted, + "drain must not complete while a Reserved permit owns capacity"); + Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, + "Reserved ownership must remain counted across the server drain boundary"); + + permit.Dispose(); + await server.CallsDrainedForDiagnostics.WaitAsync(TimeSpan.FromSeconds(1)); + Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, + "disposing the Reserved permit must release both capacity scopes"); + + await connection.CloseAsync(); + await connection.ServiceCleanupTask; + } + + private static RpcSession CreateSession(string id) + { + var input = new Pipe(); + var output = new Pipe(); + var session = RpcSessionTestFixture.CreateSessionOverTestTransport( + id, + input.Reader, + output.Writer, + RpcSessionTestFixture.ServerOptions()); + RpcSessionTestFixture.CompleteHandshake(session); + return session; + } + + private static ServerConnectionState CreateConnection(RpcSession session) + => new( + session, + new RpcSessionGeneratedServerBridge(session), + new StripedLongMap(), + CancellationToken.None, + TimeProvider.System, + maxConcurrentCalls: 1); + + private static void SetServerState(SharpLinkServer server, int state) + { + var field = typeof(SharpLinkServer).GetField( + "_state", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new Exception("cannot find server lifecycle state"); + field.SetValue(server, state); + } + + private static void InvokeTrySignalCallsDrained(SharpLinkServer server) + { + var method = typeof(SharpLinkServer).GetMethod( + "TrySignalCallsDrained", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new Exception("cannot find call-drain signal path"); + method.Invoke(server, [null]); + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new Exception(message); + } + + private sealed class IdleListener : IServerTransportListener + { + public ValueTask AcceptAsync(CancellationToken cancellationToken = default) + => ValueTask.FromException(new OperationCanceledException(cancellationToken)); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} From a51ba22d28f6aea6bfaed3b59df440e620734545 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:57:48 +0800 Subject: [PATCH 036/228] test(server): align call permit fixture with transport interface --- test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs index 8ec684130..23103ddb2 100644 --- a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs @@ -1,5 +1,7 @@ using System.IO.Pipelines; +using System.Net; using System.Reflection; +using System.Threading; using SharpLink.Server; using SharpLink.UnitTests.Runtime; @@ -159,6 +161,8 @@ private static void Ensure(bool condition, string message) private sealed class IdleListener : IServerTransportListener { + public EndPoint? LocalEndPoint => null; + public ValueTask AcceptAsync(CancellationToken cancellationToken = default) => ValueTask.FromException(new OperationCanceledException(cancellationToken)); From 905c0038bb10b94264d9648325f4e9661ebfa0fc Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:00:01 +0800 Subject: [PATCH 037/228] test(server): narrow call permit nullability in assertions --- .../Server/ServerRequestPermitTests.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs index 23103ddb2..d8621c0b1 100644 --- a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs @@ -32,7 +32,8 @@ public async Task ReservedPermitShouldHoldCapacityAndReleaseExactlyOnce() var admission = server.TryReserveCall(connection, out var permit); Ensure(admission == SharpLinkServer.ServerCallAdmissionResult.Acquired && permit is not null, "first permit must reserve call capacity"); - Ensure(permit.IsReserved && !permit.IsActive, + var reservedPermit = permit!; + Ensure(reservedPermit.IsReserved && !reservedPermit.IsActive, "new permit must start in Reserved state"); Ensure(server.PendingCallAdmissionsForDiagnostics == 0 && server.ActiveCallCountForDiagnostics == 1 && @@ -44,15 +45,15 @@ public async Task ReservedPermitShouldHoldCapacityAndReleaseExactlyOnce() rejectedPermit is null, "a Reserved permit must consume the configured connection capacity before activation"); - var alias = permit; - permit.Activate(); - Ensure(permit.IsActive && !permit.IsReserved, + var alias = reservedPermit; + reservedPermit.Activate(); + Ensure(reservedPermit.IsActive && !reservedPermit.IsReserved, "Activate must move the unique permit to Active without changing occupied capacity"); Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, "activation must not acquire a second call slot"); alias.Dispose(); - permit.Dispose(); + reservedPermit.Dispose(); Ensure(server.PendingCallAdmissionsForDiagnostics == 0 && server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, @@ -62,7 +63,7 @@ public async Task ReservedPermitShouldHoldCapacityAndReleaseExactlyOnce() Ensure(recovered == SharpLinkServer.ServerCallAdmissionResult.Acquired && recoveredPermit is not null, "capacity must be reusable after permit disposal"); - recoveredPermit.Dispose(); + recoveredPermit!.Dispose(); Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, "disposing a still-Reserved permit must roll capacity back without activation"); } @@ -95,7 +96,8 @@ public async Task ReservedPermitShouldKeepServerDrainOpenUntilDisposed() var admission = server.TryReserveCall(connection, out var permit); Ensure(admission == SharpLinkServer.ServerCallAdmissionResult.Acquired && permit is not null, "permit reservation"); - Ensure(permit.IsReserved, "permit must remain Reserved for the drain-boundary probe"); + var reservedPermit = permit!; + Ensure(reservedPermit.IsReserved, "permit must remain Reserved for the drain-boundary probe"); SetServerState(server, 3); // Draining InvokeTrySignalCallsDrained(server); @@ -104,7 +106,7 @@ public async Task ReservedPermitShouldKeepServerDrainOpenUntilDisposed() Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, "Reserved ownership must remain counted across the server drain boundary"); - permit.Dispose(); + reservedPermit.Dispose(); await server.CallsDrainedForDiagnostics.WaitAsync(TimeSpan.FromSeconds(1)); Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, "disposing the Reserved permit must release both capacity scopes"); From 4ecc2990cd932630d3f93461ef770e002f91b99c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:03:57 +0800 Subject: [PATCH 038/228] test(server): avoid duplicate session handshake in permit fixture --- test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs index d8621c0b1..2099ea64b 100644 --- a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs @@ -119,13 +119,11 @@ private static RpcSession CreateSession(string id) { var input = new Pipe(); var output = new Pipe(); - var session = RpcSessionTestFixture.CreateSessionOverTestTransport( + return RpcSessionTestFixture.CreateSessionOverTestTransport( id, input.Reader, output.Writer, RpcSessionTestFixture.ServerOptions()); - RpcSessionTestFixture.CompleteHandshake(session); - return session; } private static ServerConnectionState CreateConnection(RpcSession session) From 879611d97ba0453b4c29c0dab54bf6b4a98dbd51 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:42:25 +0800 Subject: [PATCH 039/228] fix(server): serialize request permit release completion --- .../SharpLinkServer.CallPermit.cs | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs index 6ab6404b5..a385c6f3b 100644 --- a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs +++ b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs @@ -15,6 +15,12 @@ internal sealed partial class SharpLinkServer internal ServerCallAdmissionResult TryReserveCall( ServerConnectionState connection, out ServerRequestPermit? permit) + => TryReserveCall(connection, testHooks: null, out permit); + + internal ServerCallAdmissionResult TryReserveCall( + ServerConnectionState connection, + ServerRequestPermitTestHooks? testHooks, + out ServerRequestPermit? permit) { ArgumentNullException.ThrowIfNull(connection); var admission = TryAcquireCall(connection); @@ -26,7 +32,7 @@ internal ServerCallAdmissionResult TryReserveCall( try { - permit = new ServerRequestPermit(this, connection); + permit = new ServerRequestPermit(this, connection, testHooks); return ServerCallAdmissionResult.Acquired; } catch @@ -43,18 +49,22 @@ internal sealed class ServerRequestPermit : IDisposable private const int Reserved = 0; private const int Activating = 1; private const int Active = 2; - private const int Disposed = 3; + private const int Releasing = 3; + private const int Disposed = 4; private readonly SharpLinkServer _server; private readonly ServerConnectionState _connection; + private readonly ServerRequestPermitTestHooks? _testHooks; private int _state = Reserved; internal ServerRequestPermit( SharpLinkServer server, - ServerConnectionState connection) + ServerConnectionState connection, + ServerRequestPermitTestHooks? testHooks) { _server = server; _connection = connection; + _testHooks = testHooks; } internal bool IsReserved => Volatile.Read(ref _state) == Reserved; @@ -66,7 +76,7 @@ internal void Activate() var observed = Interlocked.CompareExchange(ref _state, Activating, Reserved); if (observed != Reserved) { - if (observed == Disposed) + if (observed is Releasing or Disposed) throw new ObjectDisposedException(nameof(ServerRequestPermit)); throw new InvalidOperationException("Only a reserved call permit can be activated."); } @@ -86,18 +96,22 @@ public void Dispose() switch (observed) { case Reserved: - if (Interlocked.CompareExchange(ref _state, Disposed, Reserved) != Reserved) + if (Interlocked.CompareExchange(ref _state, Releasing, Reserved) != Reserved) continue; - _server.ReleaseCall(_connection); + ReleaseBackingCapacity(); return; case Activating: spinner.SpinOnce(); continue; case Active: - if (Interlocked.CompareExchange(ref _state, Disposed, Active) != Active) + if (Interlocked.CompareExchange(ref _state, Releasing, Active) != Active) continue; - _server.ReleaseCall(_connection); + ReleaseBackingCapacity(); return; + case Releasing: + _testHooks?.DisposeObservedReleasing?.Invoke(); + spinner.SpinOnce(); + continue; case Disposed: return; default: @@ -105,5 +119,28 @@ public void Dispose() } } } + + private void ReleaseBackingCapacity() + { + try + { + _testHooks?.ReleaseClaimed?.Invoke(); + _server.ReleaseCall(_connection); + } + finally + { + // Normal completion publishes Disposed only after both backing + // capacity scopes have been released. The finally prevents an + // invariant exception from stranding aliases forever in Releasing. + Volatile.Write(ref _state, Disposed); + } + } } } + +internal sealed class ServerRequestPermitTestHooks +{ + internal Action? ReleaseClaimed { get; init; } + + internal Action? DisposeObservedReleasing { get; init; } +} From c2103810e76023d2af7e46132437a3bdbca2f61d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:42:54 +0800 Subject: [PATCH 040/228] test(server): cover concurrent permit disposal release race --- .../Server/ServerRequestPermitTests.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs index 2099ea64b..8206186ee 100644 --- a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs @@ -75,6 +75,94 @@ public async Task ReservedPermitShouldHoldCapacityAndReleaseExactlyOnce() } } + [Test] + public async Task ConcurrentDisposeShouldWaitUntilBackingCapacityIsReleased() + { + await using var server = (SharpLinkServer)SharpLinkServerBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) + .DisableAutomaticServiceRegistration() + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 1; + options.FlowControl.MaxConcurrentCallsPerServer = 1; + }) + .UseTransport(new IdleListener()) + .Build(); + await using var session = CreateSession("permit-concurrent-dispose"); + var connection = CreateConnection(session); + Ensure(connection.MarkReady(null), "connection ready"); + SetServerState(server, 2); // Running + + using var releaseClaimed = new ManualResetEventSlim(); + using var allowRelease = new ManualResetEventSlim(); + using var secondObservedReleasing = new ManualResetEventSlim(); + Task? firstDispose = null; + Task? secondDispose = null; + SharpLinkServer.ServerRequestPermit? permit = null; + + try + { + var hooks = new ServerRequestPermitTestHooks + { + ReleaseClaimed = () => + { + releaseClaimed.Set(); + allowRelease.Wait(); + }, + DisposeObservedReleasing = () => secondObservedReleasing.Set() + }; + var admission = server.TryReserveCall(connection, hooks, out permit); + Ensure(admission == SharpLinkServer.ServerCallAdmissionResult.Acquired && permit is not null, + "permit reservation"); + var reservedPermit = permit!; + var alias = reservedPermit; + + firstDispose = Task.Run(reservedPermit.Dispose); + Ensure(releaseClaimed.Wait(TimeSpan.FromSeconds(1)), + "the first disposer must claim release before the race probe continues"); + Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, + "capacity must remain occupied while the release winner is paused before ReleaseCall"); + + var secondReturned = 0; + secondDispose = Task.Run(() => + { + alias.Dispose(); + Volatile.Write(ref secondReturned, 1); + }); + Ensure(secondObservedReleasing.Wait(TimeSpan.FromSeconds(1)), + "the second disposer must observe the in-progress Releasing state"); + Ensure(Volatile.Read(ref secondReturned) == 0 && !secondDispose.IsCompleted, + "a concurrent alias must not return from Dispose while backing capacity is still owned"); + Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, + "the second disposer must not release or hide backing capacity owned by the winner"); + + allowRelease.Set(); + await Task.WhenAll(firstDispose, secondDispose).WaitAsync(TimeSpan.FromSeconds(1)); + Ensure(Volatile.Read(ref secondReturned) == 1, + "the waiting disposer must return after terminal Disposed is published"); + Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, + "the release winner must free both capacity scopes exactly once"); + + var recovered = server.TryReserveCall(connection, out var recoveredPermit); + Ensure(recovered == SharpLinkServer.ServerCallAdmissionResult.Acquired && + recoveredPermit is not null, + "capacity must be reusable after both disposal aliases complete"); + recoveredPermit!.Dispose(); + } + finally + { + allowRelease.Set(); + if (firstDispose is not null) + await firstDispose; + if (secondDispose is not null) + await secondDispose; + permit?.Dispose(); + SetServerState(server, 3); // Draining + await connection.CloseAsync(); + await connection.ServiceCleanupTask; + } + } + [Test] public async Task ReservedPermitShouldKeepServerDrainOpenUntilDisposed() { From e36b2fbe55b06b592329a5f901f34b7a239648c8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:43:35 +0800 Subject: [PATCH 041/228] test(server): keep concurrent dispose tasks non-null --- .../Server/ServerRequestPermitTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs index 8206186ee..8c33e3afc 100644 --- a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs @@ -117,27 +117,29 @@ public async Task ConcurrentDisposeShouldWaitUntilBackingCapacityIsReleased() var reservedPermit = permit!; var alias = reservedPermit; - firstDispose = Task.Run(reservedPermit.Dispose); + var firstDisposeTask = Task.Run(reservedPermit.Dispose); + firstDispose = firstDisposeTask; Ensure(releaseClaimed.Wait(TimeSpan.FromSeconds(1)), "the first disposer must claim release before the race probe continues"); Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, "capacity must remain occupied while the release winner is paused before ReleaseCall"); var secondReturned = 0; - secondDispose = Task.Run(() => + var secondDisposeTask = Task.Run(() => { alias.Dispose(); Volatile.Write(ref secondReturned, 1); }); + secondDispose = secondDisposeTask; Ensure(secondObservedReleasing.Wait(TimeSpan.FromSeconds(1)), "the second disposer must observe the in-progress Releasing state"); - Ensure(Volatile.Read(ref secondReturned) == 0 && !secondDispose.IsCompleted, + Ensure(Volatile.Read(ref secondReturned) == 0 && !secondDisposeTask.IsCompleted, "a concurrent alias must not return from Dispose while backing capacity is still owned"); Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, "the second disposer must not release or hide backing capacity owned by the winner"); allowRelease.Set(); - await Task.WhenAll(firstDispose, secondDispose).WaitAsync(TimeSpan.FromSeconds(1)); + await Task.WhenAll(firstDisposeTask, secondDisposeTask).WaitAsync(TimeSpan.FromSeconds(1)); Ensure(Volatile.Read(ref secondReturned) == 1, "the waiting disposer must return after terminal Disposed is published"); Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, From 828d5c4ff4b3fa5d84686aeb5ee6753bfa5bf956 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:44:57 +0800 Subject: [PATCH 042/228] test(server): use dedicated disposer tasks for permit race --- .../Server/ServerRequestPermitTests.cs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs index 8c33e3afc..6f063717e 100644 --- a/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerRequestPermitTests.cs @@ -117,7 +117,11 @@ public async Task ConcurrentDisposeShouldWaitUntilBackingCapacityIsReleased() var reservedPermit = permit!; var alias = reservedPermit; - var firstDisposeTask = Task.Run(reservedPermit.Dispose); + var firstDisposeTask = Task.Factory.StartNew( + reservedPermit.Dispose, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); firstDispose = firstDisposeTask; Ensure(releaseClaimed.Wait(TimeSpan.FromSeconds(1)), "the first disposer must claim release before the race probe continues"); @@ -125,11 +129,15 @@ public async Task ConcurrentDisposeShouldWaitUntilBackingCapacityIsReleased() "capacity must remain occupied while the release winner is paused before ReleaseCall"); var secondReturned = 0; - var secondDisposeTask = Task.Run(() => - { - alias.Dispose(); - Volatile.Write(ref secondReturned, 1); - }); + var secondDisposeTask = Task.Factory.StartNew( + () => + { + alias.Dispose(); + Volatile.Write(ref secondReturned, 1); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); secondDispose = secondDisposeTask; Ensure(secondObservedReleasing.Wait(TimeSpan.FromSeconds(1)), "the second disposer must observe the in-progress Releasing state"); From 223159dd8f1d29a6c85142ea2825bcd3df4b9acd Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:04:49 +0800 Subject: [PATCH 043/228] refactor(runtime): add server decode resource budgets for #273 --- .../SharpLinkRuntimeOptions.cs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Runtime/SharpLinkRuntimeOptions.cs b/src/SharpLink.Runtime/SharpLinkRuntimeOptions.cs index eea183d0d..8c9c8c371 100644 --- a/src/SharpLink.Runtime/SharpLinkRuntimeOptions.cs +++ b/src/SharpLink.Runtime/SharpLinkRuntimeOptions.cs @@ -33,6 +33,15 @@ public sealed class SharpLinkFlowControlOptions /// The hard maximum active calls across one server instance. public const int MaximumConcurrentCallsPerServer = 1024 * 1024; + /// The default maximum concurrent compression decodes across one server instance. + public const int DefaultMaxConcurrentDecodesPerServer = 32; + + /// The default server-wide retained compressed-byte budget: 64 MiB. + public const long DefaultMaxRetainedCompressedBytesPerServer = 64L * 1024 * 1024; + + /// The default server-wide decoded-byte in-flight budget: 64 MiB. + public const long DefaultMaxDecodedBytesInFlightPerServer = 64L * 1024 * 1024; + /// Gets or sets the maximum queued outbound bytes. public int MaxSendQueueBytes { @@ -80,6 +89,24 @@ public int MaxSendQueueBytes /// public int MaxConcurrentCallsPerServer { get; set; } = DefaultMaxConcurrentCallsPerServer; + /// + /// Gets or sets the hard maximum number of provider decompressions that may execute concurrently + /// across one server instance. + /// + public int MaxConcurrentDecodesPerServer { get; set; } = DefaultMaxConcurrentDecodesPerServer; + + /// + /// Gets or sets the server-wide byte budget for compressed request payloads retained beyond the + /// reader-loop frame lifetime while waiting for or executing deferred decode. + /// + public long MaxRetainedCompressedBytesPerServer { get; set; } = DefaultMaxRetainedCompressedBytesPerServer; + + /// + /// Gets or sets the server-wide byte budget for decoded request payload storage that remains + /// owned by admitted requests. + /// + public long MaxDecodedBytesInFlightPerServer { get; set; } = DefaultMaxDecodedBytesInFlightPerServer; + /// Validates all flow-control limits. public void Validate() { @@ -99,6 +126,14 @@ public void Validate() nameof(MaxConcurrentCallsPerServer), $"MaxConcurrentCallsPerServer must be between 1 and {MaximumConcurrentCallsPerServer}."); } + if (MaxConcurrentDecodesPerServer is < 1 or > MaximumConcurrentCallsPerServer) + { + throw new ArgumentOutOfRangeException( + nameof(MaxConcurrentDecodesPerServer), + $"MaxConcurrentDecodesPerServer must be between 1 and {MaximumConcurrentCallsPerServer}."); + } + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(MaxRetainedCompressedBytesPerServer); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(MaxDecodedBytesInFlightPerServer); if (ConnectionReceiveWindowBytes < StreamReceiveWindowBytes) throw new ArgumentException("ConnectionReceiveWindowBytes cannot be smaller than StreamReceiveWindowBytes."); } @@ -112,7 +147,10 @@ internal SharpLinkFlowControlOptions CloneValidated() StreamReceiveWindowBytes = StreamReceiveWindowBytes, ConnectionReceiveWindowBytes = ConnectionReceiveWindowBytes, MaxConcurrentCallsPerConnection = MaxConcurrentCallsPerConnection, - MaxConcurrentCallsPerServer = MaxConcurrentCallsPerServer + MaxConcurrentCallsPerServer = MaxConcurrentCallsPerServer, + MaxConcurrentDecodesPerServer = MaxConcurrentDecodesPerServer, + MaxRetainedCompressedBytesPerServer = MaxRetainedCompressedBytesPerServer, + MaxDecodedBytesInFlightPerServer = MaxDecodedBytesInFlightPerServer }; clone._maxSendQueueBytes = _maxSendQueueBytes; clone._maxSendQueueBytesConfigured = _maxSendQueueBytesConfigured; @@ -130,6 +168,9 @@ internal void CopySnapshotTo(SharpLinkFlowControlOptions destination) destination.ConnectionReceiveWindowBytes = ConnectionReceiveWindowBytes; destination.MaxConcurrentCallsPerConnection = MaxConcurrentCallsPerConnection; destination.MaxConcurrentCallsPerServer = MaxConcurrentCallsPerServer; + destination.MaxConcurrentDecodesPerServer = MaxConcurrentDecodesPerServer; + destination.MaxRetainedCompressedBytesPerServer = MaxRetainedCompressedBytesPerServer; + destination.MaxDecodedBytesInFlightPerServer = MaxDecodedBytesInFlightPerServer; } } From c3222dc52e1c5bd55b892355493d39447fded22b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:05:20 +0800 Subject: [PATCH 044/228] refactor(server): add ResourceGovernor decode permits for #273 --- .../ServerResourceGovernor.cs | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 src/SharpLink.Server/ServerResourceGovernor.cs diff --git a/src/SharpLink.Server/ServerResourceGovernor.cs b/src/SharpLink.Server/ServerResourceGovernor.cs new file mode 100644 index 000000000..b10e190b0 --- /dev/null +++ b/src/SharpLink.Server/ServerResourceGovernor.cs @@ -0,0 +1,257 @@ +namespace SharpLink.Server; + +internal sealed partial class SharpLinkServer +{ + private ServerResourceGovernor? _resourceGovernor; + + private ServerResourceGovernor ResourceGovernor + { + get + { + var existing = Volatile.Read(ref _resourceGovernor); + if (existing is not null) + return existing; + + var flowControl = _runtimeContext.FlowControl; + var created = new ServerResourceGovernor( + flowControl.MaxConcurrentDecodesPerServer, + flowControl.MaxRetainedCompressedBytesPerServer, + flowControl.MaxDecodedBytesInFlightPerServer); + return Interlocked.CompareExchange(ref _resourceGovernor, created, null) ?? created; + } + } + + internal int ActiveDecodeCountForDiagnostics => ResourceGovernor.ActiveDecodeCount; + + internal long RetainedCompressedBytesForDiagnostics => ResourceGovernor.RetainedCompressedBytes; + + internal long DecodedBytesInFlightForDiagnostics => ResourceGovernor.DecodedBytesInFlight; + + internal bool TryAcquireDecodeResources( + long retainedCompressedBytes, + out ServerDecodePermit? permit) + => ResourceGovernor.TryAcquireDecode(retainedCompressedBytes, out permit); +} + +/// +/// Stable server-owned accounting for resources consumed before call activation. +/// This kernel is independent from optional admission-policy generations. +/// +internal sealed class ServerResourceGovernor +{ + private readonly int _maxConcurrentDecodes; + private readonly long _maxRetainedCompressedBytes; + private readonly long _maxDecodedBytesInFlight; + private int _activeDecodes; + private long _retainedCompressedBytes; + private long _decodedBytesInFlight; + + internal ServerResourceGovernor( + int maxConcurrentDecodes, + long maxRetainedCompressedBytes, + long maxDecodedBytesInFlight) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxConcurrentDecodes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxRetainedCompressedBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxDecodedBytesInFlight); + _maxConcurrentDecodes = maxConcurrentDecodes; + _maxRetainedCompressedBytes = maxRetainedCompressedBytes; + _maxDecodedBytesInFlight = maxDecodedBytesInFlight; + } + + internal int ActiveDecodeCount => Volatile.Read(ref _activeDecodes); + + internal long RetainedCompressedBytes => Volatile.Read(ref _retainedCompressedBytes); + + internal long DecodedBytesInFlight => Volatile.Read(ref _decodedBytesInFlight); + + internal bool TryAcquireDecode( + long retainedCompressedBytes, + out ServerDecodePermit? permit) + { + ArgumentOutOfRangeException.ThrowIfNegative(retainedCompressedBytes); + + if (!TryIncrementBounded(ref _activeDecodes, _maxConcurrentDecodes)) + { + permit = null; + return false; + } + + if (!TryAddBounded( + ref _retainedCompressedBytes, + retainedCompressedBytes, + _maxRetainedCompressedBytes)) + { + ReleaseDecodeSlot(); + permit = null; + return false; + } + + permit = new ServerDecodePermit(this, retainedCompressedBytes); + return true; + } + + internal bool TryReserveDecodedBytes(long decodedBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(decodedBytes); + return TryAddBounded(ref _decodedBytesInFlight, decodedBytes, _maxDecodedBytesInFlight); + } + + internal void ReleaseDecodeAndRetained(long retainedCompressedBytes) + { + try + { + ReleaseBytes(ref _retainedCompressedBytes, retainedCompressedBytes, "retained compressed bytes"); + } + finally + { + ReleaseDecodeSlot(); + } + } + + internal void ReleaseDecodedBytes(long decodedBytes) + => ReleaseBytes(ref _decodedBytesInFlight, decodedBytes, "decoded bytes"); + + private void ReleaseDecodeSlot() + { + var remaining = Interlocked.Decrement(ref _activeDecodes); + if (remaining >= 0) + return; + + Interlocked.Increment(ref _activeDecodes); + throw new InvalidOperationException("Server decode concurrency accounting underflowed."); + } + + private static bool TryIncrementBounded(ref int counter, int limit) + { + while (true) + { + var current = Volatile.Read(ref counter); + if (current >= limit) + return false; + if (Interlocked.CompareExchange(ref counter, current + 1, current) == current) + return true; + } + } + + private static bool TryAddBounded(ref long counter, long amount, long limit) + { + if (amount == 0) + return true; + if (amount > limit) + return false; + + while (true) + { + var current = Volatile.Read(ref counter); + if (current > limit - amount) + return false; + if (Interlocked.CompareExchange(ref counter, current + amount, current) == current) + return true; + } + } + + private static void ReleaseBytes(ref long counter, long amount, string resourceName) + { + if (amount == 0) + return; + + var remaining = Interlocked.Add(ref counter, -amount); + if (remaining >= 0) + return; + + Interlocked.Add(ref counter, amount); + throw new InvalidOperationException($"Server {resourceName} accounting underflowed."); + } +} + +/// +/// Request-owned decode resource permit. While decoding it owns one decode-concurrency credit and +/// any retained compressed bytes. releases those resources while +/// decoded-byte ownership remains attached until final disposal. +/// +internal sealed class ServerDecodePermit : IDisposable +{ + private readonly ServerResourceGovernor _governor; + private readonly long _retainedCompressedBytes; + private readonly Lock _gate = new(); + private long _decodedBytes; + private bool _decodeCompleted; + private bool _disposed; + + internal ServerDecodePermit( + ServerResourceGovernor governor, + long retainedCompressedBytes) + { + _governor = governor; + _retainedCompressedBytes = retainedCompressedBytes; + } + + internal bool IsDecodeCompleted + { + get + { + lock (_gate) + return _decodeCompleted; + } + } + + internal long DecodedBytesOwned + { + get + { + lock (_gate) + return _decodedBytes; + } + } + + internal bool TryReserveDecodedBytes(long additionalBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(additionalBytes); + if (additionalBytes == 0) + return true; + + lock (_gate) + { + if (_disposed || _decodeCompleted) + return false; + if (!_governor.TryReserveDecodedBytes(additionalBytes)) + return false; + _decodedBytes += additionalBytes; + return true; + } + } + + internal void CompleteDecode() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_decodeCompleted) + return; + + _governor.ReleaseDecodeAndRetained(_retainedCompressedBytes); + _decodeCompleted = true; + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + + try + { + if (!_decodeCompleted) + _governor.ReleaseDecodeAndRetained(_retainedCompressedBytes); + _governor.ReleaseDecodedBytes(_decodedBytes); + } + finally + { + _disposed = true; + } + } + } +} From d0ae9bb78e9fd0370a8116a4d6c59f57b7e544be Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:05:56 +0800 Subject: [PATCH 045/228] refactor(server): attach decode resources to request permits for #273 --- .../SharpLinkServer.CallPermit.cs | 91 +++++++++++++++---- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs index a385c6f3b..f28a9627a 100644 --- a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs +++ b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs @@ -55,6 +55,8 @@ internal sealed class ServerRequestPermit : IDisposable private readonly SharpLinkServer _server; private readonly ServerConnectionState _connection; private readonly ServerRequestPermitTestHooks? _testHooks; + private readonly Lock _resourceGate = new(); + private ServerDecodePermit? _decodePermit; private int _state = Reserved; internal ServerRequestPermit( @@ -71,20 +73,56 @@ internal ServerRequestPermit( internal bool IsActive => Volatile.Read(ref _state) == Active; - internal void Activate() + /// + /// Reserves the server-wide decode concurrency credit and any compressed bytes that must + /// outlive the current reader-loop frame. The resulting permit is attached to this request + /// owner so cancellation/disposal cannot orphan decode resources. + /// + internal bool TryAcquireDecodePermit( + long retainedCompressedBytes, + out ServerDecodePermit? decodePermit) { - var observed = Interlocked.CompareExchange(ref _state, Activating, Reserved); - if (observed != Reserved) + ArgumentOutOfRangeException.ThrowIfNegative(retainedCompressedBytes); + + lock (_resourceGate) { - if (observed is Releasing or Disposed) - throw new ObjectDisposedException(nameof(ServerRequestPermit)); - throw new InvalidOperationException("Only a reserved call permit can be activated."); + if (Volatile.Read(ref _state) != Reserved || _decodePermit is not null) + { + decodePermit = null; + return false; + } + + if (!_server.ResourceGovernor.TryAcquireDecode(retainedCompressedBytes, out decodePermit)) + return false; + + _decodePermit = decodePermit; + return true; } + } - // Capacity was deliberately acquired during TryReserveCall. There is - // no counter transfer here yet: this slice introduces the unique owner - // while preserving the existing Stop/Drain linearization unchanged. - Volatile.Write(ref _state, Active); + internal void Activate() + { + lock (_resourceGate) + { + if (_decodePermit is not null && !_decodePermit.IsDecodeCompleted) + { + throw new InvalidOperationException( + "A request with decode resources cannot be activated before decode completes."); + } + + var observed = Interlocked.CompareExchange(ref _state, Activating, Reserved); + if (observed != Reserved) + { + if (observed is Releasing or Disposed) + throw new ObjectDisposedException(nameof(ServerRequestPermit)); + throw new InvalidOperationException("Only a reserved call permit can be activated."); + } + + // Capacity was deliberately acquired during TryReserveCall. There is + // no counter transfer here yet: this slice introduces the unique owner + // while preserving the existing Stop/Drain linearization unchanged. + Volatile.Write(ref _state, Active); + } } public void Dispose() @@ -96,7 +134,7 @@ public void Dispose() switch (observed) { case Reserved: - if (Interlocked.CompareExchange(ref _state, Releasing, Reserved) != Reserved) + if (!TryClaimRelease(Reserved)) continue; ReleaseBackingCapacity(); return; @@ -104,7 +142,7 @@ public void Dispose() spinner.SpinOnce(); continue; case Active: - if (Interlocked.CompareExchange(ref _state, Releasing, Active) != Active) + if (!TryClaimRelease(Active)) continue; ReleaseBackingCapacity(); return; @@ -120,18 +158,39 @@ public void Dispose() } } + private bool TryClaimRelease(int expectedState) + { + lock (_resourceGate) + { + if (Volatile.Read(ref _state) != expectedState) + return false; + return Interlocked.CompareExchange(ref _state, Releasing, expectedState) == expectedState; + } + } + private void ReleaseBackingCapacity() { try { _testHooks?.ReleaseClaimed?.Invoke(); - _server.ReleaseCall(_connection); + ServerDecodePermit? decodePermit; + lock (_resourceGate) + decodePermit = _decodePermit; + + try + { + decodePermit?.Dispose(); + } + finally + { + _server.ReleaseCall(_connection); + } } finally { - // Normal completion publishes Disposed only after both backing - // capacity scopes have been released. The finally prevents an - // invariant exception from stranding aliases forever in Releasing. + // Normal completion publishes Disposed only after request-owned decode + // resources and both backing call-capacity scopes have been released. + // The finally prevents an invariant exception from stranding aliases forever. Volatile.Write(ref _state, Disposed); } } From cdf34a4abc6dd53024512a6efc29bc4e7ef51d1e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:06:27 +0800 Subject: [PATCH 046/228] refactor(server): keep decode acquisition request-owned --- src/SharpLink.Server/ServerResourceGovernor.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/SharpLink.Server/ServerResourceGovernor.cs b/src/SharpLink.Server/ServerResourceGovernor.cs index b10e190b0..fc160ec69 100644 --- a/src/SharpLink.Server/ServerResourceGovernor.cs +++ b/src/SharpLink.Server/ServerResourceGovernor.cs @@ -26,11 +26,6 @@ private ServerResourceGovernor ResourceGovernor internal long RetainedCompressedBytesForDiagnostics => ResourceGovernor.RetainedCompressedBytes; internal long DecodedBytesInFlightForDiagnostics => ResourceGovernor.DecodedBytesInFlight; - - internal bool TryAcquireDecodeResources( - long retainedCompressedBytes, - out ServerDecodePermit? permit) - => ResourceGovernor.TryAcquireDecode(retainedCompressedBytes, out permit); } /// From d91e9182212d3eb4835c7c8e108aba84ee69e1fb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:07:15 +0800 Subject: [PATCH 047/228] test(server): cover ResourceGovernor permit ownership for #273 --- .../Server/ServerResourceGovernorTests.cs | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/ServerResourceGovernorTests.cs diff --git a/test/SharpLink.UnitTests/Server/ServerResourceGovernorTests.cs b/test/SharpLink.UnitTests/Server/ServerResourceGovernorTests.cs new file mode 100644 index 000000000..6e9d8b65c --- /dev/null +++ b/test/SharpLink.UnitTests/Server/ServerResourceGovernorTests.cs @@ -0,0 +1,259 @@ +using System.IO.Pipelines; +using System.Net; +using System.Reflection; +using System.Threading; +using SharpLink.Server; +using SharpLink.UnitTests.Runtime; + +namespace SharpLink.UnitTests.Server; + +public class ServerResourceGovernorTests +{ + [Test] + public async Task DecodeResourcesShouldRemainBoundedAndRequestOwned() + { + await using var server = CreateServer( + maxCalls: 2, + maxDecodes: 2, + retainedBytes: 1024, + decodedBytes: 2048); + await using var session = CreateSession("resource-governor-bounds"); + var connection = CreateConnection(session, maxConcurrentCalls: 2); + Ensure(connection.MarkReady(null), "connection ready"); + SetServerState(server, 2); // Running + + SharpLinkServer.ServerRequestPermit? firstRequest = null; + SharpLinkServer.ServerRequestPermit? secondRequest = null; + try + { + Ensure(server.TryReserveCall(connection, out firstRequest) == + SharpLinkServer.ServerCallAdmissionResult.Acquired && firstRequest is not null, + "first call reservation"); + Ensure(server.TryReserveCall(connection, out secondRequest) == + SharpLinkServer.ServerCallAdmissionResult.Acquired && secondRequest is not null, + "second call reservation"); + + Ensure(firstRequest.TryAcquireDecodePermit(800, out var firstDecode) && firstDecode is not null, + "first decode permit"); + Ensure(server.ActiveDecodeCountForDiagnostics == 1 && + server.RetainedCompressedBytesForDiagnostics == 800, + "first decode must own one concurrency credit and its retained bytes"); + + Ensure(!secondRequest.TryAcquireDecodePermit(300, out var rejectedDecode) && rejectedDecode is null, + "retained-byte budget must reject the second decode without attaching a permit"); + Ensure(server.ActiveDecodeCountForDiagnostics == 1 && + server.RetainedCompressedBytesForDiagnostics == 800, + "failed retained-byte acquisition must roll back its provisional decode credit"); + + Ensure(secondRequest.TryAcquireDecodePermit(224, out var secondDecode) && secondDecode is not null, + "the exact remaining retained-byte budget must be reusable after rollback"); + Ensure(server.ActiveDecodeCountForDiagnostics == 2 && + server.RetainedCompressedBytesForDiagnostics == 1024, + "both successful decodes must be accounted"); + + Ensure(firstDecode.TryReserveDecodedBytes(1536), + "first decoded-byte reservation"); + Ensure(!secondDecode.TryReserveDecodedBytes(600), + "decoded-byte budget must reject an over-budget rent"); + Ensure(server.DecodedBytesInFlightForDiagnostics == 1536, + "failed decoded-byte reservation must leave accounting unchanged"); + Ensure(secondDecode.TryReserveDecodedBytes(512), + "the exact remaining decoded-byte budget must be admitted"); + Ensure(server.DecodedBytesInFlightForDiagnostics == 2048, + "successful decoded ownership must fill the configured budget exactly"); + + var prematureActivation = CaptureFailure(firstRequest.Activate); + Ensure(prematureActivation is InvalidOperationException, + "a request with attached decode resources must not activate before decode completion"); + + firstDecode.CompleteDecode(); + Ensure(server.ActiveDecodeCountForDiagnostics == 1 && + server.RetainedCompressedBytesForDiagnostics == 224 && + server.DecodedBytesInFlightForDiagnostics == 2048, + "CompleteDecode must release only CPU/retained ownership, not decoded bytes"); + firstRequest.Activate(); + + secondRequest.Dispose(); + secondRequest = null; + Ensure(server.ActiveDecodeCountForDiagnostics == 0 && + server.RetainedCompressedBytesForDiagnostics == 0 && + server.DecodedBytesInFlightForDiagnostics == 1536, + "disposing a still-decoding request must release its attached decode/retained/decoded resources"); + + firstRequest.Dispose(); + firstRequest = null; + Ensure(server.ActiveDecodeCountForDiagnostics == 0 && + server.RetainedCompressedBytesForDiagnostics == 0 && + server.DecodedBytesInFlightForDiagnostics == 0, + "final request disposal must release decoded-byte ownership exactly once"); + Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, + "resource cleanup must leave both call-capacity scopes reusable"); + } + finally + { + secondRequest?.Dispose(); + firstRequest?.Dispose(); + SetServerState(server, 3); // Draining + await connection.CloseAsync(); + await connection.ServiceCleanupTask; + } + } + + [Test] + public async Task DecodeConcurrencyShouldRejectWithoutRetainedOrDecodedSideEffects() + { + await using var server = CreateServer( + maxCalls: 2, + maxDecodes: 1, + retainedBytes: 1024, + decodedBytes: 1024); + await using var session = CreateSession("resource-governor-decode-credit"); + var connection = CreateConnection(session, maxConcurrentCalls: 2); + Ensure(connection.MarkReady(null), "connection ready"); + SetServerState(server, 2); // Running + + SharpLinkServer.ServerRequestPermit? firstRequest = null; + SharpLinkServer.ServerRequestPermit? secondRequest = null; + try + { + Ensure(server.TryReserveCall(connection, out firstRequest) == + SharpLinkServer.ServerCallAdmissionResult.Acquired && firstRequest is not null, + "first call reservation"); + Ensure(server.TryReserveCall(connection, out secondRequest) == + SharpLinkServer.ServerCallAdmissionResult.Acquired && secondRequest is not null, + "second call reservation"); + Ensure(firstRequest.TryAcquireDecodePermit(512, out var firstDecode) && firstDecode is not null, + "first decode permit"); + + Ensure(!secondRequest.TryAcquireDecodePermit(256, out var rejectedDecode) && rejectedDecode is null, + "decode-concurrency exhaustion must reject before retained ownership"); + Ensure(server.ActiveDecodeCountForDiagnostics == 1 && + server.RetainedCompressedBytesForDiagnostics == 512 && + server.DecodedBytesInFlightForDiagnostics == 0, + "decode-credit rejection must not mutate retained or decoded-byte accounting"); + + firstDecode.CompleteDecode(); + Ensure(secondRequest.TryAcquireDecodePermit(256, out var secondDecode) && secondDecode is not null, + "decode credit must be reusable immediately after CompleteDecode"); + Ensure(secondDecode.TryReserveDecodedBytes(1024), "decoded-byte reservation"); + + secondRequest.Dispose(); + secondRequest = null; + firstRequest.Dispose(); + firstRequest = null; + Ensure(server.ActiveDecodeCountForDiagnostics == 0 && + server.RetainedCompressedBytesForDiagnostics == 0 && + server.DecodedBytesInFlightForDiagnostics == 0, + "all resource accounting must return to zero"); + } + finally + { + secondRequest?.Dispose(); + firstRequest?.Dispose(); + SetServerState(server, 3); // Draining + await connection.CloseAsync(); + await connection.ServiceCleanupTask; + } + } + + [Test] + public void DecodeResourceOptionsShouldValidateHardBounds() + { + var invalidDecodeCount = CaptureFailure(new SharpLinkFlowControlOptions + { + MaxConcurrentDecodesPerServer = 0 + }.Validate); + var invalidRetainedBudget = CaptureFailure(new SharpLinkFlowControlOptions + { + MaxRetainedCompressedBytesPerServer = 0 + }.Validate); + var invalidDecodedBudget = CaptureFailure(new SharpLinkFlowControlOptions + { + MaxDecodedBytesInFlightPerServer = 0 + }.Validate); + + Ensure(invalidDecodeCount is ArgumentOutOfRangeException, + "decode concurrency must have a positive hard bound"); + Ensure(invalidRetainedBudget is ArgumentOutOfRangeException, + "retained compressed bytes must have a positive hard bound"); + Ensure(invalidDecodedBudget is ArgumentOutOfRangeException, + "decoded bytes in flight must have a positive hard bound"); + } + + private static SharpLinkServer CreateServer( + int maxCalls, + int maxDecodes, + long retainedBytes, + long decodedBytes) + => (SharpLinkServer)SharpLinkServerBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) + .DisableAutomaticServiceRegistration() + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = maxCalls; + options.FlowControl.MaxConcurrentCallsPerServer = maxCalls; + options.FlowControl.MaxConcurrentDecodesPerServer = maxDecodes; + options.FlowControl.MaxRetainedCompressedBytesPerServer = retainedBytes; + options.FlowControl.MaxDecodedBytesInFlightPerServer = decodedBytes; + }) + .UseTransport(new IdleListener()) + .Build(); + + private static RpcSession CreateSession(string id) + { + var input = new Pipe(); + var output = new Pipe(); + return RpcSessionTestFixture.CreateSessionOverTestTransport( + id, + input.Reader, + output.Writer, + RpcSessionTestFixture.ServerOptions()); + } + + private static ServerConnectionState CreateConnection(RpcSession session, int maxConcurrentCalls) + => new( + session, + new RpcSessionGeneratedServerBridge(session), + new StripedLongMap(), + CancellationToken.None, + TimeProvider.System, + maxConcurrentCalls); + + private static void SetServerState(SharpLinkServer server, int state) + { + var field = typeof(SharpLinkServer).GetField( + "_state", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new Exception("cannot find server lifecycle state"); + field.SetValue(server, state); + } + + private static Exception? CaptureFailure(Action action) + { + try + { + action(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static void Ensure(bool condition, string message) + { + if (!condition) + throw new Exception(message); + } + + private sealed class IdleListener : IServerTransportListener + { + public EndPoint? LocalEndPoint => null; + + public ValueTask AcceptAsync(CancellationToken cancellationToken = default) + => ValueTask.FromException(new OperationCanceledException(cancellationToken)); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} From ef1ca8f3977ed04d0a3ed4062570026ab9dab1f0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:07:45 +0800 Subject: [PATCH 048/228] test(server): make ResourceGovernor nullable flow explicit --- .../Server/ServerResourceGovernorTests.cs | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerResourceGovernorTests.cs b/test/SharpLink.UnitTests/Server/ServerResourceGovernorTests.cs index 6e9d8b65c..d0a3273eb 100644 --- a/test/SharpLink.UnitTests/Server/ServerResourceGovernorTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerResourceGovernorTests.cs @@ -32,55 +32,59 @@ public async Task DecodeResourcesShouldRemainBoundedAndRequestOwned() Ensure(server.TryReserveCall(connection, out secondRequest) == SharpLinkServer.ServerCallAdmissionResult.Acquired && secondRequest is not null, "second call reservation"); + var first = firstRequest!; + var second = secondRequest!; - Ensure(firstRequest.TryAcquireDecodePermit(800, out var firstDecode) && firstDecode is not null, + Ensure(first.TryAcquireDecodePermit(800, out var firstDecode) && firstDecode is not null, "first decode permit"); + var firstDecodePermit = firstDecode!; Ensure(server.ActiveDecodeCountForDiagnostics == 1 && server.RetainedCompressedBytesForDiagnostics == 800, "first decode must own one concurrency credit and its retained bytes"); - Ensure(!secondRequest.TryAcquireDecodePermit(300, out var rejectedDecode) && rejectedDecode is null, + Ensure(!second.TryAcquireDecodePermit(300, out var rejectedDecode) && rejectedDecode is null, "retained-byte budget must reject the second decode without attaching a permit"); Ensure(server.ActiveDecodeCountForDiagnostics == 1 && server.RetainedCompressedBytesForDiagnostics == 800, "failed retained-byte acquisition must roll back its provisional decode credit"); - Ensure(secondRequest.TryAcquireDecodePermit(224, out var secondDecode) && secondDecode is not null, + Ensure(second.TryAcquireDecodePermit(224, out var secondDecode) && secondDecode is not null, "the exact remaining retained-byte budget must be reusable after rollback"); + var secondDecodePermit = secondDecode!; Ensure(server.ActiveDecodeCountForDiagnostics == 2 && server.RetainedCompressedBytesForDiagnostics == 1024, "both successful decodes must be accounted"); - Ensure(firstDecode.TryReserveDecodedBytes(1536), + Ensure(firstDecodePermit.TryReserveDecodedBytes(1536), "first decoded-byte reservation"); - Ensure(!secondDecode.TryReserveDecodedBytes(600), + Ensure(!secondDecodePermit.TryReserveDecodedBytes(600), "decoded-byte budget must reject an over-budget rent"); Ensure(server.DecodedBytesInFlightForDiagnostics == 1536, "failed decoded-byte reservation must leave accounting unchanged"); - Ensure(secondDecode.TryReserveDecodedBytes(512), + Ensure(secondDecodePermit.TryReserveDecodedBytes(512), "the exact remaining decoded-byte budget must be admitted"); Ensure(server.DecodedBytesInFlightForDiagnostics == 2048, "successful decoded ownership must fill the configured budget exactly"); - var prematureActivation = CaptureFailure(firstRequest.Activate); + var prematureActivation = CaptureFailure(first.Activate); Ensure(prematureActivation is InvalidOperationException, "a request with attached decode resources must not activate before decode completion"); - firstDecode.CompleteDecode(); + firstDecodePermit.CompleteDecode(); Ensure(server.ActiveDecodeCountForDiagnostics == 1 && server.RetainedCompressedBytesForDiagnostics == 224 && server.DecodedBytesInFlightForDiagnostics == 2048, "CompleteDecode must release only CPU/retained ownership, not decoded bytes"); - firstRequest.Activate(); + first.Activate(); - secondRequest.Dispose(); + second.Dispose(); secondRequest = null; Ensure(server.ActiveDecodeCountForDiagnostics == 0 && server.RetainedCompressedBytesForDiagnostics == 0 && server.DecodedBytesInFlightForDiagnostics == 1536, "disposing a still-decoding request must release its attached decode/retained/decoded resources"); - firstRequest.Dispose(); + first.Dispose(); firstRequest = null; Ensure(server.ActiveDecodeCountForDiagnostics == 0 && server.RetainedCompressedBytesForDiagnostics == 0 && @@ -122,24 +126,29 @@ public async Task DecodeConcurrencyShouldRejectWithoutRetainedOrDecodedSideEffec Ensure(server.TryReserveCall(connection, out secondRequest) == SharpLinkServer.ServerCallAdmissionResult.Acquired && secondRequest is not null, "second call reservation"); - Ensure(firstRequest.TryAcquireDecodePermit(512, out var firstDecode) && firstDecode is not null, + var first = firstRequest!; + var second = secondRequest!; + + Ensure(first.TryAcquireDecodePermit(512, out var firstDecode) && firstDecode is not null, "first decode permit"); + var firstDecodePermit = firstDecode!; - Ensure(!secondRequest.TryAcquireDecodePermit(256, out var rejectedDecode) && rejectedDecode is null, + Ensure(!second.TryAcquireDecodePermit(256, out var rejectedDecode) && rejectedDecode is null, "decode-concurrency exhaustion must reject before retained ownership"); Ensure(server.ActiveDecodeCountForDiagnostics == 1 && server.RetainedCompressedBytesForDiagnostics == 512 && server.DecodedBytesInFlightForDiagnostics == 0, "decode-credit rejection must not mutate retained or decoded-byte accounting"); - firstDecode.CompleteDecode(); - Ensure(secondRequest.TryAcquireDecodePermit(256, out var secondDecode) && secondDecode is not null, + firstDecodePermit.CompleteDecode(); + Ensure(second.TryAcquireDecodePermit(256, out var secondDecode) && secondDecode is not null, "decode credit must be reusable immediately after CompleteDecode"); - Ensure(secondDecode.TryReserveDecodedBytes(1024), "decoded-byte reservation"); + var secondDecodePermit = secondDecode!; + Ensure(secondDecodePermit.TryReserveDecodedBytes(1024), "decoded-byte reservation"); - secondRequest.Dispose(); + second.Dispose(); secondRequest = null; - firstRequest.Dispose(); + first.Dispose(); firstRequest = null; Ensure(server.ActiveDecodeCountForDiagnostics == 0 && server.RetainedCompressedBytesForDiagnostics == 0 && From 56b3aa08e629b3f96fe0e904ba92fda8f305d5d5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:08:14 +0800 Subject: [PATCH 049/228] refactor(server): make decode permit rollback leak-safe --- .../ServerResourceGovernor.cs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/SharpLink.Server/ServerResourceGovernor.cs b/src/SharpLink.Server/ServerResourceGovernor.cs index fc160ec69..a05f39fd3 100644 --- a/src/SharpLink.Server/ServerResourceGovernor.cs +++ b/src/SharpLink.Server/ServerResourceGovernor.cs @@ -82,8 +82,16 @@ internal bool TryAcquireDecode( return false; } - permit = new ServerDecodePermit(this, retainedCompressedBytes); - return true; + try + { + permit = new ServerDecodePermit(this, retainedCompressedBytes); + return true; + } + catch + { + ReleaseDecodeAndRetained(retainedCompressedBytes); + throw; + } } internal bool TryReserveDecodedBytes(long decodedBytes) @@ -239,9 +247,15 @@ public void Dispose() try { - if (!_decodeCompleted) - _governor.ReleaseDecodeAndRetained(_retainedCompressedBytes); - _governor.ReleaseDecodedBytes(_decodedBytes); + try + { + if (!_decodeCompleted) + _governor.ReleaseDecodeAndRetained(_retainedCompressedBytes); + } + finally + { + _governor.ReleaseDecodedBytes(_decodedBytes); + } } finally { From ea6788d004c963c8db03fe029da74221323880ee Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:16:32 +0800 Subject: [PATCH 050/228] refactor(server): preserve activation state semantics with decode permits --- src/SharpLink.Server/SharpLinkServer.CallPermit.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs index f28a9627a..19da2cee5 100644 --- a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs +++ b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs @@ -104,6 +104,11 @@ internal void Activate() { lock (_resourceGate) { + var current = Volatile.Read(ref _state); + if (current is Releasing or Disposed) + throw new ObjectDisposedException(nameof(ServerRequestPermit)); + if (current != Reserved) + throw new InvalidOperationException("Only a reserved call permit can be activated."); if (_decodePermit is not null && !_decodePermit.IsDecodeCompleted) { throw new InvalidOperationException( From 2ced8aff64aa3e745d816aad9af7c87afc563d86 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:16:48 +0800 Subject: [PATCH 051/228] refactor(server): keep zero-byte decode reservations state-aware --- src/SharpLink.Server/ServerResourceGovernor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Server/ServerResourceGovernor.cs b/src/SharpLink.Server/ServerResourceGovernor.cs index a05f39fd3..22478677d 100644 --- a/src/SharpLink.Server/ServerResourceGovernor.cs +++ b/src/SharpLink.Server/ServerResourceGovernor.cs @@ -211,13 +211,13 @@ internal long DecodedBytesOwned internal bool TryReserveDecodedBytes(long additionalBytes) { ArgumentOutOfRangeException.ThrowIfNegative(additionalBytes); - if (additionalBytes == 0) - return true; lock (_gate) { if (_disposed || _decodeCompleted) return false; + if (additionalBytes == 0) + return true; if (!_governor.TryReserveDecodedBytes(additionalBytes)) return false; _decodedBytes += additionalBytes; From 81cbc960d2ad0db26a57c105bb0f968c864f987f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:41:28 +0800 Subject: [PATCH 052/228] refactor(server): add transferable retained-byte permits --- .../ServerResourceGovernor.cs | 115 +++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Server/ServerResourceGovernor.cs b/src/SharpLink.Server/ServerResourceGovernor.cs index 22478677d..412154423 100644 --- a/src/SharpLink.Server/ServerResourceGovernor.cs +++ b/src/SharpLink.Server/ServerResourceGovernor.cs @@ -60,6 +60,33 @@ internal ServerResourceGovernor( internal long DecodedBytesInFlight => Volatile.Read(ref _decodedBytesInFlight); + internal bool TryAcquireRetained( + long retainedCompressedBytes, + out ServerRetainedCompressedPermit? permit) + { + ArgumentOutOfRangeException.ThrowIfNegative(retainedCompressedBytes); + + if (!TryAddBounded( + ref _retainedCompressedBytes, + retainedCompressedBytes, + _maxRetainedCompressedBytes)) + { + permit = null; + return false; + } + + try + { + permit = new ServerRetainedCompressedPermit(this, retainedCompressedBytes); + return true; + } + catch + { + ReleaseRetained(retainedCompressedBytes); + throw; + } + } + internal bool TryAcquireDecode( long retainedCompressedBytes, out ServerDecodePermit? permit) @@ -94,17 +121,54 @@ internal bool TryAcquireDecode( } } + internal bool TryAcquireDecode( + ServerRetainedCompressedPermit retainedPermit, + out ServerDecodePermit? permit) + { + ArgumentNullException.ThrowIfNull(retainedPermit); + + if (!TryIncrementBounded(ref _activeDecodes, _maxConcurrentDecodes)) + { + permit = null; + return false; + } + + if (!retainedPermit.TryTransferToDecode(this, out var retainedCompressedBytes)) + { + ReleaseDecodeSlot(); + permit = null; + return false; + } + + try + { + permit = new ServerDecodePermit(this, retainedCompressedBytes); + return true; + } + catch + { + ReleaseDecodeAndRetained(retainedCompressedBytes); + throw; + } + } + internal bool TryReserveDecodedBytes(long decodedBytes) { ArgumentOutOfRangeException.ThrowIfNegative(decodedBytes); return TryAddBounded(ref _decodedBytesInFlight, decodedBytes, _maxDecodedBytesInFlight); } + internal void ReleaseRetained(long retainedCompressedBytes) + => ReleaseBytes( + ref _retainedCompressedBytes, + retainedCompressedBytes, + "retained compressed bytes"); + internal void ReleaseDecodeAndRetained(long retainedCompressedBytes) { try { - ReleaseBytes(ref _retainedCompressedBytes, retainedCompressedBytes, "retained compressed bytes"); + ReleaseRetained(retainedCompressedBytes); } finally { @@ -168,6 +232,55 @@ private static void ReleaseBytes(ref long counter, long amount, string resourceN } } +/// +/// Owns compressed request bytes that outlive the reader-loop frame before a call has acquired its +/// decode credit. Ownership may move exactly once into a . +/// +internal sealed class ServerRetainedCompressedPermit : IDisposable +{ + private const int Owned = 0; + private const int Transferred = 1; + private const int Disposed = 2; + + private readonly ServerResourceGovernor _governor; + private readonly long _retainedCompressedBytes; + private int _state = Owned; + + internal ServerRetainedCompressedPermit( + ServerResourceGovernor governor, + long retainedCompressedBytes) + { + _governor = governor; + _retainedCompressedBytes = retainedCompressedBytes; + } + + internal long RetainedCompressedBytes => _retainedCompressedBytes; + + internal bool TryTransferToDecode( + ServerResourceGovernor governor, + out long retainedCompressedBytes) + { + if (!ReferenceEquals(_governor, governor)) + throw new InvalidOperationException("A retained-byte permit cannot move between resource governors."); + + if (Interlocked.CompareExchange(ref _state, Transferred, Owned) != Owned) + { + retainedCompressedBytes = 0; + return false; + } + + retainedCompressedBytes = _retainedCompressedBytes; + return true; + } + + public void Dispose() + { + if (Interlocked.CompareExchange(ref _state, Disposed, Owned) != Owned) + return; + _governor.ReleaseRetained(_retainedCompressedBytes); + } +} + /// /// Request-owned decode resource permit. While decoding it owns one decode-concurrency credit and /// any retained compressed bytes. releases those resources while From 734a672a5fd11a4a5d2f87f33c103aedb58090ae Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:41:47 +0800 Subject: [PATCH 053/228] refactor(server): transfer retained bytes into decode permits --- .../SharpLinkServer.CallPermit.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs index 19da2cee5..945eff639 100644 --- a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs +++ b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs @@ -100,6 +100,33 @@ internal bool TryAcquireDecodePermit( } } + /// + /// Acquires decode concurrency by transferring an already-accounted retained compressed + /// owner into this request. This is used when admission or the decode executor must keep the + /// compressed frame alive before provider execution begins. + /// + internal bool TryAcquireDecodePermit( + ServerRetainedCompressedPermit retainedPermit, + out ServerDecodePermit? decodePermit) + { + ArgumentNullException.ThrowIfNull(retainedPermit); + + lock (_resourceGate) + { + if (Volatile.Read(ref _state) != Reserved || _decodePermit is not null) + { + decodePermit = null; + return false; + } + + if (!_server.ResourceGovernor.TryAcquireDecode(retainedPermit, out decodePermit)) + return false; + + _decodePermit = decodePermit; + return true; + } + } + internal void Activate() { lock (_resourceGate) From c2ad6566aae614615e278d1080716a5b9059c736 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:42:15 +0800 Subject: [PATCH 054/228] refactor(runtime): expose decoded request owner size --- src/SharpLink.Runtime/RpcSession.Compression.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/SharpLink.Runtime/RpcSession.Compression.cs b/src/SharpLink.Runtime/RpcSession.Compression.cs index 714ea5074..0c9d869ba 100644 --- a/src/SharpLink.Runtime/RpcSession.Compression.cs +++ b/src/SharpLink.Runtime/RpcSession.Compression.cs @@ -262,6 +262,16 @@ internal static int ReadCompressedOriginalLength( return checked((int)unchecked((uint)originalLengthBits)); } + internal static int ReadCompressedDecodedPayloadLength( + ProtocolV2FrameType type, + ProtocolV2FrameFlags flags, + ReadOnlySequence payload) + { + var prefixLength = GetBusinessPrefixLength(type, flags, payload); + var originalLength = ReadCompressedOriginalLength(type, flags, payload); + return checked(prefixLength + originalLength); + } + private static int GetBusinessPrefixLength( ProtocolV2FrameType type, ProtocolV2FrameFlags flags, From ba50f197698ba5f4b43a2338d770b0d981681482 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:42:32 +0800 Subject: [PATCH 055/228] feat(abstractions): classify decode resource exhaustion --- .../SharpLinkResourceExhaustion.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs b/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs index 78c154d8e..871581ee5 100644 --- a/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs +++ b/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs @@ -12,6 +12,9 @@ internal static class SharpLinkResourceExhaustion private const char AdmissionOtherWireCode = '\u0007'; private const char PendingRequestCapacityWireCode = '\u0008'; private const char SendQueueCapacityWireCode = '\u0009'; + private const char ServerDecodeConcurrencyWireCode = '\u000A'; + private const char ServerRetainedCompressedBytesWireCode = '\u000B'; + private const char ServerDecodedBytesWireCode = '\u000C'; private static readonly string[] s_knownReasons = [ ServerCallCapacity, @@ -22,7 +25,10 @@ internal static class SharpLinkResourceExhaustion AdmissionPartitionCapacity, AdmissionOther, PendingRequestCapacity, - SendQueueCapacity + SendQueueCapacity, + ServerDecodeConcurrency, + ServerRetainedCompressedBytes, + ServerDecodedBytes ]; internal const string Unspecified = "unspecified"; @@ -35,6 +41,9 @@ internal static class SharpLinkResourceExhaustion internal const string AdmissionOther = "admission_other"; internal const string PendingRequestCapacity = "pending_request_capacity"; internal const string SendQueueCapacity = "send_queue_capacity"; + internal const string ServerDecodeConcurrency = "server_decode_concurrency"; + internal const string ServerRetainedCompressedBytes = "server_retained_compressed_bytes"; + internal const string ServerDecodedBytes = "server_decoded_bytes"; internal static SharpLinkException Create(string reason, string message) { @@ -80,6 +89,9 @@ private static char GetWireCode(string reason) AdmissionOther => AdmissionOtherWireCode, PendingRequestCapacity => PendingRequestCapacityWireCode, SendQueueCapacity => SendQueueCapacityWireCode, + ServerDecodeConcurrency => ServerDecodeConcurrencyWireCode, + ServerRetainedCompressedBytes => ServerRetainedCompressedBytesWireCode, + ServerDecodedBytes => ServerDecodedBytesWireCode, _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, "A known resource exhaustion reason is required.") }; @@ -96,6 +108,9 @@ private static bool TryGetWireReason(char code, out string reason) AdmissionOtherWireCode => AdmissionOther, PendingRequestCapacityWireCode => PendingRequestCapacity, SendQueueCapacityWireCode => SendQueueCapacity, + ServerDecodeConcurrencyWireCode => ServerDecodeConcurrency, + ServerRetainedCompressedBytesWireCode => ServerRetainedCompressedBytes, + ServerDecodedBytesWireCode => ServerDecodedBytes, _ => Unspecified }; return reason != Unspecified; From 47cea8546e53c0eb231eedd75b18f9c5294cb766 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:42:45 +0800 Subject: [PATCH 056/228] refactor(server): gate compressed admission retention by budget --- .../SharpLinkServer.PreAdmissionStreams.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs index fad5b4d0a..cc19cdfce 100644 --- a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs +++ b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs @@ -10,6 +10,34 @@ private IRpcByteBufferWriter CopyAdmissionPayload(ReadOnlySequence payload return owner; } + private bool TryCopyAdmissionPayload( + ReadOnlySequence payload, + ProtocolV2FrameFlags flags, + out IRpcByteBufferWriter? owner, + out ServerRetainedCompressedPermit? retainedPermit) + { + owner = null; + retainedPermit = null; + var isCompressed = (flags & ProtocolV2FrameFlags.Compressed) != 0; + if (isCompressed && + !ResourceGovernor.TryAcquireRetained(payload.Length, out retainedPermit)) + { + return false; + } + + try + { + owner = CopyAdmissionPayload(payload); + return true; + } + catch + { + retainedPermit?.Dispose(); + retainedPermit = null; + throw; + } + } + private void ReservePreAdmissionRequestStreams( RpcSession session, long requestId, From a00efe2e46802cb1ae1f4846f860e18d8c474b63 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:43:16 +0800 Subject: [PATCH 057/228] refactor(server): keep compressed requests cheap in reader loop --- src/SharpLink.Server/SharpLinkServer.RequestLoop.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs b/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs index b5192aa87..65ac98c9c 100644 --- a/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs +++ b/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs @@ -55,7 +55,7 @@ private async Task ProcessRequestLoop(ServerConnectionState connection) } } if (header.Type == ProtocolV2FrameType.Request && - _admissionController is not null) + (header.Flags & ProtocolV2FrameFlags.Compressed) != 0) { session.ValidateInboundPayloadEnvelope( header.Type, header.Flags, payload); From f8ffa902a1d70eabe55eaff2559fa0270dafae4b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:43:59 +0800 Subject: [PATCH 058/228] refactor(server): centralize compressed request resource gates --- .../SharpLinkServer.DecodeResources.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/SharpLink.Server/SharpLinkServer.DecodeResources.cs diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs b/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs new file mode 100644 index 000000000..e98ea7492 --- /dev/null +++ b/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs @@ -0,0 +1,60 @@ +namespace SharpLink.Server; + +internal sealed partial class SharpLinkServer +{ + private bool TryPrepareCompressedRequestDecode( + ServerRequestPermit requestPermit, + ServerRetainedCompressedPermit? retainedCompressedPermit, + ProtocolV2FrameFlags flags, + ReadOnlySequence payload, + out ServerDecodePermit? decodePermit, + out SharpLinkException? rejection) + { + ArgumentNullException.ThrowIfNull(requestPermit); + + var acquired = retainedCompressedPermit is null + ? requestPermit.TryAcquireDecodePermit(0, out decodePermit) + : requestPermit.TryAcquireDecodePermit(retainedCompressedPermit, out decodePermit); + if (!acquired || decodePermit is null) + { + rejection = CreateDecodeResourceExhaustion( + SharpLinkResourceExhaustion.ServerDecodeConcurrency, + "Server decode concurrency is exhausted."); + return false; + } + + var decodedPayloadBytes = RpcSession.ReadCompressedDecodedPayloadLength( + ProtocolV2FrameType.Request, + flags, + payload); + if (!decodePermit.TryReserveDecodedBytes(decodedPayloadBytes)) + { + rejection = CreateDecodeResourceExhaustion( + SharpLinkResourceExhaustion.ServerDecodedBytes, + "Server decoded request byte budget is exhausted."); + return false; + } + + rejection = null; + return true; + } + + private static SharpLinkException CreateDecodeResourceExhaustion( + string reason, + string message) + { + SharpLinkTelemetry.RecordResourceExhausted("server", reason); + return SharpLinkResourceExhaustion.CreateWire( + reason, + $"{message} ({reason})."); + } + + private static SharpLinkException CreateRetainedCompressedResourceExhaustion() + { + const string reason = SharpLinkResourceExhaustion.ServerRetainedCompressedBytes; + SharpLinkTelemetry.RecordResourceExhausted("server", reason); + return SharpLinkResourceExhaustion.CreateWire( + reason, + $"Server retained compressed request byte budget is exhausted ({reason})."); + } +} From 7932f766e610bcc7a36cf6132bb0531beb5918e1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:45:04 +0800 Subject: [PATCH 059/228] refactor(server): wire request permits before compressed decode --- .../SharpLinkServer.InvocationDispatch.cs | 279 +++++++++++++++--- 1 file changed, 239 insertions(+), 40 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs index 1fab80c1e..c4d4d0643 100644 --- a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs @@ -10,11 +10,13 @@ private ValueTask DispatchRpcAsync( StripedLongMap requestCancellationMap, CancellationToken serverLoopToken, ServerCallCancellationState? admittedCallState = null, - bool admissionGranted = false) + bool admissionGranted = false, + ServerRetainedCompressedPermit? retainedCompressedPermit = null) { var session = connection.Session; var isCancellable = (flags & ProtocolV2FrameFlags.Cancellable) != 0; var hasReturnPayload = (flags & ProtocolV2FrameFlags.HasReturn) != 0; + var isCompressed = (flags & ProtocolV2FrameFlags.Compressed) != 0; var request = ReadRequestEnvelope(session, payload, flags); if (IsDeadlineExceeded(request.RpcDeadline)) @@ -118,21 +120,37 @@ private ValueTask DispatchRpcAsync( } if (!admissionTask.IsCompletedSuccessfully) { + if (!TryCopyAdmissionPayload( + payload, + flags, + out var retainedPayload, + out var queuedRetainedPermit)) + { + admittedCallState.TryCancel(ServerCallCancellationReason.AdmissionResourceExhausted); + return RejectQueuedAdmissionForRetainedBudgetAsync( + admissionTask, + connection, + requestId, + requestCancellationMap, + admittedCallState, + oneWay: false); + } + ReservePreAdmissionRequestStreams( session, requestId, descriptor.ClientStreamCount, admittedCallState); - var retainedPayload = CopyAdmissionPayload(payload); return AwaitRpcAdmissionAsync( admissionTask, - retainedPayload, + retainedPayload!, connection, requestId, flags, requestCancellationMap, serverLoopToken, - admittedCallState); + admittedCallState, + queuedRetainedPermit); } var decision = admissionTask.Result; @@ -157,8 +175,8 @@ private ValueTask DispatchRpcAsync( admittedCallState.AttachAdmissionLease(decision.Lease!); } - var admission = TryAcquireCall(connection); - if (admission != ServerCallAdmissionResult.Acquired) + var admission = TryReserveCall(connection, out var requestPermit); + if (admission != ServerCallAdmissionResult.Acquired || requestPermit is null) { if (admittedCallState is not null) ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); @@ -181,18 +199,42 @@ private ValueTask DispatchRpcAsync( return session.SendRpcErrorWithBackpressureAsync( requestId, rejection, connection.ConnectionToken); } + var requestOwner = requestPermit; IRpcByteBufferWriter? decodedRequestOwner = null; try { - if (_admissionController is not null) - { + if (isCompressed) + { + if (!TryPrepareCompressedRequestDecode( + requestOwner, + retainedCompressedPermit, + flags, + payload, + out var decodePermit, + out var resourceRejection)) + { + var rejection = resourceRejection ?? throw new InvalidOperationException( + "Compressed request decode resource rejection is missing its error."); + CompleteFailedRequestStreams(session, requestId, rejection); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, rejection, connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + payload = session.DecodeInboundPayload( ProtocolV2FrameType.Request, flags, payload, admittedCallState?.InvocationToken ?? serverLoopToken, out decodedRequestOwner); + decodePermit!.CompleteDecode(); request = ReadRequestEnvelope(session, payload, flags); } } @@ -203,7 +245,12 @@ private ValueTask DispatchRpcAsync( var responseSend = session.SendRpcErrorWithBackpressureAsync( requestId, exception, connection.ConnectionToken); return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, admittedCallState, requestId, requestCancellationMap, connection); + responseSend, + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); } catch (OperationCanceledException exception) { @@ -213,17 +260,64 @@ private ValueTask DispatchRpcAsync( MapServerCancellationException(admittedCallState, request.RpcDeadline), connection.ConnectionToken); return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, admittedCallState, requestId, requestCancellationMap, connection); + responseSend, + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); } catch (Exception exception) { session.ReturnDecodedPayload(decodedRequestOwner); CompleteFailedRequestStreams(session, requestId, exception); ReleaseDispatchResources( - admittedCallState, requestId, requestCancellationMap, connection); + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); throw; } + if (IsDeadlineExceeded(request.RpcDeadline)) + { + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + var exception = new SharpLinkException( + SharpLinkErrorCode.DeadlineExceeded, + "Request deadline exceeded before dispatch."); + CompleteFailedRequestStreams(session, requestId, exception); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, exception, connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + + if (serverLoopToken.IsCancellationRequested) + { + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + var exception = new SharpLinkException( + SharpLinkErrorCode.ConnectionClosed, + "Connection closed before dispatch."); + CompleteFailedRequestStreams(session, requestId, exception); + ReleaseDispatchResources( + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + return ValueTask.FromException(exception); + } + + requestOwner.Activate(); + var supportsCooperativeCancellation = (isCancellable || serviceInfo.Module is not null) && serviceInfo.Stub.SupportsCancellation(request.MethodHash); @@ -264,8 +358,17 @@ private ValueTask DispatchRpcAsync( connection, callState, requestId, request.RpcDeadline, serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); return AwaitDispatchRpcNoReturnAsync( - invokeTask, session, requestId, callState, requestCancellationMap, connection, - callContext, serviceInfo.Stub, request.MethodHash, invokeToken); + invokeTask, + session, + requestId, + callState, + requestCancellationMap, + connection, + callContext, + serviceInfo.Stub, + request.MethodHash, + invokeToken, + requestOwner); } if (callContext is SharpLinkServerInvocationContext { @@ -287,7 +390,12 @@ private ValueTask DispatchRpcAsync( callState, session, requestId, connection.ConnectionToken); } return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, callState, requestId, requestCancellationMap, connection); + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); } catch (OperationCanceledException exception) { @@ -306,7 +414,12 @@ private ValueTask DispatchRpcAsync( callState, session, requestId, connection.ConnectionToken); } return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, callState, requestId, requestCancellationMap, connection); + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); } catch (Exception e) { @@ -332,7 +445,12 @@ private ValueTask DispatchRpcAsync( callState, session, requestId, connection.ConnectionToken); } return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, callState, requestId, requestCancellationMap, connection); + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); } } @@ -354,9 +472,20 @@ private ValueTask DispatchRpcAsync( callState = EnsureTrackedCallState( connection, callState, requestId, request.RpcDeadline, serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); - return AwaitDispatchRpcAsync(invokeTask, session, requestId, writer, token, callState, - requestCancellationMap, connection, responseCallContext, - serviceInfo.Stub, request.MethodHash, invokeToken); + return AwaitDispatchRpcAsync( + invokeTask, + session, + requestId, + writer, + token, + callState, + requestCancellationMap, + connection, + responseCallContext, + serviceInfo.Stub, + request.MethodHash, + invokeToken, + requestOwner); } if (responseCallContext is SharpLinkServerInvocationContext { @@ -370,7 +499,12 @@ private ValueTask DispatchRpcAsync( var drainErrorSend = TrySendModuleDrainError( callState, session, requestId, connection.ConnectionToken); return ReleaseDispatchResourcesAfterResponseAsync( - drainErrorSend, callState, requestId, requestCancellationMap, connection); + drainErrorSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); } writer.EndPacket(token); ownsWriter = false; @@ -382,7 +516,8 @@ private ValueTask DispatchRpcAsync( callState, requestId, requestCancellationMap, - connection); + connection, + requestOwner); } catch (OperationCanceledException exception) @@ -406,7 +541,12 @@ private ValueTask DispatchRpcAsync( callState, session, requestId, connection.ConnectionToken); } return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, callState, requestId, requestCancellationMap, connection); + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); } catch (Exception e) { @@ -418,7 +558,12 @@ private ValueTask DispatchRpcAsync( var compressionErrorSend = session.SendRpcErrorWithBackpressureAsync( requestId, compressionException, connection.ConnectionToken); return ReleaseDispatchResourcesAfterResponseAsync( - compressionErrorSend, callState, requestId, requestCancellationMap, connection); + compressionErrorSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); } throw; } @@ -445,7 +590,12 @@ private ValueTask DispatchRpcAsync( callState, session, requestId, connection.ConnectionToken); } return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, callState, requestId, requestCancellationMap, connection); + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); } } @@ -459,7 +609,8 @@ private async ValueTask AwaitDispatchRpcNoReturnAsync( SharpLinkCallContextSnapshot callContext, IRpcStub stub, long methodId, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + ServerRequestPermit requestPermit) { using var requestScope = BeginRequestLogScope(_logger, requestId); try @@ -525,7 +676,12 @@ await TrySendModuleDrainError( } finally { - ReleaseDispatchResources(callState, requestId, requestCancellationMap, connection); + ReleaseDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestPermit); } } @@ -541,7 +697,8 @@ private async ValueTask AwaitDispatchRpcAsync( SharpLinkCallContextSnapshot callContext, IRpcStub stub, long methodId, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + ServerRequestPermit requestPermit) { var ownsWriter = true; try @@ -625,7 +782,12 @@ await TrySendModuleDrainError( } finally { - ReleaseDispatchResources(callState, requestId, requestCancellationMap, connection); + ReleaseDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestPermit); } } @@ -633,14 +795,16 @@ private void ReleaseDispatchResources( ServerCallCancellationState? callState, long requestId, StripedLongMap requestCancellationMap, - ServerConnectionState connection) + ServerConnectionState connection, + ServerRequestPermit requestPermit) { + _ = connection; if (callState is not null) { requestCancellationMap.TryRemove(requestId, callState); callState.Dispose(); } - ReleaseCall(connection); + requestPermit.Dispose(); } private ValueTask ReleaseDispatchResourcesAfterResponseAsync( @@ -648,16 +812,27 @@ private ValueTask ReleaseDispatchResourcesAfterResponseAsync( ServerCallCancellationState? callState, long requestId, StripedLongMap requestCancellationMap, - ServerConnectionState connection) + ServerConnectionState connection, + ServerRequestPermit requestPermit) { if (responseSend.IsCompletedSuccessfully) { - ReleaseDispatchResources(callState, requestId, requestCancellationMap, connection); + ReleaseDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestPermit); return ValueTask.CompletedTask; } return AwaitResponseAndReleaseDispatchResourcesAsync( - responseSend, callState, requestId, requestCancellationMap, connection); + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestPermit); } private async ValueTask AwaitResponseAndReleaseDispatchResourcesAsync( @@ -665,7 +840,8 @@ private async ValueTask AwaitResponseAndReleaseDispatchResourcesAsync( ServerCallCancellationState? callState, long requestId, StripedLongMap requestCancellationMap, - ServerConnectionState connection) + ServerConnectionState connection, + ServerRequestPermit requestPermit) { try { @@ -673,7 +849,12 @@ private async ValueTask AwaitResponseAndReleaseDispatchResourcesAsync( } finally { - ReleaseDispatchResources(callState, requestId, requestCancellationMap, connection); + ReleaseDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestPermit); } } @@ -683,16 +864,28 @@ private ValueTask CompletePayloadResponseAndReleaseDispatchResourcesAsync( ServerCallCancellationState? callState, long requestId, StripedLongMap requestCancellationMap, - ServerConnectionState connection) + ServerConnectionState connection, + ServerRequestPermit requestPermit) { if (responseSend.IsCompletedSuccessfully) { - ReleaseDispatchResources(callState, requestId, requestCancellationMap, connection); + ReleaseDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestPermit); return ValueTask.CompletedTask; } return AwaitPayloadResponseAndReleaseDispatchResourcesAsync( - responseSend, session, callState, requestId, requestCancellationMap, connection); + responseSend, + session, + callState, + requestId, + requestCancellationMap, + connection, + requestPermit); } private async ValueTask AwaitPayloadResponseAndReleaseDispatchResourcesAsync( @@ -701,7 +894,8 @@ private async ValueTask AwaitPayloadResponseAndReleaseDispatchResourcesAsync( ServerCallCancellationState? callState, long requestId, StripedLongMap requestCancellationMap, - ServerConnectionState connection) + ServerConnectionState connection, + ServerRequestPermit requestPermit) { try { @@ -719,7 +913,12 @@ await session.SendRpcErrorWithBackpressureAsync( } finally { - ReleaseDispatchResources(callState, requestId, requestCancellationMap, connection); + ReleaseDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestPermit); } } From e4dc10ad792661f46a7b4bd00a03ba87827bda9e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:46:06 +0800 Subject: [PATCH 060/228] refactor(server): bind retained accounting to admission payload owner --- .../SharpLinkServer.PreAdmissionStreams.cs | 79 +++++++++++++++---- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs index cc19cdfce..9dcc0a5b7 100644 --- a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs +++ b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs @@ -2,22 +2,13 @@ namespace SharpLink.Server; internal sealed partial class SharpLinkServer { - private IRpcByteBufferWriter CopyAdmissionPayload(ReadOnlySequence payload) - { - var owner = _runtimeContext.Buffers.Rent(checked((int)payload.Length)); - foreach (var segment in payload) - owner.Write(segment.Span); - return owner; - } - private bool TryCopyAdmissionPayload( ReadOnlySequence payload, ProtocolV2FrameFlags flags, - out IRpcByteBufferWriter? owner, - out ServerRetainedCompressedPermit? retainedPermit) + out ServerRetainedAdmissionPayload? retainedPayload) { - owner = null; - retainedPermit = null; + retainedPayload = null; + ServerRetainedCompressedPermit? retainedPermit = null; var isCompressed = (flags & ProtocolV2FrameFlags.Compressed) != 0; if (isCompressed && !ResourceGovernor.TryAcquireRetained(payload.Length, out retainedPermit)) @@ -25,16 +16,25 @@ private bool TryCopyAdmissionPayload( return false; } + IRpcByteBufferWriter? owner = null; try { - owner = CopyAdmissionPayload(payload); + owner = _runtimeContext.Buffers.Rent(checked((int)payload.Length)); + foreach (var segment in payload) + owner.Write(segment.Span); + retainedPayload = new ServerRetainedAdmissionPayload( + _runtimeContext.Buffers, + owner, + retainedPermit); + owner = null; + retainedPermit = null; return true; } - catch + finally { + if (owner is not null) + _runtimeContext.Buffers.Return(owner); retainedPermit?.Dispose(); - retainedPermit = null; - throw; } } @@ -117,3 +117,50 @@ private static void DrainFailedOneWayStreams( } } + +internal sealed class ServerRetainedAdmissionPayload : IDisposable +{ + private readonly SharpLinkBufferWriterPool _pool; + private readonly IRpcByteBufferWriter _owner; + private readonly ServerRetainedCompressedPermit? _retainedPermit; + private int _disposed; + + internal ServerRetainedAdmissionPayload( + SharpLinkBufferWriterPool pool, + IRpcByteBufferWriter owner, + ServerRetainedCompressedPermit? retainedPermit) + { + _pool = pool; + _owner = owner; + _retainedPermit = retainedPermit; + } + + internal ReadOnlySequence Payload + { + get + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + return new ReadOnlySequence(_owner.WrittenMemory); + } + } + + internal ServerRetainedCompressedPermit? RetainedPermit => _retainedPermit; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + try + { + // The physical retained buffer is returned before its accounting permit is + // released. If the permit was transferred to a decode owner, this Dispose is + // intentionally a no-op and CompleteDecode performs the accounting release. + _pool.Return(_owner); + } + finally + { + _retainedPermit?.Dispose(); + } + } +} From fc44a17cd6f1e1b87da8bb6a087bf209840cd650 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:47:16 +0800 Subject: [PATCH 061/228] refactor(server): wire one-way and queued request resource ownership --- .../SharpLinkServer.AdmissionDispatch.cs | 195 +++++++++++++++--- 1 file changed, 171 insertions(+), 24 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs index fbf898f36..8c27da31b 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs @@ -11,11 +11,13 @@ private void DispatchOneWayRpc( CancellationToken serverLoopToken, ServerCallCancellationState? admittedCallState = null, bool admissionGranted = false, - int admittedClientStreamCount = 0) + int admittedClientStreamCount = 0, + ServerRetainedAdmissionPayload? retainedAdmissionPayload = null) { var session = connection.Session; using var requestScope = BeginRequestLogScope(_logger, requestId); var isCancellable = (flags & ProtocolV2FrameFlags.Cancellable) != 0; + var isCompressed = (flags & ProtocolV2FrameFlags.Compressed) != 0; var request = ReadRequestEnvelope(session, payload, flags); if (IsDeadlineExceeded(request.RpcDeadline)) { @@ -82,16 +84,31 @@ private void DispatchOneWayRpc( } if (!admissionTask.IsCompletedSuccessfully) { + if (!TryCopyAdmissionPayload(payload, flags, out var retainedPayload)) + { + admittedCallState.TryCancel(ServerCallCancellationReason.AdmissionResourceExhausted); + ObserveUserCall( + RejectQueuedAdmissionForRetainedBudgetAsync( + admissionTask, + connection, + requestId, + requestCancellationMap, + admittedCallState, + oneWay: true, + descriptor.ClientStreamCount), + requestId); + return; + } + ReservePreAdmissionRequestStreams( session, requestId, descriptor.ClientStreamCount, admittedCallState); - var retainedPayload = CopyAdmissionPayload(payload); ObserveUserCall( new ValueTask(AwaitOneWayAdmissionAsync( admissionTask, - retainedPayload, + retainedPayload!, connection, requestId, flags, @@ -114,8 +131,8 @@ private void DispatchOneWayRpc( admittedCallState.AttachAdmissionLease(decision.Lease!); } - var admission = TryAcquireCall(connection); - if (admission != ServerCallAdmissionResult.Acquired) + var admission = TryReserveCall(connection, out var requestPermit); + if (admission != ServerCallAdmissionResult.Acquired || requestPermit is null) { DrainRejectedOneWayStreams(session, requestId, descriptor.ClientStreamCount); if (admittedCallState is not null) @@ -130,47 +147,105 @@ private void DispatchOneWayRpc( } return; } + var requestOwner = requestPermit; IRpcByteBufferWriter? decodedRequestOwner = null; try { - if (_admissionController is not null) + if (isCompressed) { + if (!TryPrepareCompressedRequestDecode( + requestOwner, + retainedAdmissionPayload?.RetainedPermit, + flags, + payload, + out var decodePermit, + out var resourceRejection)) + { + retainedAdmissionPayload?.Dispose(); + var rejection = resourceRejection ?? throw new InvalidOperationException( + "Compressed one-way decode resource rejection is missing its error."); + var reason = SharpLinkResourceExhaustion.GetReason(rejection); + Interlocked.Increment(ref _rejectedOneWayCalls); + LogOnewayRpcResourceExhausted(_logger, reason); + DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + ReleaseOneWayDispatchResources( + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + return; + } + payload = session.DecodeInboundPayload( ProtocolV2FrameType.Request, flags, payload, admittedCallState?.InvocationToken ?? serverLoopToken, out decodedRequestOwner); + retainedAdmissionPayload?.Dispose(); + decodePermit!.CompleteDecode(); request = ReadRequestEnvelope(session, payload, flags); } } catch (SharpLinkException exception) when ( exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) { + retainedAdmissionPayload?.Dispose(); Interlocked.Increment(ref _rejectedOneWayCalls); LogOnewayRpcDispatchFailed(_logger, exception); DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); ReleaseOneWayDispatchResources( - admittedCallState, requestId, requestCancellationMap, connection); + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); return; } catch (OperationCanceledException) { + retainedAdmissionPayload?.Dispose(); DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); ReleaseOneWayDispatchResources( - admittedCallState, requestId, requestCancellationMap, connection); + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); return; } catch { + retainedAdmissionPayload?.Dispose(); session.ReturnDecodedPayload(decodedRequestOwner); DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); ReleaseOneWayDispatchResources( - admittedCallState, requestId, requestCancellationMap, connection); + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); throw; } + if (IsDeadlineExceeded(request.RpcDeadline) || serverLoopToken.IsCancellationRequested) + { + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + ReleaseOneWayDispatchResources( + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + return; + } + + requestOwner.Activate(); + var supportsCooperativeCancellation = (isCancellable || serviceInfo.Module is not null) && serviceInfo.Stub.SupportsCancellation(request.MethodHash); @@ -218,7 +293,12 @@ private void DispatchOneWayRpc( } interceptorContext) interceptorContext.Status = SharpLinkInvocationStatus.Succeeded; TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken); - ReleaseOneWayDispatchResources(callState, requestId, requestCancellationMap, connection); + ReleaseOneWayDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); return; } @@ -236,7 +316,8 @@ private void DispatchOneWayRpc( session, serviceInfo.Stub, request.MethodHash, - invokeToken)), + invokeToken, + requestOwner)), requestId); } catch (Exception ex) @@ -247,7 +328,12 @@ private void DispatchOneWayRpc( LogOnewayRpcDispatchFailed(_logger, MapServiceException( ex, callContext, session, serviceInfo.Stub, request.MethodHash, requestId, invokeToken)); } - ReleaseOneWayDispatchResources(callState, requestId, requestCancellationMap, connection); + ReleaseOneWayDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); } } @@ -261,7 +347,8 @@ private async Task AwaitOneWayDispatchAsync( RpcSession session, IRpcStub stub, long methodId, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + ServerRequestPermit requestPermit) { using var requestScope = BeginRequestLogScope(_logger, requestId); try @@ -284,13 +371,18 @@ private async Task AwaitOneWayDispatchAsync( } finally { - ReleaseOneWayDispatchResources(callState, requestId, requestCancellationMap, connection); + ReleaseOneWayDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestPermit); } } private async Task AwaitOneWayAdmissionAsync( ValueTask admissionTask, - IRpcByteBufferWriter retainedPayload, + ServerRetainedAdmissionPayload retainedPayload, ServerConnectionState connection, long requestId, ProtocolV2FrameFlags flags, @@ -326,17 +418,18 @@ private async Task AwaitOneWayAdmissionAsync( connection, requestId, flags, - new ReadOnlySequence(retainedPayload.WrittenMemory), + retainedPayload.Payload, requestCancellationMap, serverLoopToken, callState, admissionGranted: true, - admittedClientStreamCount: clientStreamCount); + admittedClientStreamCount: clientStreamCount, + retainedAdmissionPayload: retainedPayload); transferred = true; } finally { - _runtimeContext.Buffers.Return(retainedPayload); + retainedPayload.Dispose(); if (!transferred) ReleasePendingAdmissionState(connection.Session, requestCancellationMap, requestId, callState); } @@ -344,7 +437,7 @@ private async Task AwaitOneWayAdmissionAsync( private async ValueTask AwaitRpcAdmissionAsync( ValueTask admissionTask, - IRpcByteBufferWriter retainedPayload, + ServerRetainedAdmissionPayload retainedPayload, ServerConnectionState connection, long requestId, ProtocolV2FrameFlags flags, @@ -380,23 +473,75 @@ await RejectAdmission( connection, requestId, flags, - new ReadOnlySequence(retainedPayload.WrittenMemory), + retainedPayload.Payload, requestCancellationMap, serverLoopToken, callState, - admissionGranted: true); + admissionGranted: true, + retainedCompressedPermit: retainedPayload.RetainedPermit); transferred = true; + if ((flags & ProtocolV2FrameFlags.Compressed) != 0) + retainedPayload.Dispose(); if (!dispatchTask.IsCompletedSuccessfully) await dispatchTask.ConfigureAwait(false); } finally { - _runtimeContext.Buffers.Return(retainedPayload); + retainedPayload.Dispose(); if (!transferred) ReleasePendingAdmissionState(connection.Session, requestCancellationMap, requestId, callState); } } + private async ValueTask RejectQueuedAdmissionForRetainedBudgetAsync( + ValueTask admissionTask, + ServerConnectionState connection, + long requestId, + StripedLongMap requestCancellationMap, + ServerCallCancellationState callState, + bool oneWay, + int clientStreamCount = 0) + { + var rejection = CreateRetainedCompressedResourceExhaustion(); + try + { + if (oneWay) + { + Interlocked.Increment(ref _rejectedOneWayCalls); + DrainRejectedOneWayStreams(connection.Session, requestId, clientStreamCount); + LogOnewayRpcResourceExhausted( + _logger, + SharpLinkResourceExhaustion.ServerRetainedCompressedBytes); + } + else + { + await connection.Session.SendRpcErrorWithBackpressureAsync( + requestId, + rejection, + connection.ConnectionToken).ConfigureAwait(false); + } + } + finally + { + try + { + var decision = await admissionTask.ConfigureAwait(false); + decision.Lease?.Dispose(); + } + catch (OperationCanceledException) + { + } + finally + { + if (oneWay) + ReleaseAdmissionCallState(requestCancellationMap, requestId, callState); + else + ReleasePendingAdmissionState( + connection.Session, requestCancellationMap, requestId, callState); + } + } + } + private ServerCallCancellationState CreateAdmissionWaitState( ServerConnectionState connection, long requestId, @@ -527,14 +672,16 @@ private void ReleaseOneWayDispatchResources( ServerCallCancellationState? callState, long requestId, StripedLongMap requestCancellationMap, - ServerConnectionState connection) + ServerConnectionState connection, + ServerRequestPermit requestPermit) { + _ = connection; if (callState is not null) { requestCancellationMap.TryRemove(requestId, callState); callState.Dispose(); } - ReleaseCall(connection); + requestPermit.Dispose(); } } From 66408cfe8cfbe3bdaa8ab74a678eef3f5df66778 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:48:09 +0800 Subject: [PATCH 062/228] refactor(server): align queued retention with decode ownership --- .../SharpLinkServer.InvocationDispatch.cs | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs index c4d4d0643..6ad5342ed 100644 --- a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs @@ -11,7 +11,7 @@ private ValueTask DispatchRpcAsync( CancellationToken serverLoopToken, ServerCallCancellationState? admittedCallState = null, bool admissionGranted = false, - ServerRetainedCompressedPermit? retainedCompressedPermit = null) + ServerRetainedAdmissionPayload? retainedAdmissionPayload = null) { var session = connection.Session; var isCancellable = (flags & ProtocolV2FrameFlags.Cancellable) != 0; @@ -120,11 +120,7 @@ private ValueTask DispatchRpcAsync( } if (!admissionTask.IsCompletedSuccessfully) { - if (!TryCopyAdmissionPayload( - payload, - flags, - out var retainedPayload, - out var queuedRetainedPermit)) + if (!TryCopyAdmissionPayload(payload, flags, out var queuedRetainedPayload)) { admittedCallState.TryCancel(ServerCallCancellationReason.AdmissionResourceExhausted); return RejectQueuedAdmissionForRetainedBudgetAsync( @@ -143,14 +139,13 @@ private ValueTask DispatchRpcAsync( admittedCallState); return AwaitRpcAdmissionAsync( admissionTask, - retainedPayload!, + queuedRetainedPayload!, connection, requestId, flags, requestCancellationMap, serverLoopToken, - admittedCallState, - queuedRetainedPermit); + admittedCallState); } var decision = admissionTask.Result; @@ -208,12 +203,13 @@ private ValueTask DispatchRpcAsync( { if (!TryPrepareCompressedRequestDecode( requestOwner, - retainedCompressedPermit, + retainedAdmissionPayload?.RetainedPermit, flags, payload, out var decodePermit, out var resourceRejection)) { + retainedAdmissionPayload?.Dispose(); var rejection = resourceRejection ?? throw new InvalidOperationException( "Compressed request decode resource rejection is missing its error."); CompleteFailedRequestStreams(session, requestId, rejection); @@ -234,6 +230,7 @@ private ValueTask DispatchRpcAsync( payload, admittedCallState?.InvocationToken ?? serverLoopToken, out decodedRequestOwner); + retainedAdmissionPayload?.Dispose(); decodePermit!.CompleteDecode(); request = ReadRequestEnvelope(session, payload, flags); } @@ -241,6 +238,7 @@ private ValueTask DispatchRpcAsync( catch (SharpLinkException exception) when ( exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) { + retainedAdmissionPayload?.Dispose(); CompleteFailedRequestStreams(session, requestId, exception); var responseSend = session.SendRpcErrorWithBackpressureAsync( requestId, exception, connection.ConnectionToken); @@ -254,6 +252,7 @@ private ValueTask DispatchRpcAsync( } catch (OperationCanceledException exception) { + retainedAdmissionPayload?.Dispose(); CompleteFailedRequestStreams(session, requestId, exception); var responseSend = session.SendRpcErrorWithBackpressureAsync( requestId, @@ -269,6 +268,7 @@ private ValueTask DispatchRpcAsync( } catch (Exception exception) { + retainedAdmissionPayload?.Dispose(); session.ReturnDecodedPayload(decodedRequestOwner); CompleteFailedRequestStreams(session, requestId, exception); ReleaseDispatchResources( @@ -764,14 +764,7 @@ await session.SendRpcErrorWithBackpressureAsync( { await session.SendRpcErrorWithBackpressureAsync( requestId, - MapServiceException( - e, - callContext, - session, - stub, - methodId, - requestId, - cancellationToken), + MapServerCancellationException(callState, callState.Deadline), connection.ConnectionToken).ConfigureAwait(false); } else From a775f99bb8e099579a510a2f17087386a2a95104 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:50:09 +0800 Subject: [PATCH 063/228] refactor(server): preserve queued retained ownership through inline decode --- .../SharpLinkServer.DecodeDispatchBridge.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/SharpLink.Server/SharpLinkServer.DecodeDispatchBridge.cs diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeDispatchBridge.cs b/src/SharpLink.Server/SharpLinkServer.DecodeDispatchBridge.cs new file mode 100644 index 000000000..781852e7e --- /dev/null +++ b/src/SharpLink.Server/SharpLinkServer.DecodeDispatchBridge.cs @@ -0,0 +1,36 @@ +namespace SharpLink.Server; + +internal sealed partial class SharpLinkServer +{ + /// + /// Queued two-way admission keeps its copied compressed frame and retained-byte permit owned by + /// the outer admission payload until this method returns from the synchronous dispatch prefix. + /// Inline B therefore acquires decode concurrency with zero transferred retained bytes; the + /// admission wrapper returns the physical copy and releases its retained budget immediately + /// after this call returns. Persistent D will instead use the explicit retained-to-decode + /// transfer primitive when ownership crosses into a decode worker. + /// + private ValueTask DispatchRpcAsync( + ServerConnectionState connection, + long requestId, + ProtocolV2FrameFlags flags, + ReadOnlySequence payload, + StripedLongMap requestCancellationMap, + CancellationToken serverLoopToken, + ServerCallCancellationState? admittedCallState, + bool admissionGranted, + ServerRetainedCompressedPermit? retainedCompressedPermit) + { + _ = retainedCompressedPermit; + return DispatchRpcAsync( + connection, + requestId, + flags, + payload, + requestCancellationMap, + serverLoopToken, + admittedCallState, + admissionGranted, + retainedAdmissionPayload: null); + } +} From 9d949e0a0d259419392ce5851587e0165528c8da Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:53:45 +0800 Subject: [PATCH 064/228] refactor(server): pass queued retained owner directly to dispatch --- src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs index 8c27da31b..2333593d7 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs @@ -478,7 +478,7 @@ await RejectAdmission( serverLoopToken, callState, admissionGranted: true, - retainedCompressedPermit: retainedPayload.RetainedPermit); + retainedAdmissionPayload: retainedPayload); transferred = true; if ((flags & ProtocolV2FrameFlags.Compressed) != 0) retainedPayload.Dispose(); From 1c3bd6abed019ed9f1b3eda02f325f9dd924d02c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:54:12 +0800 Subject: [PATCH 065/228] refactor(server): remove temporary dispatch overload --- .../SharpLinkServer.DecodeDispatchBridge.cs | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 src/SharpLink.Server/SharpLinkServer.DecodeDispatchBridge.cs diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeDispatchBridge.cs b/src/SharpLink.Server/SharpLinkServer.DecodeDispatchBridge.cs deleted file mode 100644 index 781852e7e..000000000 --- a/src/SharpLink.Server/SharpLinkServer.DecodeDispatchBridge.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace SharpLink.Server; - -internal sealed partial class SharpLinkServer -{ - /// - /// Queued two-way admission keeps its copied compressed frame and retained-byte permit owned by - /// the outer admission payload until this method returns from the synchronous dispatch prefix. - /// Inline B therefore acquires decode concurrency with zero transferred retained bytes; the - /// admission wrapper returns the physical copy and releases its retained budget immediately - /// after this call returns. Persistent D will instead use the explicit retained-to-decode - /// transfer primitive when ownership crosses into a decode worker. - /// - private ValueTask DispatchRpcAsync( - ServerConnectionState connection, - long requestId, - ProtocolV2FrameFlags flags, - ReadOnlySequence payload, - StripedLongMap requestCancellationMap, - CancellationToken serverLoopToken, - ServerCallCancellationState? admittedCallState, - bool admissionGranted, - ServerRetainedCompressedPermit? retainedCompressedPermit) - { - _ = retainedCompressedPermit; - return DispatchRpcAsync( - connection, - requestId, - flags, - payload, - requestCancellationMap, - serverLoopToken, - admittedCallState, - admissionGranted, - retainedAdmissionPayload: null); - } -} From 7c1d974a6d5d04bae9da4a36a2aaea8109be7bf5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:54:56 +0800 Subject: [PATCH 066/228] test(server): verify compressed decode follows call reservation --- .../CompressionCallCapacityAdmissionTests.cs | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs diff --git a/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs new file mode 100644 index 000000000..730377ecf --- /dev/null +++ b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs @@ -0,0 +1,439 @@ +namespace SharpLink.IntegrationTests; + +public class CompressionCallCapacityAdmissionTests +{ + [Test] + [NotInParallel] + [Arguments(false)] + [Arguments(true)] + public async Task CompressedUnaryShouldDecompressOnlyAfterCallCapacityAdmission( + bool useAdvancedAdmission) + { + TestService.ResetBlockingAdd(); + var serverProvider = new CountingCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await CapacityHarness.CreateAsync( + serverProvider, + useAdvancedAdmission); + var blocker = harness.Client.Get() + .BlockingAddAsync(1, 2, CancellationToken.None) + .AsTask(); + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(2)); + var payload = Enumerable.Repeat((byte)0x41, 32 * 1024).ToArray(); + + await EnsureResourceExhaustedAsync( + harness.Client.Get().EchoBytesAsync(payload).AsTask(), + "compressed unary capacity rejection"); + + Ensure(serverProvider.DecompressCount == 0, + "capacity-rejected compressed unary request must not be decompressed"); + + TestService.ReleaseBlockingAdd(); + Ensure(await blocker.WaitAsync(TimeSpan.FromSeconds(2)) == 3, + "capacity owner should complete after release"); + + var response = await harness.Client.Get() + .EchoBytesAsync(payload) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(2)); + Ensure(response.SequenceEqual(payload), "accepted compressed unary response"); + Ensure(serverProvider.DecompressCount == 1, + "accepted compressed unary request must be decompressed exactly once"); + } + finally + { + TestService.ReleaseBlockingAdd(); + } + } + + [Test] + [NotInParallel] + [Arguments(false)] + [Arguments(true)] + public async Task CompressedOneWayShouldDecompressOnlyAfterCallCapacityAdmission( + bool useAdvancedAdmission) + { + TestService.ResetBlockingAdd(); + CompressionService.ResetOneWay(); + var serverProvider = new CountingCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await CapacityHarness.CreateAsync( + serverProvider, + useAdvancedAdmission); + var blocker = harness.Client.Get() + .BlockingAddAsync(3, 4, CancellationToken.None) + .AsTask(); + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(2)); + var payload = Enumerable.Repeat((byte)0x42, 32 * 1024).ToArray(); + + await harness.Client.Get() + .NotifyBytesAsync(payload) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(2)); + await WaitUntilAsync( + () => harness.RejectedOneWayCalls == 1, + "compressed one-way capacity rejection"); + + Ensure(serverProvider.DecompressCount == 0, + "capacity-rejected compressed one-way request must not be decompressed"); + Ensure(!CompressionService.WaitForOneWayAsync().IsCompleted, + "capacity-rejected compressed one-way request must not execute the service"); + + TestService.ReleaseBlockingAdd(); + Ensure(await blocker.WaitAsync(TimeSpan.FromSeconds(2)) == 7, + "capacity owner should complete after release"); + + CompressionService.ResetOneWay(); + await harness.Client.Get() + .NotifyBytesAsync(payload) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(2)); + Ensure(await CompressionService.WaitForOneWayAsync().WaitAsync(TimeSpan.FromSeconds(2)) == + payload.Length, + "accepted compressed one-way request should execute"); + Ensure(serverProvider.DecompressCount == 1, + "accepted compressed one-way request must be decompressed exactly once"); + } + finally + { + TestService.ReleaseBlockingAdd(); + } + } + + [Test] + [NotInParallel] + public async Task CompressedUnaryShouldRejectIfDeadlineExpiresDuringDecompression() + { + DeadlineCompressionProbeService.Reset(); + var serverProvider = new BlockingDecompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + var requestTimeout = TimeSpan.FromMilliseconds(100); + await using var harness = await CapacityHarness.CreateAsync( + serverProvider, + useAdvancedAdmission: false, + requestTimeout); + var payload = Enumerable.Repeat((byte)0x43, 32 * 1024).ToArray(); + var call = harness.Client.Get() + .EchoAsync(payload) + .AsTask(); + + try + { + await serverProvider.WaitForDecompressionAsync().WaitAsync(TimeSpan.FromSeconds(2)); + await EnsureDeadlineExceededAsync(call, "compressed unary post-decode deadline"); + serverProvider.ReleaseDecompression(); + await WaitUntilAsync(() => harness.ActiveCalls == 0, "expired unary call release"); + + Ensure(DeadlineCompressionProbeService.UnaryInvocations == 0, + "expired compressed unary request must not execute the service"); + } + finally + { + serverProvider.ReleaseDecompression(); + } + } + + [Test] + [NotInParallel] + public async Task CompressedOneWayShouldDropIfDeadlineExpiresDuringDecompression() + { + DeadlineCompressionProbeService.Reset(); + var serverProvider = new BlockingDecompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + var requestTimeout = TimeSpan.FromMilliseconds(100); + await using var harness = await CapacityHarness.CreateAsync( + serverProvider, + useAdvancedAdmission: false); + var payload = Enumerable.Repeat((byte)0x44, 32 * 1024).ToArray(); + + try + { + await harness.Client.Get() + .NotifyAsync(payload, new SharpLinkCallOptions { Timeout = requestTimeout }) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(2)); + await serverProvider.WaitForDecompressionAsync().WaitAsync(TimeSpan.FromSeconds(2)); + await Task.Delay(requestTimeout + TimeSpan.FromMilliseconds(150)); + serverProvider.ReleaseDecompression(); + await WaitUntilAsync(() => harness.ActiveCalls == 0, "expired one-way call release"); + + Ensure(DeadlineCompressionProbeService.OneWayInvocations == 0, + "expired compressed one-way request must not execute the service"); + } + finally + { + serverProvider.ReleaseDecompression(); + } + } + + private static async Task EnsureResourceExhaustedAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(2)); + throw new Exception($"assert failed: {scenario} should fail"); + } + catch (SharpLinkException exception) + { + Ensure(exception.Code == SharpLinkErrorCode.ResourceExhausted, + $"{scenario} should return ResourceExhausted, actual {exception.Code}"); + } + catch (TimeoutException) + { + throw new Exception($"assert failed: {scenario} did not fail fast"); + } + } + + private static async Task EnsureDeadlineExceededAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(2)); + throw new Exception($"assert failed: {scenario} should fail"); + } + catch (SharpLinkException exception) + { + Ensure(exception.Code == SharpLinkErrorCode.DeadlineExceeded, + $"{scenario} should return DeadlineExceeded, actual {exception.Code}"); + } + catch (TimeoutException) + { + throw new Exception($"assert failed: {scenario} did not fail fast"); + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} was not observed"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class CountingCompressionProvider(ISharpLinkCompressionProvider inner) + : ISharpLinkCompressionProvider + { + private int _decompressCount; + + public string WireProfile => inner.WireProfile; + public int DecompressCount => Volatile.Read(ref _decompressCount); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _decompressCount); + return inner.Decompress(input, output, maxOutputBytes, cancellationToken); + } + } + + private sealed class BlockingDecompressionProvider(ISharpLinkCompressionProvider inner) + : ISharpLinkCompressionProvider + { + private readonly TaskCompletionSource _decompressionStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly ManualResetEventSlim _release = new(initialState: false); + + public string WireProfile => inner.WireProfile; + + public Task WaitForDecompressionAsync() => _decompressionStarted.Task; + + public void ReleaseDecompression() => _release.Set(); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + _decompressionStarted.TrySetResult(); + _release.Wait(); + return inner.Decompress(input, output, maxOutputBytes, cancellationToken); + } + } + + private sealed class CapacityHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + + public ISharpLinkClient Client { get; } + public long RejectedOneWayCalls + { + get + { + var reflectionField = _server.GetType().GetField( + "_rejectedOneWayCalls", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception("cannot find rejected one-way call counter"); + return (long)reflectionField.GetValue(_server)!; + } + } + public int ActiveCalls + { + get + { + var reflectionField = _server.GetType().GetField( + "_globalActiveCalls", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception("cannot find active call counter"); + return (int)reflectionField.GetValue(_server)!; + } + } + + private CapacityHarness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient client) + { + _serverCts = serverCts; + _serverTask = serverTask; + _server = server; + Client = client; + } + + public static async Task CreateAsync( + ISharpLinkCompressionProvider serverProvider, + bool useAdvancedAdmission, + TimeSpan? requestTimeout = null) + { + var serverCts = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 1; + options.FlowControl.MaxConcurrentCallsPerServer = 1; + options.Compression.Providers.Add(serverProvider); + }); + if (useAdvancedAdmission) + { + serverBuilder.UseAdmissionControl(options => + options.Global.UseConcurrency(8)); + } + + serverBuilder.UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = Task.Run(async () => + { + try + { + await server.RunAsync(serverCts.Token); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + + var clientBuilder = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .UseRuntime(options => options.Compression.Providers.Add( + SharpLinkCompressionProviders.CreateBrotli())); + if (requestTimeout is { } timeout) + clientBuilder.UseRequestTimeout(timeout); + var client = clientBuilder.Build(); + await client.ConnectAsync(); + + return new CapacityHarness(serverCts, serverTask, server, client); + } + + public async ValueTask DisposeAsync() + { + await Client.StopAsync(); + await _serverCts.CancelAsync(); + await _server.StopAsync(TimeSpan.Zero); + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + } +} + +[RpcContract] +public interface IDeadlineCompressionProbeService : IService +{ + [NonCancellable] + ValueTask EchoAsync(byte[] value); + + [Oneway] + [NonCancellable] + ValueTask NotifyAsync(byte[] value, SharpLinkCallOptions options); +} + +[RpcService] +public sealed class DeadlineCompressionProbeService : IDeadlineCompressionProbeService +{ + private static int s_unaryInvocations; + private static int s_oneWayInvocations; + + internal static int UnaryInvocations => Volatile.Read(ref s_unaryInvocations); + internal static int OneWayInvocations => Volatile.Read(ref s_oneWayInvocations); + + internal static void Reset() + { + Volatile.Write(ref s_unaryInvocations, 0); + Volatile.Write(ref s_oneWayInvocations, 0); + } + + public ValueTask EchoAsync(byte[] value) + { + Interlocked.Increment(ref s_unaryInvocations); + return ValueTask.FromResult(value); + } + + public ValueTask NotifyAsync(byte[] value, SharpLinkCallOptions options) + { + _ = value; + _ = options; + Interlocked.Increment(ref s_oneWayInvocations); + return ValueTask.CompletedTask; + } +} \ No newline at end of file From 42c98b25169ca7bd159a31b243de65be171d9192 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:10:00 +0800 Subject: [PATCH 067/228] test(server): fix compression capacity acceptance formatting --- .../CompressionCallCapacityAdmissionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs index 730377ecf..02630166c 100644 --- a/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs @@ -436,4 +436,4 @@ public ValueTask NotifyAsync(byte[] value, SharpLinkCallOptions options) Interlocked.Increment(ref s_oneWayInvocations); return ValueTask.CompletedTask; } -} \ No newline at end of file +} From b942b38e998bd94fff9ce7ebf9ecd920771cd239 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:11:02 +0800 Subject: [PATCH 068/228] fix(server): preserve service exception mapping after request permit wiring --- .../SharpLinkServer.InvocationDispatch.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs index 6ad5342ed..71fa8b9f1 100644 --- a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs @@ -518,7 +518,6 @@ private ValueTask DispatchRpcAsync( requestCancellationMap, connection, requestOwner); - } catch (OperationCanceledException exception) { @@ -764,7 +763,14 @@ await session.SendRpcErrorWithBackpressureAsync( { await session.SendRpcErrorWithBackpressureAsync( requestId, - MapServerCancellationException(callState, callState.Deadline), + MapServiceException( + e, + callContext, + session, + stub, + methodId, + requestId, + cancellationToken), connection.ConnectionToken).ConfigureAwait(false); } else From 70c5a587d57273132cde62c56804c954ba1c7f06 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:16:46 +0800 Subject: [PATCH 069/228] test(server): pass retained owner slot to dispatch harness --- .../Server/SharpLinkServerInvocationTests.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs b/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs index 895556908..0b0f9bdb9 100644 --- a/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs +++ b/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs @@ -412,9 +412,6 @@ public async Task StopAndTerminalReleaseShouldPublishDrainAfterTheConnectionSlot Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, "the admitted invocation must hold one global and one connection slot"); - // This direct ServerConnectionState is not registered through a transport - // handshake. MarkDraining models GoAway publication while the real - // RunAsync/StopAsync path waits for the paired invocation release. connection.MarkDraining(); var stopTask = server.StopAsync(TimeSpan.FromSeconds(2)).AsTask(); await YieldUntilAsync( @@ -473,10 +470,6 @@ public async Task StopShouldWaitForPendingAdmissionBetweenConnectionAndGlobalSlo }); Ensure(connection.MarkReady(null), "connection ready"); - // The direct connection is deliberately outside the transport registry; - // the test drives the real admission and StopAsync state machines while - // the Debug-only instance probe controls only the local-to-global gap. - var runTask = server.RunAsync().AsTask(); await listener.AcceptStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); var admissionTask = LongRunningTestWorker.Run(() => server.TryAcquireCall(connection)); @@ -1352,7 +1345,8 @@ internal ValueTask Dispatch(long requestId, ProtocolV2FrameFlags flags) Connection.CallCancellations, CancellationToken.None, null, - (flags & ProtocolV2FrameFlags.Cancellable) != 0 + (flags & ProtocolV2FrameFlags.Cancellable) != 0, + null ])!; } From 70e3a0b7447b5d57d87f4b57e699f9f83268c97b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:21:09 +0800 Subject: [PATCH 070/228] test(server): cover request decode hard-budget rejection --- .../CompressionCallCapacityAdmissionTests.cs | 135 ++++++++++++++---- 1 file changed, 109 insertions(+), 26 deletions(-) diff --git a/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs index 02630166c..a454963a6 100644 --- a/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionCallCapacityAdmissionTests.cs @@ -106,6 +106,75 @@ await harness.Client.Get() } } + [Test] + [NotInParallel] + [Arguments(false)] + [Arguments(true)] + public async Task CompressedUnaryShouldNotDecompressWhenDecodedByteBudgetIsExhausted( + bool useAdvancedAdmission) + { + var serverProvider = new CountingCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await CapacityHarness.CreateAsync( + serverProvider, + useAdvancedAdmission, + maxDecodedBytesInFlightPerServer: 1024); + var payload = Enumerable.Repeat((byte)0x45, 32 * 1024).ToArray(); + + await EnsureResourceExhaustedAsync( + harness.Client.Get().EchoBytesAsync(payload).AsTask(), + "decoded-byte budget rejection"); + + Ensure(serverProvider.DecompressCount == 0, + "decoded-byte-budget rejection must happen before provider decompression"); + await WaitUntilAsync( + () => harness.ActiveCalls == 0 && + harness.ActiveDecodes == 0 && + harness.DecodedBytesInFlight == 0, + "decoded-byte rejection resource release"); + } + + [Test] + [NotInParallel] + public async Task QueuedCompressedUnaryShouldRejectBeforeRetentionWhenRetainedByteBudgetIsExhausted() + { + TestService.ResetBlockingAdd(); + var serverProvider = new CountingCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await CapacityHarness.CreateAsync( + serverProvider, + useAdvancedAdmission: true, + admissionConcurrency: 1, + maxRetainedCompressedBytesPerServer: 1); + var blocker = harness.Client.Get() + .BlockingAddAsync(5, 6, CancellationToken.None) + .AsTask(); + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(2)); + var decompressionsBeforeQueuedRequest = serverProvider.DecompressCount; + var payload = Enumerable.Repeat((byte)0x46, 32 * 1024).ToArray(); + + await EnsureResourceExhaustedAsync( + harness.Client.Get().EchoBytesAsync(payload).AsTask(), + "retained compressed-byte budget rejection"); + + Ensure(serverProvider.DecompressCount == decompressionsBeforeQueuedRequest, + "retained-byte-budget rejection must happen before provider decompression"); + await WaitUntilAsync( + () => harness.RetainedCompressedBytes == 0, + "retained compressed-byte rejection resource release"); + } + finally + { + TestService.ReleaseBlockingAdd(); + } + + Ensure(await blocker.WaitAsync(TimeSpan.FromSeconds(2)) == 11, + "admission owner should complete after retained-budget rejection"); + } + [Test] [NotInParallel] public async Task CompressedUnaryShouldRejectIfDeadlineExpiresDuringDecompression() @@ -293,30 +362,13 @@ private sealed class CapacityHarness : IAsyncDisposable private readonly ISharpLinkServer _server; public ISharpLinkClient Client { get; } - public long RejectedOneWayCalls - { - get - { - var reflectionField = _server.GetType().GetField( - "_rejectedOneWayCalls", - System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.NonPublic) - ?? throw new Exception("cannot find rejected one-way call counter"); - return (long)reflectionField.GetValue(_server)!; - } - } - public int ActiveCalls - { - get - { - var reflectionField = _server.GetType().GetField( - "_globalActiveCalls", - System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.NonPublic) - ?? throw new Exception("cannot find active call counter"); - return (int)reflectionField.GetValue(_server)!; - } - } + public long RejectedOneWayCalls => ReadField("_rejectedOneWayCalls"); + public int ActiveCalls => ReadField("_globalActiveCalls"); + public int ActiveDecodes => ReadDiagnosticProperty("ActiveDecodeCountForDiagnostics"); + public long RetainedCompressedBytes => + ReadDiagnosticProperty("RetainedCompressedBytesForDiagnostics"); + public long DecodedBytesInFlight => + ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); private CapacityHarness( CancellationTokenSource serverCts, @@ -333,7 +385,10 @@ private CapacityHarness( public static async Task CreateAsync( ISharpLinkCompressionProvider serverProvider, bool useAdvancedAdmission, - TimeSpan? requestTimeout = null) + TimeSpan? requestTimeout = null, + int admissionConcurrency = 8, + long? maxRetainedCompressedBytesPerServer = null, + long? maxDecodedBytesInFlightPerServer = null) { var serverCts = new CancellationTokenSource(); var serverBuilder = SharpLinkServerBuilder.Create() @@ -342,12 +397,20 @@ public static async Task CreateAsync( { options.FlowControl.MaxConcurrentCallsPerConnection = 1; options.FlowControl.MaxConcurrentCallsPerServer = 1; + if (maxRetainedCompressedBytesPerServer is { } retainedBudget) + { + options.FlowControl.MaxRetainedCompressedBytesPerServer = retainedBudget; + } + if (maxDecodedBytesInFlightPerServer is { } decodedBudget) + { + options.FlowControl.MaxDecodedBytesInFlightPerServer = decodedBudget; + } options.Compression.Providers.Add(serverProvider); }); if (useAdvancedAdmission) { serverBuilder.UseAdmissionControl(options => - options.Global.UseConcurrency(8)); + options.Global.UseConcurrency(admissionConcurrency)); } serverBuilder.UseTcp(0, IPAddress.Loopback.ToString()); @@ -394,6 +457,26 @@ public async ValueTask DisposeAsync() await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); _serverCts.Dispose(); } + + private T ReadField(string name) + { + var reflectionField = _server.GetType().GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server field {name}"); + return (T)reflectionField.GetValue(_server)!; + } + + private T ReadDiagnosticProperty(string name) + { + var reflectionProperty = _server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic property {name}"); + return (T)reflectionProperty.GetValue(_server)!; + } } } From ff99e156f44f54d4ab02f3cd30891530fd663fa6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:26:15 +0800 Subject: [PATCH 071/228] test(server): restore drain-race harness rationale --- .../Server/SharpLinkServerInvocationTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs b/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs index 0b0f9bdb9..eddaa96f3 100644 --- a/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs +++ b/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs @@ -412,6 +412,9 @@ public async Task StopAndTerminalReleaseShouldPublishDrainAfterTheConnectionSlot Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, "the admitted invocation must hold one global and one connection slot"); + // This direct ServerConnectionState is not registered through a transport + // handshake. MarkDraining models GoAway publication while the real + // RunAsync/StopAsync path waits for the paired invocation release. connection.MarkDraining(); var stopTask = server.StopAsync(TimeSpan.FromSeconds(2)).AsTask(); await YieldUntilAsync( @@ -470,6 +473,10 @@ public async Task StopShouldWaitForPendingAdmissionBetweenConnectionAndGlobalSlo }); Ensure(connection.MarkReady(null), "connection ready"); + // The direct connection is deliberately outside the transport registry; + // the test drives the real admission and StopAsync state machines while + // the Debug-only instance probe controls only the local-to-global gap. + var runTask = server.RunAsync().AsTask(); await listener.AcceptStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); var admissionTask = LongRunningTestWorker.Run(() => server.TryAcquireCall(connection)); From 33313ee6144d35f390f2e0ecdb0c569e16e67044 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:09:44 +0800 Subject: [PATCH 072/228] fix(server): create pre-decode cancellation state independently of admission --- .../SharpLinkServer.CallTracking.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.CallTracking.cs b/src/SharpLink.Server/SharpLinkServer.CallTracking.cs index 7b87250a1..56f2379c7 100644 --- a/src/SharpLink.Server/SharpLinkServer.CallTracking.cs +++ b/src/SharpLink.Server/SharpLinkServer.CallTracking.cs @@ -30,6 +30,31 @@ internal sealed partial class SharpLinkServer return callState; } + private ServerCallCancellationState EnsurePreDecodeCallState( + ServerConnectionState connection, + ServerCallCancellationState? callState, + long requestId, + RpcDeadline deadline, + CancellationToken serverLoopToken, + CancellationToken moduleDrainingToken, + StripedLongMap requestCancellationMap) + { + if (callState is not null) + return callState; + + callState = ServerCallCancellationState.Rent( + requestId, + deadline, + _runtimeContext.TimeProvider, + serverLoopToken, + _forceStopCts.Token, + moduleDrainingToken, + supportsCooperativeCancellation: true); + requestCancellationMap.Set(requestId, callState); + connection.DeadlineScheduler.Register(callState); + return callState; + } + private ServerCallCancellationState EnsureTrackedCallState( ServerConnectionState connection, ServerCallCancellationState? callState, From e977e6aeecfe169053f6c2b8400b20e77465470e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:10:04 +0800 Subject: [PATCH 073/228] fix(server): release failed decode ownership before response backpressure --- .../SharpLinkServer.CallPermit.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs index 945eff639..fbaf17d34 100644 --- a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs +++ b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs @@ -127,6 +127,32 @@ internal bool TryAcquireDecodePermit( } } + /// + /// Ends decode-only ownership without releasing call capacity. Failed or rejected requests + /// use this after their physical retained/decoded buffers have been returned so response + /// backpressure cannot pin the global decode/byte budgets. + /// + internal void ReleaseDecodeResources() + { + ServerDecodePermit? decodePermit; + lock (_resourceGate) + { + var current = Volatile.Read(ref _state); + if (current is Activating or Active) + { + throw new InvalidOperationException( + "Decode resources cannot be detached after call activation."); + } + if (current is Releasing or Disposed) + return; + + decodePermit = _decodePermit; + _decodePermit = null; + } + + decodePermit?.Dispose(); + } + internal void Activate() { lock (_resourceGate) @@ -207,7 +233,10 @@ private void ReleaseBackingCapacity() _testHooks?.ReleaseClaimed?.Invoke(); ServerDecodePermit? decodePermit; lock (_resourceGate) + { decodePermit = _decodePermit; + _decodePermit = null; + } try { From d17629e87f2abe40ff6e63335a2f2ddbf3c1e14d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:11:06 +0800 Subject: [PATCH 074/228] fix(server): release decode ownership before error response backpressure --- .../SharpLinkServer.InvocationDispatch.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs index 71fa8b9f1..f53dd394b 100644 --- a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs @@ -201,6 +201,14 @@ private ValueTask DispatchRpcAsync( { if (isCompressed) { + admittedCallState = EnsurePreDecodeCallState( + connection, + admittedCallState, + requestId, + request.RpcDeadline, + serverLoopToken, + serviceInfo.ModuleCancellation, + requestCancellationMap); if (!TryPrepareCompressedRequestDecode( requestOwner, retainedAdmissionPayload?.RetainedPermit, @@ -210,6 +218,7 @@ private ValueTask DispatchRpcAsync( out var resourceRejection)) { retainedAdmissionPayload?.Dispose(); + requestOwner.ReleaseDecodeResources(); var rejection = resourceRejection ?? throw new InvalidOperationException( "Compressed request decode resource rejection is missing its error."); CompleteFailedRequestStreams(session, requestId, rejection); @@ -228,7 +237,7 @@ private ValueTask DispatchRpcAsync( ProtocolV2FrameType.Request, flags, payload, - admittedCallState?.InvocationToken ?? serverLoopToken, + admittedCallState.InvocationToken, out decodedRequestOwner); retainedAdmissionPayload?.Dispose(); decodePermit!.CompleteDecode(); @@ -239,6 +248,9 @@ private ValueTask DispatchRpcAsync( exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) { retainedAdmissionPayload?.Dispose(); + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); CompleteFailedRequestStreams(session, requestId, exception); var responseSend = session.SendRpcErrorWithBackpressureAsync( requestId, exception, connection.ConnectionToken); @@ -253,6 +265,9 @@ private ValueTask DispatchRpcAsync( catch (OperationCanceledException exception) { retainedAdmissionPayload?.Dispose(); + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); CompleteFailedRequestStreams(session, requestId, exception); var responseSend = session.SendRpcErrorWithBackpressureAsync( requestId, @@ -270,6 +285,8 @@ private ValueTask DispatchRpcAsync( { retainedAdmissionPayload?.Dispose(); session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); CompleteFailedRequestStreams(session, requestId, exception); ReleaseDispatchResources( admittedCallState, @@ -284,6 +301,7 @@ private ValueTask DispatchRpcAsync( { session.ReturnDecodedPayload(decodedRequestOwner); decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); var exception = new SharpLinkException( SharpLinkErrorCode.DeadlineExceeded, "Request deadline exceeded before dispatch."); @@ -303,6 +321,7 @@ private ValueTask DispatchRpcAsync( { session.ReturnDecodedPayload(decodedRequestOwner); decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); var exception = new SharpLinkException( SharpLinkErrorCode.ConnectionClosed, "Connection closed before dispatch."); From 5e5bd6096837c73fa839ea1bdd6848fd0da9f613 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:11:55 +0800 Subject: [PATCH 075/228] fix(server): align one-way decode deadline ownership --- .../SharpLinkServer.AdmissionDispatch.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs index 2333593d7..7dea2eac2 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs @@ -154,6 +154,14 @@ private void DispatchOneWayRpc( { if (isCompressed) { + admittedCallState = EnsurePreDecodeCallState( + connection, + admittedCallState, + requestId, + request.RpcDeadline, + serverLoopToken, + serviceInfo.ModuleCancellation, + requestCancellationMap); if (!TryPrepareCompressedRequestDecode( requestOwner, retainedAdmissionPayload?.RetainedPermit, @@ -163,6 +171,7 @@ private void DispatchOneWayRpc( out var resourceRejection)) { retainedAdmissionPayload?.Dispose(); + requestOwner.ReleaseDecodeResources(); var rejection = resourceRejection ?? throw new InvalidOperationException( "Compressed one-way decode resource rejection is missing its error."); var reason = SharpLinkResourceExhaustion.GetReason(rejection); @@ -182,7 +191,7 @@ private void DispatchOneWayRpc( ProtocolV2FrameType.Request, flags, payload, - admittedCallState?.InvocationToken ?? serverLoopToken, + admittedCallState.InvocationToken, out decodedRequestOwner); retainedAdmissionPayload?.Dispose(); decodePermit!.CompleteDecode(); @@ -193,6 +202,9 @@ private void DispatchOneWayRpc( exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) { retainedAdmissionPayload?.Dispose(); + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); Interlocked.Increment(ref _rejectedOneWayCalls); LogOnewayRpcDispatchFailed(_logger, exception); DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); @@ -207,6 +219,9 @@ private void DispatchOneWayRpc( catch (OperationCanceledException) { retainedAdmissionPayload?.Dispose(); + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); ReleaseOneWayDispatchResources( admittedCallState, @@ -220,6 +235,8 @@ private void DispatchOneWayRpc( { retainedAdmissionPayload?.Dispose(); session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); ReleaseOneWayDispatchResources( admittedCallState, @@ -234,6 +251,7 @@ private void DispatchOneWayRpc( { session.ReturnDecodedPayload(decodedRequestOwner); decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); ReleaseOneWayDispatchResources( admittedCallState, From 8e4e1f3301721d3963aa56caad792fa1bd60728b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:14:04 +0800 Subject: [PATCH 076/228] test(server): prove decode resources release before response backpressure --- .../ServerDecodeResponseBackpressureTests.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs new file mode 100644 index 000000000..cd72cd714 --- /dev/null +++ b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs @@ -0,0 +1,116 @@ +using SharpLink.Server; +using SharpLink.UnitTests.Runtime; +using System.Collections.Concurrent; +using System.IO.Pipelines; +using System.Reflection; + +namespace SharpLink.UnitTests.Server; + +public class ServerDecodeResponseBackpressureTests +{ + [Test] + public async Task DecodeResourcesShouldReleaseWhileErrorResponseRemainsBackpressured() + { + await using var server = (SharpLinkServer)SharpLinkServerBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) + .DisableAutomaticServiceRegistration() + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 1; + options.FlowControl.MaxConcurrentCallsPerServer = 1; + options.FlowControl.MaxConcurrentDecodesPerServer = 1; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = 1024; + }) + .UseTransport(new IdleListener()) + .Build(); + var runtimeContext = (SharpLinkRuntimeContext)( + typeof(SharpLinkServer).GetField( + "_runtimeContext", + BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(server)!); + var input = new Pipe(); + var output = new Pipe(); + await using var session = RpcSessionTestFixture.CreateSessionOverTestTransport( + "decode-response-backpressure", + input.Reader, + output.Writer, + RpcSessionTestFixture.ServerOptions(runtimeContext)); + var callCancellations = new StripedLongMap(runtimeContext.Concurrency); + var connection = new ServerConnectionState( + session, + new RpcSessionGeneratedServerBridge(session), + callCancellations, + CancellationToken.None, + runtimeContext.TimeProvider, + maxConcurrentCalls: 1); + Ensure(connection.MarkReady(null), "connection ready"); + typeof(SharpLinkServer).GetField( + "_state", + BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(server, 2); // Running + + var admission = server.TryReserveCall(connection, out var requestPermit); + Ensure(admission == SharpLinkServer.ServerCallAdmissionResult.Acquired && requestPermit is not null, + "request permit acquired"); + Ensure(requestPermit.TryAcquireDecodePermit(128, out var decodePermit) && decodePermit is not null, + "decode permit acquired"); + Ensure(decodePermit.TryReserveDecodedBytes(256), "decoded-byte budget acquired"); + Ensure(server.ActiveDecodeCountForDiagnostics == 1 && + server.RetainedCompressedBytesForDiagnostics == 128 && + server.DecodedBytesInFlightForDiagnostics == 256, + "decode ownership established"); + + var responseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseAfterResponse = typeof(SharpLinkServer).GetMethod( + "ReleaseDispatchResourcesAfterResponseAsync", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new Exception("cannot find response-completion release helper"); + var responseRelease = (ValueTask)releaseAfterResponse.Invoke(server, + [ + new ValueTask(responseGate.Task), + null, + 71L, + callCancellations, + connection, + requestPermit + ])!; + Ensure(!responseRelease.IsCompleted && server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, + "pending response must retain call capacity"); + + requestPermit.ReleaseDecodeResources(); + + Ensure(!responseRelease.IsCompleted, + "decode sub-ownership release must not complete the pending response"); + Ensure(server.ActiveDecodeCountForDiagnostics == 0 && + server.RetainedCompressedBytesForDiagnostics == 0 && + server.DecodedBytesInFlightForDiagnostics == 0, + "pending response must not retain decode or byte budgets"); + Ensure(server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, + "call capacity remains tied to response completion"); + + responseGate.TrySetResult(); + await responseRelease.AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, + "response completion releases call capacity"); + + await connection.CloseAsync(); + await input.Writer.CompleteAsync(); + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class IdleListener : IServerTransportListener + { + public System.Net.EndPoint? LocalEndPoint => null; + + public ValueTask AcceptAsync(CancellationToken cancellationToken = default) + => ValueTask.FromException(new NotSupportedException()); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} From 5eadc0f0eaf3c4e54aee52e83bc38f8520270f9b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:14:28 +0800 Subject: [PATCH 077/228] test(server): prove decode deadline cancellation is admission-independent --- ...ecodeDeadlineAdmissionIndependenceTests.cs | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/CompressionDecodeDeadlineAdmissionIndependenceTests.cs diff --git a/test/SharpLink.IntegrationTests/CompressionDecodeDeadlineAdmissionIndependenceTests.cs b/test/SharpLink.IntegrationTests/CompressionDecodeDeadlineAdmissionIndependenceTests.cs new file mode 100644 index 000000000..6f6bcacfa --- /dev/null +++ b/test/SharpLink.IntegrationTests/CompressionDecodeDeadlineAdmissionIndependenceTests.cs @@ -0,0 +1,227 @@ +namespace SharpLink.IntegrationTests; + +public class CompressionDecodeDeadlineAdmissionIndependenceTests +{ + [Test] + [NotInParallel] + [Arguments(false)] + [Arguments(true)] + public async Task CompressedUnaryDeadlineShouldCancelProviderRegardlessOfAdvancedAdmission( + bool useAdvancedAdmission) + { + DeadlineCompressionProbeService.Reset(); + var serverProvider = new DeadlineBlockingCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await DeadlineHarness.CreateAsync( + serverProvider, + useAdvancedAdmission, + TimeSpan.FromMilliseconds(100)); + var payload = Enumerable.Repeat((byte)0x51, 32 * 1024).ToArray(); + var call = harness.Client.Get() + .EchoAsync(payload) + .AsTask(); + + await serverProvider.WaitForDecompressionAsync().WaitAsync(TimeSpan.FromSeconds(2)); + await serverProvider.WaitForCancellationAsync().WaitAsync(TimeSpan.FromSeconds(2)); + await EnsureDeadlineExceededAsync(call, "deadline-aware provider cancellation"); + await WaitUntilAsync( + () => harness.ActiveCalls == 0 && + harness.ActiveDecodes == 0 && + harness.RetainedCompressedBytes == 0 && + harness.DecodedBytesInFlight == 0, + "deadline decode ownership release"); + + Ensure(DeadlineCompressionProbeService.UnaryInvocations == 0, + "deadline-cancelled compressed request must not execute the service"); + } + + private static async Task EnsureDeadlineExceededAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(2)); + throw new Exception($"assert failed: {scenario} should fail"); + } + catch (SharpLinkException exception) + { + Ensure(exception.Code == SharpLinkErrorCode.DeadlineExceeded, + $"{scenario} should return DeadlineExceeded, actual {exception.Code}"); + } + catch (TimeoutException) + { + throw new Exception($"assert failed: {scenario} did not fail fast"); + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} was not observed"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class DeadlineBlockingCompressionProvider(ISharpLinkCompressionProvider inner) + : ISharpLinkCompressionProvider + { + private readonly TaskCompletionSource _decompressionStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _cancellationObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public string WireProfile => inner.WireProfile; + + public Task WaitForDecompressionAsync() => _decompressionStarted.Task; + + public Task WaitForCancellationAsync() => _cancellationObserved.Task; + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + _decompressionStarted.TrySetResult(); + try + { + using var blocked = new ManualResetEventSlim(initialState: false); + blocked.Wait(cancellationToken); + throw new InvalidOperationException("The deadline probe must be cancelled before decompression resumes."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _cancellationObserved.TrySetResult(); + throw; + } + } + } + + private sealed class DeadlineHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + + private DeadlineHarness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient client) + { + _serverCts = serverCts; + _serverTask = serverTask; + _server = server; + Client = client; + } + + public ISharpLinkClient Client { get; } + public int ActiveCalls => ReadField("_globalActiveCalls"); + public int ActiveDecodes => ReadDiagnosticProperty("ActiveDecodeCountForDiagnostics"); + public long RetainedCompressedBytes => + ReadDiagnosticProperty("RetainedCompressedBytesForDiagnostics"); + public long DecodedBytesInFlight => + ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); + + internal static async Task CreateAsync( + ISharpLinkCompressionProvider serverProvider, + bool useAdvancedAdmission, + TimeSpan requestTimeout) + { + var serverCts = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 1; + options.FlowControl.MaxConcurrentCallsPerServer = 1; + options.Compression.Providers.Add(serverProvider); + }); + if (useAdvancedAdmission) + { + serverBuilder.UseAdmissionControl(options => + options.Global.UseConcurrency(8)); + } + + serverBuilder.UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = Task.Run(async () => + { + try + { + await server.RunAsync(serverCts.Token); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + + var client = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRequestTimeout(requestTimeout) + .UseTcp(IPAddress.Loopback.ToString(), port) + .UseRuntime(options => options.Compression.Providers.Add( + SharpLinkCompressionProviders.CreateBrotli())) + .Build(); + await client.ConnectAsync(); + return new DeadlineHarness(serverCts, serverTask, server, client); + } + + public async ValueTask DisposeAsync() + { + await Client.StopAsync(); + await _serverCts.CancelAsync(); + await _server.StopAsync(TimeSpan.Zero); + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + + private T ReadField(string name) + { + var field = _server.GetType().GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server field {name}"); + return (T)field.GetValue(_server)!; + } + + private T ReadDiagnosticProperty(string name) + { + var property = _server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic property {name}"); + return (T)property.GetValue(_server)!; + } + } +} From 8a5114f67b6fcfa9686115d0032c392a815cfe01 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:14:57 +0800 Subject: [PATCH 078/228] test(server): tighten decode backpressure ownership assertions --- .../Server/ServerDecodeResponseBackpressureTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs index cd72cd714..d81e218b8 100644 --- a/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs @@ -1,6 +1,5 @@ using SharpLink.Server; using SharpLink.UnitTests.Runtime; -using System.Collections.Concurrent; using System.IO.Pipelines; using System.Reflection; @@ -53,9 +52,11 @@ public async Task DecodeResourcesShouldReleaseWhileErrorResponseRemainsBackpress var admission = server.TryReserveCall(connection, out var requestPermit); Ensure(admission == SharpLinkServer.ServerCallAdmissionResult.Acquired && requestPermit is not null, "request permit acquired"); - Ensure(requestPermit.TryAcquireDecodePermit(128, out var decodePermit) && decodePermit is not null, + var permit = requestPermit ?? throw new Exception("request permit was not returned"); + Ensure(permit.TryAcquireDecodePermit(128, out var decodePermit) && decodePermit is not null, "decode permit acquired"); - Ensure(decodePermit.TryReserveDecodedBytes(256), "decoded-byte budget acquired"); + var decode = decodePermit ?? throw new Exception("decode permit was not returned"); + Ensure(decode.TryReserveDecodedBytes(256), "decoded-byte budget acquired"); Ensure(server.ActiveDecodeCountForDiagnostics == 1 && server.RetainedCompressedBytesForDiagnostics == 128 && server.DecodedBytesInFlightForDiagnostics == 256, @@ -73,12 +74,12 @@ public async Task DecodeResourcesShouldReleaseWhileErrorResponseRemainsBackpress 71L, callCancellations, connection, - requestPermit + permit ])!; Ensure(!responseRelease.IsCompleted && server.ActiveCallCountForDiagnostics == 1 && connection.ActiveCalls == 1, "pending response must retain call capacity"); - requestPermit.ReleaseDecodeResources(); + permit.ReleaseDecodeResources(); Ensure(!responseRelease.IsCompleted, "decode sub-ownership release must not complete the pending response"); From 1994212563541c2e6e67c775afce4066c5766282 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:18:05 +0800 Subject: [PATCH 079/228] test(server): import cancellation primitives for decode backpressure probe --- .../Server/ServerDecodeResponseBackpressureTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs index d81e218b8..afc4a83b5 100644 --- a/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs @@ -2,6 +2,7 @@ using SharpLink.UnitTests.Runtime; using System.IO.Pipelines; using System.Reflection; +using System.Threading; namespace SharpLink.UnitTests.Server; From 6cff14d83b54717cfcd929846fa3d44b2615aee9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:42:27 +0800 Subject: [PATCH 080/228] fix(server): transfer decoded-byte ownership with payload lifetime --- .../ServerResourceGovernor.cs | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Server/ServerResourceGovernor.cs b/src/SharpLink.Server/ServerResourceGovernor.cs index 412154423..9361f6ce8 100644 --- a/src/SharpLink.Server/ServerResourceGovernor.cs +++ b/src/SharpLink.Server/ServerResourceGovernor.cs @@ -281,10 +281,38 @@ public void Dispose() } } +/// +/// Owns decoded-byte accounting after it has moved out of the decode permit and onto the physical +/// decoded payload owner. Disposal is exactly once and releases only the decoded-byte budget. +/// +internal sealed class ServerDecodedBytesPermit : IDisposable +{ + private readonly ServerResourceGovernor _governor; + private readonly long _decodedBytes; + private int _disposed; + + internal ServerDecodedBytesPermit(ServerResourceGovernor governor, long decodedBytes) + { + _governor = governor ?? throw new ArgumentNullException(nameof(governor)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(decodedBytes); + _decodedBytes = decodedBytes; + } + + internal long DecodedBytes => _decodedBytes; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + _governor.ReleaseDecodedBytes(_decodedBytes); + } +} + /// /// Request-owned decode resource permit. While decoding it owns one decode-concurrency credit and /// any retained compressed bytes. releases those resources while -/// decoded-byte ownership remains attached until final disposal. +/// decoded-byte ownership remains attached until final disposal or is transferred to the physical +/// decoded payload owner. /// internal sealed class ServerDecodePermit : IDisposable { @@ -351,6 +379,25 @@ internal void CompleteDecode() } } + internal ServerDecodedBytesPermit? DetachDecodedBytesOwnership() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_decodeCompleted) + { + throw new InvalidOperationException( + "Decoded-byte ownership cannot move before provider decode completes."); + } + if (_decodedBytes == 0) + return null; + + var decodedBytesPermit = new ServerDecodedBytesPermit(_governor, _decodedBytes); + _decodedBytes = 0; + return decodedBytesPermit; + } + } + public void Dispose() { lock (_gate) From d500c934978d297393a885cfa1d22d52b96c7408 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:42:49 +0800 Subject: [PATCH 081/228] fix(server): transfer decoded-byte permit to call state --- .../SharpLinkServer.CallPermit.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs index fbaf17d34..ab1b6a489 100644 --- a/src/SharpLink.Server/SharpLinkServer.CallPermit.cs +++ b/src/SharpLink.Server/SharpLinkServer.CallPermit.cs @@ -153,6 +153,38 @@ internal void ReleaseDecodeResources() decodePermit?.Dispose(); } + /// + /// Moves successful decoded-byte ownership onto the call-state payload owner. The call state + /// then releases the byte budget only after the physical decoded buffer is returned, even + /// when an external cancellation-state lease delays final call-state teardown. + /// + internal void TransferDecodedBytesTo(ServerCallCancellationState callState) + { + ArgumentNullException.ThrowIfNull(callState); + + ServerDecodedBytesPermit? decodedBytesPermit; + lock (_resourceGate) + { + var decodePermit = _decodePermit; + if (decodePermit is null) + return; + decodedBytesPermit = decodePermit.DetachDecodedBytesOwnership(); + } + + if (decodedBytesPermit is null) + return; + + try + { + callState.AttachDecodedBytesPermit(decodedBytesPermit); + } + catch + { + decodedBytesPermit.Dispose(); + throw; + } + } + internal void Activate() { lock (_resourceGate) From 6b16222930b8913b25ca18c0784ebea6413103fd Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:43:16 +0800 Subject: [PATCH 082/228] fix(server): couple decoded-byte charge to payload owner --- .../ServerCallCancellationState.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/SharpLink.Server/ServerCallCancellationState.cs b/src/SharpLink.Server/ServerCallCancellationState.cs index 79d149da7..2fb20ea87 100644 --- a/src/SharpLink.Server/ServerCallCancellationState.cs +++ b/src/SharpLink.Server/ServerCallCancellationState.cs @@ -67,6 +67,7 @@ internal sealed class ServerCallCancellationState : IDisposable private AdmissionLease? _admissionLease; private SharpLinkBufferWriterPool? _payloadPool; private IRpcByteBufferWriter? _payloadOwner; + private ServerDecodedBytesPermit? _decodedBytesPermit; private TimeProvider? _timeProvider; private ServerCallCancellationState() @@ -85,6 +86,8 @@ public ServerCallCancellationReason Reason public bool IsAbandoned => Reason is not (ServerCallCancellationReason.None or ServerCallCancellationReason.Completed); + internal bool HasPayloadOwnerForDiagnostics => Volatile.Read(ref _payloadOwner) is not null; + public static ServerCallCancellationState Rent( long requestId, RpcDeadline deadline, @@ -125,6 +128,7 @@ public static ServerCallCancellationState Rent( state._admissionLease = null; state._payloadPool = null; state._payloadOwner = null; + state._decodedBytesPermit = null; state._disposeRequested = false; state._externalUsers = 0; state._serverStoppingRegistration = default; @@ -181,6 +185,18 @@ internal void AttachPayloadOwner( _payloadPool = pool; } + internal void AttachDecodedBytesPermit(ServerDecodedBytesPermit decodedBytesPermit) + { + ArgumentNullException.ThrowIfNull(decodedBytesPermit); + if (Volatile.Read(ref _payloadOwner) is null) + { + throw new InvalidOperationException( + "Decoded-byte ownership cannot outlive a call without its physical decoded payload owner."); + } + if (Interlocked.CompareExchange(ref _decodedBytesPermit, decodedBytesPermit, null) is not null) + throw new InvalidOperationException("Decoded-byte ownership is already attached to this call."); + } + internal ServerCallCancellationLease CaptureLease(long requestId) => new(this, requestId, Volatile.Read(ref _leaseGeneration)); @@ -300,9 +316,20 @@ private void ReturnCore() Interlocked.Exchange(ref _admissionLease, null)?.Dispose(); var payloadOwner = Interlocked.Exchange(ref _payloadOwner, null); var payloadPool = Interlocked.Exchange(ref _payloadPool, null); + var decodedBytesPermit = Interlocked.Exchange(ref _decodedBytesPermit, null); if (payloadOwner is not null) + { (payloadPool ?? throw new InvalidOperationException("A retained payload has no owning pool.")) .Return(payloadOwner); + decodedBytesPermit?.Dispose(); + } + else if (decodedBytesPermit is not null) + { + // This should be unreachable because decoded-byte ownership is attached only after the + // corresponding physical owner. Release conservatively rather than leak accounting if + // an invariant violation reaches teardown. + decodedBytesPermit.Dispose(); + } _invocationCancellation = null; _connectionClosedRegistration = default; _serverStoppingRegistration = default; From 89c31248137b4f43f5d3eba51090f2327e373260 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:44:48 +0800 Subject: [PATCH 083/228] fix(server): transfer one-way decoded-byte ownership before teardown --- src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs index 7dea2eac2..0c39e078c 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs @@ -696,6 +696,7 @@ private void ReleaseOneWayDispatchResources( _ = connection; if (callState is not null) { + requestPermit.TransferDecodedBytesTo(callState); requestCancellationMap.TryRemove(requestId, callState); callState.Dispose(); } From 18b1fabc9e869c5b51d35f537be0d2fd81d0e271 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:45:51 +0800 Subject: [PATCH 084/228] fix(server): transfer decoded-byte ownership before rpc teardown --- src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs index f53dd394b..839cf371d 100644 --- a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs @@ -819,6 +819,7 @@ private void ReleaseDispatchResources( _ = connection; if (callState is not null) { + requestPermit.TransferDecodedBytesTo(callState); requestCancellationMap.TryRemove(requestId, callState); callState.Dispose(); } From 61beafd6117dd52835675d7093c95dc318646e87 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:47:07 +0800 Subject: [PATCH 085/228] test(server): hold decoded charge across external call-state lease --- .../ServerDecodeResponseBackpressureTests.cs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs index afc4a83b5..dad03630e 100644 --- a/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs @@ -100,6 +100,119 @@ public async Task DecodeResourcesShouldReleaseWhileErrorResponseRemainsBackpress await input.Writer.CompleteAsync(); } + [Test] + [NotInParallel] + public async Task DecodedByteAccountingShouldFollowDeferredPayloadOwnerReturn() + { + await using var server = (SharpLinkServer)SharpLinkServerBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) + .DisableAutomaticServiceRegistration() + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 1; + options.FlowControl.MaxConcurrentCallsPerServer = 1; + options.FlowControl.MaxConcurrentDecodesPerServer = 1; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = 1024; + }) + .UseTransport(new IdleListener()) + .Build(); + var runtimeContext = (SharpLinkRuntimeContext)( + typeof(SharpLinkServer).GetField( + "_runtimeContext", + BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(server)!); + var input = new Pipe(); + var output = new Pipe(); + await using var session = RpcSessionTestFixture.CreateSessionOverTestTransport( + "decoded-byte-external-lease", + input.Reader, + output.Writer, + RpcSessionTestFixture.ServerOptions(runtimeContext)); + var callCancellations = new StripedLongMap(runtimeContext.Concurrency); + var connection = new ServerConnectionState( + session, + new RpcSessionGeneratedServerBridge(session), + callCancellations, + CancellationToken.None, + runtimeContext.TimeProvider, + maxConcurrentCalls: 1); + Ensure(connection.MarkReady(null), "connection ready"); + typeof(SharpLinkServer).GetField( + "_state", + BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(server, 2); // Running + + var admission = server.TryReserveCall(connection, out var requestPermit); + Ensure(admission == SharpLinkServer.ServerCallAdmissionResult.Acquired && requestPermit is not null, + "request permit acquired"); + var permit = requestPermit ?? throw new Exception("request permit was not returned"); + Ensure(permit.TryAcquireDecodePermit(0, out var decodePermit) && decodePermit is not null, + "decode permit acquired"); + var decode = decodePermit ?? throw new Exception("decode permit was not returned"); + Ensure(decode.TryReserveDecodedBytes(256), "decoded-byte budget acquired"); + decode.CompleteDecode(); + permit.Activate(); + Ensure(server.ActiveDecodeCountForDiagnostics == 0 && + server.DecodedBytesInFlightForDiagnostics == 256, + "decoded-byte ownership survives decode completion"); + + var payloadOwner = runtimeContext.Buffers.Rent(256); + payloadOwner.GetSpan(256)[..256].Fill(0x2A); + payloadOwner.Advance(256); + var callState = ServerCallCancellationState.Rent( + 72, + default, + runtimeContext.TimeProvider, + CancellationToken.None, + CancellationToken.None, + supportsCooperativeCancellation: false); + callState.AttachPayloadOwner(runtimeContext.Buffers, payloadOwner); + callCancellations.Set(72, callState); + var externalLease = callState.CaptureLease(72); + Ensure(externalLease.TryAcquire(), "external call-state lease acquired"); + var externalUseOwned = true; + + try + { + var releaseDispatch = typeof(SharpLinkServer).GetMethod( + "ReleaseDispatchResources", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new Exception("cannot find dispatch resource release helper"); + releaseDispatch.Invoke(server, + [ + callState, + 72L, + callCancellations, + connection, + permit + ]); + + Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, + "dispatch teardown releases call capacity even while the call-state lease is retained"); + Ensure(callState.HasPayloadOwnerForDiagnostics, + "external call-state lease must keep the physical decoded payload owner alive"); + Ensure(server.DecodedBytesInFlightForDiagnostics == 256, + "decoded-byte accounting must remain charged while the physical owner is retained"); + + externalLease.ReleaseUse(); + externalUseOwned = false; + + Ensure(server.DecodedBytesInFlightForDiagnostics == 0, + "returning the physical decoded payload must release its decoded-byte accounting"); + } + finally + { + if (externalUseOwned) + externalLease.ReleaseUse(); + permit.Dispose(); + callState.Dispose(); + } + + await connection.CloseAsync(); + await input.Writer.CompleteAsync(); + } + private static void Ensure(bool condition, string scenario) { if (!condition) From 7cc27cc88ffd832a0bda2bd51d81120c8914bf57 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:47:46 +0800 Subject: [PATCH 086/228] test(server): avoid touching recycled call state after lease release --- .../Server/ServerDecodeResponseBackpressureTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs index dad03630e..58426f361 100644 --- a/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerDecodeResponseBackpressureTests.cs @@ -172,6 +172,7 @@ public async Task DecodedByteAccountingShouldFollowDeferredPayloadOwnerReturn() var externalLease = callState.CaptureLease(72); Ensure(externalLease.TryAcquire(), "external call-state lease acquired"); var externalUseOwned = true; + var dispatchTeardownOwned = false; try { @@ -187,6 +188,7 @@ public async Task DecodedByteAccountingShouldFollowDeferredPayloadOwnerReturn() connection, permit ]); + dispatchTeardownOwned = true; Ensure(server.ActiveCallCountForDiagnostics == 0 && connection.ActiveCalls == 0, "dispatch teardown releases call capacity even while the call-state lease is retained"); @@ -205,8 +207,11 @@ public async Task DecodedByteAccountingShouldFollowDeferredPayloadOwnerReturn() { if (externalUseOwned) externalLease.ReleaseUse(); - permit.Dispose(); - callState.Dispose(); + if (!dispatchTeardownOwned) + { + permit.Dispose(); + callState.Dispose(); + } } await connection.CloseAsync(); From 7562d8bf48067c1c0a7c458c82c6764fb37b7cc9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:13:06 +0800 Subject: [PATCH 087/228] refactor(server): restack persistent decode executor foundation --- src/SharpLink.Server/ServerDecodeExecutor.cs | 227 ++++++++++++++++++ .../SharpLinkServer.DecodeExecutor.cs | 66 +++++ .../SharpLinkServer.PreAdmissionStreams.cs | 76 +++++- .../SharpLinkServer.RunLoop.cs | 1 + .../ServerDecodeExecutorLifecycleTests.cs | 62 +++++ .../Server/ServerDecodeExecutorTests.cs | 224 +++++++++++++++++ 6 files changed, 650 insertions(+), 6 deletions(-) create mode 100644 src/SharpLink.Server/ServerDecodeExecutor.cs create mode 100644 src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs create mode 100644 test/SharpLink.UnitTests/Server/ServerDecodeExecutorLifecycleTests.cs create mode 100644 test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs diff --git a/src/SharpLink.Server/ServerDecodeExecutor.cs b/src/SharpLink.Server/ServerDecodeExecutor.cs new file mode 100644 index 000000000..97ae60c8b --- /dev/null +++ b/src/SharpLink.Server/ServerDecodeExecutor.cs @@ -0,0 +1,227 @@ +using System.Threading.Channels; + +namespace SharpLink.Server; + +/// +/// Persistent bounded worker pool for request decompression. The executor owns only queue/worker +/// lifetime; request, retained-compressed, decode and decoded-byte ownership remain attached to the +/// caller's request permit and work item until provider execution has either completed or been +/// skipped before start. +/// +internal sealed class ServerDecodeExecutor : IAsyncDisposable +{ + private readonly Channel _channel; + private readonly Task[] _workers; + private readonly Task _completion; + private int _completionRequested; + private int _queueDepth; + private int _skippedBeforeStart; + + internal ServerDecodeExecutor(int workerCount, int queueCapacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(workerCount); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(queueCapacity); + + _channel = Channel.CreateBounded(new BoundedChannelOptions(queueCapacity) + { + AllowSynchronousContinuations = false, + FullMode = BoundedChannelFullMode.Wait, + SingleReader = workerCount == 1, + SingleWriter = false + }); + _workers = new Task[workerCount]; + for (var index = 0; index < _workers.Length; index++) + _workers[index] = Task.Run(WorkerLoopAsync); + _completion = Task.WhenAll(_workers); + } + + internal int WorkerCount => _workers.Length; + + /// + /// Number of decode operations waiting for worker service, including writers currently blocked + /// by the bounded channel. This is intentionally a pending-work count rather than Channel.Count. + /// + internal int QueueDepth => Volatile.Read(ref _queueDepth); + + internal int SkippedBeforeStart => Volatile.Read(ref _skippedBeforeStart); + + internal Task Completion => _completion; + + internal ValueTask EnqueueAsync( + ServerDecodeWorkItem workItem, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(workItem); + if (Volatile.Read(ref _completionRequested) != 0) + { + return cancellationToken.IsCancellationRequested + ? ValueTask.FromCanceled(cancellationToken) + : ValueTask.FromException( + new InvalidOperationException("The server decode executor is no longer accepting work.")); + } + + return EnqueueCoreAsync(workItem, cancellationToken); + } + + internal void StopAccepting() + { + if (Interlocked.Exchange(ref _completionRequested, 1) == 0) + _channel.Writer.TryComplete(); + } + + internal async ValueTask CompleteAsync() + { + StopAccepting(); + await _completion.ConfigureAwait(false); + } + + public ValueTask DisposeAsync() => CompleteAsync(); + + private async ValueTask EnqueueCoreAsync( + ServerDecodeWorkItem workItem, + CancellationToken cancellationToken) + { + workItem.EnableQueuedCancellation(cancellationToken); + Interlocked.Increment(ref _queueDepth); + var published = false; + try + { + await _channel.Writer.WriteAsync(workItem, cancellationToken).ConfigureAwait(false); + published = true; + await workItem.Completion.ConfigureAwait(false); + } + catch (Exception exception) + { + if (!published) + { + workItem.AbandonBeforePublication(); + var remaining = Interlocked.Decrement(ref _queueDepth); + if (remaining < 0) + throw new InvalidOperationException("Server decode queue depth accounting underflowed."); + } + if (exception is ChannelClosedException && cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + throw; + } + } + + private async Task WorkerLoopAsync() + { + await foreach (var workItem in _channel.Reader.ReadAllAsync().ConfigureAwait(false)) + { + var remaining = Interlocked.Decrement(ref _queueDepth); + if (remaining < 0) + throw new InvalidOperationException("Server decode queue depth accounting underflowed."); + + if (!workItem.TryStart()) + { + if (!workItem.IsCancelledBeforeStart) + throw new InvalidOperationException("Server decode work item entered an invalid queued state."); + Interlocked.Increment(ref _skippedBeforeStart); + workItem.CompleteSkippedBeforeStart(); + continue; + } + + await workItem.RunAsync().ConfigureAwait(false); + } + } +} + +/// +/// One queued decode operation. Cancellation may complete the caller before worker service only if +/// it wins the Queued -> CancelledBeforeStart transition. If a worker wins Queued -> Running, the +/// caller remains joined to worker completion so request-owned buffers cannot be released while the +/// provider can still access them. +/// +internal sealed class ServerDecodeWorkItem +{ + private const int Queued = 0; + private const int Running = 1; + private const int CancelledBeforeStart = 2; + private const int Completed = 3; + + private readonly Func _executeAsync; + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private CancellationTokenRegistration _queuedCancellationRegistration; + private CancellationToken _cancellationToken; + private int _state = Queued; + private int _cancellationRegistrationEnabled; + + internal ServerDecodeWorkItem(Func executeAsync) + => _executeAsync = executeAsync ?? throw new ArgumentNullException(nameof(executeAsync)); + + internal Task Completion => _completion.Task; + + internal bool IsCancelledBeforeStart + => Volatile.Read(ref _state) == CancelledBeforeStart; + + internal void EnableQueuedCancellation(CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref _cancellationRegistrationEnabled, 1) != 0) + throw new InvalidOperationException("Queued cancellation can only be enabled once."); + + _cancellationToken = cancellationToken; + if (cancellationToken.CanBeCanceled) + { + _queuedCancellationRegistration = cancellationToken.UnsafeRegister( + static state => ((ServerDecodeWorkItem)state!).CancelBeforeStart(), + this); + } + } + + internal bool TryStart() + { + if (Interlocked.CompareExchange(ref _state, Running, Queued) != Queued) + return false; + + _queuedCancellationRegistration.Dispose(); + return true; + } + + internal async ValueTask RunAsync() + { + if (Volatile.Read(ref _state) != Running) + throw new InvalidOperationException("Only running decode work can execute provider code."); + + try + { + _cancellationToken.ThrowIfCancellationRequested(); + await _executeAsync(_cancellationToken).ConfigureAwait(false); + _completion.TrySetResult(); + } + catch (OperationCanceledException) when (_cancellationToken.IsCancellationRequested) + { + _completion.TrySetCanceled(_cancellationToken); + } + catch (Exception exception) + { + _completion.TrySetException(exception); + } + finally + { + Volatile.Write(ref _state, Completed); + } + } + + internal void CompleteSkippedBeforeStart() + { + if (Volatile.Read(ref _state) != CancelledBeforeStart) + throw new InvalidOperationException("Only cancelled queued decode work can be skipped."); + _queuedCancellationRegistration.Dispose(); + Volatile.Write(ref _state, Completed); + } + + internal void AbandonBeforePublication() + { + _queuedCancellationRegistration.Dispose(); + Interlocked.Exchange(ref _state, Completed); + } + + private void CancelBeforeStart() + { + if (Interlocked.CompareExchange(ref _state, CancelledBeforeStart, Queued) != Queued) + return; + _completion.TrySetCanceled(_cancellationToken); + } +} diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs new file mode 100644 index 000000000..e7d7357fb --- /dev/null +++ b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs @@ -0,0 +1,66 @@ +namespace SharpLink.Server; + +internal sealed partial class SharpLinkServer +{ + private const int MaxPersistentDecodeWorkers = 4; + private const int MinimumPersistentDecodeQueueCapacity = 32; + // Phase 0 has current-D performance evidence at 1 MiB. Smaller cutovers remain hypotheses until + // real RequestLoop control-plane measurements are collected in this slice. + private const int InitialPersistentDecodeThresholdBytes = 1024 * 1024; + private ServerDecodeExecutor? _decodeExecutor; + + private void StartDecodeExecutor() + { + if (_runtimeContext.Compression.ProviderBindings.Count == 0) + return; + if (Volatile.Read(ref _decodeExecutor) is not null) + throw new InvalidOperationException("The server decode executor was started more than once."); + + var flowControl = _runtimeContext.FlowControl; + var workerCount = Math.Min( + flowControl.MaxConcurrentDecodesPerServer, + Math.Clamp(Environment.ProcessorCount, 1, MaxPersistentDecodeWorkers)); + var queueCapacity = Math.Max( + MinimumPersistentDecodeQueueCapacity, + checked(workerCount * 8)); + var executor = new ServerDecodeExecutor(workerCount, queueCapacity); + Volatile.Write(ref _decodeExecutor, executor); + _ = _forceStopCts.Token.UnsafeRegister( + static state => ((ServerDecodeExecutor)state!).StopAccepting(), + executor); + TrackFrameworkTask(executor.Completion, "DecodeExecutor"); + } + + private bool ShouldUsePersistentDecode( + ProtocolV2FrameFlags flags, + ServiceRegistration serviceInfo, + ServerRequestEnvelope request, + ReadOnlySequence payload) + { + if ((flags & ProtocolV2FrameFlags.Compressed) == 0 || + (flags & ProtocolV2FrameFlags.Cancellable) == 0 || + Volatile.Read(ref _decodeExecutor) is null || + !serviceInfo.Stub.SupportsCancellation(request.MethodHash)) + { + return false; + } + + return RpcSession.ReadCompressedDecodedPayloadLength( + ProtocolV2FrameType.Request, + flags, + payload) >= InitialPersistentDecodeThresholdBytes; + } + + private ServerDecodeExecutor DecodeExecutor + => Volatile.Read(ref _decodeExecutor) ?? throw new InvalidOperationException( + "The server decode executor is unavailable because compression is not configured or the server has not started."); + + internal int DecodeWorkerCountForDiagnostics + => Volatile.Read(ref _decodeExecutor)?.WorkerCount ?? 0; + + internal int DecodeQueueDepthForDiagnostics + => Volatile.Read(ref _decodeExecutor)?.QueueDepth ?? 0; + + internal int DecodeSkippedBeforeStartForDiagnostics + => Volatile.Read(ref _decodeExecutor)?.SkippedBeforeStart ?? 0; +} diff --git a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs index 9dcc0a5b7..91371564a 100644 --- a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs +++ b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs @@ -123,7 +123,10 @@ internal sealed class ServerRetainedAdmissionPayload : IDisposable private readonly SharpLinkBufferWriterPool _pool; private readonly IRpcByteBufferWriter _owner; private readonly ServerRetainedCompressedPermit? _retainedPermit; - private int _disposed; + private readonly Lock _lifetimeGate = new(); + private int _activeUses; + private bool _disposeRequested; + private bool _released; internal ServerRetainedAdmissionPayload( SharpLinkBufferWriterPool pool, @@ -139,18 +142,79 @@ internal ReadOnlySequence Payload { get { - ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); - return new ReadOnlySequence(_owner.WrittenMemory); + lock (_lifetimeGate) + { + ObjectDisposedException.ThrowIf(_released, this); + return new ReadOnlySequence(_owner.WrittenMemory); + } } } - internal ServerRetainedCompressedPermit? RetainedPermit => _retainedPermit; + internal ServerRetainedCompressedPermit? RetainedPermit + { + get + { + lock (_lifetimeGate) + { + ObjectDisposedException.ThrowIf(_released, this); + return _retainedPermit; + } + } + } + + /// + /// Pins the physical retained buffer across an asynchronous consumer. Dispose may be requested + /// while a use is active; the buffer is returned only after the final use releases it. + /// + internal void AcquireUse() + { + lock (_lifetimeGate) + { + ObjectDisposedException.ThrowIf(_disposeRequested || _released, this); + _activeUses++; + } + } + + internal void ReleaseUse() + { + var release = false; + lock (_lifetimeGate) + { + if (--_activeUses < 0) + { + _activeUses++; + throw new InvalidOperationException("Retained admission payload use count underflowed."); + } + if (_disposeRequested && _activeUses == 0 && !_released) + { + _released = true; + release = true; + } + } + if (release) + ReleaseCore(); + } public void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - return; + var release = false; + lock (_lifetimeGate) + { + if (_disposeRequested) + return; + _disposeRequested = true; + if (_activeUses == 0 && !_released) + { + _released = true; + release = true; + } + } + if (release) + ReleaseCore(); + } + private void ReleaseCore() + { try { // The physical retained buffer is returned before its accounting permit is diff --git a/src/SharpLink.Server/SharpLinkServer.RunLoop.cs b/src/SharpLink.Server/SharpLinkServer.RunLoop.cs index 8797be9a8..a707d3f97 100644 --- a/src/SharpLink.Server/SharpLinkServer.RunLoop.cs +++ b/src/SharpLink.Server/SharpLinkServer.RunLoop.cs @@ -36,6 +36,7 @@ private async Task RunCoreAsync(CancellationToken cancellationToken) _logger, _connectionAdmission.MaxConnections, _connectionAdmission.MaxHandshakes); + StartDecodeExecutor(); TrackFrameworkTask( RunHeartbeatCheckLoopAsync(_forceStopCts.Token), "HeartbeatCheckLoop"); diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorLifecycleTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorLifecycleTests.cs new file mode 100644 index 000000000..dd028cbbe --- /dev/null +++ b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorLifecycleTests.cs @@ -0,0 +1,62 @@ +using SharpLink.Server; +using System.Net; +using System.Threading; + +namespace SharpLink.UnitTests.Server; + +public class ServerDecodeExecutorLifecycleTests +{ + [Test] + public async Task CompressionServerShouldSupervisePersistentDecodeWorkersThroughStop() + { + var listener = new BlockingListener(); + await using var server = (SharpLinkServer)SharpLinkServerBuilder.Create() + .UseGeneratedManifestSource(FixedGeneratedManifestSource.Empty) + .DisableAutomaticServiceRegistration() + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentDecodesPerServer = 2; + options.Compression.Providers.Add(SharpLinkCompressionProviders.CreateBrotli()); + }) + .UseTransport(listener) + .Build(); + + var runTask = server.RunAsync().AsTask(); + await listener.AcceptStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(server.DecodeWorkerCountForDiagnostics is > 0 and <= 2, + "compression-enabled server must start a bounded persistent decode worker set"); + Ensure(server.DecodeQueueDepthForDiagnostics == 0, + "idle persistent decode workers must begin with an empty queue"); + + await server.StopAsync(TimeSpan.FromSeconds(2)).AsTask().WaitAsync(TimeSpan.FromSeconds(3)); + await runTask.WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(server.DecodeQueueDepthForDiagnostics == 0, + "successful Stop must drain the persistent decode executor"); + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class BlockingListener : IServerTransportListener + { + internal TaskCompletionSource AcceptStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public EndPoint? LocalEndPoint => null; + + public async ValueTask AcceptAsync( + CancellationToken cancellationToken = default) + { + AcceptStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("The cancelled accept must not continue."); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs new file mode 100644 index 000000000..4f7ab0043 --- /dev/null +++ b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs @@ -0,0 +1,224 @@ +using SharpLink.Server; +using System.Threading; + +namespace SharpLink.UnitTests.Server; + +public class ServerDecodeExecutorTests +{ + [Test] + public async Task QueuedCancellationShouldCompleteCallerBeforeWorkerAndSkipProvider() + { + await using var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity: 1); + var firstStarted = NewSignal(); + var releaseFirst = NewSignal(); + var secondExecutions = 0; + + var first = executor.EnqueueAsync( + new ServerDecodeWorkItem(async _ => + { + firstStarted.TrySetResult(); + await releaseFirst.Task.ConfigureAwait(false); + }), + CancellationToken.None).AsTask(); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + using var cancellation = new CancellationTokenSource(); + var second = executor.EnqueueAsync( + new ServerDecodeWorkItem(_ => + { + Interlocked.Increment(ref secondExecutions); + return ValueTask.CompletedTask; + }), + cancellation.Token).AsTask(); + await WaitUntilAsync(() => executor.QueueDepth == 1, "second decode was not queued"); + + cancellation.Cancel(); + await EnsureCancelledAsync(second, "queued decode cancellation"); + Ensure(secondExecutions == 0, "cancelled queued work must not execute provider code"); + Ensure(executor.QueueDepth == 1, + "published cancelled work remains queued until a worker observes and skips it"); + + releaseFirst.TrySetResult(); + await first.WaitAsync(TimeSpan.FromSeconds(2)); + await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(executor.QueueDepth == 0, "drained executor queue depth"); + Ensure(executor.SkippedBeforeStart == 1, "cancelled queued work must be counted as skipped"); + Ensure(secondExecutions == 0, "skipped work must never execute provider code later"); + } + + [Test] + public async Task BlockedWriterCancellationShouldRollbackPendingDepthWithoutPublication() + { + await using var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity: 1); + var firstStarted = NewSignal(); + var releaseFirst = NewSignal(); + var thirdExecutions = 0; + + var first = executor.EnqueueAsync( + new ServerDecodeWorkItem(async _ => + { + firstStarted.TrySetResult(); + await releaseFirst.Task.ConfigureAwait(false); + }), + CancellationToken.None).AsTask(); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + var second = executor.EnqueueAsync( + new ServerDecodeWorkItem(_ => ValueTask.CompletedTask), + CancellationToken.None).AsTask(); + await WaitUntilAsync(() => executor.QueueDepth == 1, "second decode was not queued"); + + using var cancellation = new CancellationTokenSource(); + var third = executor.EnqueueAsync( + new ServerDecodeWorkItem(_ => + { + Interlocked.Increment(ref thirdExecutions); + return ValueTask.CompletedTask; + }), + cancellation.Token).AsTask(); + await WaitUntilAsync( + () => executor.QueueDepth == 2 && !third.IsCompleted, + "third decode did not block behind the full bounded queue"); + + cancellation.Cancel(); + await EnsureCancelledAsync(third, "blocked writer cancellation"); + Ensure(executor.QueueDepth == 1, + "blocked writer cancellation must roll back its pending-depth ownership"); + Ensure(executor.SkippedBeforeStart == 0, + "work cancelled before publication must never reach the worker skip path"); + Ensure(thirdExecutions == 0, "unpublished work must not execute provider code"); + + releaseFirst.TrySetResult(); + await first.WaitAsync(TimeSpan.FromSeconds(2)); + await second.WaitAsync(TimeSpan.FromSeconds(2)); + await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + Ensure(executor.QueueDepth == 0, "executor must drain after blocked-writer cancellation"); + } + + [Test] + public async Task WorkerWinningCancellationRaceShouldKeepCallerJoinedUntilProviderReturns() + { + await using var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity: 1); + var providerStarted = NewSignal(); + var releaseProvider = NewSignal(); + using var cancellation = new CancellationTokenSource(); + + var operation = executor.EnqueueAsync( + new ServerDecodeWorkItem(async _ => + { + providerStarted.TrySetResult(); + await releaseProvider.Task.ConfigureAwait(false); + }), + cancellation.Token).AsTask(); + await providerStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + cancellation.Cancel(); + await Task.Yield(); + Ensure(!operation.IsCompleted, + "once the worker owns provider execution cancellation must not release the caller early"); + + releaseProvider.TrySetResult(); + await operation.WaitAsync(TimeSpan.FromSeconds(2)); + await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + Ensure(executor.SkippedBeforeStart == 0, "running work must not be counted as queue-skipped"); + } + + [Test] + public async Task CompleteShouldStopPublicationAndDrainAlreadyPublishedWork() + { + await using var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity: 1); + var firstStarted = NewSignal(); + var releaseFirst = NewSignal(); + var secondExecutions = 0; + + var first = executor.EnqueueAsync( + new ServerDecodeWorkItem(async _ => + { + firstStarted.TrySetResult(); + await releaseFirst.Task.ConfigureAwait(false); + }), + CancellationToken.None).AsTask(); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + var second = executor.EnqueueAsync( + new ServerDecodeWorkItem(_ => + { + Interlocked.Increment(ref secondExecutions); + return ValueTask.CompletedTask; + }), + CancellationToken.None).AsTask(); + await WaitUntilAsync(() => executor.QueueDepth == 1, "second decode was not queued"); + + var completion = executor.CompleteAsync().AsTask(); + Ensure(!completion.IsCompleted, "completion must wait for running and queued work to drain"); + + var rejected = executor.EnqueueAsync( + new ServerDecodeWorkItem(_ => ValueTask.CompletedTask), + CancellationToken.None).AsTask(); + await EnsureFailsAsync(rejected, "post-completion enqueue"); + + releaseFirst.TrySetResult(); + await first.WaitAsync(TimeSpan.FromSeconds(2)); + await second.WaitAsync(TimeSpan.FromSeconds(2)); + await completion.WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(secondExecutions == 1, "work published before completion must drain exactly once"); + Ensure(executor.QueueDepth == 0, "completed executor queue depth"); + } + + private static TaskCompletionSource NewSignal() + => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static async Task EnsureCancelledAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(2)); + throw new Exception($"assert failed: {scenario} should cancel"); + } + catch (OperationCanceledException) + { + } + catch (TimeoutException) + { + throw new Exception($"assert failed: {scenario} did not complete"); + } + } + + private static async Task EnsureFailsAsync(Task task, string scenario) + where TException : Exception + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(2)); + throw new Exception($"assert failed: {scenario} should fail"); + } + catch (TException) + { + } + catch (TimeoutException) + { + throw new Exception($"assert failed: {scenario} did not complete"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } +} From 7832955ebddb4a9c4fc5739909fdfd9537a1bdab Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:16:32 +0800 Subject: [PATCH 088/228] refactor(server): route large compressed requests through persistent decode --- .../SharpLinkServer.InvocationContinuation.cs | 343 +++++++++++++++++ .../SharpLinkServer.InvocationDispatch.cs | 354 ++---------------- ...harpLinkServer.PersistentDecodeDispatch.cs | 241 ++++++++++++ 3 files changed, 615 insertions(+), 323 deletions(-) create mode 100644 src/SharpLink.Server/SharpLinkServer.InvocationContinuation.cs create mode 100644 src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationContinuation.cs b/src/SharpLink.Server/SharpLinkServer.InvocationContinuation.cs new file mode 100644 index 000000000..fea89a32f --- /dev/null +++ b/src/SharpLink.Server/SharpLinkServer.InvocationContinuation.cs @@ -0,0 +1,343 @@ +namespace SharpLink.Server; + +internal sealed partial class SharpLinkServer +{ + /// + /// Continues one two-way RPC after request preparation has completed. The caller supplies the + /// exact service-registration snapshot captured before any await so dynamic generation changes + /// cannot retarget an in-flight request. + /// + private ValueTask ContinueRpcDispatch( + ServerConnectionState connection, + long requestId, + ProtocolV2FrameFlags flags, + ServerRequestEnvelope request, + ServiceRegistration serviceInfo, + StripedLongMap requestCancellationMap, + CancellationToken serverLoopToken, + ServerCallCancellationState? callState, + ServerRequestPermit requestOwner, + IRpcByteBufferWriter? decodedRequestOwner) + { + var session = connection.Session; + var isCancellable = (flags & ProtocolV2FrameFlags.Cancellable) != 0; + var hasReturnPayload = (flags & ProtocolV2FrameFlags.HasReturn) != 0; + + if (IsDeadlineExceeded(request.RpcDeadline)) + { + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + var exception = new SharpLinkException( + SharpLinkErrorCode.DeadlineExceeded, + "Request deadline exceeded before dispatch."); + CompleteFailedRequestStreams(session, requestId, exception); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, exception, connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + + if (serverLoopToken.IsCancellationRequested) + { + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + var exception = new SharpLinkException( + SharpLinkErrorCode.ConnectionClosed, + "Connection closed before dispatch."); + CompleteFailedRequestStreams(session, requestId, exception); + ReleaseDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + return ValueTask.FromException(exception); + } + + requestOwner.Activate(); + + var supportsCooperativeCancellation = + (isCancellable || serviceInfo.Module is not null) && + serviceInfo.Stub.SupportsCancellation(request.MethodHash); + callState ??= CreateTrackedCallState( + connection, + requestId, + request.RpcDeadline, + serverLoopToken, + serviceInfo.ModuleCancellation, + supportsCooperativeCancellation, + requestCancellationMap); + if (decodedRequestOwner is not null) + { + callState = EnsureTrackedCallState( + connection, callState, requestId, request.RpcDeadline, + serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); + callState.AttachPayloadOwner(_runtimeContext.Buffers, decodedRequestOwner); + decodedRequestOwner = null; + } + var invokeToken = supportsCooperativeCancellation + ? callState!.InvocationToken + : serverLoopToken; + + if (!hasReturnPayload) + { + var callContext = CreateCallContext( + connection, serviceInfo.Stub, request.MethodHash, requestId, + request.Deadline, request.Metadata, invokeToken); + try + { + using var callContextScope = SharpLinkCallContext.Push(callContext); + var invokeTask = InvokeServiceAsync( + serviceInfo, connection, session, request.MethodHash, requestId, + request.Arguments, output: null, invokeToken, callContext); + if (!invokeTask.IsCompletedSuccessfully) + { + callState = EnsureTrackedCallState( + connection, callState, requestId, request.RpcDeadline, + serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); + return AwaitDispatchRpcNoReturnAsync( + invokeTask, + session, + requestId, + callState, + requestCancellationMap, + connection, + callContext, + serviceInfo.Stub, + request.MethodHash, + invokeToken, + requestOwner); + } + if (callContext is SharpLinkServerInvocationContext + { + Status: SharpLinkInvocationStatus.Pending + } interceptorContext) + interceptorContext.Status = SharpLinkInvocationStatus.Succeeded; + var responseSend = ValueTask.CompletedTask; + if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) + { + responseSend = session.SendPacketWithBackpressureAsync( + ProtocolV2FrameType.Response, + ProtocolV2FrameFlags.None, + requestId, + connection.ConnectionToken); + } + else + { + responseSend = TrySendModuleDrainError( + callState, session, requestId, connection.ConnectionToken); + } + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + catch (OperationCanceledException exception) + { + CompleteFailedRequestStreams(session, requestId, exception); + var responseSend = ValueTask.CompletedTask; + if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) + { + responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + MapServerCancellationException(callState, request.RpcDeadline), + connection.ConnectionToken); + } + else + { + responseSend = TrySendModuleDrainError( + callState, session, requestId, connection.ConnectionToken); + } + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + catch (Exception exception) + { + CompleteFailedRequestStreams(session, requestId, exception); + var responseSend = ValueTask.CompletedTask; + if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) + { + responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + MapServiceException( + exception, + callContext, + session, + serviceInfo.Stub, + request.MethodHash, + requestId, + invokeToken), + connection.ConnectionToken); + } + else + { + responseSend = TrySendModuleDrainError( + callState, session, requestId, connection.ConnectionToken); + } + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + } + + var writer = session.RentFrameWriter(); + var ownsWriter = true; + var token = writer.BeginPacket( + ProtocolV2FrameType.Response, ProtocolV2FrameFlags.None, unchecked((ulong)requestId)); + var responseCallContext = CreateCallContext( + connection, serviceInfo.Stub, request.MethodHash, requestId, + request.Deadline, request.Metadata, invokeToken); + try + { + using var callContextScope = SharpLinkCallContext.Push(responseCallContext); + var invokeTask = InvokeServiceAsync( + serviceInfo, connection, session, request.MethodHash, requestId, + request.Arguments, writer, invokeToken, responseCallContext); + if (!invokeTask.IsCompletedSuccessfully) + { + callState = EnsureTrackedCallState( + connection, callState, requestId, request.RpcDeadline, + serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); + return AwaitDispatchRpcAsync( + invokeTask, + session, + requestId, + writer, + token, + callState, + requestCancellationMap, + connection, + responseCallContext, + serviceInfo.Stub, + request.MethodHash, + invokeToken, + requestOwner); + } + if (responseCallContext is SharpLinkServerInvocationContext + { + Status: SharpLinkInvocationStatus.Pending + } interceptorContext) + interceptorContext.Status = SharpLinkInvocationStatus.Succeeded; + if (!TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) + { + _runtimeContext.Buffers.Return(writer); + ownsWriter = false; + var drainErrorSend = TrySendModuleDrainError( + callState, session, requestId, connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + drainErrorSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + writer.EndPacket(token); + ownsWriter = false; + var responseSend = session + .SendPacketWithBackpressureAsync(writer, connection.ConnectionToken); + return CompletePayloadResponseAndReleaseDispatchResourcesAsync( + responseSend, + session, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + catch (OperationCanceledException exception) + { + CompleteFailedRequestStreams(session, requestId, exception); + if (!ownsWriter) + throw; + + _runtimeContext.Buffers.Return(writer); + var responseSend = ValueTask.CompletedTask; + if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) + { + responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + MapServerCancellationException(callState, request.RpcDeadline), + connection.ConnectionToken); + } + else + { + responseSend = TrySendModuleDrainError( + callState, session, requestId, connection.ConnectionToken); + } + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + catch (Exception exception) + { + CompleteFailedRequestStreams(session, requestId, exception); + if (!ownsWriter) + { + if (exception is SharpLinkCompressionProviderException compressionException) + { + var compressionErrorSend = session.SendRpcErrorWithBackpressureAsync( + requestId, compressionException, connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + compressionErrorSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + throw; + } + + _runtimeContext.Buffers.Return(writer); + var responseSend = ValueTask.CompletedTask; + if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) + { + responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + MapServiceException( + exception, + responseCallContext, + session, + serviceInfo.Stub, + request.MethodHash, + requestId, + invokeToken), + connection.ConnectionToken); + } + else + { + responseSend = TrySendModuleDrainError( + callState, session, requestId, connection.ConnectionToken); + } + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + } +} diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs index 839cf371d..f83dfd47f 100644 --- a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs @@ -14,8 +14,6 @@ private ValueTask DispatchRpcAsync( ServerRetainedAdmissionPayload? retainedAdmissionPayload = null) { var session = connection.Session; - var isCancellable = (flags & ProtocolV2FrameFlags.Cancellable) != 0; - var hasReturnPayload = (flags & ProtocolV2FrameFlags.HasReturn) != 0; var isCompressed = (flags & ProtocolV2FrameFlags.Compressed) != 0; var request = ReadRequestEnvelope(session, payload, flags); @@ -196,6 +194,22 @@ private ValueTask DispatchRpcAsync( } var requestOwner = requestPermit; + if (isCompressed && ShouldUsePersistentDecode(flags, serviceInfo, request, payload)) + { + return DispatchRpcWithPersistentDecodeAsync( + connection, + requestId, + flags, + payload, + request, + serviceInfo, + requestCancellationMap, + serverLoopToken, + admittedCallState, + requestOwner, + retainedAdmissionPayload); + } + IRpcByteBufferWriter? decodedRequestOwner = null; try { @@ -218,7 +232,6 @@ private ValueTask DispatchRpcAsync( out var resourceRejection)) { retainedAdmissionPayload?.Dispose(); - requestOwner.ReleaseDecodeResources(); var rejection = resourceRejection ?? throw new InvalidOperationException( "Compressed request decode resource rejection is missing its error."); CompleteFailedRequestStreams(session, requestId, rejection); @@ -250,7 +263,6 @@ private ValueTask DispatchRpcAsync( retainedAdmissionPayload?.Dispose(); session.ReturnDecodedPayload(decodedRequestOwner); decodedRequestOwner = null; - requestOwner.ReleaseDecodeResources(); CompleteFailedRequestStreams(session, requestId, exception); var responseSend = session.SendRpcErrorWithBackpressureAsync( requestId, exception, connection.ConnectionToken); @@ -267,7 +279,6 @@ private ValueTask DispatchRpcAsync( retainedAdmissionPayload?.Dispose(); session.ReturnDecodedPayload(decodedRequestOwner); decodedRequestOwner = null; - requestOwner.ReleaseDecodeResources(); CompleteFailedRequestStreams(session, requestId, exception); var responseSend = session.SendRpcErrorWithBackpressureAsync( requestId, @@ -285,8 +296,6 @@ private ValueTask DispatchRpcAsync( { retainedAdmissionPayload?.Dispose(); session.ReturnDecodedPayload(decodedRequestOwner); - decodedRequestOwner = null; - requestOwner.ReleaseDecodeResources(); CompleteFailedRequestStreams(session, requestId, exception); ReleaseDispatchResources( admittedCallState, @@ -297,324 +306,17 @@ private ValueTask DispatchRpcAsync( throw; } - if (IsDeadlineExceeded(request.RpcDeadline)) - { - session.ReturnDecodedPayload(decodedRequestOwner); - decodedRequestOwner = null; - requestOwner.ReleaseDecodeResources(); - var exception = new SharpLinkException( - SharpLinkErrorCode.DeadlineExceeded, - "Request deadline exceeded before dispatch."); - CompleteFailedRequestStreams(session, requestId, exception); - var responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, exception, connection.ConnectionToken); - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - - if (serverLoopToken.IsCancellationRequested) - { - session.ReturnDecodedPayload(decodedRequestOwner); - decodedRequestOwner = null; - requestOwner.ReleaseDecodeResources(); - var exception = new SharpLinkException( - SharpLinkErrorCode.ConnectionClosed, - "Connection closed before dispatch."); - CompleteFailedRequestStreams(session, requestId, exception); - ReleaseDispatchResources( - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - return ValueTask.FromException(exception); - } - - requestOwner.Activate(); - - var supportsCooperativeCancellation = - (isCancellable || serviceInfo.Module is not null) && - serviceInfo.Stub.SupportsCancellation(request.MethodHash); - var callState = admittedCallState ?? CreateTrackedCallState( + return ContinueRpcDispatch( connection, requestId, - request.RpcDeadline, + flags, + request, + serviceInfo, + requestCancellationMap, serverLoopToken, - serviceInfo.ModuleCancellation, - supportsCooperativeCancellation, - requestCancellationMap); - if (decodedRequestOwner is not null) - { - callState = EnsureTrackedCallState( - connection, callState, requestId, request.RpcDeadline, - serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); - callState.AttachPayloadOwner(_runtimeContext.Buffers, decodedRequestOwner); - decodedRequestOwner = null; - } - var invokeToken = supportsCooperativeCancellation - ? callState!.InvocationToken - : serverLoopToken; - - if (!hasReturnPayload) - { - var callContext = CreateCallContext( - connection, serviceInfo.Stub, request.MethodHash, requestId, - request.Deadline, request.Metadata, invokeToken); - try - { - using var callContextScope = SharpLinkCallContext.Push(callContext); - var invokeTask = InvokeServiceAsync( - serviceInfo, connection, session, request.MethodHash, requestId, - request.Arguments, output: null, invokeToken, callContext); - if (!invokeTask.IsCompletedSuccessfully) - { - callState = EnsureTrackedCallState( - connection, callState, requestId, request.RpcDeadline, - serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); - return AwaitDispatchRpcNoReturnAsync( - invokeTask, - session, - requestId, - callState, - requestCancellationMap, - connection, - callContext, - serviceInfo.Stub, - request.MethodHash, - invokeToken, - requestOwner); - } - if (callContext is SharpLinkServerInvocationContext - { - Status: SharpLinkInvocationStatus.Pending - } interceptorContext) - interceptorContext.Status = SharpLinkInvocationStatus.Succeeded; - var responseSend = ValueTask.CompletedTask; - if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) - { - responseSend = session.SendPacketWithBackpressureAsync( - ProtocolV2FrameType.Response, - ProtocolV2FrameFlags.None, - requestId, - connection.ConnectionToken); - } - else - { - responseSend = TrySendModuleDrainError( - callState, session, requestId, connection.ConnectionToken); - } - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - catch (OperationCanceledException exception) - { - CompleteFailedRequestStreams(session, requestId, exception); - var responseSend = ValueTask.CompletedTask; - if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) - { - responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, - MapServerCancellationException(callState, request.RpcDeadline), - connection.ConnectionToken); - } - else - { - responseSend = TrySendModuleDrainError( - callState, session, requestId, connection.ConnectionToken); - } - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - catch (Exception e) - { - CompleteFailedRequestStreams(session, requestId, e); - var responseSend = ValueTask.CompletedTask; - if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) - { - responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, - MapServiceException( - e, - callContext, - session, - serviceInfo.Stub, - request.MethodHash, - requestId, - invokeToken), - connection.ConnectionToken); - } - else - { - responseSend = TrySendModuleDrainError( - callState, session, requestId, connection.ConnectionToken); - } - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - } - - var writer = session.RentFrameWriter(); - var ownsWriter = true; - var token = writer.BeginPacket( - ProtocolV2FrameType.Response, ProtocolV2FrameFlags.None, unchecked((ulong)requestId)); - var responseCallContext = CreateCallContext( - connection, serviceInfo.Stub, request.MethodHash, requestId, - request.Deadline, request.Metadata, invokeToken); - try - { - using var callContextScope = SharpLinkCallContext.Push(responseCallContext); - var invokeTask = InvokeServiceAsync( - serviceInfo, connection, session, request.MethodHash, requestId, - request.Arguments, writer, invokeToken, responseCallContext); - if (!invokeTask.IsCompletedSuccessfully) - { - callState = EnsureTrackedCallState( - connection, callState, requestId, request.RpcDeadline, - serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); - return AwaitDispatchRpcAsync( - invokeTask, - session, - requestId, - writer, - token, - callState, - requestCancellationMap, - connection, - responseCallContext, - serviceInfo.Stub, - request.MethodHash, - invokeToken, - requestOwner); - } - if (responseCallContext is SharpLinkServerInvocationContext - { - Status: SharpLinkInvocationStatus.Pending - } interceptorContext) - interceptorContext.Status = SharpLinkInvocationStatus.Succeeded; - if (!TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) - { - _runtimeContext.Buffers.Return(writer); - ownsWriter = false; - var drainErrorSend = TrySendModuleDrainError( - callState, session, requestId, connection.ConnectionToken); - return ReleaseDispatchResourcesAfterResponseAsync( - drainErrorSend, - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - writer.EndPacket(token); - ownsWriter = false; - var responseSend = session - .SendPacketWithBackpressureAsync(writer, connection.ConnectionToken); - return CompletePayloadResponseAndReleaseDispatchResourcesAsync( - responseSend, - session, - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - catch (OperationCanceledException exception) - { - CompleteFailedRequestStreams(session, requestId, exception); - if (!ownsWriter) - throw; - - _runtimeContext.Buffers.Return(writer); - var responseSend = ValueTask.CompletedTask; - if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) - { - responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, - MapServerCancellationException(callState, request.RpcDeadline), - connection.ConnectionToken); - } - else - { - responseSend = TrySendModuleDrainError( - callState, session, requestId, connection.ConnectionToken); - } - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - catch (Exception e) - { - CompleteFailedRequestStreams(session, requestId, e); - if (!ownsWriter) - { - if (e is SharpLinkCompressionProviderException compressionException) - { - var compressionErrorSend = session.SendRpcErrorWithBackpressureAsync( - requestId, compressionException, connection.ConnectionToken); - return ReleaseDispatchResourcesAfterResponseAsync( - compressionErrorSend, - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - throw; - } - - _runtimeContext.Buffers.Return(writer); - var responseSend = ValueTask.CompletedTask; - if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) - { - responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, - MapServiceException( - e, - responseCallContext, - session, - serviceInfo.Stub, - request.MethodHash, - requestId, - invokeToken), - connection.ConnectionToken); - } - else - { - responseSend = TrySendModuleDrainError( - callState, session, requestId, connection.ConnectionToken); - } - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } + admittedCallState, + requestOwner, + decodedRequestOwner); } private async ValueTask AwaitDispatchRpcNoReturnAsync( @@ -817,9 +519,12 @@ private void ReleaseDispatchResources( ServerRequestPermit requestPermit) { _ = connection; + if (requestPermit.IsReserved) + requestPermit.ReleaseDecodeResources(); if (callState is not null) { - requestPermit.TransferDecodedBytesTo(callState); + if (requestPermit.IsActive) + requestPermit.TransferDecodedBytesTo(callState); requestCancellationMap.TryRemove(requestId, callState); callState.Dispose(); } @@ -834,6 +539,9 @@ private ValueTask ReleaseDispatchResourcesAfterResponseAsync( ServerConnectionState connection, ServerRequestPermit requestPermit) { + if (requestPermit.IsReserved) + requestPermit.ReleaseDecodeResources(); + if (responseSend.IsCompletedSuccessfully) { ReleaseDispatchResources( diff --git a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs new file mode 100644 index 000000000..61342c7a8 --- /dev/null +++ b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs @@ -0,0 +1,241 @@ +namespace SharpLink.Server; + +internal sealed partial class SharpLinkServer +{ + private ValueTask DispatchRpcWithPersistentDecodeAsync( + ServerConnectionState connection, + long requestId, + ProtocolV2FrameFlags flags, + ReadOnlySequence payload, + ServerRequestEnvelope request, + ServiceRegistration serviceInfo, + StripedLongMap requestCancellationMap, + CancellationToken serverLoopToken, + ServerCallCancellationState? admittedCallState, + ServerRequestPermit requestOwner, + ServerRetainedAdmissionPayload? retainedAdmissionPayload) + { + var session = connection.Session; + var retainedPayload = retainedAdmissionPayload; + var retainedUseOwned = false; + var callState = admittedCallState; + try + { + if (retainedPayload is null) + { + if (!TryCopyAdmissionPayload(payload, flags, out retainedPayload)) + { + var rejection = CreateRetainedCompressedResourceExhaustion(); + CompleteFailedRequestStreams(session, requestId, rejection); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + rejection, + connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + } + + retainedPayload!.AcquireUse(); + retainedUseOwned = true; + var stablePayload = retainedPayload.Payload; + if (!TryPrepareCompressedRequestDecode( + requestOwner, + retainedPayload.RetainedPermit, + flags, + stablePayload, + out var decodePermit, + out var resourceRejection)) + { + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + var rejection = resourceRejection ?? throw new InvalidOperationException( + "Persistent request decode resource rejection is missing its error."); + CompleteFailedRequestStreams(session, requestId, rejection); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + rejection, + connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + + callState ??= CreateTrackedCallState( + connection, + requestId, + request.RpcDeadline, + serverLoopToken, + serviceInfo.ModuleCancellation, + supportsCooperativeCancellation: true, + requestCancellationMap) ?? throw new InvalidOperationException( + "Persistent decode requires a pre-activation cancellation state."); + var result = new PersistentDecodeResult(); + var workItem = new ServerDecodeWorkItem(cancellationToken => + { + result.Payload = session.DecodeInboundPayload( + ProtocolV2FrameType.Request, + flags, + stablePayload, + cancellationToken, + out var decodedOwner); + result.Owner = decodedOwner; + return ValueTask.CompletedTask; + }); + var decodeTask = DecodeExecutor.EnqueueAsync(workItem, callState.InvocationToken); + retainedUseOwned = false; + return AwaitPersistentDecodeAndContinueAsync( + decodeTask, + decodePermit!, + retainedPayload, + result, + connection, + requestId, + flags, + request, + serviceInfo, + requestCancellationMap, + serverLoopToken, + callState, + requestOwner); + } + catch + { + if (retainedPayload is not null) + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + ReleaseDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + throw; + } + } + + private async ValueTask AwaitPersistentDecodeAndContinueAsync( + ValueTask decodeTask, + ServerDecodePermit decodePermit, + ServerRetainedAdmissionPayload retainedPayload, + PersistentDecodeResult result, + ServerConnectionState connection, + long requestId, + ProtocolV2FrameFlags flags, + ServerRequestEnvelope request, + ServiceRegistration serviceInfo, + StripedLongMap requestCancellationMap, + CancellationToken serverLoopToken, + ServerCallCancellationState callState, + ServerRequestPermit requestOwner) + { + var session = connection.Session; + var retainedUseOwned = true; + try + { + await decodeTask.ConfigureAwait(false); + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + decodePermit.CompleteDecode(); + request = ReadRequestEnvelope(session, result.Payload, flags); + } + catch (SharpLinkException exception) when ( + exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) + { + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + session.ReturnDecodedPayload(result.Owner); + result.Owner = null; + CompleteFailedRequestStreams(session, requestId, exception); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + exception, + connection.ConnectionToken); + await ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner).ConfigureAwait(false); + return; + } + catch (OperationCanceledException exception) + { + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + session.ReturnDecodedPayload(result.Owner); + result.Owner = null; + CompleteFailedRequestStreams(session, requestId, exception); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + MapServerCancellationException(callState, request.RpcDeadline), + connection.ConnectionToken); + await ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner).ConfigureAwait(false); + return; + } + catch (Exception exception) + { + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + session.ReturnDecodedPayload(result.Owner); + result.Owner = null; + CompleteFailedRequestStreams(session, requestId, exception); + ReleaseDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + throw; + } + + var decodedOwner = result.Owner; + result.Owner = null; + await ContinueRpcDispatch( + connection, + requestId, + flags, + request, + serviceInfo, + requestCancellationMap, + serverLoopToken, + callState, + requestOwner, + decodedOwner).ConfigureAwait(false); + } + + private static void ReleaseRetainedPayloadUse( + ServerRetainedAdmissionPayload retainedPayload, + ref bool retainedUseOwned) + { + if (!retainedUseOwned) + return; + + retainedUseOwned = false; + try + { + retainedPayload.Dispose(); + } + finally + { + retainedPayload.ReleaseUse(); + } + } + + private sealed class PersistentDecodeResult + { + internal ReadOnlySequence Payload { get; set; } + + internal IRpcByteBufferWriter? Owner { get; set; } + } +} From 853ac9e3effa18095a56096672fe7d0b5090f8b6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:23:46 +0800 Subject: [PATCH 089/228] test(server): expose deterministic persistent decode starts --- src/SharpLink.Server/ServerDecodeExecutor.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/SharpLink.Server/ServerDecodeExecutor.cs b/src/SharpLink.Server/ServerDecodeExecutor.cs index 97ae60c8b..7b346357c 100644 --- a/src/SharpLink.Server/ServerDecodeExecutor.cs +++ b/src/SharpLink.Server/ServerDecodeExecutor.cs @@ -16,6 +16,7 @@ internal sealed class ServerDecodeExecutor : IAsyncDisposable private int _completionRequested; private int _queueDepth; private int _skippedBeforeStart; + private int _startedWorkItems; internal ServerDecodeExecutor(int workerCount, int queueCapacity) { @@ -45,6 +46,12 @@ internal ServerDecodeExecutor(int workerCount, int queueCapacity) internal int SkippedBeforeStart => Volatile.Read(ref _skippedBeforeStart); + /// + /// Monotonic count of work items that won the Queued -> Running transition. This is a test and + /// diagnostics signal for proving RequestLoop routing without relying on timing heuristics. + /// + internal int StartedWorkItems => Volatile.Read(ref _startedWorkItems); + internal Task Completion => _completion; internal ValueTask EnqueueAsync( @@ -122,6 +129,7 @@ private async Task WorkerLoopAsync() continue; } + Interlocked.Increment(ref _startedWorkItems); await workItem.RunAsync().ConfigureAwait(false); } } From 10d85b2b55fd3c68b92557257d5f862e5da24fb2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:24:04 +0800 Subject: [PATCH 090/228] test(server): expose persistent decode start diagnostics --- src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs index e7d7357fb..f44d032a9 100644 --- a/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs +++ b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs @@ -63,4 +63,7 @@ internal int DecodeQueueDepthForDiagnostics internal int DecodeSkippedBeforeStartForDiagnostics => Volatile.Read(ref _decodeExecutor)?.SkippedBeforeStart ?? 0; + + internal int DecodeStartedWorkCountForDiagnostics + => Volatile.Read(ref _decodeExecutor)?.StartedWorkItems ?? 0; } From 3b14628704ca3b8900a54d23d5c3465272210150 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:25:04 +0800 Subject: [PATCH 091/228] test(server): cover persistent decode control-plane ownership --- ...essionPersistentDecodeControlPlaneTests.cs | 488 ++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs new file mode 100644 index 000000000..d95ab80b1 --- /dev/null +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs @@ -0,0 +1,488 @@ +namespace SharpLink.IntegrationTests; + +public class CompressionPersistentDecodeControlPlaneTests +{ + private const int SmallPayloadBytes = 64 * 1024; + private const int LargePayloadBytes = 2 * 1024 * 1024; + + [Test] + [NotInParallel] + public async Task CurrentCutoverShouldKeep64KiBInlineAndRoute2MiBThroughPersistentExecutor() + { + PersistentDecodeControlPlaneService.Reset(); + var serverProvider = new BlockingServerCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli(), initiallyReleased: true); + await using var harness = await PersistentDecodeHarness.CreateAsync(serverProvider); + await WaitUntilAsync(() => harness.DecodeWorkerCount > 0, "persistent decode workers started"); + var service = harness.Client.Get(); + + using var smallCancellation = new CancellationTokenSource(); + var small = Enumerable.Repeat((byte)0x31, SmallPayloadBytes).ToArray(); + Ensure(await service.MeasureAsync(small, smallCancellation.Token) == small.Length, + "64KiB compressed request result"); + Ensure(harness.DecodeStartedWorkCount == 0, + "64KiB request must remain on inline B at the current conservative cutover"); + + using var largeCancellation = new CancellationTokenSource(); + var large = Enumerable.Repeat((byte)0x32, LargePayloadBytes).ToArray(); + Ensure(await service.MeasureAsync(large, largeCancellation.Token) == large.Length, + "2MiB compressed request result"); + Ensure(harness.DecodeStartedWorkCount == 1, + "2MiB request must execute through persistent D at the current conservative cutover"); + Ensure(PersistentDecodeControlPlaneService.Invocations == 2, + "both routing paths must invoke the service exactly once"); + await AssertResourcesReleasedAsync(harness, "cutover routing"); + } + + [Test] + [NotInParallel] + public async Task RunningPersistentDecodeShouldObserveRemoteCancelFromRequestLoop() + { + PersistentDecodeControlPlaneService.Reset(); + var serverProvider = new BlockingServerCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await PersistentDecodeHarness.CreateAsync(serverProvider); + await WaitUntilAsync(() => harness.DecodeWorkerCount > 0, "persistent decode workers started"); + using var cancellation = new CancellationTokenSource(); + var call = harness.Client.Get() + .MeasureAsync(CreateLargePayload(0x41), cancellation.Token) + .AsTask(); + + try + { + await serverProvider.WaitForStartedCountAsync(1); + await cancellation.CancelAsync(); + await serverProvider.WaitForCancellationCountAsync(1); + await EnsureRemoteCancelledAsync(call, "running persistent decode remote cancel"); + await AssertResourcesReleasedAsync(harness, "running remote cancel"); + Ensure(PersistentDecodeControlPlaneService.Invocations == 0, + "remote-cancelled decode must not invoke the service"); + } + finally + { + serverProvider.ReleaseAll(); + } + } + + [Test] + [NotInParallel] + public async Task QueuedPersistentDecodeShouldCancelBeforeProviderStart() + { + PersistentDecodeControlPlaneService.Reset(); + var serverProvider = new BlockingServerCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await PersistentDecodeHarness.CreateAsync(serverProvider); + await WaitUntilAsync(() => harness.DecodeWorkerCount > 0, "persistent decode workers started"); + var workerCount = harness.DecodeWorkerCount; + var service = harness.Client.Get(); + var blockerCancellations = Enumerable.Range(0, workerCount) + .Select(static _ => new CancellationTokenSource()) + .ToArray(); + var blockers = blockerCancellations + .Select((cancellation, index) => service.MeasureAsync( + CreateLargePayload((byte)(0x50 + index)), cancellation.Token) + .AsTask()) + .ToArray(); + using var queuedCancellation = new CancellationTokenSource(); + + try + { + await serverProvider.WaitForStartedCountAsync(workerCount); + Ensure(harness.DecodeStartedWorkCount == workerCount, + "all persistent workers must be occupied before queueing the cancellation probe"); + + var queued = service.MeasureAsync(CreateLargePayload(0x60), queuedCancellation.Token).AsTask(); + await WaitUntilAsync( + () => harness.DecodeQueueDepth >= 1 && harness.ActiveDecodes == workerCount + 1, + "persistent decode queued request ownership"); + await queuedCancellation.CancelAsync(); + await EnsureRemoteCancelledAsync(queued, "queued persistent decode remote cancel"); + await WaitUntilAsync( + () => harness.ActiveCalls == workerCount && harness.ActiveDecodes == workerCount, + "queued cancellation resource release before worker service"); + Ensure(serverProvider.StartedCount == workerCount && + harness.DecodeStartedWorkCount == workerCount, + "queued cancellation must not start provider work"); + + serverProvider.ReleaseAll(); + await Task.WhenAll(blockers).WaitAsync(TimeSpan.FromSeconds(5)); + await WaitUntilAsync( + () => harness.DecodeSkippedBeforeStart >= 1 && harness.DecodeQueueDepth == 0, + "cancelled queued work skipped by worker"); + Ensure(serverProvider.StartedCount == workerCount && + harness.DecodeStartedWorkCount == workerCount, + "skipping the cancelled work must never execute the provider"); + await AssertResourcesReleasedAsync(harness, "queued remote cancel"); + Ensure(PersistentDecodeControlPlaneService.Invocations == workerCount, + "only the worker-owned blocker calls may reach the service"); + } + finally + { + serverProvider.ReleaseAll(); + foreach (var cancellation in blockerCancellations) + { + await cancellation.CancelAsync(); + cancellation.Dispose(); + } + await Task.WhenAll(blockers.Select(ObserveTerminalAsync)); + } + } + + [Test] + [NotInParallel] + public async Task RunningPersistentDecodeShouldObserveConnectionClose() + { + PersistentDecodeControlPlaneService.Reset(); + var serverProvider = new BlockingServerCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await PersistentDecodeHarness.CreateAsync(serverProvider); + await WaitUntilAsync(() => harness.DecodeWorkerCount > 0, "persistent decode workers started"); + using var cancellation = new CancellationTokenSource(); + var call = harness.Client.Get() + .MeasureAsync(CreateLargePayload(0x71), cancellation.Token) + .AsTask(); + + try + { + await serverProvider.WaitForStartedCountAsync(1); + await harness.StopClientAsync(); + await serverProvider.WaitForCancellationCountAsync(1); + await EnsureConnectionClosedAsync(call, "persistent decode connection close"); + await AssertResourcesReleasedAsync(harness, "connection close"); + Ensure(PersistentDecodeControlPlaneService.Invocations == 0, + "connection-closed decode must not invoke the service"); + } + finally + { + serverProvider.ReleaseAll(); + } + } + + [Test] + [NotInParallel] + public async Task ForceStopShouldCancelRunningPersistentDecodeAndDrainExecutor() + { + PersistentDecodeControlPlaneService.Reset(); + var serverProvider = new BlockingServerCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await PersistentDecodeHarness.CreateAsync(serverProvider); + await WaitUntilAsync(() => harness.DecodeWorkerCount > 0, "persistent decode workers started"); + using var cancellation = new CancellationTokenSource(); + var call = harness.Client.Get() + .MeasureAsync(CreateLargePayload(0x72), cancellation.Token) + .AsTask(); + + try + { + await serverProvider.WaitForStartedCountAsync(1); + await harness.StopServerAsync(TimeSpan.Zero).AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + await serverProvider.WaitForCancellationCountAsync(1); + await ObserveTerminalAsync(call); + await AssertResourcesReleasedAsync(harness, "force stop"); + Ensure(harness.DecodeQueueDepth == 0, + "force stop must leave no pending persistent decode work"); + Ensure(PersistentDecodeControlPlaneService.Invocations == 0, + "force-stopped decode must not invoke the service"); + } + finally + { + serverProvider.ReleaseAll(); + } + } + + private static byte[] CreateLargePayload(byte value) + => Enumerable.Repeat(value, LargePayloadBytes).ToArray(); + + private static async Task AssertResourcesReleasedAsync(PersistentDecodeHarness harness, string scenario) + { + await WaitUntilAsync( + () => harness.ActiveCalls == 0 && + harness.ActiveDecodes == 0 && + harness.RetainedCompressedBytes == 0 && + harness.DecodedBytesInFlight == 0, + $"{scenario} resource release"); + } + + private static async Task EnsureRemoteCancelledAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + throw new Exception($"assert failed: {scenario} should cancel"); + } + catch (OperationCanceledException) + { + } + catch (SharpLinkException exception) when (exception.Code == SharpLinkErrorCode.Cancelled) + { + } + } + + private static async Task EnsureConnectionClosedAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + throw new Exception($"assert failed: {scenario} should fail"); + } + catch (OperationCanceledException) + { + } + catch (SharpLinkException exception) when ( + exception.Code is SharpLinkErrorCode.ConnectionClosed or SharpLinkErrorCode.Cancelled) + { + } + } + + private static async Task ObserveTerminalAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} was not observed"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class BlockingServerCompressionProvider( + ISharpLinkCompressionProvider inner, + bool initiallyReleased = false) : ISharpLinkCompressionProvider + { + private readonly ManualResetEventSlim _release = new(initiallyReleased); + private int _startedCount; + private int _cancellationCount; + + public string WireProfile => inner.WireProfile; + + internal int StartedCount => Volatile.Read(ref _startedCount); + + internal void ReleaseAll() => _release.Set(); + + internal Task WaitForStartedCountAsync(int count) + => WaitForCounterAsync(() => StartedCount, count, "provider starts"); + + internal Task WaitForCancellationCountAsync(int count) + => WaitForCounterAsync( + () => Volatile.Read(ref _cancellationCount), + count, + "provider cancellations"); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _startedCount); + try + { + _release.Wait(cancellationToken); + return inner.Decompress(input, output, maxOutputBytes, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Interlocked.Increment(ref _cancellationCount); + throw; + } + } + + private static async Task WaitForCounterAsync( + Func read, + int expected, + string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (read() < expected) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} did not reach {expected}"); + } + } + } + + private sealed class PersistentDecodeHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + private bool _clientStopped; + private bool _serverStopped; + + private PersistentDecodeHarness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient client) + { + _serverCts = serverCts; + _serverTask = serverTask; + _server = server; + Client = client; + } + + internal ISharpLinkClient Client { get; } + + internal int ActiveCalls => ReadField("_globalActiveCalls"); + internal int ActiveDecodes => ReadDiagnosticProperty("ActiveDecodeCountForDiagnostics"); + internal long RetainedCompressedBytes => + ReadDiagnosticProperty("RetainedCompressedBytesForDiagnostics"); + internal long DecodedBytesInFlight => + ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); + internal int DecodeWorkerCount => ReadDiagnosticProperty("DecodeWorkerCountForDiagnostics"); + internal int DecodeQueueDepth => ReadDiagnosticProperty("DecodeQueueDepthForDiagnostics"); + internal int DecodeSkippedBeforeStart => + ReadDiagnosticProperty("DecodeSkippedBeforeStartForDiagnostics"); + internal int DecodeStartedWorkCount => + ReadDiagnosticProperty("DecodeStartedWorkCountForDiagnostics"); + + internal static async Task CreateAsync( + ISharpLinkCompressionProvider serverProvider) + { + var serverCts = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 16; + options.FlowControl.MaxConcurrentCallsPerServer = 16; + options.FlowControl.MaxConcurrentDecodesPerServer = 8; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 32L * 1024 * 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = 128L * 1024 * 1024; + options.Compression.Providers.Add(serverProvider); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = Task.Run(async () => + { + try + { + await server.RunAsync(serverCts.Token); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + + var client = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .UseRuntime(options => options.Compression.Providers.Add( + SharpLinkCompressionProviders.CreateBrotli())) + .Build(); + await client.ConnectAsync(); + return new PersistentDecodeHarness(serverCts, serverTask, server, client); + } + + internal async ValueTask StopClientAsync() + { + if (_clientStopped) + return; + _clientStopped = true; + await Client.StopAsync(); + } + + internal async ValueTask StopServerAsync(TimeSpan timeout) + { + if (_serverStopped) + return; + _serverStopped = true; + await _server.StopAsync(timeout); + } + + public async ValueTask DisposeAsync() + { + if (!_clientStopped) + await StopClientAsync(); + await _serverCts.CancelAsync(); + if (!_serverStopped) + await StopServerAsync(TimeSpan.Zero); + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + + private T ReadField(string name) + { + var field = _server.GetType().GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server field {name}"); + return (T)field.GetValue(_server)!; + } + + private T ReadDiagnosticProperty(string name) + { + var property = _server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic property {name}"); + return (T)property.GetValue(_server)!; + } + } +} + +[RpcContract] +public interface IPersistentDecodeControlPlaneService : IService +{ + ValueTask MeasureAsync(byte[] value, CancellationToken cancellationToken); +} + +[RpcService] +public sealed class PersistentDecodeControlPlaneService : IPersistentDecodeControlPlaneService +{ + private static int s_invocations; + + internal static int Invocations => Volatile.Read(ref s_invocations); + + internal static void Reset() => Volatile.Write(ref s_invocations, 0); + + public ValueTask MeasureAsync(byte[] value, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref s_invocations); + return ValueTask.FromResult(value.Length); + } +} From 13b2e7817090408f3802e807adbb0d67fbdc7199 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:11:49 +0800 Subject: [PATCH 092/228] fix(server): close persistent decode publication on drain --- src/SharpLink.Server/ServerDecodeExecutor.cs | 31 +++++++-- .../SharpLinkServer.DecodeExecutor.cs | 8 +++ ...harpLinkServer.PersistentDecodeDispatch.cs | 25 +++++++ .../Server/ServerDecodeExecutorTests.cs | 65 +++++++++++++++++++ 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Server/ServerDecodeExecutor.cs b/src/SharpLink.Server/ServerDecodeExecutor.cs index 7b346357c..2c9f88a2d 100644 --- a/src/SharpLink.Server/ServerDecodeExecutor.cs +++ b/src/SharpLink.Server/ServerDecodeExecutor.cs @@ -52,6 +52,8 @@ internal ServerDecodeExecutor(int workerCount, int queueCapacity) /// internal int StartedWorkItems => Volatile.Read(ref _startedWorkItems); + internal bool IsAccepting => Volatile.Read(ref _completionRequested) == 0; + internal Task Completion => _completion; internal ValueTask EnqueueAsync( @@ -63,8 +65,7 @@ internal ValueTask EnqueueAsync( { return cancellationToken.IsCancellationRequested ? ValueTask.FromCanceled(cancellationToken) - : ValueTask.FromException( - new InvalidOperationException("The server decode executor is no longer accepting work.")); + : ValueTask.FromException(new ServerDecodeExecutorClosedException()); } return EnqueueCoreAsync(workItem, cancellationToken); @@ -105,9 +106,14 @@ private async ValueTask EnqueueCoreAsync( var remaining = Interlocked.Decrement(ref _queueDepth); if (remaining < 0) throw new InvalidOperationException("Server decode queue depth accounting underflowed."); + + if (exception is ChannelClosedException) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + throw new ServerDecodeExecutorClosedException(exception); + } } - if (exception is ChannelClosedException && cancellationToken.IsCancellationRequested) - throw new OperationCanceledException(cancellationToken); throw; } } @@ -135,6 +141,23 @@ private async Task WorkerLoopAsync() } } +/// +/// Signals that decode publication lost the executor Stop/Drain race before provider execution. +/// This is a normal server-lifecycle boundary, not a worker/provider failure. +/// +internal sealed class ServerDecodeExecutorClosedException : InvalidOperationException +{ + internal ServerDecodeExecutorClosedException() + : base("The server decode executor is no longer accepting work.") + { + } + + internal ServerDecodeExecutorClosedException(Exception innerException) + : base("The server decode executor is no longer accepting work.", innerException) + { + } +} + /// /// One queued decode operation. Cancellation may complete the caller before worker service only if /// it wins the Queued -> CancelledBeforeStart transition. If a worker wins Queued -> Running, the diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs index f44d032a9..bcfedc657 100644 --- a/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs +++ b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs @@ -25,6 +25,11 @@ private void StartDecodeExecutor() checked(workerCount * 8)); var executor = new ServerDecodeExecutor(workerCount, queueCapacity); Volatile.Write(ref _decodeExecutor, executor); + // Stop publication at the server's acceptance/drain boundary, before force cancellation. + // The force-stop registration remains as an idempotent safety net for failure cleanup. + _ = _acceptCts.Token.UnsafeRegister( + static state => ((ServerDecodeExecutor)state!).StopAccepting(), + executor); _ = _forceStopCts.Token.UnsafeRegister( static state => ((ServerDecodeExecutor)state!).StopAccepting(), executor); @@ -66,4 +71,7 @@ internal int DecodeSkippedBeforeStartForDiagnostics internal int DecodeStartedWorkCountForDiagnostics => Volatile.Read(ref _decodeExecutor)?.StartedWorkItems ?? 0; + + internal bool DecodeAcceptingForDiagnostics + => Volatile.Read(ref _decodeExecutor)?.IsAccepting ?? false; } diff --git a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs index 61342c7a8..299d919b8 100644 --- a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs @@ -145,6 +145,31 @@ private async ValueTask AwaitPersistentDecodeAndContinueAsync( decodePermit.CompleteDecode(); request = ReadRequestEnvelope(session, result.Payload, flags); } + catch (ServerDecodeExecutorClosedException) + { + // Stop/Drain can close publication after this request owns retained/decode budgets but + // before a worker owns the physical payload. Return physical owners first, then release + // their accounting through the normal Reserved teardown path. + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + session.ReturnDecodedPayload(result.Owner); + result.Owner = null; + var exception = new SharpLinkException( + SharpLinkErrorCode.Unavailable, + "Server is draining."); + CompleteFailedRequestStreams(session, requestId, exception); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + exception, + connection.ConnectionToken); + await ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner).ConfigureAwait(false); + return; + } catch (SharpLinkException exception) when ( exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) { diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs index 4f7ab0043..661a47ad9 100644 --- a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs @@ -124,6 +124,71 @@ public async Task WorkerWinningCancellationRaceShouldKeepCallerJoinedUntilProvid Ensure(executor.SkippedBeforeStart == 0, "running work must not be counted as queue-skipped"); } + [Test] + public async Task StopAcceptingShouldRejectBlockedWriterAndDrainPublishedWork() + { + await using var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity: 1); + var firstStarted = NewSignal(); + var releaseFirst = NewSignal(); + var secondExecutions = 0; + var thirdExecutions = 0; + + var first = executor.EnqueueAsync( + new ServerDecodeWorkItem(async _ => + { + firstStarted.TrySetResult(); + await releaseFirst.Task.ConfigureAwait(false); + }), + CancellationToken.None).AsTask(); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + var second = executor.EnqueueAsync( + new ServerDecodeWorkItem(_ => + { + Interlocked.Increment(ref secondExecutions); + return ValueTask.CompletedTask; + }), + CancellationToken.None).AsTask(); + await WaitUntilAsync(() => executor.QueueDepth == 1, "second decode was not queued"); + + var third = executor.EnqueueAsync( + new ServerDecodeWorkItem(_ => + { + Interlocked.Increment(ref thirdExecutions); + return ValueTask.CompletedTask; + }), + CancellationToken.None).AsTask(); + await WaitUntilAsync( + () => executor.QueueDepth == 2 && !third.IsCompleted, + "third decode did not block behind the full bounded queue"); + + executor.StopAccepting(); + Ensure(!executor.IsAccepting, "StopAccepting must publish the drain boundary synchronously"); + await EnsureFailsAsync( + third, + "blocked writer crossing the drain boundary"); + Ensure(executor.QueueDepth == 1, + "blocked writer rejected by StopAccepting must roll back pending-depth ownership"); + Ensure(thirdExecutions == 0, + "work rejected before publication must never execute provider code"); + + var rejected = executor.EnqueueAsync( + new ServerDecodeWorkItem(_ => ValueTask.CompletedTask), + CancellationToken.None).AsTask(); + await EnsureFailsAsync( + rejected, + "post-drain enqueue"); + + releaseFirst.TrySetResult(); + await first.WaitAsync(TimeSpan.FromSeconds(2)); + await second.WaitAsync(TimeSpan.FromSeconds(2)); + await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(secondExecutions == 1, "work published before drain must execute exactly once"); + Ensure(thirdExecutions == 0, "unpublished drain-race work must remain skipped"); + Ensure(executor.QueueDepth == 0, "drained executor queue depth"); + } + [Test] public async Task CompleteShouldStopPublicationAndDrainAlreadyPublishedWork() { From 32ddcfae908dbba380f888fa46a947d14fc90678 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:13:01 +0800 Subject: [PATCH 093/228] test(server): cover persistent decode graceful drain failures --- ...ionPersistentDecodeDrainAndFailureTests.cs | 413 ++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs new file mode 100644 index 000000000..017bee7ed --- /dev/null +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs @@ -0,0 +1,413 @@ +namespace SharpLink.IntegrationTests; + +public class CompressionPersistentDecodeDrainAndFailureTests +{ + private const int LargePayloadBytes = 2 * 1024 * 1024; + + [Test] + [NotInParallel] + public async Task GracefulStopShouldClosePublicationAndDrainAlreadyQueuedDecodeWork() + { + PersistentDecodeControlPlaneService.Reset(); + var serverProvider = new BlockingServerCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await PersistentDecodeHarness.CreateAsync(serverProvider); + await WaitUntilAsync(() => harness.DecodeWorkerCount > 0, "persistent decode workers started"); + Ensure(harness.DecodeAccepting, "persistent decode executor must accept work after server start"); + + var service = harness.Client.Get(); + var workerCount = harness.DecodeWorkerCount; + var cancellations = Enumerable.Range(0, workerCount + 1) + .Select(static _ => new CancellationTokenSource()) + .ToArray(); + var running = Enumerable.Range(0, workerCount) + .Select(index => service.MeasureAsync( + CreateLargePayload((byte)(0x80 + index)), + cancellations[index].Token) + .AsTask()) + .ToArray(); + Task? queued = null; + Task? stopTask = null; + + try + { + await serverProvider.WaitForStartedCountAsync(workerCount); + queued = service.MeasureAsync( + CreateLargePayload(0x90), + cancellations[workerCount].Token) + .AsTask(); + await WaitUntilAsync( + () => harness.DecodeQueueDepth >= 1 && + harness.ActiveDecodes == workerCount + 1, + "decode queued before graceful drain"); + + stopTask = harness.BeginStopServer(TimeSpan.FromSeconds(5)); + await WaitUntilAsync( + () => !harness.DecodeAccepting, + "graceful stop decode publication boundary"); + + Ensure(!stopTask.IsCompleted, + "graceful stop must remain joined to running and queued persistent decodes"); + Ensure(serverProvider.CancellationCount == 0, + "graceful drain must not force-cancel provider work before its timeout"); + + serverProvider.ReleaseAll(); + await Task.WhenAll(running.Append(queued)).WaitAsync(TimeSpan.FromSeconds(5)); + await stopTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Ensure(serverProvider.StartedCount == workerCount + 1, + "work published before the drain boundary must still receive worker service"); + Ensure(serverProvider.CancellationCount == 0, + "successful graceful drain must not cancel persistent decode providers"); + Ensure(harness.DecodeQueueDepth == 0, + "graceful stop must drain the persistent decode queue"); + await AssertResourcesReleasedAsync(harness, "graceful persistent decode stop"); + } + finally + { + serverProvider.ReleaseAll(); + foreach (var cancellation in cancellations) + { + await cancellation.CancelAsync(); + cancellation.Dispose(); + } + await Task.WhenAll(running.Select(ObserveTerminalAsync)); + if (queued is not null) + await ObserveTerminalAsync(queued); + if (stopTask is not null) + await ObserveTerminalAsync(stopTask); + } + } + + [Test] + [NotInParallel] + public Task PersistentDecodeDataLossShouldReleaseAllRequestResources() + => RunProviderFailureCaseAsync( + static () => new InvalidDataException("synthetic corrupt compressed payload"), + SharpLinkErrorCode.DataLoss, + "persistent D DataLoss"); + + [Test] + [NotInParallel] + public Task PersistentDecodeInternalShouldReleaseAllRequestResources() + => RunProviderFailureCaseAsync( + static () => new InvalidOperationException("synthetic provider failure"), + SharpLinkErrorCode.Internal, + "persistent D Internal"); + + private static async Task RunProviderFailureCaseAsync( + Func failureFactory, + SharpLinkErrorCode expectedCode, + string scenario) + { + PersistentDecodeControlPlaneService.Reset(); + var serverProvider = new ThrowingServerCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli(), + failureFactory); + await using var harness = await PersistentDecodeHarness.CreateAsync(serverProvider); + await WaitUntilAsync(() => harness.DecodeWorkerCount > 0, "persistent decode workers started"); + using var cancellation = new CancellationTokenSource(); + var call = harness.Client.Get() + .MeasureAsync(CreateLargePayload(0xA1), cancellation.Token) + .AsTask(); + + await EnsureRpcFailureAsync(call, expectedCode, scenario); + Ensure(serverProvider.StartedCount == 1, + $"{scenario} must execute exactly once on a persistent decode worker"); + Ensure(harness.DecodeStartedWorkCount == 1, + $"{scenario} executor start count"); + Ensure(PersistentDecodeControlPlaneService.Invocations == 0, + $"{scenario} must fail before service invocation"); + await AssertResourcesReleasedAsync(harness, scenario); + } + + private static byte[] CreateLargePayload(byte value) + => Enumerable.Repeat(value, LargePayloadBytes).ToArray(); + + private static async Task AssertResourcesReleasedAsync(PersistentDecodeHarness harness, string scenario) + { + await WaitUntilAsync( + () => harness.ActiveCalls == 0 && + harness.ActiveDecodes == 0 && + harness.RetainedCompressedBytes == 0 && + harness.DecodedBytesInFlight == 0 && + harness.DecodeQueueDepth == 0, + $"{scenario} resource release"); + } + + private static async Task EnsureRpcFailureAsync( + Task task, + SharpLinkErrorCode expectedCode, + string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + throw new Exception($"assert failed: {scenario} should fail with {expectedCode}"); + } + catch (SharpLinkException exception) when (exception.Code == expectedCode) + { + } + } + + private static async Task ObserveTerminalAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} was not observed"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class BlockingServerCompressionProvider( + ISharpLinkCompressionProvider inner) : ISharpLinkCompressionProvider + { + private readonly ManualResetEventSlim _release = new(); + private int _startedCount; + private int _cancellationCount; + + public string WireProfile => inner.WireProfile; + + internal int StartedCount => Volatile.Read(ref _startedCount); + + internal int CancellationCount => Volatile.Read(ref _cancellationCount); + + internal void ReleaseAll() => _release.Set(); + + internal Task WaitForStartedCountAsync(int count) + => WaitForCounterAsync(() => StartedCount, count, "provider starts"); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _startedCount); + try + { + _release.Wait(cancellationToken); + return inner.Decompress(input, output, maxOutputBytes, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Interlocked.Increment(ref _cancellationCount); + throw; + } + } + } + + private sealed class ThrowingServerCompressionProvider( + ISharpLinkCompressionProvider inner, + Func failureFactory) : ISharpLinkCompressionProvider + { + private int _startedCount; + + public string WireProfile => inner.WireProfile; + + internal int StartedCount => Volatile.Read(ref _startedCount); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _startedCount); + throw failureFactory(); + } + } + + private sealed class PersistentDecodeHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + private bool _clientStopped; + private bool _serverStopped; + + private PersistentDecodeHarness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient client) + { + _serverCts = serverCts; + _serverTask = serverTask; + _server = server; + Client = client; + } + + internal ISharpLinkClient Client { get; } + + internal int ActiveCalls => ReadField("_globalActiveCalls"); + internal int ActiveDecodes => ReadDiagnosticProperty("ActiveDecodeCountForDiagnostics"); + internal long RetainedCompressedBytes => + ReadDiagnosticProperty("RetainedCompressedBytesForDiagnostics"); + internal long DecodedBytesInFlight => + ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); + internal int DecodeWorkerCount => ReadDiagnosticProperty("DecodeWorkerCountForDiagnostics"); + internal int DecodeQueueDepth => ReadDiagnosticProperty("DecodeQueueDepthForDiagnostics"); + internal int DecodeStartedWorkCount => + ReadDiagnosticProperty("DecodeStartedWorkCountForDiagnostics"); + internal bool DecodeAccepting => ReadDiagnosticProperty("DecodeAcceptingForDiagnostics"); + + internal static async Task CreateAsync( + ISharpLinkCompressionProvider serverProvider) + { + var serverCts = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 16; + options.FlowControl.MaxConcurrentCallsPerServer = 16; + options.FlowControl.MaxConcurrentDecodesPerServer = 8; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 32L * 1024 * 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = 128L * 1024 * 1024; + options.Compression.Providers.Add(serverProvider); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = Task.Run(async () => + { + try + { + await server.RunAsync(serverCts.Token); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + + var client = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .UseRuntime(options => options.Compression.Providers.Add( + SharpLinkCompressionProviders.CreateBrotli())) + .Build(); + await client.ConnectAsync(); + return new PersistentDecodeHarness(serverCts, serverTask, server, client); + } + + internal Task BeginStopServer(TimeSpan timeout) + { + if (_serverStopped) + return Task.CompletedTask; + _serverStopped = true; + return _server.StopAsync(timeout).AsTask(); + } + + private static async Task WaitForCounterAsync( + Func read, + int expected, + string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (read() < expected) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} did not reach {expected}"); + } + } + + public async ValueTask DisposeAsync() + { + if (!_clientStopped) + { + _clientStopped = true; + try + { + await Client.StopAsync(); + } + catch (Exception) + { + } + } + await _serverCts.CancelAsync(); + if (!_serverStopped) + { + _serverStopped = true; + await _server.StopAsync(TimeSpan.Zero); + } + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + + private T ReadField(string name) + { + var field = _server.GetType().GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server field {name}"); + return (T)field.GetValue(_server)!; + } + + private T ReadDiagnosticProperty(string name) + { + var property = _server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic property {name}"); + return (T)property.GetValue(_server)!; + } + } + + private static Task WaitForCounterAsync( + Func read, + int expected, + string scenario) + => PersistentDecodeHarness.WaitForCounterAsync(read, expected, scenario); +} From f0711e948512eb72ada39c47828d0b3b49067d31 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:15:46 +0800 Subject: [PATCH 094/228] fix(test): expose persistent decode counter waiter --- .../CompressionPersistentDecodeDrainAndFailureTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs index 017bee7ed..6c207f3d8 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs @@ -344,7 +344,7 @@ internal Task BeginStopServer(TimeSpan timeout) return _server.StopAsync(timeout).AsTask(); } - private static async Task WaitForCounterAsync( + internal static async Task WaitForCounterAsync( Func read, int expected, string scenario) From 1ce8568f0cda3ea293ba7ae6d27640bff8498cd3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:48:04 +0800 Subject: [PATCH 095/228] fix(server): admit persistent decode before resource ownership --- src/SharpLink.Server/ServerDecodeExecutor.cs | 168 +++++++++++++++--- .../SharpLinkServer.DecodeExecutor.cs | 26 ++- .../SharpLinkServer.DecodeResources.cs | 9 + ...harpLinkServer.PersistentDecodeDispatch.cs | 107 +++++++---- 4 files changed, 250 insertions(+), 60 deletions(-) diff --git a/src/SharpLink.Server/ServerDecodeExecutor.cs b/src/SharpLink.Server/ServerDecodeExecutor.cs index 2c9f88a2d..e341766ff 100644 --- a/src/SharpLink.Server/ServerDecodeExecutor.cs +++ b/src/SharpLink.Server/ServerDecodeExecutor.cs @@ -3,17 +3,18 @@ namespace SharpLink.Server; /// -/// Persistent bounded worker pool for request decompression. The executor owns only queue/worker -/// lifetime; request, retained-compressed, decode and decoded-byte ownership remain attached to the -/// caller's request permit and work item until provider execution has either completed or been -/// skipped before start. +/// Persistent bounded worker pool for request decompression. Production callers reserve one queue +/// slot before retaining request bytes. Decode concurrency and decoded-byte budgets are acquired only +/// after a worker wins the queued-to-running transition. /// internal sealed class ServerDecodeExecutor : IAsyncDisposable { - private readonly Channel _channel; + private readonly Channel _channel; private readonly Task[] _workers; private readonly Task _completion; + private readonly int _queueCapacity; private int _completionRequested; + private int _queueReservations; private int _queueDepth; private int _skippedBeforeStart; private int _startedWorkItems; @@ -23,7 +24,8 @@ internal ServerDecodeExecutor(int workerCount, int queueCapacity) ArgumentOutOfRangeException.ThrowIfNegativeOrZero(workerCount); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(queueCapacity); - _channel = Channel.CreateBounded(new BoundedChannelOptions(queueCapacity) + _queueCapacity = queueCapacity; + _channel = Channel.CreateBounded(new BoundedChannelOptions(queueCapacity) { AllowSynchronousContinuations = false, FullMode = BoundedChannelFullMode.Wait, @@ -39,23 +41,90 @@ internal ServerDecodeExecutor(int workerCount, int queueCapacity) internal int WorkerCount => _workers.Length; /// - /// Number of decode operations waiting for worker service, including writers currently blocked - /// by the bounded channel. This is intentionally a pending-work count rather than Channel.Count. + /// Number of published operations waiting for worker service, plus compatibility-path writers + /// blocked by the bounded channel. Production reserved publication does not block on channel + /// capacity because a queue slot is acquired first. /// internal int QueueDepth => Volatile.Read(ref _queueDepth); - internal int SkippedBeforeStart => Volatile.Read(ref _skippedBeforeStart); - /// - /// Monotonic count of work items that won the Queued -> Running transition. This is a test and - /// diagnostics signal for proving RequestLoop routing without relying on timing heuristics. + /// Number of production queue slots reserved but not yet handed to a worker. This includes the + /// short pre-publication interval used to copy/retain a request after scheduler admission. /// + internal int QueueReservations => Volatile.Read(ref _queueReservations); + + internal int SkippedBeforeStart => Volatile.Read(ref _skippedBeforeStart); + internal int StartedWorkItems => Volatile.Read(ref _startedWorkItems); internal bool IsAccepting => Volatile.Read(ref _completionRequested) == 0; internal Task Completion => _completion; + /// + /// Reserves scheduler capacity before a production request acquires retained/decode/decoded-byte + /// ownership. Queue reservations are bounded independently from provider decode concurrency. + /// + internal bool TryReserveQueueSlot(out ServerDecodeQueuePermit? permit) + { + permit = null; + if (Volatile.Read(ref _completionRequested) != 0) + return false; + + while (true) + { + var current = Volatile.Read(ref _queueReservations); + if (current >= _queueCapacity) + return false; + if (Interlocked.CompareExchange(ref _queueReservations, current + 1, current) != current) + continue; + + if (Volatile.Read(ref _completionRequested) == 0) + { + permit = new ServerDecodeQueuePermit(this); + return true; + } + + ReleaseQueueReservation(); + return false; + } + } + + /// + /// Production publication path. A previously reserved slot guarantees that this caller never + /// waits behind the bounded channel while owning downstream decode resources. + /// + internal ValueTask EnqueueReservedAsync( + ServerDecodeQueuePermit queuePermit, + ServerDecodeWorkItem workItem, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(queuePermit); + ArgumentNullException.ThrowIfNull(workItem); + queuePermit.MarkEnqueued(this); + + workItem.EnableQueuedCancellation(cancellationToken); + Interlocked.Increment(ref _queueDepth); + if (_channel.Writer.TryWrite(new ServerDecodeQueueEntry(workItem, queuePermit))) + return new ValueTask(workItem.Completion); + + workItem.AbandonBeforePublication(); + DecrementQueueDepth(); + queuePermit.Dispose(); + + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled(cancellationToken); + if (Volatile.Read(ref _completionRequested) != 0) + return ValueTask.FromException(new ServerDecodeExecutorClosedException()); + + return ValueTask.FromException(new InvalidOperationException( + "A reserved server decode queue slot could not be published to the bounded channel.")); + } + + /// + /// Compatibility/test publication path retained for executor-local race tests. Production D + /// dispatch uses plus . + /// internal ValueTask EnqueueAsync( ServerDecodeWorkItem workItem, CancellationToken cancellationToken) @@ -85,6 +154,16 @@ internal async ValueTask CompleteAsync() public ValueTask DisposeAsync() => CompleteAsync(); + internal void ReleaseQueueReservation() + { + var remaining = Interlocked.Decrement(ref _queueReservations); + if (remaining >= 0) + return; + + Interlocked.Increment(ref _queueReservations); + throw new InvalidOperationException("Server decode queue reservation accounting underflowed."); + } + private async ValueTask EnqueueCoreAsync( ServerDecodeWorkItem workItem, CancellationToken cancellationToken) @@ -94,7 +173,9 @@ private async ValueTask EnqueueCoreAsync( var published = false; try { - await _channel.Writer.WriteAsync(workItem, cancellationToken).ConfigureAwait(false); + await _channel.Writer.WriteAsync( + new ServerDecodeQueueEntry(workItem, queuePermit: null), + cancellationToken).ConfigureAwait(false); published = true; await workItem.Completion.ConfigureAwait(false); } @@ -103,9 +184,7 @@ private async ValueTask EnqueueCoreAsync( if (!published) { workItem.AbandonBeforePublication(); - var remaining = Interlocked.Decrement(ref _queueDepth); - if (remaining < 0) - throw new InvalidOperationException("Server decode queue depth accounting underflowed."); + DecrementQueueDepth(); if (exception is ChannelClosedException) { @@ -120,12 +199,12 @@ private async ValueTask EnqueueCoreAsync( private async Task WorkerLoopAsync() { - await foreach (var workItem in _channel.Reader.ReadAllAsync().ConfigureAwait(false)) + await foreach (var entry in _channel.Reader.ReadAllAsync().ConfigureAwait(false)) { - var remaining = Interlocked.Decrement(ref _queueDepth); - if (remaining < 0) - throw new InvalidOperationException("Server decode queue depth accounting underflowed."); + DecrementQueueDepth(); + entry.QueuePermit?.Dispose(); + var workItem = entry.WorkItem; if (!workItem.TryStart()) { if (!workItem.IsCancelledBeforeStart) @@ -139,6 +218,53 @@ private async Task WorkerLoopAsync() await workItem.RunAsync().ConfigureAwait(false); } } + + private void DecrementQueueDepth() + { + var remaining = Interlocked.Decrement(ref _queueDepth); + if (remaining >= 0) + return; + + Interlocked.Increment(ref _queueDepth); + throw new InvalidOperationException("Server decode queue depth accounting underflowed."); + } + + private readonly record struct ServerDecodeQueueEntry( + ServerDecodeWorkItem WorkItem, + ServerDecodeQueuePermit? QueuePermit); +} + +/// +/// One bounded persistent-executor queue slot. It is acquired before long-lived request retention and +/// released when a worker dequeues the corresponding work or publication fails. +/// +internal sealed class ServerDecodeQueuePermit : IDisposable +{ + private const int Reserved = 0; + private const int Enqueued = 1; + private const int Disposed = 2; + + private readonly ServerDecodeExecutor _executor; + private int _state = Reserved; + + internal ServerDecodeQueuePermit(ServerDecodeExecutor executor) + => _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + + internal void MarkEnqueued(ServerDecodeExecutor executor) + { + if (!ReferenceEquals(_executor, executor)) + throw new InvalidOperationException("A decode queue permit cannot move between executors."); + if (Interlocked.CompareExchange(ref _state, Enqueued, Reserved) != Reserved) + throw new InvalidOperationException("A decode queue permit can only be enqueued once."); + } + + public void Dispose() + { + var previous = Interlocked.Exchange(ref _state, Disposed); + if (previous == Disposed) + return; + _executor.ReleaseQueueReservation(); + } } /// @@ -160,7 +286,7 @@ internal ServerDecodeExecutorClosedException(Exception innerException) /// /// One queued decode operation. Cancellation may complete the caller before worker service only if -/// it wins the Queued -> CancelledBeforeStart transition. If a worker wins Queued -> Running, the +/// it wins the Queued -> CancelledBeforeStart transition. If a worker wins Queued -> Running, the /// caller remains joined to worker completion so request-owned buffers cannot be released while the /// provider can still access them. /// diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs index bcfedc657..0a96e5a08 100644 --- a/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs +++ b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs @@ -4,8 +4,9 @@ internal sealed partial class SharpLinkServer { private const int MaxPersistentDecodeWorkers = 4; private const int MinimumPersistentDecodeQueueCapacity = 32; - // Phase 0 has current-D performance evidence at 1 MiB. Smaller cutovers remain hypotheses until - // real RequestLoop control-plane measurements are collected in this slice. + // Phase 0 has current-D performance evidence at 1 MiB decoded size. The same conservative + // bound also caps synchronous compressed-input work on the RequestLoop: built-in Brotli scans + // the complete compressed body for integrity before its cancellable decode loop. private const int InitialPersistentDecodeThresholdBytes = 1024 * 1024; private ServerDecodeExecutor? _decodeExecutor; @@ -25,8 +26,6 @@ private void StartDecodeExecutor() checked(workerCount * 8)); var executor = new ServerDecodeExecutor(workerCount, queueCapacity); Volatile.Write(ref _decodeExecutor, executor); - // Stop publication at the server's acceptance/drain boundary, before force cancellation. - // The force-stop registration remains as an idempotent safety net for failure cleanup. _ = _acceptCts.Token.UnsafeRegister( static state => ((ServerDecodeExecutor)state!).StopAccepting(), executor); @@ -42,18 +41,26 @@ private bool ShouldUsePersistentDecode( ServerRequestEnvelope request, ReadOnlySequence payload) { + _ = serviceInfo; + _ = request; if ((flags & ProtocolV2FrameFlags.Compressed) == 0 || (flags & ProtocolV2FrameFlags.Cancellable) == 0 || - Volatile.Read(ref _decodeExecutor) is null || - !serviceInfo.Stub.SupportsCancellation(request.MethodHash)) + Volatile.Read(ref _decodeExecutor) is null) { return false; } - return RpcSession.ReadCompressedDecodedPayloadLength( + var decodedPayloadBytes = RpcSession.ReadCompressedDecodedPayloadLength( ProtocolV2FrameType.Request, flags, - payload) >= InitialPersistentDecodeThresholdBytes; + payload); + + // Execution location is a pre-invocation decode decision. It is intentionally independent + // from whether the eventual service handler consumes a cancellation token. Include both + // output work and compressed-input work so a small declared output cannot force a large + // synchronous provider pre-scan onto the RequestLoop. + return decodedPayloadBytes >= InitialPersistentDecodeThresholdBytes || + payload.Length >= InitialPersistentDecodeThresholdBytes; } private ServerDecodeExecutor DecodeExecutor @@ -66,6 +73,9 @@ internal int DecodeWorkerCountForDiagnostics internal int DecodeQueueDepthForDiagnostics => Volatile.Read(ref _decodeExecutor)?.QueueDepth ?? 0; + internal int DecodeQueueReservationsForDiagnostics + => Volatile.Read(ref _decodeExecutor)?.QueueReservations ?? 0; + internal int DecodeSkippedBeforeStartForDiagnostics => Volatile.Read(ref _decodeExecutor)?.SkippedBeforeStart ?? 0; diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs b/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs index e98ea7492..a84971a87 100644 --- a/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs +++ b/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs @@ -49,6 +49,15 @@ private static SharpLinkException CreateDecodeResourceExhaustion( $"{message} ({reason})."); } + private static SharpLinkException CreateDecodeQueueResourceExhaustion() + { + const string reason = "server_decode_queue"; + SharpLinkTelemetry.RecordResourceExhausted("server", reason); + return SharpLinkResourceExhaustion.CreateWire( + reason, + $"Server persistent decode queue is exhausted ({reason})."); + } + private static SharpLinkException CreateRetainedCompressedResourceExhaustion() { const string reason = SharpLinkResourceExhaustion.ServerRetainedCompressedBytes; diff --git a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs index 299d919b8..32a6fbdde 100644 --- a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs @@ -17,14 +17,38 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( { var session = connection.Session; var retainedPayload = retainedAdmissionPayload; + ServerDecodeQueuePermit? queuePermit = null; var retainedUseOwned = false; var callState = admittedCallState; try { + // Scheduler admission precedes D-specific long-lived retention and all provider/decode + // budgets. A full executor therefore rejects without copying this RequestLoop frame or + // reserving decode/decoded-byte resources. + if (!DecodeExecutor.TryReserveQueueSlot(out queuePermit)) + { + retainedPayload?.Dispose(); + var rejection = CreateDecodeQueueResourceExhaustion(); + CompleteFailedRequestStreams(session, requestId, rejection); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + rejection, + connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + if (retainedPayload is null) { if (!TryCopyAdmissionPayload(payload, flags, out retainedPayload)) { + queuePermit.Dispose(); + queuePermit = null; var rejection = CreateRetainedCompressedResourceExhaustion(); CompleteFailedRequestStreams(session, requestId, rejection); var responseSend = session.SendRpcErrorWithBackpressureAsync( @@ -44,30 +68,6 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( retainedPayload!.AcquireUse(); retainedUseOwned = true; var stablePayload = retainedPayload.Payload; - if (!TryPrepareCompressedRequestDecode( - requestOwner, - retainedPayload.RetainedPermit, - flags, - stablePayload, - out var decodePermit, - out var resourceRejection)) - { - ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); - var rejection = resourceRejection ?? throw new InvalidOperationException( - "Persistent request decode resource rejection is missing its error."); - CompleteFailedRequestStreams(session, requestId, rejection); - var responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, - rejection, - connection.ConnectionToken); - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } callState ??= CreateTrackedCallState( connection, @@ -78,9 +78,27 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( supportsCooperativeCancellation: true, requestCancellationMap) ?? throw new InvalidOperationException( "Persistent decode requires a pre-activation cancellation state."); + var result = new PersistentDecodeResult(); var workItem = new ServerDecodeWorkItem(cancellationToken => { + // Provider-concurrency and decoded-byte ownership begin only after a worker has won + // Queued -> Running. Queued requests therefore do not consume these global budgets. + if (!TryPrepareCompressedRequestDecode( + requestOwner, + retainedPayload.RetainedPermit, + flags, + stablePayload, + out var decodePermit, + out var resourceRejection)) + { + result.DecodePermit = decodePermit; + result.ResourceRejection = resourceRejection ?? throw new InvalidOperationException( + "Persistent request decode resource rejection is missing its error."); + return ValueTask.CompletedTask; + } + + result.DecodePermit = decodePermit; result.Payload = session.DecodeInboundPayload( ProtocolV2FrameType.Request, flags, @@ -90,11 +108,15 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( result.Owner = decodedOwner; return ValueTask.CompletedTask; }); - var decodeTask = DecodeExecutor.EnqueueAsync(workItem, callState.InvocationToken); + + var decodeTask = DecodeExecutor.EnqueueReservedAsync( + queuePermit, + workItem, + callState.InvocationToken); + queuePermit = null; retainedUseOwned = false; return AwaitPersistentDecodeAndContinueAsync( decodeTask, - decodePermit!, retainedPayload, result, connection, @@ -109,6 +131,7 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( } catch { + queuePermit?.Dispose(); if (retainedPayload is not null) ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); ReleaseDispatchResources( @@ -123,7 +146,6 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( private async ValueTask AwaitPersistentDecodeAndContinueAsync( ValueTask decodeTask, - ServerDecodePermit decodePermit, ServerRetainedAdmissionPayload retainedPayload, PersistentDecodeResult result, ServerConnectionState connection, @@ -141,15 +163,34 @@ private async ValueTask AwaitPersistentDecodeAndContinueAsync( try { await decodeTask.ConfigureAwait(false); + + if (result.ResourceRejection is { } resourceRejection) + { + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + session.ReturnDecodedPayload(result.Owner); + result.Owner = null; + CompleteFailedRequestStreams(session, requestId, resourceRejection); + var rejectionSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + resourceRejection, + connection.ConnectionToken); + await ReleaseDispatchResourcesAfterResponseAsync( + rejectionSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner).ConfigureAwait(false); + return; + } + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); - decodePermit.CompleteDecode(); + (result.DecodePermit ?? throw new InvalidOperationException( + "Persistent decode completed without a provider decode permit.")).CompleteDecode(); request = ReadRequestEnvelope(session, result.Payload, flags); } catch (ServerDecodeExecutorClosedException) { - // Stop/Drain can close publication after this request owns retained/decode budgets but - // before a worker owns the physical payload. Return physical owners first, then release - // their accounting through the normal Reserved teardown path. ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); session.ReturnDecodedPayload(result.Owner); result.Owner = null; @@ -262,5 +303,9 @@ private sealed class PersistentDecodeResult internal ReadOnlySequence Payload { get; set; } internal IRpcByteBufferWriter? Owner { get; set; } + + internal ServerDecodePermit? DecodePermit { get; set; } + + internal SharpLinkException? ResourceRejection { get; set; } } } From 0d71aaa3e8b30cff0d26239904545c1e6b848405 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:50:39 +0800 Subject: [PATCH 096/228] fix(server): satisfy persistent decode nullability --- src/SharpLink.Server/ServerDecodeExecutor.cs | 2 +- .../SharpLinkServer.PersistentDecodeDispatch.cs | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/SharpLink.Server/ServerDecodeExecutor.cs b/src/SharpLink.Server/ServerDecodeExecutor.cs index e341766ff..9e2848e27 100644 --- a/src/SharpLink.Server/ServerDecodeExecutor.cs +++ b/src/SharpLink.Server/ServerDecodeExecutor.cs @@ -174,7 +174,7 @@ private async ValueTask EnqueueCoreAsync( try { await _channel.Writer.WriteAsync( - new ServerDecodeQueueEntry(workItem, queuePermit: null), + new ServerDecodeQueueEntry(workItem, null), cancellationToken).ConfigureAwait(false); published = true; await workItem.Completion.ConfigureAwait(false); diff --git a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs index 32a6fbdde..9b0368b33 100644 --- a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs @@ -43,11 +43,14 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( requestOwner); } + var reservedQueuePermit = queuePermit ?? throw new InvalidOperationException( + "Persistent decode queue admission did not return its permit."); + if (retainedPayload is null) { if (!TryCopyAdmissionPayload(payload, flags, out retainedPayload)) { - queuePermit.Dispose(); + reservedQueuePermit.Dispose(); queuePermit = null; var rejection = CreateRetainedCompressedResourceExhaustion(); CompleteFailedRequestStreams(session, requestId, rejection); @@ -65,9 +68,11 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( } } - retainedPayload!.AcquireUse(); + var persistentRetainedPayload = retainedPayload ?? throw new InvalidOperationException( + "Persistent decode requires a retained request payload."); + persistentRetainedPayload.AcquireUse(); retainedUseOwned = true; - var stablePayload = retainedPayload.Payload; + var stablePayload = persistentRetainedPayload.Payload; callState ??= CreateTrackedCallState( connection, @@ -86,7 +91,7 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( // Queued -> Running. Queued requests therefore do not consume these global budgets. if (!TryPrepareCompressedRequestDecode( requestOwner, - retainedPayload.RetainedPermit, + persistentRetainedPayload.RetainedPermit, flags, stablePayload, out var decodePermit, @@ -110,14 +115,14 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( }); var decodeTask = DecodeExecutor.EnqueueReservedAsync( - queuePermit, + reservedQueuePermit, workItem, callState.InvocationToken); queuePermit = null; retainedUseOwned = false; return AwaitPersistentDecodeAndContinueAsync( decodeTask, - retainedPayload, + persistentRetainedPayload, result, connection, requestId, From b9985bf060bd4b6bece5b7806c5a737585604d69 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:57:10 +0800 Subject: [PATCH 097/228] test(server): cover persistent decode review invariants --- ...essionPersistentDecodeControlPlaneTests.cs | 17 +- .../CompressionPersistentDecodeReviewTests.cs | 608 ++++++++++++++++++ 2 files changed, 620 insertions(+), 5 deletions(-) create mode 100644 test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs index d95ab80b1..467ff1287 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs @@ -93,8 +93,10 @@ public async Task QueuedPersistentDecodeShouldCancelBeforeProviderStart() var queued = service.MeasureAsync(CreateLargePayload(0x60), queuedCancellation.Token).AsTask(); await WaitUntilAsync( - () => harness.DecodeQueueDepth >= 1 && harness.ActiveDecodes == workerCount + 1, - "persistent decode queued request ownership"); + () => harness.DecodeQueueDepth >= 1 && + harness.DecodeQueueReservations >= 1 && + harness.ActiveDecodes == workerCount, + "persistent decode queued request scheduler ownership without decode credit"); await queuedCancellation.CancelAsync(); await EnsureRemoteCancelledAsync(queued, "queued persistent decode remote cancel"); await WaitUntilAsync( @@ -107,7 +109,9 @@ await WaitUntilAsync( serverProvider.ReleaseAll(); await Task.WhenAll(blockers).WaitAsync(TimeSpan.FromSeconds(5)); await WaitUntilAsync( - () => harness.DecodeSkippedBeforeStart >= 1 && harness.DecodeQueueDepth == 0, + () => harness.DecodeSkippedBeforeStart >= 1 && + harness.DecodeQueueDepth == 0 && + harness.DecodeQueueReservations == 0, "cancelled queued work skipped by worker"); Ensure(serverProvider.StartedCount == workerCount && harness.DecodeStartedWorkCount == workerCount, @@ -199,7 +203,8 @@ await WaitUntilAsync( () => harness.ActiveCalls == 0 && harness.ActiveDecodes == 0 && harness.RetainedCompressedBytes == 0 && - harness.DecodedBytesInFlight == 0, + harness.DecodedBytesInFlight == 0 && + harness.DecodeQueueReservations == 0, $"{scenario} resource release"); } @@ -362,6 +367,8 @@ private PersistentDecodeHarness( ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); internal int DecodeWorkerCount => ReadDiagnosticProperty("DecodeWorkerCountForDiagnostics"); internal int DecodeQueueDepth => ReadDiagnosticProperty("DecodeQueueDepthForDiagnostics"); + internal int DecodeQueueReservations => + ReadDiagnosticProperty("DecodeQueueReservationsForDiagnostics"); internal int DecodeSkippedBeforeStart => ReadDiagnosticProperty("DecodeSkippedBeforeStartForDiagnostics"); internal int DecodeStartedWorkCount => @@ -485,4 +492,4 @@ public ValueTask MeasureAsync(byte[] value, CancellationToken cancellationT Interlocked.Increment(ref s_invocations); return ValueTask.FromResult(value.Length); } -} +} \ No newline at end of file diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs new file mode 100644 index 000000000..8fac6476d --- /dev/null +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs @@ -0,0 +1,608 @@ +namespace SharpLink.IntegrationTests; + +public class CompressionPersistentDecodeReviewTests +{ + private const int LargePayloadBytes = 2 * 1024 * 1024; + private const int ProductionQueueCapacityWithOneWorker = 32; + + [Test] + [NotInParallel] + public async Task FullPersistentQueueShouldNotPreAcquireDecodeOrDecodedByteBudgets() + { + PersistentDecodeReviewService.Reset(); + var serverProvider = new BlockingReviewCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await ReviewHarness.CreateAsync( + serverProvider, + maxConcurrentCalls: 64, + maxConcurrentDecodes: 1, + maxDecodedBytes: 4L * 1024 * 1024); + await WaitUntilAsync(() => harness.DecodeWorkerCount == 1, "single persistent decode worker started"); + var service = harness.Client.Get(); + using var cancellation = new CancellationTokenSource(); + var payload = Enumerable.Repeat((byte)0x2a, LargePayloadBytes).ToArray(); + + var running = service.MeasureAsync(payload, cancellation.Token).AsTask(); + await serverProvider.WaitForStartedCountAsync(1); + Ensure(harness.ActiveDecodes == 1, "running provider owns the only decode credit"); + + var queued = Enumerable.Range(0, ProductionQueueCapacityWithOneWorker) + .Select(_ => service.MeasureAsync(payload, cancellation.Token).AsTask()) + .ToArray(); + await WaitUntilAsync( + () => harness.DecodeQueueDepth == ProductionQueueCapacityWithOneWorker && + harness.DecodeQueueReservations == ProductionQueueCapacityWithOneWorker, + "production persistent decode queue filled"); + + Ensure(harness.ActiveDecodes == 1, + "queued D work must not consume provider decode concurrency"); + var decodedBytesBeforeRejected = harness.DecodedBytesInFlight; + Ensure(decodedBytesBeforeRejected > 0 && decodedBytesBeforeRejected < 4L * 1024 * 1024, + "only the running D work may own decoded-byte budget"); + var retainedBytesBeforeRejected = harness.RetainedCompressedBytes; + + var rejected = service.MeasureAsync(payload, cancellation.Token).AsTask(); + await EnsureResourceExhaustedAsync(rejected, "full persistent decode queue"); + + Ensure(harness.ActiveDecodes == 1, + "queue-full rejection must not acquire an additional decode credit"); + Ensure(harness.DecodedBytesInFlight == decodedBytesBeforeRejected, + "queue-full rejection must not reserve decoded-byte budget"); + Ensure(harness.RetainedCompressedBytes == retainedBytesBeforeRejected, + "queue-full rejection must happen before D-specific retained-byte ownership"); + Ensure(harness.DecodeQueueReservations == ProductionQueueCapacityWithOneWorker, + "queue-full rejection must not perturb accepted scheduler reservations"); + Ensure(serverProvider.StartedCount == 1, + "queue-full rejection must not execute provider code"); + + serverProvider.ReleaseAll(); + await Task.WhenAll(queued.Prepend(running)).WaitAsync(TimeSpan.FromSeconds(10)); + await AssertResourcesReleasedAsync(harness, "full queue drain"); + Ensure(PersistentDecodeReviewService.CancellableInvocations == + ProductionQueueCapacityWithOneWorker + 1, + "only scheduler-admitted requests may invoke the service"); + } + + [Test] + [NotInParallel] + public async Task LargeNonCancellableHandlerRequestShouldStillUsePersistentDecodeAndHonorDeadlineBeforeActivation() + { + PersistentDecodeReviewService.Reset(); + var serverProvider = new BlockingReviewCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await ReviewHarness.CreateAsync( + serverProvider, + maxConcurrentCalls: 8, + maxConcurrentDecodes: 1, + clientRequestTimeout: TimeSpan.FromMilliseconds(750)); + await WaitUntilAsync(() => harness.DecodeWorkerCount == 1, "persistent decode worker started"); + var service = harness.Client.Get(); + var payload = Enumerable.Repeat((byte)0x39, LargePayloadBytes).ToArray(); + + var call = service.MeasureNonCancellableAsync(payload).AsTask(); + try + { + await serverProvider.WaitForStartedCountAsync(1); + Ensure(harness.DecodeStartedWorkCount == 1, + "large NonCancellable handler request must route through D"); + await serverProvider.WaitForCancellationCountAsync(1); + await EnsureDeadlineOrCancellationAsync(call, "NonCancellable pre-activation deadline"); + await AssertResourcesReleasedAsync(harness, "NonCancellable deadline"); + Ensure(PersistentDecodeReviewService.NonCancellableInvocations == 0, + "deadline during D must prevent NonCancellable handler activation"); + } + finally + { + serverProvider.ReleaseAll(); + } + } + + [Test] + [NotInParallel] + public async Task LargeCompressedInputWithSmallDeclaredOutputShouldOffloadBeforeRequestLoopCancel() + { + var provider = new BlockingRawInputCompressionProvider(); + await using var harness = await RawInputHarness.CreateAsync(provider); + await WaitUntilAsync(() => harness.DecodeWorkerCount > 0, "raw-input persistent decode worker started"); + + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(IPAddress.Loopback, harness.Port); + await using var stream = new NetworkStream(socket, ownsSocket: false); + const ulong requestId = 41; + const int declaredDecodedArgumentsBytes = 64 * 1024; + const int compressedBodyBytes = 2 * 1024 * 1024; + + using var frames = new PooledByteBufferWriter(); + var limits = new SharpLinkProtocolOptions(); + var handshake = ProtocolV2FrameWriter.BeginFrame( + frames, + ProtocolV2FrameType.HandshakeRequest, + ProtocolV2FrameFlags.None, + 0); + ProtocolV2PayloadCodec.WriteHandshakeRequest( + frames, + new ProtocolV2HandshakeRequest( + ProtocolV2Constants.MinorVersion, + ProtocolV2Capabilities.Compression, + ProtocolV2Capabilities.Compression, + SharpLinkProtocolOptions.DefaultMaxFramePayloadBytes, + 1024 * 1024, + 16 * 1024 * 1024, + ReadOnlyMemory.Empty, + new[] { provider.WireProfile }), + limits); + ProtocolV2FrameWriter.EndFrame(frames, handshake); + + var request = ProtocolV2FrameWriter.BeginFrame( + frames, + ProtocolV2FrameType.Request, + ProtocolV2FrameFlags.Compressed | ProtocolV2FrameFlags.Cancellable, + requestId); + Span requestPrefix = stackalloc byte[ProtocolV2Constants.RequestPrefixBytes]; + System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian( + requestPrefix, + harness.InterfaceHash); + System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian( + requestPrefix[sizeof(long)..], + 1L); + frames.Write(requestPrefix); + Span originalLength = stackalloc byte[sizeof(uint)]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian( + originalLength, + declaredDecodedArgumentsBytes); + frames.Write(originalLength); + frames.Write(new byte[compressedBodyBytes]); + ProtocolV2FrameWriter.EndFrame(frames, request); + + await stream.WriteAsync(frames.WrittenMemory); + await stream.FlushAsync(); + await provider.WaitForStartedCountAsync(1); + Ensure(harness.DecodeStartedWorkCount == 1, + "large compressed input must route to D even when declared output is below 1 MiB"); + + using var cancel = new PooledByteBufferWriter(); + ProtocolV2FrameWriter.WriteEmptyFrame( + cancel, + ProtocolV2FrameType.Cancel, + ProtocolV2FrameFlags.None, + requestId); + await stream.WriteAsync(cancel.WrittenMemory); + await stream.FlushAsync(); + + try + { + await provider.WaitForCancellationCountAsync(1); + await AssertResourcesReleasedAsync(harness, "large compressed-input cancel"); + Ensure(provider.StartedCount == 1, + "hostile large compressed input should execute provider exactly once on D"); + } + finally + { + provider.ReleaseAll(); + } + } + + private static async Task EnsureResourceExhaustedAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + throw new Exception($"assert failed: {scenario} should reject"); + } + catch (SharpLinkException exception) when (exception.Code == SharpLinkErrorCode.ResourceExhausted) + { + } + } + + private static async Task EnsureDeadlineOrCancellationAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + throw new Exception($"assert failed: {scenario} should terminate"); + } + catch (OperationCanceledException) + { + } + catch (SharpLinkException exception) when ( + exception.Code is SharpLinkErrorCode.DeadlineExceeded or SharpLinkErrorCode.Cancelled) + { + } + } + + private static async Task AssertResourcesReleasedAsync(IReviewDiagnostics harness, string scenario) + { + await WaitUntilAsync( + () => harness.ActiveCalls == 0 && + harness.ActiveDecodes == 0 && + harness.RetainedCompressedBytes == 0 && + harness.DecodedBytesInFlight == 0 && + harness.DecodeQueueDepth == 0 && + harness.DecodeQueueReservations == 0, + $"{scenario} resource release"); + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} was not observed"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private interface IReviewDiagnostics + { + int ActiveCalls { get; } + int ActiveDecodes { get; } + long RetainedCompressedBytes { get; } + long DecodedBytesInFlight { get; } + int DecodeQueueDepth { get; } + int DecodeQueueReservations { get; } + } + + private abstract class ReviewDiagnosticsBase(ISharpLinkServer server) : IReviewDiagnostics + { + protected ISharpLinkServer Server { get; } = server; + + public int ActiveCalls => ReadField("_globalActiveCalls"); + public int ActiveDecodes => ReadDiagnosticProperty("ActiveDecodeCountForDiagnostics"); + public long RetainedCompressedBytes => + ReadDiagnosticProperty("RetainedCompressedBytesForDiagnostics"); + public long DecodedBytesInFlight => + ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); + public int DecodeQueueDepth => ReadDiagnosticProperty("DecodeQueueDepthForDiagnostics"); + public int DecodeQueueReservations => + ReadDiagnosticProperty("DecodeQueueReservationsForDiagnostics"); + internal int DecodeWorkerCount => ReadDiagnosticProperty("DecodeWorkerCountForDiagnostics"); + internal int DecodeStartedWorkCount => + ReadDiagnosticProperty("DecodeStartedWorkCountForDiagnostics"); + + protected T ReadField(string name) + { + var field = Server.GetType().GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server field {name}"); + return (T)field.GetValue(Server)!; + } + + protected T ReadDiagnosticProperty(string name) + { + var property = Server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic property {name}"); + return (T)property.GetValue(Server)!; + } + } + + private sealed class ReviewHarness : ReviewDiagnosticsBase, IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private bool _stopped; + + private ReviewHarness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient client) + : base(server) + { + _serverCts = serverCts; + _serverTask = serverTask; + Client = client; + } + + internal ISharpLinkClient Client { get; } + + internal static async Task CreateAsync( + ISharpLinkCompressionProvider serverProvider, + int maxConcurrentCalls, + int maxConcurrentDecodes, + long maxDecodedBytes = 128L * 1024 * 1024, + TimeSpan? clientRequestTimeout = null) + { + var serverCts = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = maxConcurrentCalls; + options.FlowControl.MaxConcurrentCallsPerServer = maxConcurrentCalls; + options.FlowControl.MaxConcurrentDecodesPerServer = maxConcurrentDecodes; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 32L * 1024 * 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = maxDecodedBytes; + options.Compression.Providers.Add(serverProvider); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = RunServerAsync(server, serverCts.Token); + + var clientBuilder = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .UseRuntime(options => options.Compression.Providers.Add( + SharpLinkCompressionProviders.CreateBrotli())); + if (clientRequestTimeout is { } timeout) + clientBuilder.UseRequestTimeout(timeout); + var client = clientBuilder.Build(); + await client.ConnectAsync(); + return new ReviewHarness(serverCts, serverTask, server, client); + } + + public async ValueTask DisposeAsync() + { + if (_stopped) + return; + _stopped = true; + try + { + await Client.StopAsync(); + } + finally + { + await _serverCts.CancelAsync(); + await Server.StopAsync(TimeSpan.Zero); + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + } + } + + private sealed class RawInputHarness : ReviewDiagnosticsBase, IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private bool _stopped; + + private RawInputHarness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + int port, + long interfaceHash) + : base(server) + { + _serverCts = serverCts; + _serverTask = serverTask; + Port = port; + InterfaceHash = interfaceHash; + } + + internal int Port { get; } + internal long InterfaceHash { get; } + + internal static Task CreateAsync(ISharpLinkCompressionProvider provider) + { + var serverCts = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 8; + options.FlowControl.MaxConcurrentCallsPerServer = 8; + options.FlowControl.MaxConcurrentDecodesPerServer = 1; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 16L * 1024 * 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = 8L * 1024 * 1024; + options.Compression.Providers.Add(provider); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var interfaceHash = ReadAnyInterfaceHash(server); + var serverTask = RunServerAsync(server, serverCts.Token); + return Task.FromResult(new RawInputHarness( + serverCts, + serverTask, + server, + port, + interfaceHash)); + } + + public async ValueTask DisposeAsync() + { + if (_stopped) + return; + _stopped = true; + await _serverCts.CancelAsync(); + await Server.StopAsync(TimeSpan.Zero); + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + + private static long ReadAnyInterfaceHash(ISharpLinkServer server) + { + var field = server.GetType().GetField( + "_services", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception("cannot find server services field"); + var services = field.GetValue(server) ?? throw new Exception("server services are unavailable"); + var keys = services.GetType().GetProperty("Keys")?.GetValue(services) as System.Collections.IEnumerable + ?? throw new Exception("cannot enumerate server service hashes"); + foreach (var key in keys) + return (long)key!; + throw new Exception("server has no registered service hash"); + } + } + + private static Task RunServerAsync(ISharpLinkServer server, CancellationToken cancellationToken) + => Task.Run(async () => + { + try + { + await server.RunAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + + private sealed class BlockingReviewCompressionProvider(ISharpLinkCompressionProvider inner) + : ISharpLinkCompressionProvider + { + private readonly ManualResetEventSlim _release = new(); + private int _startedCount; + private int _cancellationCount; + + public string WireProfile => inner.WireProfile; + internal int StartedCount => Volatile.Read(ref _startedCount); + internal void ReleaseAll() => _release.Set(); + internal Task WaitForStartedCountAsync(int count) + => WaitForCounterAsync(() => StartedCount, count, "review provider starts"); + internal Task WaitForCancellationCountAsync(int count) + => WaitForCounterAsync( + () => Volatile.Read(ref _cancellationCount), + count, + "review provider cancellations"); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _startedCount); + try + { + _release.Wait(cancellationToken); + return inner.Decompress(input, output, maxOutputBytes, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Interlocked.Increment(ref _cancellationCount); + throw; + } + } + } + + private sealed class BlockingRawInputCompressionProvider : ISharpLinkCompressionProvider + { + private readonly ManualResetEventSlim _release = new(); + private int _startedCount; + private int _cancellationCount; + + public string WireProfile => "review-input-cost"; + internal int StartedCount => Volatile.Read(ref _startedCount); + internal void ReleaseAll() => _release.Set(); + internal Task WaitForStartedCountAsync(int count) + => WaitForCounterAsync(() => StartedCount, count, "raw-input provider starts"); + internal Task WaitForCancellationCountAsync(int count) + => WaitForCounterAsync( + () => Volatile.Read(ref _cancellationCount), + count, + "raw-input provider cancellations"); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _startedCount); + try + { + _release.Wait(cancellationToken); + throw new InvalidDataException("raw input probe was released without cancellation"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Interlocked.Increment(ref _cancellationCount); + throw; + } + } + } + + private static async Task WaitForCounterAsync( + Func read, + int expected, + string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (read() < expected) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} did not reach {expected}"); + } + } +} + +[RpcContract] +public interface IPersistentDecodeReviewService : IService +{ + ValueTask MeasureAsync(byte[] value, CancellationToken cancellationToken); + + [NonCancellable] + ValueTask MeasureNonCancellableAsync(byte[] value); +} + +[RpcService] +public sealed class PersistentDecodeReviewService : IPersistentDecodeReviewService +{ + private static int s_cancellableInvocations; + private static int s_nonCancellableInvocations; + + internal static int CancellableInvocations => Volatile.Read(ref s_cancellableInvocations); + internal static int NonCancellableInvocations => Volatile.Read(ref s_nonCancellableInvocations); + + internal static void Reset() + { + Volatile.Write(ref s_cancellableInvocations, 0); + Volatile.Write(ref s_nonCancellableInvocations, 0); + } + + public ValueTask MeasureAsync(byte[] value, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref s_cancellableInvocations); + return ValueTask.FromResult(value.Length); + } + + public ValueTask MeasureNonCancellableAsync(byte[] value) + { + Interlocked.Increment(ref s_nonCancellableInvocations); + return ValueTask.FromResult(value.Length); + } +} \ No newline at end of file From 9601b58b626440cda6db9a03e680b145b8e01b6d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:59:20 +0800 Subject: [PATCH 098/228] fix(server): release decode concurrency at worker boundary --- ...harpLinkServer.PersistentDecodeDispatch.cs | 93 +++++++++++++------ 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs index 9b0368b33..d96a411ad 100644 --- a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs @@ -87,31 +87,53 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( var result = new PersistentDecodeResult(); var workItem = new ServerDecodeWorkItem(cancellationToken => { - // Provider-concurrency and decoded-byte ownership begin only after a worker has won - // Queued -> Running. Queued requests therefore do not consume these global budgets. - if (!TryPrepareCompressedRequestDecode( - requestOwner, - persistentRetainedPayload.RetainedPermit, - flags, - stablePayload, - out var decodePermit, - out var resourceRejection)) + var workerRetainedUseOwned = true; + try { + // Provider-concurrency and decoded-byte ownership begin only after a worker has + // won Queued -> Running. Queued requests therefore do not consume these budgets. + if (!TryPrepareCompressedRequestDecode( + requestOwner, + persistentRetainedPayload.RetainedPermit, + flags, + stablePayload, + out var decodePermit, + out var resourceRejection)) + { + result.DecodePermit = decodePermit; + result.ResourceRejection = resourceRejection ?? throw new InvalidOperationException( + "Persistent request decode resource rejection is missing its error."); + return ValueTask.CompletedTask; + } + result.DecodePermit = decodePermit; - result.ResourceRejection = resourceRejection ?? throw new InvalidOperationException( - "Persistent request decode resource rejection is missing its error."); + result.Payload = session.DecodeInboundPayload( + ProtocolV2FrameType.Request, + flags, + stablePayload, + cancellationToken, + out var decodedOwner); + result.Owner = decodedOwner; return ValueTask.CompletedTask; } - - result.DecodePermit = decodePermit; - result.Payload = session.DecodeInboundPayload( - ProtocolV2FrameType.Request, - flags, - stablePayload, - cancellationToken, - out var decodedOwner); - result.Owner = decodedOwner; - return ValueTask.CompletedTask; + finally + { + try + { + // Provider execution is done before this worker can service another item. + // Return the physical compressed owner first; only then release the active + // decode credit/retained accounting. Decoded-byte ownership remains attached + // to the completed permit until request activation/teardown transfers it. + ReleaseRetainedPayloadUse( + persistentRetainedPayload, + ref workerRetainedUseOwned); + } + finally + { + result.DecodePermit?.CompleteDecode(); + Volatile.Write(ref result.RetainedUseReleased, 1); + } + } }); var decodeTask = DecodeExecutor.EnqueueReservedAsync( @@ -168,10 +190,10 @@ private async ValueTask AwaitPersistentDecodeAndContinueAsync( try { await decodeTask.ConfigureAwait(false); + ReconcileRetainedPayloadUse(result, retainedPayload, ref retainedUseOwned); if (result.ResourceRejection is { } resourceRejection) { - ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); session.ReturnDecodedPayload(result.Owner); result.Owner = null; CompleteFailedRequestStreams(session, requestId, resourceRejection); @@ -189,14 +211,11 @@ await ReleaseDispatchResourcesAfterResponseAsync( return; } - ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); - (result.DecodePermit ?? throw new InvalidOperationException( - "Persistent decode completed without a provider decode permit.")).CompleteDecode(); request = ReadRequestEnvelope(session, result.Payload, flags); } catch (ServerDecodeExecutorClosedException) { - ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + ReconcileRetainedPayloadUse(result, retainedPayload, ref retainedUseOwned); session.ReturnDecodedPayload(result.Owner); result.Owner = null; var exception = new SharpLinkException( @@ -219,7 +238,7 @@ await ReleaseDispatchResourcesAfterResponseAsync( catch (SharpLinkException exception) when ( exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) { - ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + ReconcileRetainedPayloadUse(result, retainedPayload, ref retainedUseOwned); session.ReturnDecodedPayload(result.Owner); result.Owner = null; CompleteFailedRequestStreams(session, requestId, exception); @@ -238,7 +257,7 @@ await ReleaseDispatchResourcesAfterResponseAsync( } catch (OperationCanceledException exception) { - ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + ReconcileRetainedPayloadUse(result, retainedPayload, ref retainedUseOwned); session.ReturnDecodedPayload(result.Owner); result.Owner = null; CompleteFailedRequestStreams(session, requestId, exception); @@ -257,7 +276,7 @@ await ReleaseDispatchResourcesAfterResponseAsync( } catch (Exception exception) { - ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + ReconcileRetainedPayloadUse(result, retainedPayload, ref retainedUseOwned); session.ReturnDecodedPayload(result.Owner); result.Owner = null; CompleteFailedRequestStreams(session, requestId, exception); @@ -285,6 +304,20 @@ await ContinueRpcDispatch( decodedOwner).ConfigureAwait(false); } + private static void ReconcileRetainedPayloadUse( + PersistentDecodeResult result, + ServerRetainedAdmissionPayload retainedPayload, + ref bool retainedUseOwned) + { + if (Volatile.Read(ref result.RetainedUseReleased) != 0) + { + retainedUseOwned = false; + return; + } + + ReleaseRetainedPayloadUse(retainedPayload, ref retainedUseOwned); + } + private static void ReleaseRetainedPayloadUse( ServerRetainedAdmissionPayload retainedPayload, ref bool retainedUseOwned) @@ -312,5 +345,7 @@ private sealed class PersistentDecodeResult internal ServerDecodePermit? DecodePermit { get; set; } internal SharpLinkException? ResourceRejection { get; set; } + + internal int RetainedUseReleased; } } From f70d9855877ae7f01980c4e9cf4b366722a67238 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:01:33 +0800 Subject: [PATCH 099/228] style: add final newline to persistent decode control-plane tests --- .../CompressionPersistentDecodeControlPlaneTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs index 467ff1287..8f0ad551f 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeControlPlaneTests.cs @@ -492,4 +492,4 @@ public ValueTask MeasureAsync(byte[] value, CancellationToken cancellationT Interlocked.Increment(ref s_invocations); return ValueTask.FromResult(value.Length); } -} \ No newline at end of file +} From fd5e320ae62f64cc7d962af42ad38b4da7d5735f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:02:17 +0800 Subject: [PATCH 100/228] style: add final newline to persistent decode review tests --- .../CompressionPersistentDecodeReviewTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs index 8fac6476d..13fc3313b 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs @@ -605,4 +605,4 @@ public ValueTask MeasureNonCancellableAsync(byte[] value) Interlocked.Increment(ref s_nonCancellableInvocations); return ValueTask.FromResult(value.Length); } -} \ No newline at end of file +} From 610fddc3d163ef060d9f3dcce9b269e2d3cc6e15 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:27:10 +0800 Subject: [PATCH 101/228] fix(protocol): register persistent decode queue exhaustion --- src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs b/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs index 871581ee5..d83968470 100644 --- a/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs +++ b/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs @@ -15,6 +15,7 @@ internal static class SharpLinkResourceExhaustion private const char ServerDecodeConcurrencyWireCode = '\u000A'; private const char ServerRetainedCompressedBytesWireCode = '\u000B'; private const char ServerDecodedBytesWireCode = '\u000C'; + private const char ServerDecodeQueueWireCode = '\u000D'; private static readonly string[] s_knownReasons = [ ServerCallCapacity, @@ -28,7 +29,8 @@ internal static class SharpLinkResourceExhaustion SendQueueCapacity, ServerDecodeConcurrency, ServerRetainedCompressedBytes, - ServerDecodedBytes + ServerDecodedBytes, + ServerDecodeQueue ]; internal const string Unspecified = "unspecified"; @@ -44,6 +46,7 @@ internal static class SharpLinkResourceExhaustion internal const string ServerDecodeConcurrency = "server_decode_concurrency"; internal const string ServerRetainedCompressedBytes = "server_retained_compressed_bytes"; internal const string ServerDecodedBytes = "server_decoded_bytes"; + internal const string ServerDecodeQueue = "server_decode_queue"; internal static SharpLinkException Create(string reason, string message) { @@ -92,6 +95,7 @@ private static char GetWireCode(string reason) ServerDecodeConcurrency => ServerDecodeConcurrencyWireCode, ServerRetainedCompressedBytes => ServerRetainedCompressedBytesWireCode, ServerDecodedBytes => ServerDecodedBytesWireCode, + ServerDecodeQueue => ServerDecodeQueueWireCode, _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, "A known resource exhaustion reason is required.") }; @@ -111,6 +115,7 @@ private static bool TryGetWireReason(char code, out string reason) ServerDecodeConcurrencyWireCode => ServerDecodeConcurrency, ServerRetainedCompressedBytesWireCode => ServerRetainedCompressedBytes, ServerDecodedBytesWireCode => ServerDecodedBytes, + ServerDecodeQueueWireCode => ServerDecodeQueue, _ => Unspecified }; return reason != Unspecified; From 503eb1771369c8c7ae8c6a756fe94f018e316ee4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:27:19 +0800 Subject: [PATCH 102/228] fix(server): use registered decode queue exhaustion reason --- src/SharpLink.Server/SharpLinkServer.DecodeResources.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs b/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs index a84971a87..059237b7c 100644 --- a/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs +++ b/src/SharpLink.Server/SharpLinkServer.DecodeResources.cs @@ -51,7 +51,7 @@ private static SharpLinkException CreateDecodeResourceExhaustion( private static SharpLinkException CreateDecodeQueueResourceExhaustion() { - const string reason = "server_decode_queue"; + const string reason = SharpLinkResourceExhaustion.ServerDecodeQueue; SharpLinkTelemetry.RecordResourceExhausted("server", reason); return SharpLinkResourceExhaustion.CreateWire( reason, From e44fb1aac76d52084333c89dd70fbbcb18c87fec Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:27:44 +0800 Subject: [PATCH 103/228] fix(server): linearize pre-activation cancellation --- .../ServerCallCancellationState.cs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/SharpLink.Server/ServerCallCancellationState.cs b/src/SharpLink.Server/ServerCallCancellationState.cs index 2fb20ea87..4ab1bd1c1 100644 --- a/src/SharpLink.Server/ServerCallCancellationState.cs +++ b/src/SharpLink.Server/ServerCallCancellationState.cs @@ -54,6 +54,7 @@ internal sealed class ServerCallCancellationState : IDisposable private static int s_retainedCount; private readonly Lock _lifetimeGate = new(); + private readonly Lock _terminalGate = new(); private CancellationTokenSource? _invocationCancellation; private CancellationTokenRegistration _serverStoppingRegistration; private CancellationTokenRegistration _connectionClosedRegistration; @@ -212,6 +213,18 @@ internal bool TryAcquire(long expectedRequestId, long expectedGeneration) } } + internal bool TryActivateRequest(SharpLinkServer.ServerRequestPermit requestPermit) + { + ArgumentNullException.ThrowIfNull(requestPermit); + lock (_terminalGate) + { + if (Reason != ServerCallCancellationReason.None) + return false; + requestPermit.Activate(); + return true; + } + } + public void ReleaseUse() { var shouldDispose = false; @@ -230,10 +243,13 @@ public bool TryCancel(ServerCallCancellationReason reason) if (reason is ServerCallCancellationReason.None or ServerCallCancellationReason.Completed) throw new ArgumentOutOfRangeException(nameof(reason)); - if (Interlocked.CompareExchange(ref _reason, (int)reason, (int)ServerCallCancellationReason.None) != - (int)ServerCallCancellationReason.None) + lock (_terminalGate) { - return false; + if (Interlocked.CompareExchange(ref _reason, (int)reason, (int)ServerCallCancellationReason.None) != + (int)ServerCallCancellationReason.None) + { + return false; + } } try From a89c5a6139ea1e441e17dd180efc914f451ebd87 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:28:15 +0800 Subject: [PATCH 104/228] fix(server): reject cancellation before call activation --- .../SharpLinkServer.InvocationContinuation.cs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationContinuation.cs b/src/SharpLink.Server/SharpLinkServer.InvocationContinuation.cs index fea89a32f..7b432e4ab 100644 --- a/src/SharpLink.Server/SharpLinkServer.InvocationContinuation.cs +++ b/src/SharpLink.Server/SharpLinkServer.InvocationContinuation.cs @@ -59,7 +59,42 @@ private ValueTask ContinueRpcDispatch( return ValueTask.FromException(exception); } - requestOwner.Activate(); + if (callState is not null) + { + // Cancellation and Reserved -> Active share one terminal gate. If cancellation wins + // before activation, no generated stub or user code may run even when provider decode + // completed successfully. Once activation wins, later cancellation keeps the existing + // cooperative/non-cooperative handler semantics below. + if (!callState.TryActivateRequest(requestOwner)) + { + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + var exception = MapServerCancellationException(callState, request.RpcDeadline); + CompleteFailedRequestStreams(session, requestId, exception); + _ = TryClaimCallCompletion(callState); + var responseSend = callState.Reason == ServerCallCancellationReason.ModuleDraining + ? TrySendModuleDrainError( + callState, + session, + requestId, + connection.ConnectionToken) + : session.SendRpcErrorWithBackpressureAsync( + requestId, + exception, + connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + } + else + { + requestOwner.Activate(); + } var supportsCooperativeCancellation = (isCancellable || serviceInfo.Module is not null) && From 15c5cae8acd14dbfc393fdf31dbb27dc0e384c7e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:28:46 +0800 Subject: [PATCH 105/228] test(server): align graceful drain with running decode credits --- .../CompressionPersistentDecodeDrainAndFailureTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs index 6c207f3d8..595055c3c 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeDrainAndFailureTests.cs @@ -38,8 +38,9 @@ public async Task GracefulStopShouldClosePublicationAndDrainAlreadyQueuedDecodeW .AsTask(); await WaitUntilAsync( () => harness.DecodeQueueDepth >= 1 && - harness.ActiveDecodes == workerCount + 1, - "decode queued before graceful drain"); + harness.DecodeQueueReservations >= 1 && + harness.ActiveDecodes == workerCount, + "decode queued before graceful drain without queued decode credit"); stopTask = harness.BeginStopServer(TimeSpan.FromSeconds(5)); await WaitUntilAsync( @@ -131,7 +132,8 @@ await WaitUntilAsync( harness.ActiveDecodes == 0 && harness.RetainedCompressedBytes == 0 && harness.DecodedBytesInFlight == 0 && - harness.DecodeQueueDepth == 0, + harness.DecodeQueueDepth == 0 && + harness.DecodeQueueReservations == 0, $"{scenario} resource release"); } @@ -284,6 +286,8 @@ private PersistentDecodeHarness( ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); internal int DecodeWorkerCount => ReadDiagnosticProperty("DecodeWorkerCountForDiagnostics"); internal int DecodeQueueDepth => ReadDiagnosticProperty("DecodeQueueDepthForDiagnostics"); + internal int DecodeQueueReservations => + ReadDiagnosticProperty("DecodeQueueReservationsForDiagnostics"); internal int DecodeStartedWorkCount => ReadDiagnosticProperty("DecodeStartedWorkCountForDiagnostics"); internal bool DecodeAccepting => ReadDiagnosticProperty("DecodeAcceptingForDiagnostics"); From c257f32cef3ad93ff638eeb64be169cdc117a918 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:29:16 +0800 Subject: [PATCH 106/228] test(server): cover persistent decode cancel before activation --- ...nPersistentDecodePreActivationRaceTests.cs | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs new file mode 100644 index 000000000..d18f92d3c --- /dev/null +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodePreActivationRaceTests.cs @@ -0,0 +1,280 @@ +namespace SharpLink.IntegrationTests; + +public class CompressionPersistentDecodePreActivationRaceTests +{ + private const int LargePayloadBytes = 2 * 1024 * 1024; + + [Test] + [NotInParallel] + public async Task RemoteCancelBeforeActivationShouldWinEvenWhenProviderReturnsSuccessfully() + { + PersistentDecodeReviewService.Reset(); + var provider = new SuccessfulAfterCancelCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await RaceHarness.CreateAsync(provider); + await WaitUntilAsync(() => harness.DecodeWorkerCount == 1, "persistent decode worker started"); + + using var cancellation = new CancellationTokenSource(); + var payload = Enumerable.Repeat((byte)0x5a, LargePayloadBytes).ToArray(); + var call = harness.Client.Get() + .MeasureAsync(payload, cancellation.Token) + .AsTask(); + + try + { + await provider.WaitForStartedCountAsync(1); + await cancellation.CancelAsync(); + await provider.WaitForCancellationObservedCountAsync(1); + + Ensure(harness.DecodeStartedWorkCount == 1, + "the cancellation race probe must execute through persistent D"); + Ensure(PersistentDecodeReviewService.CancellableInvocations == 0, + "the handler must not start while provider decode remains blocked"); + + provider.ReleaseAll(); + await EnsureCancelledAsync(call, "remote cancel before activation"); + await WaitUntilAsync( + () => harness.ActiveCalls == 0 && + harness.ActiveDecodes == 0 && + harness.RetainedCompressedBytes == 0 && + harness.DecodedBytesInFlight == 0 && + harness.DecodeQueueDepth == 0 && + harness.DecodeQueueReservations == 0, + "pre-activation cancellation resource release"); + + Ensure(provider.CompletedCount == 1, + "provider must return successfully after server cancellation was already observed"); + Ensure(PersistentDecodeReviewService.CancellableInvocations == 0, + "cancellation that wins before activation must prevent user dispatch"); + } + finally + { + provider.ReleaseAll(); + } + } + + private static async Task EnsureCancelledAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + throw new Exception($"assert failed: {scenario} should cancel"); + } + catch (OperationCanceledException) + { + } + catch (SharpLinkException exception) when (exception.Code == SharpLinkErrorCode.Cancelled) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} was not observed"); + } + } + + private static async Task WaitForCounterAsync( + Func read, + int expected, + string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (read() < expected) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} did not reach {expected}"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class SuccessfulAfterCancelCompressionProvider(ISharpLinkCompressionProvider inner) + : ISharpLinkCompressionProvider + { + private readonly ManualResetEventSlim _release = new(); + private int _startedCount; + private int _cancellationObservedCount; + private int _completedCount; + + public string WireProfile => inner.WireProfile; + internal int CompletedCount => Volatile.Read(ref _completedCount); + + internal void ReleaseAll() => _release.Set(); + + internal Task WaitForStartedCountAsync(int count) + => WaitForCounterAsync( + () => Volatile.Read(ref _startedCount), + count, + "race provider starts"); + + internal Task WaitForCancellationObservedCountAsync(int count) + => WaitForCounterAsync( + () => Volatile.Read(ref _cancellationObservedCount), + count, + "race provider server cancellation observations"); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _startedCount); + using var registration = cancellationToken.UnsafeRegister( + static state => Interlocked.Increment( + ref ((SuccessfulAfterCancelCompressionProvider)state!)._cancellationObservedCount), + this); + + // Deliberately ignore cancellation while blocked, then decode with CancellationToken.None. + // This proves the framework's pre-activation terminal check rather than relying on a + // cooperative provider to throw OperationCanceledException. + _release.Wait(); + var result = inner.Decompress(input, output, maxOutputBytes, CancellationToken.None); + Interlocked.Increment(ref _completedCount); + return result; + } + } + + private sealed class RaceHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + private bool _stopped; + + private RaceHarness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient client) + { + _serverCts = serverCts; + _serverTask = serverTask; + _server = server; + Client = client; + } + + internal ISharpLinkClient Client { get; } + internal int ActiveCalls => ReadField("_globalActiveCalls"); + internal int ActiveDecodes => ReadDiagnosticProperty("ActiveDecodeCountForDiagnostics"); + internal long RetainedCompressedBytes => + ReadDiagnosticProperty("RetainedCompressedBytesForDiagnostics"); + internal long DecodedBytesInFlight => + ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); + internal int DecodeWorkerCount => ReadDiagnosticProperty("DecodeWorkerCountForDiagnostics"); + internal int DecodeQueueDepth => ReadDiagnosticProperty("DecodeQueueDepthForDiagnostics"); + internal int DecodeQueueReservations => + ReadDiagnosticProperty("DecodeQueueReservationsForDiagnostics"); + internal int DecodeStartedWorkCount => + ReadDiagnosticProperty("DecodeStartedWorkCountForDiagnostics"); + + internal static async Task CreateAsync(ISharpLinkCompressionProvider provider) + { + var serverCts = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 8; + options.FlowControl.MaxConcurrentCallsPerServer = 8; + options.FlowControl.MaxConcurrentDecodesPerServer = 1; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 16L * 1024 * 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = 16L * 1024 * 1024; + options.Compression.Providers.Add(provider); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = Task.Run(async () => + { + try + { + await server.RunAsync(serverCts.Token); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + + var client = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .UseRuntime(options => options.Compression.Providers.Add( + SharpLinkCompressionProviders.CreateBrotli())) + .Build(); + await client.ConnectAsync(); + return new RaceHarness(serverCts, serverTask, server, client); + } + + public async ValueTask DisposeAsync() + { + if (_stopped) + return; + _stopped = true; + try + { + await Client.StopAsync(); + } + catch (Exception) + { + } + await _serverCts.CancelAsync(); + await _server.StopAsync(TimeSpan.Zero); + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + + private T ReadField(string name) + { + var field = _server.GetType().GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server field {name}"); + return (T)field.GetValue(_server)!; + } + + private T ReadDiagnosticProperty(string name) + { + var property = _server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic property {name}"); + return (T)property.GetValue(_server)!; + } + } +} From f082ae98e452b921c210af39984a45069bb07d30 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:34:24 +0800 Subject: [PATCH 107/228] test(server): isolate decode queue ordering from byte budget drain --- .../CompressionPersistentDecodeReviewTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs index 13fc3313b..51a9b6151 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeReviewTests.cs @@ -16,7 +16,7 @@ public async Task FullPersistentQueueShouldNotPreAcquireDecodeOrDecodedByteBudge serverProvider, maxConcurrentCalls: 64, maxConcurrentDecodes: 1, - maxDecodedBytes: 4L * 1024 * 1024); + maxDecodedBytes: 128L * 1024 * 1024); await WaitUntilAsync(() => harness.DecodeWorkerCount == 1, "single persistent decode worker started"); var service = harness.Client.Get(); using var cancellation = new CancellationTokenSource(); @@ -191,6 +191,8 @@ private static async Task EnsureResourceExhaustedAsync(Task task, string scenari } catch (SharpLinkException exception) when (exception.Code == SharpLinkErrorCode.ResourceExhausted) { + Ensure(exception.Message.Contains("server_decode_queue", StringComparison.Ordinal), + $"{scenario} must preserve the persistent decode queue exhaustion reason"); } } From 4b6f51c86a550abf33ec435e6ff0cce04af18e50 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:02:32 +0800 Subject: [PATCH 108/228] feat(server): add fair persistent decode scheduler --- src/SharpLink.Server/ServerDecodeExecutor.cs | 362 ++++++++++++++++--- 1 file changed, 307 insertions(+), 55 deletions(-) diff --git a/src/SharpLink.Server/ServerDecodeExecutor.cs b/src/SharpLink.Server/ServerDecodeExecutor.cs index 9e2848e27..f1371f6c7 100644 --- a/src/SharpLink.Server/ServerDecodeExecutor.cs +++ b/src/SharpLink.Server/ServerDecodeExecutor.cs @@ -1,19 +1,29 @@ -using System.Threading.Channels; +using System.Runtime.CompilerServices; namespace SharpLink.Server; /// /// Persistent bounded worker pool for request decompression. Production callers reserve one queue -/// slot before retaining request bytes. Decode concurrency and decoded-byte budgets are acquired only -/// after a worker wins the queued-to-running transition. +/// slot before retaining request bytes. Published work is scheduled round-robin by connection key; +/// decode concurrency and decoded-byte budgets are acquired only after a worker wins the +/// queued-to-running transition. /// internal sealed class ServerDecodeExecutor : IAsyncDisposable { - private readonly Channel _channel; + private static readonly object s_compatibilitySchedulingKey = new(); + + private readonly Lock _schedulerGate = new(); + private readonly Dictionary _connectionQueues = + new(ReferenceKeyComparer.Instance); + private readonly LinkedList _readyConnections = []; + private readonly SemaphoreSlim _readySignal = new(0); + private readonly SemaphoreSlim _compatibilitySlots; + private readonly CancellationTokenSource _compatibilityStop = new(); private readonly Task[] _workers; private readonly Task _completion; private readonly int _queueCapacity; private int _completionRequested; + private int _disposeRequested; private int _queueReservations; private int _queueDepth; private int _skippedBeforeStart; @@ -25,13 +35,7 @@ internal ServerDecodeExecutor(int workerCount, int queueCapacity) ArgumentOutOfRangeException.ThrowIfNegativeOrZero(queueCapacity); _queueCapacity = queueCapacity; - _channel = Channel.CreateBounded(new BoundedChannelOptions(queueCapacity) - { - AllowSynchronousContinuations = false, - FullMode = BoundedChannelFullMode.Wait, - SingleReader = workerCount == 1, - SingleWriter = false - }); + _compatibilitySlots = new SemaphoreSlim(queueCapacity, queueCapacity); _workers = new Task[workerCount]; for (var index = 0; index < _workers.Length; index++) _workers[index] = Task.Run(WorkerLoopAsync); @@ -42,8 +46,7 @@ internal ServerDecodeExecutor(int workerCount, int queueCapacity) /// /// Number of published operations waiting for worker service, plus compatibility-path writers - /// blocked by the bounded channel. Production reserved publication does not block on channel - /// capacity because a queue slot is acquired first. + /// blocked by queue capacity. Production reserved publication never blocks on scheduler capacity. /// internal int QueueDepth => Volatile.Read(ref _queueDepth); @@ -61,9 +64,18 @@ internal ServerDecodeExecutor(int workerCount, int queueCapacity) internal Task Completion => _completion; + internal int ScheduledConnectionCount + { + get + { + lock (_schedulerGate) + return _connectionQueues.Count; + } + } + /// /// Reserves scheduler capacity before a production request acquires retained/decode/decoded-byte - /// ownership. Queue reservations are bounded independently from provider decode concurrency. + /// ownership. Queue reservations remain globally bounded independently from fair scheduling. /// internal bool TryReserveQueueSlot(out ServerDecodeQueuePermit? permit) { @@ -92,43 +104,71 @@ internal bool TryReserveQueueSlot(out ServerDecodeQueuePermit? permit) /// /// Production publication path. A previously reserved slot guarantees that this caller never - /// waits behind the bounded channel while owning downstream decode resources. + /// waits while owning downstream decode resources. The scheduling key is normally the physical + /// server connection and is compared by reference identity. /// internal ValueTask EnqueueReservedAsync( + object schedulingKey, ServerDecodeQueuePermit queuePermit, ServerDecodeWorkItem workItem, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(schedulingKey); ArgumentNullException.ThrowIfNull(queuePermit); ArgumentNullException.ThrowIfNull(workItem); queuePermit.MarkEnqueued(this); - workItem.EnableQueuedCancellation(cancellationToken); - Interlocked.Increment(ref _queueDepth); - if (_channel.Writer.TryWrite(new ServerDecodeQueueEntry(workItem, queuePermit))) - return new ValueTask(workItem.Completion); + var entry = new ServerDecodeQueueEntry( + schedulingKey, + workItem, + queuePermit, + releaseCompatibilitySlot: false); + workItem.EnableQueuedCancellation( + cancellationToken, + () => RemoveCancelledBeforeStart(entry)); + + var published = false; + var signalWorker = false; + lock (_schedulerGate) + { + if (Volatile.Read(ref _completionRequested) == 0 && !workItem.IsCancelledBeforeStart) + { + PublishEntryLocked(entry, out signalWorker); + published = true; + } + } - workItem.AbandonBeforePublication(); - DecrementQueueDepth(); - queuePermit.Dispose(); + if (!published) + { + workItem.AbandonBeforePublication(); + queuePermit.Dispose(); - if (cancellationToken.IsCancellationRequested) - return ValueTask.FromCanceled(cancellationToken); - if (Volatile.Read(ref _completionRequested) != 0) + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled(cancellationToken); return ValueTask.FromException(new ServerDecodeExecutorClosedException()); + } - return ValueTask.FromException(new InvalidOperationException( - "A reserved server decode queue slot could not be published to the bounded channel.")); + if (signalWorker) + _readySignal.Release(); + return new ValueTask(workItem.Completion); } /// /// Compatibility/test publication path retained for executor-local race tests. Production D - /// dispatch uses plus . + /// dispatch uses plus the connection-keyed reserved overload. /// internal ValueTask EnqueueAsync( ServerDecodeWorkItem workItem, CancellationToken cancellationToken) + => EnqueueAsync(s_compatibilitySchedulingKey, workItem, cancellationToken); + + /// Executor-local keyed path used by deterministic fairness tests. + internal ValueTask EnqueueAsync( + object schedulingKey, + ServerDecodeWorkItem workItem, + CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(schedulingKey); ArgumentNullException.ThrowIfNull(workItem); if (Volatile.Read(ref _completionRequested) != 0) { @@ -137,13 +177,16 @@ internal ValueTask EnqueueAsync( : ValueTask.FromException(new ServerDecodeExecutorClosedException()); } - return EnqueueCoreAsync(workItem, cancellationToken); + return EnqueueCoreAsync(schedulingKey, workItem, cancellationToken); } internal void StopAccepting() { - if (Interlocked.Exchange(ref _completionRequested, 1) == 0) - _channel.Writer.TryComplete(); + if (Interlocked.Exchange(ref _completionRequested, 1) != 0) + return; + + _compatibilityStop.Cancel(); + _readySignal.Release(_workers.Length); } internal async ValueTask CompleteAsync() @@ -152,7 +195,16 @@ internal async ValueTask CompleteAsync() await _completion.ConfigureAwait(false); } - public ValueTask DisposeAsync() => CompleteAsync(); + public async ValueTask DisposeAsync() + { + await CompleteAsync().ConfigureAwait(false); + if (Interlocked.Exchange(ref _disposeRequested, 1) != 0) + return; + + _compatibilityStop.Dispose(); + _compatibilitySlots.Dispose(); + _readySignal.Dispose(); + } internal void ReleaseQueueReservation() { @@ -165,45 +217,84 @@ internal void ReleaseQueueReservation() } private async ValueTask EnqueueCoreAsync( + object schedulingKey, ServerDecodeWorkItem workItem, CancellationToken cancellationToken) { - workItem.EnableQueuedCancellation(cancellationToken); Interlocked.Increment(ref _queueDepth); + var slotAcquired = false; var published = false; + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _compatibilityStop.Token); try { - await _channel.Writer.WriteAsync( - new ServerDecodeQueueEntry(workItem, null), - cancellationToken).ConfigureAwait(false); - published = true; + await _compatibilitySlots.WaitAsync(linkedCancellation.Token).ConfigureAwait(false); + slotAcquired = true; + + var entry = new ServerDecodeQueueEntry( + schedulingKey, + workItem, + queuePermit: null, + releaseCompatibilitySlot: true); + workItem.EnableQueuedCancellation( + cancellationToken, + () => RemoveCancelledBeforeStart(entry)); + + var signalWorker = false; + lock (_schedulerGate) + { + if (Volatile.Read(ref _completionRequested) == 0 && !workItem.IsCancelledBeforeStart) + { + PublishEntryLocked(entry, out signalWorker, queueDepthAlreadyOwned: true); + published = true; + } + } + + if (!published) + { + workItem.AbandonBeforePublication(); + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + throw new ServerDecodeExecutorClosedException(); + } + + if (signalWorker) + _readySignal.Release(); await workItem.Completion.ConfigureAwait(false); } - catch (Exception exception) + catch (OperationCanceledException) when (!published) + { + workItem.AbandonBeforePublication(); + if (_compatibilityStop.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + throw new ServerDecodeExecutorClosedException(); + throw; + } + finally { if (!published) { - workItem.AbandonBeforePublication(); + if (slotAcquired) + _compatibilitySlots.Release(); DecrementQueueDepth(); - - if (exception is ChannelClosedException) - { - if (cancellationToken.IsCancellationRequested) - throw new OperationCanceledException(cancellationToken); - throw new ServerDecodeExecutorClosedException(exception); - } } - throw; } } private async Task WorkerLoopAsync() { - await foreach (var entry in _channel.Reader.ReadAllAsync().ConfigureAwait(false)) + while (true) { - DecrementQueueDepth(); - entry.QueuePermit?.Dispose(); + await _readySignal.WaitAsync().ConfigureAwait(false); + + if (!TryTakeNextEntry(out var entry)) + { + if (Volatile.Read(ref _completionRequested) != 0 && QueueDepth == 0) + return; + continue; + } + ReleaseQueuedOwnership(entry); var workItem = entry.WorkItem; if (!workItem.TryStart()) { @@ -219,6 +310,120 @@ private async Task WorkerLoopAsync() } } + private void PublishEntryLocked( + ServerDecodeQueueEntry entry, + out bool signalWorker, + bool queueDepthAlreadyOwned = false) + { + if (!_connectionQueues.TryGetValue(entry.SchedulingKey, out var queue)) + { + queue = new ConnectionQueue(entry.SchedulingKey); + _connectionQueues.Add(entry.SchedulingKey, queue); + } + + entry.Owner = queue; + entry.PendingNode = queue.Pending.AddLast(entry); + if (!queueDepthAlreadyOwned) + Interlocked.Increment(ref _queueDepth); + + signalWorker = false; + if (queue.ReadyNode is null) + { + queue.ReadyNode = _readyConnections.AddLast(queue); + signalWorker = true; + } + } + + private bool TryTakeNextEntry(out ServerDecodeQueueEntry entry) + { + var signalAnotherWorker = false; + lock (_schedulerGate) + { + while (_readyConnections.First is { } readyNode) + { + var queue = readyNode.Value; + _readyConnections.Remove(readyNode); + queue.ReadyNode = null; + + if (queue.Pending.First is not { } pendingNode) + { + _connectionQueues.Remove(queue.SchedulingKey); + continue; + } + + entry = pendingNode.Value; + queue.Pending.Remove(pendingNode); + entry.PendingNode = null; + entry.Owner = null; + DecrementQueueDepth(); + + if (queue.Pending.Count == 0) + { + _connectionQueues.Remove(queue.SchedulingKey); + } + else + { + queue.ReadyNode = _readyConnections.AddLast(queue); + signalAnotherWorker = true; + } + + goto Found; + } + + entry = null!; + return false; + } + + Found: + if (signalAnotherWorker) + _readySignal.Release(); + return true; + } + + private void RemoveCancelledBeforeStart(ServerDecodeQueueEntry entry) + { + var removed = false; + lock (_schedulerGate) + { + var queue = entry.Owner; + var pendingNode = entry.PendingNode; + if (queue is null || pendingNode is null || pendingNode.List is null) + return; + + queue.Pending.Remove(pendingNode); + entry.PendingNode = null; + entry.Owner = null; + DecrementQueueDepth(); + + if (queue.Pending.Count == 0) + { + if (queue.ReadyNode is { } readyNode && readyNode.List is not null) + { + _readyConnections.Remove(readyNode); + queue.ReadyNode = null; + } + _connectionQueues.Remove(queue.SchedulingKey); + } + + removed = true; + } + + if (!removed) + return; + + ReleaseQueuedOwnership(entry); + Interlocked.Increment(ref _skippedBeforeStart); + entry.WorkItem.CompleteRemovedBeforeStart(); + } + + private void ReleaseQueuedOwnership(ServerDecodeQueueEntry entry) + { + if (entry.QueuePermit is not null) + entry.QueuePermit.Dispose(); + if (entry.ReleaseCompatibilitySlot) + _compatibilitySlots.Release(); + } + private void DecrementQueueDepth() { var remaining = Interlocked.Decrement(ref _queueDepth); @@ -229,14 +434,48 @@ private void DecrementQueueDepth() throw new InvalidOperationException("Server decode queue depth accounting underflowed."); } - private readonly record struct ServerDecodeQueueEntry( - ServerDecodeWorkItem WorkItem, - ServerDecodeQueuePermit? QueuePermit); + private sealed class ConnectionQueue(object schedulingKey) + { + internal object SchedulingKey { get; } = schedulingKey; + + internal LinkedList Pending { get; } = []; + + internal LinkedListNode? ReadyNode { get; set; } + } + + private sealed class ServerDecodeQueueEntry( + object schedulingKey, + ServerDecodeWorkItem workItem, + ServerDecodeQueuePermit? queuePermit, + bool releaseCompatibilitySlot) + { + internal object SchedulingKey { get; } = schedulingKey; + + internal ServerDecodeWorkItem WorkItem { get; } = workItem; + + internal ServerDecodeQueuePermit? QueuePermit { get; } = queuePermit; + + internal bool ReleaseCompatibilitySlot { get; } = releaseCompatibilitySlot; + + internal ConnectionQueue? Owner { get; set; } + + internal LinkedListNode? PendingNode { get; set; } + } + + private sealed class ReferenceKeyComparer : IEqualityComparer + { + internal static ReferenceKeyComparer Instance { get; } = new(); + + public new bool Equals(object? x, object? y) => ReferenceEquals(x, y); + + public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj); + } } /// /// One bounded persistent-executor queue slot. It is acquired before long-lived request retention and -/// released when a worker dequeues the corresponding work or publication fails. +/// released when a worker dequeues the corresponding work, queued cancellation removes it, or +/// publication fails. /// internal sealed class ServerDecodeQueuePermit : IDisposable { @@ -302,6 +541,7 @@ internal sealed class ServerDecodeWorkItem new(TaskCreationOptions.RunContinuationsAsynchronously); private CancellationTokenRegistration _queuedCancellationRegistration; private CancellationToken _cancellationToken; + private Action? _cancelledBeforeStart; private int _state = Queued; private int _cancellationRegistrationEnabled; @@ -313,12 +553,15 @@ internal ServerDecodeWorkItem(Func executeAsync) internal bool IsCancelledBeforeStart => Volatile.Read(ref _state) == CancelledBeforeStart; - internal void EnableQueuedCancellation(CancellationToken cancellationToken) + internal void EnableQueuedCancellation( + CancellationToken cancellationToken, + Action? cancelledBeforeStart = null) { if (Interlocked.Exchange(ref _cancellationRegistrationEnabled, 1) != 0) throw new InvalidOperationException("Queued cancellation can only be enabled once."); _cancellationToken = cancellationToken; + _cancelledBeforeStart = cancelledBeforeStart; if (cancellationToken.CanBeCanceled) { _queuedCancellationRegistration = cancellationToken.UnsafeRegister( @@ -369,6 +612,14 @@ internal void CompleteSkippedBeforeStart() Volatile.Write(ref _state, Completed); } + internal void CompleteRemovedBeforeStart() + { + if (Volatile.Read(ref _state) != CancelledBeforeStart) + throw new InvalidOperationException("Only cancelled queued decode work can be removed."); + _queuedCancellationRegistration.Unregister(); + Volatile.Write(ref _state, Completed); + } + internal void AbandonBeforePublication() { _queuedCancellationRegistration.Dispose(); @@ -380,5 +631,6 @@ private void CancelBeforeStart() if (Interlocked.CompareExchange(ref _state, CancelledBeforeStart, Queued) != Queued) return; _completion.TrySetCanceled(_cancellationToken); + _cancelledBeforeStart?.Invoke(); } } From a4d12ad4bb64c45f94b6c65b8be8114f5216e59b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:03:23 +0800 Subject: [PATCH 109/228] feat(server): schedule persistent decode by connection --- src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs index d96a411ad..961e78fab 100644 --- a/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.PersistentDecodeDispatch.cs @@ -137,6 +137,7 @@ private ValueTask DispatchRpcWithPersistentDecodeAsync( }); var decodeTask = DecodeExecutor.EnqueueReservedAsync( + connection, reservedQueuePermit, workItem, callState.InvocationToken); From 1f59df7aec2e23440771175b0f8b823b47e37c87 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:04:08 +0800 Subject: [PATCH 110/228] test(server): cover per-connection decode fairness --- .../Server/ServerDecodeExecutorTests.cs | 192 +++++++++++++++++- 1 file changed, 186 insertions(+), 6 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs index 661a47ad9..af936dae0 100644 --- a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorTests.cs @@ -1,4 +1,5 @@ using SharpLink.Server; +using System.Collections.Concurrent; using System.Threading; namespace SharpLink.UnitTests.Server; @@ -6,7 +7,7 @@ namespace SharpLink.UnitTests.Server; public class ServerDecodeExecutorTests { [Test] - public async Task QueuedCancellationShouldCompleteCallerBeforeWorkerAndSkipProvider() + public async Task QueuedCancellationShouldCompleteCallerBeforeWorkerAndRemovePendingOwnership() { await using var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity: 1); var firstStarted = NewSignal(); @@ -35,16 +36,19 @@ public async Task QueuedCancellationShouldCompleteCallerBeforeWorkerAndSkipProvi cancellation.Cancel(); await EnsureCancelledAsync(second, "queued decode cancellation"); Ensure(secondExecutions == 0, "cancelled queued work must not execute provider code"); - Ensure(executor.QueueDepth == 1, - "published cancelled work remains queued until a worker observes and skips it"); + Ensure(executor.QueueDepth == 0, + "cancelled queued work must release pending scheduler ownership immediately"); + Ensure(executor.ScheduledConnectionCount == 0, + "empty connection scheduling metadata must be reclaimed after queued cancellation"); + Ensure(executor.SkippedBeforeStart == 1, + "cancelled queued work must be counted as skipped before provider start"); releaseFirst.TrySetResult(); await first.WaitAsync(TimeSpan.FromSeconds(2)); await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); Ensure(executor.QueueDepth == 0, "drained executor queue depth"); - Ensure(executor.SkippedBeforeStart == 1, "cancelled queued work must be counted as skipped"); - Ensure(secondExecutions == 0, "skipped work must never execute provider code later"); + Ensure(secondExecutions == 0, "removed work must never execute provider code later"); } [Test] @@ -86,7 +90,7 @@ await WaitUntilAsync( Ensure(executor.QueueDepth == 1, "blocked writer cancellation must roll back its pending-depth ownership"); Ensure(executor.SkippedBeforeStart == 0, - "work cancelled before publication must never reach the worker skip path"); + "work cancelled before publication must never reach the scheduler skip path"); Ensure(thirdExecutions == 0, "unpublished work must not execute provider code"); releaseFirst.TrySetResult(); @@ -94,6 +98,7 @@ await WaitUntilAsync( await second.WaitAsync(TimeSpan.FromSeconds(2)); await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); Ensure(executor.QueueDepth == 0, "executor must drain after blocked-writer cancellation"); + Ensure(executor.ScheduledConnectionCount == 0, "drain must reclaim scheduling metadata"); } [Test] @@ -124,6 +129,170 @@ public async Task WorkerWinningCancellationRaceShouldKeepCallerJoinedUntilProvid Ensure(executor.SkippedBeforeStart == 0, "running work must not be counted as queue-skipped"); } + [Test] + public async Task FairSchedulingShouldServeSecondConnectionBeforeFirstConnectionGetsAnotherTurn() + { + await using var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity: 8); + var connectionA = new object(); + var connectionB = new object(); + var firstStarted = NewSignal(); + var releaseFirst = NewSignal(); + var order = new ConcurrentQueue(); + + var first = executor.EnqueueAsync( + connectionA, + new ServerDecodeWorkItem(async _ => + { + order.Enqueue("A1"); + firstStarted.TrySetResult(); + await releaseFirst.Task.ConfigureAwait(false); + }), + CancellationToken.None).AsTask(); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + var a2 = executor.EnqueueAsync( + connectionA, + NewRecordingWorkItem(order, "A2"), + CancellationToken.None).AsTask(); + var a3 = executor.EnqueueAsync( + connectionA, + NewRecordingWorkItem(order, "A3"), + CancellationToken.None).AsTask(); + var b1 = executor.EnqueueAsync( + connectionB, + NewRecordingWorkItem(order, "B1"), + CancellationToken.None).AsTask(); + + await WaitUntilAsync( + () => executor.QueueDepth == 3 && executor.ScheduledConnectionCount == 2, + "both connection queues were not scheduled"); + + releaseFirst.TrySetResult(); + await Task.WhenAll(first, a2, a3, b1).WaitAsync(TimeSpan.FromSeconds(2)); + await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + + var observed = order.ToArray(); + Ensure(Array.IndexOf(observed, "B1") < Array.IndexOf(observed, "A3"), + "connection B must receive service before connection A receives a second queued turn"); + Ensure(executor.ScheduledConnectionCount == 0, "completed connection queues must be reclaimed"); + } + + [Test] + public async Task UnevenBacklogShouldNotStarveSecondConnection() + { + const int aBacklog = 64; + const int bBacklog = 8; + + await using var executor = new ServerDecodeExecutor( + workerCount: 1, + queueCapacity: aBacklog + bBacklog + 1); + var connectionA = new object(); + var connectionB = new object(); + var firstStarted = NewSignal(); + var releaseFirst = NewSignal(); + var order = new ConcurrentQueue(); + var operations = new List(aBacklog + bBacklog + 1); + + operations.Add(executor.EnqueueAsync( + connectionA, + new ServerDecodeWorkItem(async _ => + { + order.Enqueue("A0"); + firstStarted.TrySetResult(); + await releaseFirst.Task.ConfigureAwait(false); + }), + CancellationToken.None).AsTask()); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + for (var index = 1; index <= aBacklog; index++) + { + operations.Add(executor.EnqueueAsync( + connectionA, + NewRecordingWorkItem(order, $"A{index}"), + CancellationToken.None).AsTask()); + } + for (var index = 1; index <= bBacklog; index++) + { + operations.Add(executor.EnqueueAsync( + connectionB, + NewRecordingWorkItem(order, $"B{index}"), + CancellationToken.None).AsTask()); + } + + await WaitUntilAsync( + () => executor.QueueDepth == aBacklog + bBacklog, + "uneven backlog was not fully queued"); + releaseFirst.TrySetResult(); + + await Task.WhenAll(operations).WaitAsync(TimeSpan.FromSeconds(5)); + await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + + var observed = order.ToArray(); + for (var index = 1; index <= bBacklog; index++) + { + var position = Array.IndexOf(observed, $"B{index}"); + Ensure(position >= 0 && position <= index * 2, + $"B{index} must receive a bounded round-robin turn under A's sustained backlog"); + } + Ensure(executor.QueueDepth == 0, "stress drain must clear all pending work"); + Ensure(executor.ScheduledConnectionCount == 0, "stress drain must reclaim all connection metadata"); + } + + [Test] + public async Task CancellingOneConnectionQueueShouldNotDelayAnotherConnection() + { + await using var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity: 4); + var connectionA = new object(); + var connectionB = new object(); + var firstStarted = NewSignal(); + var releaseFirst = NewSignal(); + var bStarted = NewSignal(); + var cancelledExecutions = 0; + + var first = executor.EnqueueAsync( + connectionA, + new ServerDecodeWorkItem(async _ => + { + firstStarted.TrySetResult(); + await releaseFirst.Task.ConfigureAwait(false); + }), + CancellationToken.None).AsTask(); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + using var cancellation = new CancellationTokenSource(); + var cancelled = executor.EnqueueAsync( + connectionA, + new ServerDecodeWorkItem(_ => + { + Interlocked.Increment(ref cancelledExecutions); + return ValueTask.CompletedTask; + }), + cancellation.Token).AsTask(); + var other = executor.EnqueueAsync( + connectionB, + new ServerDecodeWorkItem(_ => + { + bStarted.TrySetResult(); + return ValueTask.CompletedTask; + }), + CancellationToken.None).AsTask(); + + await WaitUntilAsync(() => executor.QueueDepth == 2, "two queued connections were not published"); + cancellation.Cancel(); + await EnsureCancelledAsync(cancelled, "connection A queued cancellation"); + await WaitUntilAsync( + () => executor.QueueDepth == 1 && executor.ScheduledConnectionCount == 1, + "cancelled connection queue ownership was not reclaimed"); + + releaseFirst.TrySetResult(); + await bStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await Task.WhenAll(first, other).WaitAsync(TimeSpan.FromSeconds(2)); + await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(cancelledExecutions == 0, "cancelled connection work must never execute provider code"); + Ensure(executor.QueueDepth == 0, "remaining connection must drain normally"); + } + [Test] public async Task StopAcceptingShouldRejectBlockedWriterAndDrainPublishedWork() { @@ -187,6 +356,7 @@ await EnsureFailsAsync( Ensure(secondExecutions == 1, "work published before drain must execute exactly once"); Ensure(thirdExecutions == 0, "unpublished drain-race work must remain skipped"); Ensure(executor.QueueDepth == 0, "drained executor queue depth"); + Ensure(executor.ScheduledConnectionCount == 0, "drain must reclaim fair-scheduler metadata"); } [Test] @@ -229,8 +399,18 @@ public async Task CompleteShouldStopPublicationAndDrainAlreadyPublishedWork() Ensure(secondExecutions == 1, "work published before completion must drain exactly once"); Ensure(executor.QueueDepth == 0, "completed executor queue depth"); + Ensure(executor.ScheduledConnectionCount == 0, "completion must reclaim scheduler metadata"); } + private static ServerDecodeWorkItem NewRecordingWorkItem( + ConcurrentQueue order, + string value) + => new(_ => + { + order.Enqueue(value); + return ValueTask.CompletedTask; + }); + private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); From b82cd2adb3c1aae68b61bf899be04f32d92958e8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:06:40 +0800 Subject: [PATCH 111/228] test(server): expose fair decode scheduler diagnostics --- src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs index 0a96e5a08..56024ac4c 100644 --- a/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs +++ b/src/SharpLink.Server/SharpLinkServer.DecodeExecutor.cs @@ -76,6 +76,9 @@ internal int DecodeQueueDepthForDiagnostics internal int DecodeQueueReservationsForDiagnostics => Volatile.Read(ref _decodeExecutor)?.QueueReservations ?? 0; + internal int DecodeScheduledConnectionCountForDiagnostics + => Volatile.Read(ref _decodeExecutor)?.ScheduledConnectionCount ?? 0; + internal int DecodeSkippedBeforeStartForDiagnostics => Volatile.Read(ref _decodeExecutor)?.SkippedBeforeStart ?? 0; From 8bc20967249685274427b6865c1095f0449af846 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:07:33 +0800 Subject: [PATCH 112/228] test(server): prove persistent decode fairness across connections --- ...ompressionPersistentDecodeFairnessTests.cs | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs new file mode 100644 index 000000000..ed24aff04 --- /dev/null +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs @@ -0,0 +1,361 @@ +using System.Collections.Concurrent; + +namespace SharpLink.IntegrationTests; + +public class CompressionPersistentDecodeFairnessTests +{ + private const int LargePayloadBytes = 2 * 1024 * 1024; + + [Test] + [NotInParallel] + public async Task NoisyConnectionShouldNotTakeTwoQueuedTurnsBeforePeerConnection() + { + PersistentDecodeReviewService.Reset(); + var coordinator = new FairnessCoordinator(ignoreFirstCancellation: false); + await using var harness = await FairHarness.CreateAsync(coordinator); + await WaitUntilAsync(() => harness.DecodeWorkerCount == 1, "single fair decode worker started"); + + var serviceA = harness.ClientA.Get(); + var serviceB = harness.ClientB.Get(); + var payloadA = Enumerable.Repeat((byte)0x41, LargePayloadBytes).ToArray(); + var payloadB = Enumerable.Repeat((byte)0x42, LargePayloadBytes).ToArray(); + + var a1 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + await coordinator.WaitForStartedCountAsync(1); + Ensure(coordinator.StartOrder[0] == "A", "connection A must own the intentionally blocked first turn"); + + var a2 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + var a3 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + var b1 = serviceB.MeasureAsync(payloadB, CancellationToken.None).AsTask(); + await WaitUntilAsync( + () => harness.DecodeQueueDepth == 3 && + harness.DecodeQueueReservations == 3 && + harness.DecodeScheduledConnectionCount == 2, + "A backlog and B request entered two fair scheduler queues"); + + coordinator.ReleaseFirst(); + await coordinator.WaitForStartedCountAsync(4); + await Task.WhenAll(a1, a2, a3, b1).WaitAsync(TimeSpan.FromSeconds(10)); + + var order = coordinator.StartOrder; + Ensure(order.Count >= 4, "all four provider starts must be recorded"); + Ensure(order[0] == "A" && order[1] == "A" && order[2] == "B", + $"round-robin service must give B the next connection turn; observed {string.Join(',', order)}"); + await AssertResourcesReleasedAsync(harness, "two-connection fair drain"); + } + + [Test] + [NotInParallel] + public async Task ClosingConnectionShouldRemoveItsQueuedTurnBeforeBlockedProviderReturns() + { + PersistentDecodeReviewService.Reset(); + var coordinator = new FairnessCoordinator(ignoreFirstCancellation: true); + await using var harness = await FairHarness.CreateAsync(coordinator); + await WaitUntilAsync(() => harness.DecodeWorkerCount == 1, "single fair decode worker started"); + + var serviceA = harness.ClientA.Get(); + var serviceB = harness.ClientB.Get(); + var payloadA = Enumerable.Repeat((byte)0x51, LargePayloadBytes).ToArray(); + var payloadB = Enumerable.Repeat((byte)0x52, LargePayloadBytes).ToArray(); + + var a1 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + await coordinator.WaitForStartedCountAsync(1); + var a2 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + var b1 = serviceB.MeasureAsync(payloadB, CancellationToken.None).AsTask(); + await WaitUntilAsync( + () => harness.DecodeQueueDepth == 2 && + harness.DecodeQueueReservations == 2 && + harness.DecodeScheduledConnectionCount == 2, + "two connections queued behind the blocked provider"); + + var stopA = harness.ClientA.StopAsync().AsTask(); + await WaitUntilAsync( + () => harness.DecodeQueueDepth == 1 && + harness.DecodeQueueReservations == 1 && + harness.DecodeScheduledConnectionCount == 1, + "closed connection queued ownership removed before worker availability"); + Ensure(coordinator.StartOrder.Count == 1, + "connection-close cleanup must not require the blocked provider to return first"); + + coordinator.ReleaseFirst(); + await coordinator.WaitForStartedCountAsync(2); + await b1.WaitAsync(TimeSpan.FromSeconds(10)); + await ObserveExpectedTerminationAsync(a1); + await ObserveExpectedTerminationAsync(a2); + await stopA.WaitAsync(TimeSpan.FromSeconds(5)); + + var order = coordinator.StartOrder; + Ensure(order.Count >= 2 && order[1] == "B", + $"remaining connection must receive the next worker turn after A closes; observed {string.Join(',', order)}"); + await AssertResourcesReleasedAsync(harness, "connection-close fair cleanup"); + } + + private static async Task ObserveExpectedTerminationAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (OperationCanceledException) + { + } + catch (SharpLinkException exception) when ( + exception.Code is SharpLinkErrorCode.Cancelled or + SharpLinkErrorCode.ConnectionClosed or + SharpLinkErrorCode.Unavailable) + { + } + catch (IOException) + { + } + } + + private static async Task AssertResourcesReleasedAsync(FairHarness harness, string scenario) + { + await WaitUntilAsync( + () => harness.ActiveCalls == 0 && + harness.ActiveDecodes == 0 && + harness.RetainedCompressedBytes == 0 && + harness.DecodedBytesInFlight == 0 && + harness.DecodeQueueDepth == 0 && + harness.DecodeQueueReservations == 0 && + harness.DecodeScheduledConnectionCount == 0, + $"{scenario} resource release"); + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} was not observed"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class FairHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + private bool _stopped; + + private FairHarness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient clientA, + ISharpLinkClient clientB) + { + _serverCts = serverCts; + _serverTask = serverTask; + _server = server; + ClientA = clientA; + ClientB = clientB; + } + + internal ISharpLinkClient ClientA { get; } + internal ISharpLinkClient ClientB { get; } + + internal int ActiveCalls => ReadField("_globalActiveCalls"); + internal int ActiveDecodes => ReadDiagnosticProperty("ActiveDecodeCountForDiagnostics"); + internal long RetainedCompressedBytes => + ReadDiagnosticProperty("RetainedCompressedBytesForDiagnostics"); + internal long DecodedBytesInFlight => + ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); + internal int DecodeWorkerCount => ReadDiagnosticProperty("DecodeWorkerCountForDiagnostics"); + internal int DecodeQueueDepth => ReadDiagnosticProperty("DecodeQueueDepthForDiagnostics"); + internal int DecodeQueueReservations => + ReadDiagnosticProperty("DecodeQueueReservationsForDiagnostics"); + internal int DecodeScheduledConnectionCount => + ReadDiagnosticProperty("DecodeScheduledConnectionCountForDiagnostics"); + + internal static async Task CreateAsync(FairnessCoordinator coordinator) + { + var serverCts = new CancellationTokenSource(); + var serverProviderA = new TaggedCompressionProvider( + "review-fair-a", + "A", + SharpLinkCompressionProviders.CreateBrotli(), + coordinator); + var serverProviderB = new TaggedCompressionProvider( + "review-fair-b", + "B", + SharpLinkCompressionProviders.CreateBrotli(), + coordinator); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 16; + options.FlowControl.MaxConcurrentCallsPerServer = 32; + options.FlowControl.MaxConcurrentDecodesPerServer = 1; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 32L * 1024 * 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = 32L * 1024 * 1024; + options.Compression.Providers.Add(serverProviderA); + options.Compression.Providers.Add(serverProviderB); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = RunServerAsync(server, serverCts.Token); + + var clientA = CreateClient(port, "review-fair-a", "A"); + var clientB = CreateClient(port, "review-fair-b", "B"); + await clientA.ConnectAsync(); + await clientB.ConnectAsync(); + return new FairHarness(serverCts, serverTask, server, clientA, clientB); + } + + public async ValueTask DisposeAsync() + { + if (_stopped) + return; + _stopped = true; + try + { + await ClientA.StopAsync(); + await ClientB.StopAsync(); + } + finally + { + await _serverCts.CancelAsync(); + await _server.StopAsync(TimeSpan.Zero); + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + } + + private static ISharpLinkClient CreateClient(int port, string wireProfile, string tag) + => SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .UseRuntime(options => options.Compression.Providers.Add( + new TaggedCompressionProvider( + wireProfile, + tag, + SharpLinkCompressionProviders.CreateBrotli(), + coordinator: null))) + .Build(); + + private T ReadField(string name) + { + var field = _server.GetType().GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server field {name}"); + return (T)field.GetValue(_server)!; + } + + private T ReadDiagnosticProperty(string name) + { + var property = _server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic property {name}"); + return (T)property.GetValue(_server)!; + } + } + + private sealed class FairnessCoordinator(bool ignoreFirstCancellation) + { + private readonly ManualResetEventSlim _releaseFirst = new(); + private readonly ConcurrentQueue _startOrder = new(); + private int _startedCount; + + internal IReadOnlyList StartOrder => _startOrder.ToArray(); + + internal void ReleaseFirst() => _releaseFirst.Set(); + + internal async Task WaitForStartedCountAsync(int expected) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (Volatile.Read(ref _startedCount) < expected) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: provider starts did not reach {expected}"); + } + } + + internal CancellationToken RecordStartAndBlockFirst(string tag, CancellationToken cancellationToken) + { + _startOrder.Enqueue(tag); + var ordinal = Interlocked.Increment(ref _startedCount); + if (ordinal != 1) + return cancellationToken; + + if (ignoreFirstCancellation) + { + _releaseFirst.Wait(CancellationToken.None); + return CancellationToken.None; + } + + _releaseFirst.Wait(cancellationToken); + return cancellationToken; + } + } + + private sealed class TaggedCompressionProvider( + string wireProfile, + string tag, + ISharpLinkCompressionProvider inner, + FairnessCoordinator? coordinator) : ISharpLinkCompressionProvider + { + public string WireProfile => wireProfile; + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + var effectiveCancellation = coordinator?.RecordStartAndBlockFirst(tag, cancellationToken) + ?? cancellationToken; + return inner.Decompress(input, output, maxOutputBytes, effectiveCancellation); + } + } + + private static Task RunServerAsync(ISharpLinkServer server, CancellationToken cancellationToken) + => Task.Run(async () => + { + try + { + await server.RunAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); +} From 93155c53ffa03449af9dd80af6b6c643dfcf37e7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:09:26 +0800 Subject: [PATCH 113/228] test: include generic collections in unit globals --- test/SharpLink.UnitTests/GlobalUsings.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.UnitTests/GlobalUsings.cs b/test/SharpLink.UnitTests/GlobalUsings.cs index 776d29524..0cddc8772 100644 --- a/test/SharpLink.UnitTests/GlobalUsings.cs +++ b/test/SharpLink.UnitTests/GlobalUsings.cs @@ -1,5 +1,6 @@ global using System; global using System.Buffers; +global using System.Collections.Generic; global using System.IO; global using SharpLink.Abstractions; global using SharpLink.Runtime; From d7bebc9561d1df3076efc322b7a28b25625893bb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:09:52 +0800 Subject: [PATCH 114/228] test(server): preserve global decode pending bound across connections --- .../ServerDecodeFairSchedulerCapacityTests.cs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/ServerDecodeFairSchedulerCapacityTests.cs diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeFairSchedulerCapacityTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeFairSchedulerCapacityTests.cs new file mode 100644 index 000000000..240e814b2 --- /dev/null +++ b/test/SharpLink.UnitTests/Server/ServerDecodeFairSchedulerCapacityTests.cs @@ -0,0 +1,89 @@ +using SharpLink.Server; +using System.Threading; + +namespace SharpLink.UnitTests.Server; + +public class ServerDecodeFairSchedulerCapacityTests +{ + [Test] + public async Task DistinctConnectionQueuesShouldShareOneGlobalProductionPendingBound() + { + const int queueCapacity = 4; + await using var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity); + var runningKey = new object(); + var runningStarted = NewSignal(); + var releaseRunning = NewSignal(); + + Ensure(executor.TryReserveQueueSlot(out var runningPermit) && runningPermit is not null, + "running production work must reserve scheduler capacity"); + var running = executor.EnqueueReservedAsync( + runningKey, + runningPermit!, + new ServerDecodeWorkItem(async _ => + { + runningStarted.TrySetResult(); + await releaseRunning.Task.ConfigureAwait(false); + }), + CancellationToken.None).AsTask(); + await runningStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await WaitUntilAsync( + () => executor.QueueReservations == 0 && executor.QueueDepth == 0, + "worker start released the running request queue reservation"); + + var queued = new List(queueCapacity); + for (var index = 0; index < queueCapacity; index++) + { + Ensure(executor.TryReserveQueueSlot(out var permit) && permit is not null, + $"connection {index} must share the available global pending capacity"); + queued.Add(executor.EnqueueReservedAsync( + new object(), + permit!, + new ServerDecodeWorkItem(_ => ValueTask.CompletedTask), + CancellationToken.None).AsTask()); + } + + await WaitUntilAsync( + () => executor.QueueReservations == queueCapacity && + executor.QueueDepth == queueCapacity && + executor.ScheduledConnectionCount == queueCapacity, + "all distinct connection queues consumed the one global pending budget"); + + Ensure(!executor.TryReserveQueueSlot(out var rejectedPermit) && rejectedPermit is null, + "an extra connection must not receive a private queue budget beyond the global capacity"); + Ensure(executor.QueueReservations == queueCapacity, + "rejected scheduler admission must not perturb accepted queue reservations"); + + releaseRunning.TrySetResult(); + await running.WaitAsync(TimeSpan.FromSeconds(2)); + await Task.WhenAll(queued).WaitAsync(TimeSpan.FromSeconds(2)); + await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(executor.QueueReservations == 0, "drain must release all global queue reservations"); + Ensure(executor.QueueDepth == 0, "drain must clear all pending work"); + Ensure(executor.ScheduledConnectionCount == 0, + "drain must reclaim all per-connection scheduling metadata"); + } + + private static TaskCompletionSource NewSignal() + => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } +} From 77db7d2f7a2c63386c7d059d2a1878920dc70109 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:11:32 +0800 Subject: [PATCH 115/228] fix(server): let fair decode workers exit after publication seals --- src/SharpLink.Server/ServerDecodeExecutor.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Server/ServerDecodeExecutor.cs b/src/SharpLink.Server/ServerDecodeExecutor.cs index f1371f6c7..b3b0c4488 100644 --- a/src/SharpLink.Server/ServerDecodeExecutor.cs +++ b/src/SharpLink.Server/ServerDecodeExecutor.cs @@ -289,7 +289,10 @@ private async Task WorkerLoopAsync() if (!TryTakeNextEntry(out var entry)) { - if (Volatile.Read(ref _completionRequested) != 0 && QueueDepth == 0) + // Stop seals publication and cancels compatibility writers that have not published. + // With no ready connection left there is therefore no work a worker can still own, + // even if QueueDepth transiently includes a blocked writer rolling back its count. + if (Volatile.Read(ref _completionRequested) != 0) return; continue; } From 6094bdd8e762342ef82bcc0e194ecdbf054e4e5c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:12:51 +0800 Subject: [PATCH 116/228] test(server): make real connection fairness ordering deterministic --- .../CompressionPersistentDecodeFairnessTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs index ed24aff04..39c97fe30 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs @@ -26,6 +26,12 @@ public async Task NoisyConnectionShouldNotTakeTwoQueuedTurnsBeforePeerConnection var a2 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); var a3 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + await WaitUntilAsync( + () => harness.DecodeQueueDepth == 2 && + harness.DecodeQueueReservations == 2 && + harness.DecodeScheduledConnectionCount == 1, + "connection A backlog entered its scheduler queue before B publication"); + var b1 = serviceB.MeasureAsync(payloadB, CancellationToken.None).AsTask(); await WaitUntilAsync( () => harness.DecodeQueueDepth == 3 && From ab2b48d5587227a3a41d1cbd8bfcfd2608482634 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:13:36 +0800 Subject: [PATCH 117/228] test(server): cover fair scheduler stop lifecycle across connections --- ...ssionPersistentDecodeFairLifecycleTests.cs | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs new file mode 100644 index 000000000..d84b7c3fe --- /dev/null +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs @@ -0,0 +1,354 @@ +namespace SharpLink.IntegrationTests; + +public class CompressionPersistentDecodeFairLifecycleTests +{ + private const int LargePayloadBytes = 2 * 1024 * 1024; + + [Test] + [NotInParallel] + public async Task GracefulStopShouldDrainPublishedWorkAcrossConnectionQueues() + { + PersistentDecodeReviewService.Reset(); + var provider = new BlockingLifecycleCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await LifecycleHarness.CreateAsync(provider); + await WaitUntilAsync(() => harness.DecodeWorkerCount == 1, "single fair decode worker started"); + + var serviceA = harness.ClientA.Get(); + var serviceB = harness.ClientB.Get(); + var a1 = serviceA.MeasureAsync(CreateLargePayload(0x61), CancellationToken.None).AsTask(); + await provider.WaitForStartedCountAsync(1); + + var a2 = serviceA.MeasureAsync(CreateLargePayload(0x62), CancellationToken.None).AsTask(); + var b1 = serviceB.MeasureAsync(CreateLargePayload(0x63), CancellationToken.None).AsTask(); + await WaitUntilAsync( + () => harness.DecodeQueueDepth == 2 && + harness.DecodeQueueReservations == 2 && + harness.DecodeScheduledConnectionCount == 2 && + harness.ActiveDecodes == 1, + "two connection queues published before graceful stop"); + + var stopTask = harness.BeginStopServer(TimeSpan.FromSeconds(5)); + await WaitUntilAsync(() => !harness.DecodeAccepting, "graceful stop sealed decode publication"); + Ensure(!stopTask.IsCompleted, + "graceful stop must remain joined to running and queued fair-scheduled decode work"); + Ensure(provider.CancellationCount == 0, + "graceful stop must not force-cancel the running provider before its timeout"); + + provider.ReleaseAll(); + await Task.WhenAll(a1, a2, b1).WaitAsync(TimeSpan.FromSeconds(10)); + await stopTask.WaitAsync(TimeSpan.FromSeconds(10)); + + Ensure(provider.StartedCount == 3, + "all work published across both connection queues before drain must receive worker service"); + Ensure(provider.CancellationCount == 0, + "successful graceful fair-scheduler drain must not cancel providers"); + await AssertResourcesReleasedAsync(harness, "multi-connection graceful stop"); + } + + [Test] + [NotInParallel] + public async Task ForceStopShouldCancelRunningWorkAndRemoveAllConnectionQueues() + { + PersistentDecodeReviewService.Reset(); + var provider = new BlockingLifecycleCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await LifecycleHarness.CreateAsync(provider); + await WaitUntilAsync(() => harness.DecodeWorkerCount == 1, "single fair decode worker started"); + + var serviceA = harness.ClientA.Get(); + var serviceB = harness.ClientB.Get(); + var a1 = serviceA.MeasureAsync(CreateLargePayload(0x71), CancellationToken.None).AsTask(); + await provider.WaitForStartedCountAsync(1); + + var a2 = serviceA.MeasureAsync(CreateLargePayload(0x72), CancellationToken.None).AsTask(); + var b1 = serviceB.MeasureAsync(CreateLargePayload(0x73), CancellationToken.None).AsTask(); + await WaitUntilAsync( + () => harness.DecodeQueueDepth == 2 && + harness.DecodeQueueReservations == 2 && + harness.DecodeScheduledConnectionCount == 2 && + harness.ActiveDecodes == 1, + "two connection queues published before force stop"); + + var stopTask = harness.BeginStopServer(TimeSpan.Zero); + await provider.WaitForCancellationCountAsync(1); + await stopTask.WaitAsync(TimeSpan.FromSeconds(10)); + await Task.WhenAll( + ObserveExpectedTerminationAsync(a1), + ObserveExpectedTerminationAsync(a2), + ObserveExpectedTerminationAsync(b1)); + + Ensure(provider.StartedCount == 1, + "force stop should remove queued fair-scheduler work before another provider start"); + Ensure(provider.CancellationCount == 1, + "force stop must cancel the running provider exactly once"); + Ensure(PersistentDecodeReviewService.CancellableInvocations == 0, + "force stop before decode completion must prevent service activation"); + await AssertResourcesReleasedAsync(harness, "multi-connection force stop"); + } + + private static byte[] CreateLargePayload(byte value) + => Enumerable.Repeat(value, LargePayloadBytes).ToArray(); + + private static async Task ObserveExpectedTerminationAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (OperationCanceledException) + { + } + catch (SharpLinkException exception) when ( + exception.Code is SharpLinkErrorCode.Cancelled or + SharpLinkErrorCode.ConnectionClosed or + SharpLinkErrorCode.Unavailable) + { + } + catch (IOException) + { + } + } + + private static async Task AssertResourcesReleasedAsync(LifecycleHarness harness, string scenario) + { + await WaitUntilAsync( + () => harness.ActiveCalls == 0 && + harness.ActiveDecodes == 0 && + harness.RetainedCompressedBytes == 0 && + harness.DecodedBytesInFlight == 0 && + harness.DecodeQueueDepth == 0 && + harness.DecodeQueueReservations == 0 && + harness.DecodeScheduledConnectionCount == 0, + $"{scenario} resource release"); + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} was not observed"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class LifecycleHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + private bool _disposed; + + private LifecycleHarness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient clientA, + ISharpLinkClient clientB) + { + _serverCts = serverCts; + _serverTask = serverTask; + _server = server; + ClientA = clientA; + ClientB = clientB; + } + + internal ISharpLinkClient ClientA { get; } + internal ISharpLinkClient ClientB { get; } + + internal int ActiveCalls => ReadField("_globalActiveCalls"); + internal int ActiveDecodes => ReadDiagnosticProperty("ActiveDecodeCountForDiagnostics"); + internal long RetainedCompressedBytes => + ReadDiagnosticProperty("RetainedCompressedBytesForDiagnostics"); + internal long DecodedBytesInFlight => + ReadDiagnosticProperty("DecodedBytesInFlightForDiagnostics"); + internal int DecodeWorkerCount => ReadDiagnosticProperty("DecodeWorkerCountForDiagnostics"); + internal int DecodeQueueDepth => ReadDiagnosticProperty("DecodeQueueDepthForDiagnostics"); + internal int DecodeQueueReservations => + ReadDiagnosticProperty("DecodeQueueReservationsForDiagnostics"); + internal int DecodeScheduledConnectionCount => + ReadDiagnosticProperty("DecodeScheduledConnectionCountForDiagnostics"); + internal bool DecodeAccepting => ReadDiagnosticProperty("DecodeAcceptingForDiagnostics"); + + internal static async Task CreateAsync(ISharpLinkCompressionProvider serverProvider) + { + var serverCts = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 16; + options.FlowControl.MaxConcurrentCallsPerServer = 32; + options.FlowControl.MaxConcurrentDecodesPerServer = 1; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 32L * 1024 * 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = 32L * 1024 * 1024; + options.Compression.Providers.Add(serverProvider); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = RunServerAsync(server, serverCts.Token); + + var clientA = CreateClient(port); + var clientB = CreateClient(port); + await clientA.ConnectAsync(); + await clientB.ConnectAsync(); + return new LifecycleHarness(serverCts, serverTask, server, clientA, clientB); + } + + internal Task BeginStopServer(TimeSpan gracefulTimeout) + => _server.StopAsync(gracefulTimeout).AsTask(); + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + _disposed = true; + try + { + await StopClientAsync(ClientA); + await StopClientAsync(ClientB); + } + finally + { + await _serverCts.CancelAsync(); + await _server.StopAsync(TimeSpan.Zero); + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + } + + private static ISharpLinkClient CreateClient(int port) + => SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .UseRuntime(options => options.Compression.Providers.Add( + SharpLinkCompressionProviders.CreateBrotli())) + .Build(); + + private static async Task StopClientAsync(ISharpLinkClient client) + { + try + { + await client.StopAsync(); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) + { + } + } + + private T ReadField(string name) + { + var field = _server.GetType().GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server field {name}"); + return (T)field.GetValue(_server)!; + } + + private T ReadDiagnosticProperty(string name) + { + var property = _server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic property {name}"); + return (T)property.GetValue(_server)!; + } + } + + private sealed class BlockingLifecycleCompressionProvider(ISharpLinkCompressionProvider inner) + : ISharpLinkCompressionProvider + { + private readonly ManualResetEventSlim _release = new(); + private int _startedCount; + private int _cancellationCount; + + public string WireProfile => inner.WireProfile; + + internal int StartedCount => Volatile.Read(ref _startedCount); + internal int CancellationCount => Volatile.Read(ref _cancellationCount); + + internal void ReleaseAll() => _release.Set(); + + internal Task WaitForStartedCountAsync(int expected) + => WaitForCounterAsync(() => StartedCount, expected, "lifecycle provider starts"); + + internal Task WaitForCancellationCountAsync(int expected) + => WaitForCounterAsync(() => CancellationCount, expected, "lifecycle provider cancellations"); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _startedCount); + try + { + _release.Wait(cancellationToken); + return inner.Decompress(input, output, maxOutputBytes, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Interlocked.Increment(ref _cancellationCount); + throw; + } + } + } + + private static async Task WaitForCounterAsync(Func read, int expected, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (read() < expected) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario} did not reach {expected}"); + } + } + + private static Task RunServerAsync(ISharpLinkServer server, CancellationToken cancellationToken) + => Task.Run(async () => + { + try + { + await server.RunAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); +} From c3991da04debd706e6a0b1f6d7fb302b85f49c2f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:14:22 +0800 Subject: [PATCH 118/228] test(server): make fair decode routing explicitly cancellable --- .../CompressionPersistentDecodeFairnessTests.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs index 39c97fe30..82e04fdce 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairnessTests.cs @@ -19,20 +19,21 @@ public async Task NoisyConnectionShouldNotTakeTwoQueuedTurnsBeforePeerConnection var serviceB = harness.ClientB.Get(); var payloadA = Enumerable.Repeat((byte)0x41, LargePayloadBytes).ToArray(); var payloadB = Enumerable.Repeat((byte)0x42, LargePayloadBytes).ToArray(); + using var cancellation = new CancellationTokenSource(); - var a1 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + var a1 = serviceA.MeasureAsync(payloadA, cancellation.Token).AsTask(); await coordinator.WaitForStartedCountAsync(1); Ensure(coordinator.StartOrder[0] == "A", "connection A must own the intentionally blocked first turn"); - var a2 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); - var a3 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + var a2 = serviceA.MeasureAsync(payloadA, cancellation.Token).AsTask(); + var a3 = serviceA.MeasureAsync(payloadA, cancellation.Token).AsTask(); await WaitUntilAsync( () => harness.DecodeQueueDepth == 2 && harness.DecodeQueueReservations == 2 && harness.DecodeScheduledConnectionCount == 1, "connection A backlog entered its scheduler queue before B publication"); - var b1 = serviceB.MeasureAsync(payloadB, CancellationToken.None).AsTask(); + var b1 = serviceB.MeasureAsync(payloadB, cancellation.Token).AsTask(); await WaitUntilAsync( () => harness.DecodeQueueDepth == 3 && harness.DecodeQueueReservations == 3 && @@ -63,11 +64,12 @@ public async Task ClosingConnectionShouldRemoveItsQueuedTurnBeforeBlockedProvide var serviceB = harness.ClientB.Get(); var payloadA = Enumerable.Repeat((byte)0x51, LargePayloadBytes).ToArray(); var payloadB = Enumerable.Repeat((byte)0x52, LargePayloadBytes).ToArray(); + using var cancellation = new CancellationTokenSource(); - var a1 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + var a1 = serviceA.MeasureAsync(payloadA, cancellation.Token).AsTask(); await coordinator.WaitForStartedCountAsync(1); - var a2 = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); - var b1 = serviceB.MeasureAsync(payloadB, CancellationToken.None).AsTask(); + var a2 = serviceA.MeasureAsync(payloadA, cancellation.Token).AsTask(); + var b1 = serviceB.MeasureAsync(payloadB, cancellation.Token).AsTask(); await WaitUntilAsync( () => harness.DecodeQueueDepth == 2 && harness.DecodeQueueReservations == 2 && From df7ebe04611ec49d0eae0c84292932ab2f56f95a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:14:57 +0800 Subject: [PATCH 119/228] test(server): make fair lifecycle requests explicitly cancellable --- ...ompressionPersistentDecodeFairLifecycleTests.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs index d84b7c3fe..a2fbe1d39 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFairLifecycleTests.cs @@ -16,11 +16,12 @@ public async Task GracefulStopShouldDrainPublishedWorkAcrossConnectionQueues() var serviceA = harness.ClientA.Get(); var serviceB = harness.ClientB.Get(); - var a1 = serviceA.MeasureAsync(CreateLargePayload(0x61), CancellationToken.None).AsTask(); + using var cancellation = new CancellationTokenSource(); + var a1 = serviceA.MeasureAsync(CreateLargePayload(0x61), cancellation.Token).AsTask(); await provider.WaitForStartedCountAsync(1); - var a2 = serviceA.MeasureAsync(CreateLargePayload(0x62), CancellationToken.None).AsTask(); - var b1 = serviceB.MeasureAsync(CreateLargePayload(0x63), CancellationToken.None).AsTask(); + var a2 = serviceA.MeasureAsync(CreateLargePayload(0x62), cancellation.Token).AsTask(); + var b1 = serviceB.MeasureAsync(CreateLargePayload(0x63), cancellation.Token).AsTask(); await WaitUntilAsync( () => harness.DecodeQueueDepth == 2 && harness.DecodeQueueReservations == 2 && @@ -58,11 +59,12 @@ public async Task ForceStopShouldCancelRunningWorkAndRemoveAllConnectionQueues() var serviceA = harness.ClientA.Get(); var serviceB = harness.ClientB.Get(); - var a1 = serviceA.MeasureAsync(CreateLargePayload(0x71), CancellationToken.None).AsTask(); + using var cancellation = new CancellationTokenSource(); + var a1 = serviceA.MeasureAsync(CreateLargePayload(0x71), cancellation.Token).AsTask(); await provider.WaitForStartedCountAsync(1); - var a2 = serviceA.MeasureAsync(CreateLargePayload(0x72), CancellationToken.None).AsTask(); - var b1 = serviceB.MeasureAsync(CreateLargePayload(0x73), CancellationToken.None).AsTask(); + var a2 = serviceA.MeasureAsync(CreateLargePayload(0x72), cancellation.Token).AsTask(); + var b1 = serviceB.MeasureAsync(CreateLargePayload(0x73), cancellation.Token).AsTask(); await WaitUntilAsync( () => harness.DecodeQueueDepth == 2 && harness.DecodeQueueReservations == 2 && From 830886960b1d9c007568bf0323e8e9d7b19453d1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:50:54 +0800 Subject: [PATCH 120/228] feat(protocol): add pre-admission stream budget exhaustion reason --- src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs b/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs index d83968470..fdcf8fffa 100644 --- a/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs +++ b/src/SharpLink.Abstractions/SharpLinkResourceExhaustion.cs @@ -16,6 +16,7 @@ internal static class SharpLinkResourceExhaustion private const char ServerRetainedCompressedBytesWireCode = '\u000B'; private const char ServerDecodedBytesWireCode = '\u000C'; private const char ServerDecodeQueueWireCode = '\u000D'; + private const char ServerPreAdmissionStreamBytesWireCode = '\u000E'; private static readonly string[] s_knownReasons = [ ServerCallCapacity, @@ -30,7 +31,8 @@ internal static class SharpLinkResourceExhaustion ServerDecodeConcurrency, ServerRetainedCompressedBytes, ServerDecodedBytes, - ServerDecodeQueue + ServerDecodeQueue, + ServerPreAdmissionStreamBytes ]; internal const string Unspecified = "unspecified"; @@ -47,6 +49,7 @@ internal static class SharpLinkResourceExhaustion internal const string ServerRetainedCompressedBytes = "server_retained_compressed_bytes"; internal const string ServerDecodedBytes = "server_decoded_bytes"; internal const string ServerDecodeQueue = "server_decode_queue"; + internal const string ServerPreAdmissionStreamBytes = "server_pre_admission_stream_bytes"; internal static SharpLinkException Create(string reason, string message) { @@ -96,6 +99,7 @@ private static char GetWireCode(string reason) ServerRetainedCompressedBytes => ServerRetainedCompressedBytesWireCode, ServerDecodedBytes => ServerDecodedBytesWireCode, ServerDecodeQueue => ServerDecodeQueueWireCode, + ServerPreAdmissionStreamBytes => ServerPreAdmissionStreamBytesWireCode, _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, "A known resource exhaustion reason is required.") }; @@ -116,6 +120,7 @@ private static bool TryGetWireReason(char code, out string reason) ServerRetainedCompressedBytesWireCode => ServerRetainedCompressedBytes, ServerDecodedBytesWireCode => ServerDecodedBytes, ServerDecodeQueueWireCode => ServerDecodeQueue, + ServerPreAdmissionStreamBytesWireCode => ServerPreAdmissionStreamBytes, _ => Unspecified }; return reason != Unspecified; From 511dedcff4dda4574abb7e976016fdd9a9f40eb2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:52:22 +0800 Subject: [PATCH 121/228] feat(runtime): configure pre-admission stream byte budget --- src/SharpLink.Runtime/SharpLinkRuntimeOptions.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Runtime/SharpLinkRuntimeOptions.cs b/src/SharpLink.Runtime/SharpLinkRuntimeOptions.cs index 8c9c8c371..94eddc8e4 100644 --- a/src/SharpLink.Runtime/SharpLinkRuntimeOptions.cs +++ b/src/SharpLink.Runtime/SharpLinkRuntimeOptions.cs @@ -42,6 +42,9 @@ public sealed class SharpLinkFlowControlOptions /// The default server-wide decoded-byte in-flight budget: 64 MiB. public const long DefaultMaxDecodedBytesInFlightPerServer = 64L * 1024 * 1024; + /// The default server-wide pre-admission client-stream buffer budget: 64 MiB. + public const long DefaultMaxPreAdmissionStreamBytesPerServer = 64L * 1024 * 1024; + /// Gets or sets the maximum queued outbound bytes. public int MaxSendQueueBytes { @@ -107,6 +110,13 @@ public int MaxSendQueueBytes /// public long MaxDecodedBytesInFlightPerServer { get; set; } = DefaultMaxDecodedBytesInFlightPerServer; + /// + /// Gets or sets the server-wide byte budget for client-stream frames physically retained while + /// their request is still waiting for server admission. This budget is independent from Dynamic + /// Admission queue limits and remains stable for the server runtime lifetime. + /// + public long MaxPreAdmissionStreamBytesPerServer { get; set; } = DefaultMaxPreAdmissionStreamBytesPerServer; + /// Validates all flow-control limits. public void Validate() { @@ -134,6 +144,7 @@ public void Validate() } ArgumentOutOfRangeException.ThrowIfNegativeOrZero(MaxRetainedCompressedBytesPerServer); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(MaxDecodedBytesInFlightPerServer); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(MaxPreAdmissionStreamBytesPerServer); if (ConnectionReceiveWindowBytes < StreamReceiveWindowBytes) throw new ArgumentException("ConnectionReceiveWindowBytes cannot be smaller than StreamReceiveWindowBytes."); } @@ -150,7 +161,8 @@ internal SharpLinkFlowControlOptions CloneValidated() MaxConcurrentCallsPerServer = MaxConcurrentCallsPerServer, MaxConcurrentDecodesPerServer = MaxConcurrentDecodesPerServer, MaxRetainedCompressedBytesPerServer = MaxRetainedCompressedBytesPerServer, - MaxDecodedBytesInFlightPerServer = MaxDecodedBytesInFlightPerServer + MaxDecodedBytesInFlightPerServer = MaxDecodedBytesInFlightPerServer, + MaxPreAdmissionStreamBytesPerServer = MaxPreAdmissionStreamBytesPerServer }; clone._maxSendQueueBytes = _maxSendQueueBytes; clone._maxSendQueueBytesConfigured = _maxSendQueueBytesConfigured; @@ -171,6 +183,7 @@ internal void CopySnapshotTo(SharpLinkFlowControlOptions destination) destination.MaxConcurrentDecodesPerServer = MaxConcurrentDecodesPerServer; destination.MaxRetainedCompressedBytesPerServer = MaxRetainedCompressedBytesPerServer; destination.MaxDecodedBytesInFlightPerServer = MaxDecodedBytesInFlightPerServer; + destination.MaxPreAdmissionStreamBytesPerServer = MaxPreAdmissionStreamBytesPerServer; } } From aefb13ff3b16cfbbb1c14e485ea87e5288af4325 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:52:58 +0800 Subject: [PATCH 122/228] feat(server): govern pre-admission stream bytes --- .../ServerResourceGovernor.cs | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Server/ServerResourceGovernor.cs b/src/SharpLink.Server/ServerResourceGovernor.cs index 9361f6ce8..945071cc9 100644 --- a/src/SharpLink.Server/ServerResourceGovernor.cs +++ b/src/SharpLink.Server/ServerResourceGovernor.cs @@ -16,7 +16,8 @@ private ServerResourceGovernor ResourceGovernor var created = new ServerResourceGovernor( flowControl.MaxConcurrentDecodesPerServer, flowControl.MaxRetainedCompressedBytesPerServer, - flowControl.MaxDecodedBytesInFlightPerServer); + flowControl.MaxDecodedBytesInFlightPerServer, + flowControl.MaxPreAdmissionStreamBytesPerServer); return Interlocked.CompareExchange(ref _resourceGovernor, created, null) ?? created; } } @@ -26,6 +27,8 @@ private ServerResourceGovernor ResourceGovernor internal long RetainedCompressedBytesForDiagnostics => ResourceGovernor.RetainedCompressedBytes; internal long DecodedBytesInFlightForDiagnostics => ResourceGovernor.DecodedBytesInFlight; + + internal long PreAdmissionStreamBytesForDiagnostics => ResourceGovernor.PreAdmissionStreamBytes; } /// @@ -37,21 +40,26 @@ internal sealed class ServerResourceGovernor private readonly int _maxConcurrentDecodes; private readonly long _maxRetainedCompressedBytes; private readonly long _maxDecodedBytesInFlight; + private readonly long _maxPreAdmissionStreamBytes; private int _activeDecodes; private long _retainedCompressedBytes; private long _decodedBytesInFlight; + private long _preAdmissionStreamBytes; internal ServerResourceGovernor( int maxConcurrentDecodes, long maxRetainedCompressedBytes, - long maxDecodedBytesInFlight) + long maxDecodedBytesInFlight, + long maxPreAdmissionStreamBytes) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxConcurrentDecodes); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxRetainedCompressedBytes); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxDecodedBytesInFlight); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxPreAdmissionStreamBytes); _maxConcurrentDecodes = maxConcurrentDecodes; _maxRetainedCompressedBytes = maxRetainedCompressedBytes; _maxDecodedBytesInFlight = maxDecodedBytesInFlight; + _maxPreAdmissionStreamBytes = maxPreAdmissionStreamBytes; } internal int ActiveDecodeCount => Volatile.Read(ref _activeDecodes); @@ -60,6 +68,8 @@ internal ServerResourceGovernor( internal long DecodedBytesInFlight => Volatile.Read(ref _decodedBytesInFlight); + internal long PreAdmissionStreamBytes => Volatile.Read(ref _preAdmissionStreamBytes); + internal bool TryAcquireRetained( long retainedCompressedBytes, out ServerRetainedCompressedPermit? permit) @@ -87,6 +97,33 @@ internal bool TryAcquireRetained( } } + internal bool TryAcquirePreAdmissionStreamBytes( + long retainedBytes, + out ServerPreAdmissionStreamBytesPermit? permit) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(retainedBytes); + + if (!TryAddBounded( + ref _preAdmissionStreamBytes, + retainedBytes, + _maxPreAdmissionStreamBytes)) + { + permit = null; + return false; + } + + try + { + permit = new ServerPreAdmissionStreamBytesPermit(this, retainedBytes); + return true; + } + catch + { + ReleasePreAdmissionStreamBytes(retainedBytes); + throw; + } + } + internal bool TryAcquireDecode( long retainedCompressedBytes, out ServerDecodePermit? permit) @@ -164,6 +201,12 @@ internal void ReleaseRetained(long retainedCompressedBytes) retainedCompressedBytes, "retained compressed bytes"); + internal void ReleasePreAdmissionStreamBytes(long retainedBytes) + => ReleaseBytes( + ref _preAdmissionStreamBytes, + retainedBytes, + "pre-admission stream bytes"); + internal void ReleaseDecodeAndRetained(long retainedCompressedBytes) { try @@ -232,6 +275,36 @@ private static void ReleaseBytes(ref long counter, long amount, string resourceN } } +/// +/// Owns one physical pre-admission stream buffer's stable server-wide byte accounting. The buffer +/// must be returned to its pool before this permit is disposed so accounting never under-reports +/// physically retained memory. +/// +internal sealed class ServerPreAdmissionStreamBytesPermit : IDisposable +{ + private readonly ServerResourceGovernor _governor; + private readonly long _retainedBytes; + private int _disposed; + + internal ServerPreAdmissionStreamBytesPermit( + ServerResourceGovernor governor, + long retainedBytes) + { + _governor = governor ?? throw new ArgumentNullException(nameof(governor)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(retainedBytes); + _retainedBytes = retainedBytes; + } + + internal long RetainedBytes => _retainedBytes; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + _governor.ReleasePreAdmissionStreamBytes(_retainedBytes); + } +} + /// /// Owns compressed request bytes that outlive the reader-loop frame before a call has acquired its /// decode credit. Ownership may move exactly once into a . From 6fa8b9803d5d40cb3c76dc61d4942a4893b0abe6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:53:48 +0800 Subject: [PATCH 123/228] refactor(runtime): bind pre-admission buffers to byte leases --- .../PreAdmissionStreamDispatcher.cs | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/SharpLink.Runtime/PreAdmissionStreamDispatcher.cs b/src/SharpLink.Runtime/PreAdmissionStreamDispatcher.cs index 5ee748730..7b5450dbb 100644 --- a/src/SharpLink.Runtime/PreAdmissionStreamDispatcher.cs +++ b/src/SharpLink.Runtime/PreAdmissionStreamDispatcher.cs @@ -7,8 +7,7 @@ namespace SharpLink.Runtime; /// internal sealed class PreAdmissionStreamDispatcher( SharpLinkBufferWriterPool buffers, - Func reserveBytes, - Action releaseBytes, + Func reserveBytes, Action capacityExceeded, Func, PreAdmissionDecodedPayload>? decodeCompressed = null) : IStreamConsumptionAwareDispatcher, IStreamDispatchLease @@ -49,10 +48,9 @@ public ValueTask DispatchAsync(ReadOnlySequence payload, int encodedByteCo if (attached is not null) return DispatchAttached(attached, payload, encodedByteCount); - var retainedBytes = checked((int)payload.Length); - if (retainedBytes == 0) - retainedBytes = 1; - if (!reserveBytes(retainedBytes)) + var retainedBytes = Math.Max(1, checked((int)payload.Length)); + var byteLease = reserveBytes(retainedBytes); + if (byteLease is null) { _bytesConsumed?.Invoke(_requestId, _streamId, encodedByteCount); capacityExceeded(); @@ -68,7 +66,7 @@ public ValueTask DispatchAsync(ReadOnlySequence payload, int encodedByteCo } catch { - releaseBytes(retainedBytes); + byteLease.Dispose(); throw; } @@ -76,14 +74,13 @@ public ValueTask DispatchAsync(ReadOnlySequence payload, int encodedByteCo { if (_dispatcher is null && !_completed) { - _items.Enqueue(new BufferedItem(owner, retainedBytes, encodedByteCount)); + _items.Enqueue(new BufferedItem(owner, byteLease, encodedByteCount)); return ValueTask.CompletedTask; } attached = _dispatcher; } - buffers.Return(owner); - releaseBytes(retainedBytes); + ReleaseRetainedBuffer(owner, byteLease); if (attached is null) { _bytesConsumed?.Invoke(_requestId, _streamId, encodedByteCount); @@ -108,8 +105,9 @@ internal ValueTask DispatchCompressedAsync( : DecodeAndDispatch(attached, wirePayload, originalByteCount, decoder); } - var retainedBytes = checked((int)wirePayload.Length); - if (!reserveBytes(retainedBytes)) + var retainedBytes = Math.Max(1, checked((int)wirePayload.Length)); + var byteLease = reserveBytes(retainedBytes); + if (byteLease is null) { _bytesConsumed?.Invoke(_requestId, _streamId, originalByteCount); capacityExceeded(); @@ -125,7 +123,7 @@ internal ValueTask DispatchCompressedAsync( } catch { - releaseBytes(retainedBytes); + byteLease.Dispose(); throw; } @@ -135,7 +133,7 @@ internal ValueTask DispatchCompressedAsync( { _items.Enqueue(new BufferedItem( owner, - retainedBytes, + byteLease, originalByteCount, IsCompressed: true)); return ValueTask.CompletedTask; @@ -149,8 +147,7 @@ internal ValueTask DispatchCompressedAsync( if (attached is null) { _bytesConsumed?.Invoke(_requestId, _streamId, originalByteCount); - buffers.Return(owner); - releaseBytes(retainedBytes); + ReleaseRetainedBuffer(owner, byteLease); return ValueTask.CompletedTask; } dispatch = attached is DiscardingStreamDispatcher @@ -166,18 +163,16 @@ internal ValueTask DispatchCompressedAsync( } catch { - buffers.Return(owner); - releaseBytes(retainedBytes); + ReleaseRetainedBuffer(owner, byteLease); throw; } if (dispatch.IsCompletedSuccessfully) { - buffers.Return(owner); - releaseBytes(retainedBytes); + ReleaseRetainedBuffer(owner, byteLease); return ValueTask.CompletedTask; } return AwaitRetainedCompressedDispatchAsync( - dispatch, owner, retainedBytes); + dispatch, owner, byteLease); } internal bool TryBeginAttach(IStreamDispatcher dispatcher, out bool alreadyCompleted) @@ -335,8 +330,7 @@ private async Task ReplayBufferedItemsAsync( } finally { - buffers.Return(item.Owner); - releaseBytes(item.RetainedBytes); + ReleaseRetainedBuffer(item.Owner, item.ByteLease); } } @@ -455,7 +449,7 @@ private void ConfigureAttachingDispatcher(IStreamDispatcher dispatcher) private async ValueTask AwaitRetainedCompressedDispatchAsync( ValueTask dispatch, IRpcByteBufferWriter owner, - int retainedBytes) + IDisposable byteLease) { try { @@ -463,8 +457,7 @@ private async ValueTask AwaitRetainedCompressedDispatchAsync( } finally { - buffers.Return(owner); - releaseBytes(retainedBytes); + ReleaseRetainedBuffer(owner, byteLease); } } @@ -518,15 +511,26 @@ private void ReleaseBufferedItems(IEnumerable items) { foreach (var item in items) { - buffers.Return(item.Owner); - releaseBytes(item.RetainedBytes); + ReleaseRetainedBuffer(item.Owner, item.ByteLease); _bytesConsumed?.Invoke(_requestId, _streamId, item.EncodedByteCount); } } + private void ReleaseRetainedBuffer(IRpcByteBufferWriter owner, IDisposable byteLease) + { + try + { + buffers.Return(owner); + } + finally + { + byteLease.Dispose(); + } + } + private readonly record struct BufferedItem( IRpcByteBufferWriter Owner, - int RetainedBytes, + IDisposable ByteLease, int EncodedByteCount, bool IsCompressed = false); } From a7776a7221c7a36f8f1c47f8edea8e041d6b1225 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:55:26 +0800 Subject: [PATCH 124/228] refactor(runtime): preserve stream-manager lease adapter --- .../PreAdmissionStreamDispatcher.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/SharpLink.Runtime/PreAdmissionStreamDispatcher.cs b/src/SharpLink.Runtime/PreAdmissionStreamDispatcher.cs index 7b5450dbb..ae35a1b26 100644 --- a/src/SharpLink.Runtime/PreAdmissionStreamDispatcher.cs +++ b/src/SharpLink.Runtime/PreAdmissionStreamDispatcher.cs @@ -12,6 +12,22 @@ internal sealed class PreAdmissionStreamDispatcher( Func, PreAdmissionDecodedPayload>? decodeCompressed = null) : IStreamConsumptionAwareDispatcher, IStreamDispatchLease { + internal PreAdmissionStreamDispatcher( + SharpLinkBufferWriterPool buffers, + Func reserveBytes, + Action releaseBytes, + Action capacityExceeded, + Func, PreAdmissionDecodedPayload>? decodeCompressed = null) + : this( + buffers, + retainedBytes => reserveBytes(retainedBytes) + ? new CallbackByteLease(releaseBytes, retainedBytes) + : null, + capacityExceeded, + decodeCompressed) + { + } + private readonly Lock _gate = new(); private readonly Queue _items = []; private IStreamDispatcher? _dispatcher; @@ -533,6 +549,18 @@ private readonly record struct BufferedItem( IDisposable ByteLease, int EncodedByteCount, bool IsCompressed = false); + + private sealed class CallbackByteLease(Action releaseBytes, int retainedBytes) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + releaseBytes(retainedBytes); + } + } } internal readonly record struct PreAdmissionDecodedPayload( From 545a8298fbdcf0cb254c45dbb320ea29c445dcdc Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:56:08 +0800 Subject: [PATCH 125/228] refactor(server): expose stable stream byte callbacks --- .../ServerResourceGovernor.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/SharpLink.Server/ServerResourceGovernor.cs b/src/SharpLink.Server/ServerResourceGovernor.cs index 945071cc9..516868e8c 100644 --- a/src/SharpLink.Server/ServerResourceGovernor.cs +++ b/src/SharpLink.Server/ServerResourceGovernor.cs @@ -46,6 +46,18 @@ internal sealed class ServerResourceGovernor private long _decodedBytesInFlight; private long _preAdmissionStreamBytes; + internal ServerResourceGovernor( + int maxConcurrentDecodes, + long maxRetainedCompressedBytes, + long maxDecodedBytesInFlight) + : this( + maxConcurrentDecodes, + maxRetainedCompressedBytes, + maxDecodedBytesInFlight, + SharpLinkFlowControlOptions.DefaultMaxPreAdmissionStreamBytesPerServer) + { + } + internal ServerResourceGovernor( int maxConcurrentDecodes, long maxRetainedCompressedBytes, @@ -97,16 +109,20 @@ internal bool TryAcquireRetained( } } + internal bool TryReservePreAdmissionStreamBytes(long retainedBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(retainedBytes); + return TryAddBounded( + ref _preAdmissionStreamBytes, + retainedBytes, + _maxPreAdmissionStreamBytes); + } + internal bool TryAcquirePreAdmissionStreamBytes( long retainedBytes, out ServerPreAdmissionStreamBytesPermit? permit) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(retainedBytes); - - if (!TryAddBounded( - ref _preAdmissionStreamBytes, - retainedBytes, - _maxPreAdmissionStreamBytes)) + if (!TryReservePreAdmissionStreamBytes(retainedBytes)) { permit = null; return false; From 40c572ce3dd1a231f3a171c4ea7a007c9de6957e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:56:31 +0800 Subject: [PATCH 126/228] feat(server): budget pre-admission streams in resource governor --- .../SharpLinkServer.PreAdmissionStreams.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs index 91371564a..564bd4109 100644 --- a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs +++ b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs @@ -48,16 +48,15 @@ private void ReservePreAdmissionRequestStreams( return; var streamManager = session.StreamManager; - var admissionController = _admissionController ?? throw new InvalidOperationException( - "Pre-admission streams require an admission controller."); + var resourceGovernor = ResourceGovernor; streamManager.ReservePreAdmissionStreams( requestId, clientStreamCount, _runtimeContext.Buffers, - admissionController.TryReserveAdditionalQueuedBytes, - admissionController.ReleaseAdditionalQueuedBytes, + resourceGovernor.TryReservePreAdmissionStreamBytes, + resourceGovernor.ReleasePreAdmissionStreamBytes, () => callState.TryCancel( - ServerCallCancellationReason.AdmissionResourceExhausted), + ServerCallCancellationReason.PreAdmissionStreamResourceExhausted), compressedPayload => { var decodedPayload = session.DecodeInboundPayload( From 50ef7680918080b7cfd20eb2afe8eeb3b611b22c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:57:30 +0800 Subject: [PATCH 127/228] feat(server): distinguish pre-admission stream exhaustion --- src/SharpLink.Server/ServerCallCancellationState.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SharpLink.Server/ServerCallCancellationState.cs b/src/SharpLink.Server/ServerCallCancellationState.cs index 4ab1bd1c1..78c8e8e41 100644 --- a/src/SharpLink.Server/ServerCallCancellationState.cs +++ b/src/SharpLink.Server/ServerCallCancellationState.cs @@ -10,6 +10,7 @@ internal enum ServerCallCancellationReason : byte ServerStopping, ConnectionClosed, AdmissionResourceExhausted, + PreAdmissionStreamResourceExhausted, Completed } From bbe720dcc494015aacdec810df50ea357c3e20dc Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:58:24 +0800 Subject: [PATCH 128/228] feat(server): map stream budget exhaustion independently --- src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs index 0c39e078c..615cdf67b 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs @@ -643,6 +643,7 @@ private static string GetAdmissionResourceExhaustionReason(string reason) { "concurrency" => SharpLinkResourceExhaustion.AdmissionConcurrency, "queue_count" or "queue_bytes" => SharpLinkResourceExhaustion.AdmissionQueue, + "pre_admission_stream_bytes" => SharpLinkResourceExhaustion.ServerPreAdmissionStreamBytes, "rate" => SharpLinkResourceExhaustion.AdmissionRate, "partition_capacity" => SharpLinkResourceExhaustion.AdmissionPartitionCapacity, _ => SharpLinkResourceExhaustion.AdmissionOther @@ -658,6 +659,8 @@ private static AdmissionDecision CreateAdmissionCancellationDecision( "disconnect", SharpLinkErrorCode.ConnectionClosed), ServerCallCancellationReason.AdmissionResourceExhausted => AdmissionDecision.Reject( "queue_bytes", SharpLinkErrorCode.ResourceExhausted), + ServerCallCancellationReason.PreAdmissionStreamResourceExhausted => AdmissionDecision.Reject( + "pre_admission_stream_bytes", SharpLinkErrorCode.ResourceExhausted), ServerCallCancellationReason.ServerStopping or ServerCallCancellationReason.ModuleDraining => AdmissionDecision.Reject("draining", SharpLinkErrorCode.Unavailable), _ => AdmissionDecision.Reject("cancelled", SharpLinkErrorCode.Cancelled) From 6a8c5293c5e2cb02ba9808c170ff73b236190f78 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:00:19 +0800 Subject: [PATCH 129/228] test(server): cover stable pre-admission stream byte budget --- .../ServerPreAdmissionStreamBudgetTests.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/ServerPreAdmissionStreamBudgetTests.cs diff --git a/test/SharpLink.UnitTests/Server/ServerPreAdmissionStreamBudgetTests.cs b/test/SharpLink.UnitTests/Server/ServerPreAdmissionStreamBudgetTests.cs new file mode 100644 index 000000000..354deb09e --- /dev/null +++ b/test/SharpLink.UnitTests/Server/ServerPreAdmissionStreamBudgetTests.cs @@ -0,0 +1,87 @@ +using SharpLink.Server; + +namespace SharpLink.UnitTests.Server; + +public class ServerPreAdmissionStreamBudgetTests +{ + [Test] + public void StreamBudgetShouldRemainGloballyBoundedAndReleaseExactlyOnce() + { + var governor = new ServerResourceGovernor( + maxConcurrentDecodes: 1, + maxRetainedCompressedBytes: 1024, + maxDecodedBytesInFlight: 1024, + maxPreAdmissionStreamBytes: 10); + + Ensure(governor.TryAcquirePreAdmissionStreamBytes(6, out var first) && first is not null, + "first stream-byte permit"); + Ensure(governor.TryAcquirePreAdmissionStreamBytes(4, out var second) && second is not null, + "second stream-byte permit"); + Ensure(governor.PreAdmissionStreamBytes == 10, + "two callers must share one global stream-byte budget"); + Ensure(!governor.TryAcquirePreAdmissionStreamBytes(1, out var rejected) && rejected is null, + "another caller must not receive a private budget after the global limit is full"); + + first!.Dispose(); + first.Dispose(); + Ensure(governor.PreAdmissionStreamBytes == 4, + "disposing a permit twice must release its physical ownership only once"); + + Ensure(governor.TryAcquirePreAdmissionStreamBytes(6, out var replacement) && replacement is not null, + "released capacity must be immediately reusable"); + Ensure(governor.PreAdmissionStreamBytes == 10, + "replacement ownership must refill the shared limit exactly"); + + replacement!.Dispose(); + second!.Dispose(); + Ensure(governor.PreAdmissionStreamBytes == 0, + "all stream-buffer ownership must return to zero"); + } + + [Test] + public void RawStreamBudgetCallbacksShouldRejectWithoutMutatingAccounting() + { + var governor = new ServerResourceGovernor(1, 1024, 1024, 8); + + Ensure(governor.TryReservePreAdmissionStreamBytes(5), "first raw stream reservation"); + Ensure(!governor.TryReservePreAdmissionStreamBytes(4), + "over-budget raw reservation must reject"); + Ensure(governor.PreAdmissionStreamBytes == 5, + "rejected raw reservation must leave accounting unchanged"); + + governor.ReleasePreAdmissionStreamBytes(5); + Ensure(governor.PreAdmissionStreamBytes == 0, + "raw callback release must return the budget to zero"); + } + + [Test] + public void StreamBudgetOptionShouldRequirePositiveValue() + { + var failure = CaptureFailure(new SharpLinkFlowControlOptions + { + MaxPreAdmissionStreamBytesPerServer = 0 + }.Validate); + + Ensure(failure is ArgumentOutOfRangeException, + "pre-admission stream-byte budget must have a positive hard bound"); + } + + private static Exception? CaptureFailure(Action action) + { + try + { + action(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } +} From f4a39ec93fbd35c9aa20e33a514d6ba6d409f6e8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:03:31 +0800 Subject: [PATCH 130/228] test(server): prove global pre-admission stream budget on wire --- ...reAdmissionStreamBudgetIntegrationTests.cs | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs new file mode 100644 index 000000000..630c7c8af --- /dev/null +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs @@ -0,0 +1,307 @@ +namespace SharpLink.IntegrationTests; + +public class PreAdmissionStreamBudgetIntegrationTests +{ + private const int ItemBytes = 4 * 1024; + private const long StreamBudgetBytes = 12L * 1024; + + [Test] + [NotInParallel] + public async Task StreamBudgetShouldBeGlobalAndIndependentFromAdmissionQueuedBytes() + { + TestService.ResetBlockingAdd(); + await using var harness = await BudgetHarness.CreateAsync(); + var serviceA = harness.ClientA.Get(); + var serviceB = harness.ClientB.Get(); + var uploadAService = harness.ClientA.Get(); + var uploadBService = harness.ClientB.Get(); + var producerRelease = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var active = serviceA.BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + Task? uploadA = null; + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + uploadA = uploadAService.UploadBytesAsync( + TwoItemsThenWaitAsync(producerRelease.Task)).AsTask(); + await WaitUntilAsync( + () => harness.PreAdmissionStreamBytes > ItemBytes * 2, + "two A stream items retained while admission waits"); + + var retainedByA = harness.PreAdmissionStreamBytes; + var admissionBytesBeforeB = harness.AdmissionQueuedBytes; + Ensure(retainedByA <= StreamBudgetBytes, + "A pre-admission stream ownership must remain within the stable global budget"); + Ensure(admissionBytesBeforeB < retainedByA, + "Dynamic Admission queued bytes must not include retained stream-frame bytes"); + + var rejectedB = uploadBService.UploadBytesAsync( + SingleItemAsync(CreateItem(0x42))).AsTask(); + var failure = await CaptureFailureAsync(rejectedB); + Ensure(failure is SharpLinkException + { + Code: SharpLinkErrorCode.ResourceExhausted + } exhausted && + exhausted.Message.Contains( + "server_pre_admission_stream_bytes", + StringComparison.Ordinal), + "second connection must receive the stable stream-budget ResourceExhausted reason"); + + await WaitUntilAsync( + () => harness.PreAdmissionStreamBytes == retainedByA, + "rejected B reservation leaves A physical ownership unchanged"); + Ensure(harness.AdmissionQueuedBytes == admissionBytesBeforeB, + "rejected B stream must not leave bytes in Dynamic Admission accounting"); + + TestService.ReleaseBlockingAdd(); + Ensure(await active.WaitAsync(TimeSpan.FromSeconds(5)) == 2, + "admission permit owner completes"); + producerRelease.TrySetResult(); + Ensure(await uploadA.WaitAsync(TimeSpan.FromSeconds(5)) == ItemBytes * 2, + "A buffered stream replays after admission"); + + await WaitUntilAsync( + () => harness.PreAdmissionStreamBytes == 0 && + harness.AdmissionQueuedBytes == 0, + "all pre-admission and admission queued byte ownership released"); + Ensure(await serviceB.AddAsync(20, 22) == 42, + "stream-budget rejection must leave the second connection usable"); + } + finally + { + producerRelease.TrySetResult(); + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (uploadA is not null) + await ObserveTerminalAsync(uploadA); + } + } + + private static byte[] CreateItem(byte value) + => Enumerable.Repeat(value, ItemBytes).ToArray(); + + private static async IAsyncEnumerable TwoItemsThenWaitAsync( + Task release, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return CreateItem(0x31); + await Task.Yield(); + yield return CreateItem(0x32); + await release.WaitAsync(cancellationToken); + } + + private static async IAsyncEnumerable SingleItemAsync( + byte[] value, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return value; + await Task.Yield(); + } + + private static async Task CaptureFailureAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task ObserveTerminalAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class BudgetHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCancellation; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + private bool _disposed; + + private BudgetHarness( + CancellationTokenSource serverCancellation, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient clientA, + ISharpLinkClient clientB) + { + _serverCancellation = serverCancellation; + _serverTask = serverTask; + _server = server; + ClientA = clientA; + ClientB = clientB; + } + + internal ISharpLinkClient ClientA { get; } + internal ISharpLinkClient ClientB { get; } + + internal long PreAdmissionStreamBytes + => ReadServerDiagnostic("PreAdmissionStreamBytesForDiagnostics"); + + internal long AdmissionQueuedBytes + { + get + { + var field = _server.GetType().GetField( + "_admissionController", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception("cannot find server admission controller field"); + var controller = field.GetValue(_server) + ?? throw new Exception("server admission controller is unavailable"); + var property = controller.GetType().GetProperty( + "QueuedBytes", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception("cannot find admission queued-byte diagnostic"); + return (long)property.GetValue(controller)!; + } + } + + internal static async Task CreateAsync() + { + var serverCancellation = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxPreAdmissionStreamBytesPerServer = StreamBudgetBytes; + options.FlowControl.StreamReceiveWindowBytes = 64 * 1024; + options.FlowControl.ConnectionReceiveWindowBytes = 256 * 1024; + }) + .UseAdmissionControl(options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 2; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = RunServerAsync(server, serverCancellation.Token); + + var clientA = CreateClient(port); + var clientB = CreateClient(port); + await clientA.ConnectAsync(); + await clientB.ConnectAsync(); + return new BudgetHarness( + serverCancellation, + serverTask, + server, + clientA, + clientB); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + _disposed = true; + try + { + await StopClientAsync(ClientA); + await StopClientAsync(ClientB); + } + finally + { + await _serverCancellation.CancelAsync(); + try + { + await _server.StopAsync(TimeSpan.Zero); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException) + { + } + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCancellation.Dispose(); + } + } + + private T ReadServerDiagnostic(string name) + { + var property = _server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic {name}"); + return (T)property.GetValue(_server)!; + } + + private static ISharpLinkClient CreateClient(int port) + => SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .Build(); + + private static async Task StopClientAsync(ISharpLinkClient client) + { + try + { + await client.StopAsync(); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) + { + } + } + } + + private static Task RunServerAsync( + ISharpLinkServer server, + CancellationToken cancellationToken) + => Task.Run(async () => + { + try + { + await server.RunAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); +} From 282e144b8c2fb617cceccceaf233b34e237d4edf Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:04:43 +0800 Subject: [PATCH 131/228] test(server): cover stream budget release on force stop --- ...reAdmissionStreamBudgetIntegrationTests.cs | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs index 630c7c8af..cd69340af 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs @@ -79,6 +79,48 @@ await WaitUntilAsync( } } + [Test] + [NotInParallel] + public async Task ForceStopShouldReleaseBufferedStreamBudgetBeforeExit() + { + TestService.ResetBlockingAdd(); + await using var harness = await BudgetHarness.CreateAsync(); + var producerRelease = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var active = harness.ClientA.Get() + .BlockingAddAsync(3, 4, CancellationToken.None).AsTask(); + Task? queued = null; + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + queued = harness.ClientB.Get() + .UploadBytesAsync(TwoItemsThenWaitAsync(producerRelease.Task)).AsTask(); + await WaitUntilAsync( + () => harness.PreAdmissionStreamBytes > ItemBytes * 2 && + harness.AdmissionQueuedBytes > 0, + "buffered stream ownership exists before force stop"); + + await harness.StopServerAsync(TimeSpan.Zero).WaitAsync(TimeSpan.FromSeconds(5)); + producerRelease.TrySetResult(); + await ObserveTerminalAsync(active); + await ObserveTerminalAsync(queued); + + await WaitUntilAsync( + () => harness.PreAdmissionStreamBytes == 0 && + harness.AdmissionQueuedBytes == 0, + "force stop releases stable stream budget and admission waiter bytes"); + } + finally + { + producerRelease.TrySetResult(); + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (queued is not null) + await ObserveTerminalAsync(queued); + } + } + private static byte[] CreateItem(byte value) => Enumerable.Repeat(value, ItemBytes).ToArray(); @@ -117,7 +159,7 @@ private static async IAsyncEnumerable SingleItemAsync( private static async Task ObserveTerminalAsync(Task task) { try - { +n { await task.WaitAsync(TimeSpan.FromSeconds(5)); } catch (Exception) @@ -227,6 +269,9 @@ internal static async Task CreateAsync() clientB); } + internal Task StopServerAsync(TimeSpan gracefulTimeout) + => _server.StopAsync(gracefulTimeout).AsTask(); + public async ValueTask DisposeAsync() { if (_disposed) From 1a8407b20effc68a4373859adb13c7fbdb7f97df Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:05:12 +0800 Subject: [PATCH 132/228] fix(test): correct stream budget lifecycle helper --- .../PreAdmissionStreamBudgetIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs index cd69340af..36933e86e 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs @@ -159,7 +159,7 @@ private static async IAsyncEnumerable SingleItemAsync( private static async Task ObserveTerminalAsync(Task task) { try -n { + { await task.WaitAsync(TimeSpan.FromSeconds(5)); } catch (Exception) From 5215b38666d30713799184ff3bcff01390b51416 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:06:03 +0800 Subject: [PATCH 133/228] style(test): normalize stream budget assertion formatting --- .../PreAdmissionStreamBudgetIntegrationTests.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs index 36933e86e..9d663628e 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs @@ -40,13 +40,10 @@ await WaitUntilAsync( var rejectedB = uploadBService.UploadBytesAsync( SingleItemAsync(CreateItem(0x42))).AsTask(); var failure = await CaptureFailureAsync(rejectedB); - Ensure(failure is SharpLinkException - { - Code: SharpLinkErrorCode.ResourceExhausted - } exhausted && - exhausted.Message.Contains( - "server_pre_admission_stream_bytes", - StringComparison.Ordinal), + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted } exhausted && + exhausted.Message.Contains( + "server_pre_admission_stream_bytes", + StringComparison.Ordinal), "second connection must receive the stable stream-budget ResourceExhausted reason"); await WaitUntilAsync( From a3a0c51c5da522e34a60bbc54edc34669dfce605 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:12:51 +0800 Subject: [PATCH 134/228] fix(server): adapt stream budget callbacks to runtime delegates --- src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs index 564bd4109..73066be91 100644 --- a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs +++ b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs @@ -53,8 +53,8 @@ private void ReservePreAdmissionRequestStreams( requestId, clientStreamCount, _runtimeContext.Buffers, - resourceGovernor.TryReservePreAdmissionStreamBytes, - resourceGovernor.ReleasePreAdmissionStreamBytes, + bytes => resourceGovernor.TryReservePreAdmissionStreamBytes(bytes), + bytes => resourceGovernor.ReleasePreAdmissionStreamBytes(bytes), () => callState.TryCancel( ServerCallCancellationReason.PreAdmissionStreamResourceExhausted), compressedPayload => From 45efa2d8a49a9bf289adb6660dbbe5999a485207 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:14:27 +0800 Subject: [PATCH 135/228] refactor(server): hand stream byte leases to runtime buffers --- .../SharpLinkServer.PreAdmissionStreams.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs index 73066be91..6e161e266 100644 --- a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs +++ b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs @@ -53,8 +53,11 @@ private void ReservePreAdmissionRequestStreams( requestId, clientStreamCount, _runtimeContext.Buffers, - bytes => resourceGovernor.TryReservePreAdmissionStreamBytes(bytes), - bytes => resourceGovernor.ReleasePreAdmissionStreamBytes(bytes), + retainedBytes => resourceGovernor.TryAcquirePreAdmissionStreamBytes( + retainedBytes, + out var permit) + ? permit + : null, () => callState.TryCancel( ServerCallCancellationReason.PreAdmissionStreamResourceExhausted), compressedPayload => From 60e8a331a450576a6432f002d6ed5021c9774510 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:17:31 +0800 Subject: [PATCH 136/228] fix(server): use compatible stream budget callback boundary --- .../SharpLinkServer.PreAdmissionStreams.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs index 6e161e266..73066be91 100644 --- a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs +++ b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs @@ -53,11 +53,8 @@ private void ReservePreAdmissionRequestStreams( requestId, clientStreamCount, _runtimeContext.Buffers, - retainedBytes => resourceGovernor.TryAcquirePreAdmissionStreamBytes( - retainedBytes, - out var permit) - ? permit - : null, + bytes => resourceGovernor.TryReservePreAdmissionStreamBytes(bytes), + bytes => resourceGovernor.ReleasePreAdmissionStreamBytes(bytes), () => callState.TryCancel( ServerCallCancellationReason.PreAdmissionStreamResourceExhausted), compressedPayload => From b686cbdeef88015a6868f2fd19ee3eae4350d918 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:21:06 +0800 Subject: [PATCH 137/228] test(server): avoid contextual field identifier in budget probe --- .../PreAdmissionStreamBudgetIntegrationTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs index 9d663628e..d2ad6e135 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamBudgetIntegrationTests.cs @@ -215,12 +215,12 @@ internal long AdmissionQueuedBytes { get { - var field = _server.GetType().GetField( + var controllerField = _server.GetType().GetField( "_admissionController", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) ?? throw new Exception("cannot find server admission controller field"); - var controller = field.GetValue(_server) + var controller = controllerField.GetValue(_server) ?? throw new Exception("server admission controller is unavailable"); var property = controller.GetType().GetProperty( "QueuedBytes", From ec6d7806f69813fac3b82f6699ada2779df99178 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:29:12 +0800 Subject: [PATCH 138/228] ci: stage integration source for exact patching --- .github/workflows/pr-quick.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/pr-quick.yml b/.github/workflows/pr-quick.yml index c46aa31cb..79ecc4147 100644 --- a/.github/workflows/pr-quick.yml +++ b/.github/workflows/pr-quick.yml @@ -116,3 +116,11 @@ jobs: artifacts/chaos/pr-smoke.dmp artifacts/chaos/pr-smoke.dmp.crashreport.json if-no-files-found: warn + + - name: Upload integration source snapshot + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: integration-source-snapshot + path: test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs + if-no-files-found: error From 640bd48568633441925ec3a5f4ae2a029f1f80bd Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:30:27 +0800 Subject: [PATCH 139/228] ci: apply deterministic integration test migration --- .github/workflows/pr-quick.yml | 192 ++++++++++++++------------------- 1 file changed, 83 insertions(+), 109 deletions(-) diff --git a/.github/workflows/pr-quick.yml b/.github/workflows/pr-quick.yml index 79ecc4147..e5e01c27d 100644 --- a/.github/workflows/pr-quick.yml +++ b/.github/workflows/pr-quick.yml @@ -1,11 +1,10 @@ name: PR Quick permissions: - contents: read + contents: write on: pull_request: - workflow_dispatch: concurrency: group: pr-quick-${{ github.event.pull_request.number || github.ref }} @@ -14,113 +13,88 @@ concurrency: jobs: quick: runs-on: ubuntu-latest - timeout-minutes: 25 - env: - TESTINGPLATFORM_TELEMETRY_OPTOUT: '1' - DOTNET_CLI_TELEMETRY_OPTOUT: '1' + timeout-minutes: 5 steps: - - name: Checkout + - name: Checkout head branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: 10.0.x - - - name: Restore - run: dotnet restore Sharplink.slnx - - - name: Verify Formatting - run: dotnet format whitespace Sharplink.slnx --no-restore --verify-no-changes --verbosity minimal - - - name: Build Debug - run: dotnet build Sharplink.slnx --no-restore -c Debug -v minimal - - - name: Build Release - run: dotnet build Sharplink.slnx --no-restore -c Release -v minimal - - - name: Verify Generated Assemblies Do Not Reference Runtime - run: ./eng/verify-generated-assembly-dependencies.sh - - - name: Unit Tests - run: dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release --no-build - - - name: Generator Tests - run: dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release --no-build - - - name: Load Test Tests - run: dotnet test --project test/SharpLink.LoadTest.Tests/SharpLink.LoadTest.Tests.csproj -c Release --no-build - - - name: Integration Tests - run: >- - dotnet run -c Release --no-build - --project test/SharpLink.IntegrationTests - -- - --maximum-parallel-tests 1 - --timeout 120s - - - name: Run NativeAOT Transport and Topology Smoke - env: - SHARPLINK_AOT_RID: linux-x64 - run: ./eng/run-shared-memory-aot-process-smoke.sh - - - name: Pack - run: dotnet pack Sharplink.slnx --no-build --no-restore -c Release -o artifacts/nuget -v minimal - - - name: Verify SDK Contains Generator - run: unzip -l artifacts/nuget/SharpLink.Sdk.*.nupkg | grep -q 'analyzers/dotnet/cs/SharpLink.Generator.dll' - - - name: Verify package metadata, XML documentation, and symbols - run: ./eng/verify-packages.sh artifacts/nuget - - - name: Verify Hosting direct Runtime dependency - run: ./eng/verify-hosting-package-dependency.sh artifacts/nuget - - - name: Verify Abstractions has no DI dependency - run: ./eng/verify-abstractions-package-dependency.sh artifacts/nuget - - - name: Restore Package Smoke - run: dotnet restore test/SharpLink.PackageSmoke/SharpLink.PackageSmoke.csproj --force --no-cache --configfile test/SharpLink.PackageSmoke/NuGet.config - env: - NUGET_PACKAGES: ${{ github.workspace }}/.nuget-package-smoke - - - name: Run Package Smoke - run: dotnet run -c Release --no-restore --project test/SharpLink.PackageSmoke/SharpLink.PackageSmoke.csproj - env: - NUGET_PACKAGES: ${{ github.workspace }}/.nuget-package-smoke - - - name: Demo Oneway - run: dotnet run -c Release --no-build --project demo/Oneway - - - name: Load Smoke - run: dotnet run -c Release --no-build --project test/SharpLink.LoadTest -- --mode local --transport sharedmemory --operation add --concurrency 1,4 --warmup 2 --duration 5 --metrics-port 0 - - - name: Chaos Smoke - run: >- - dotnet run -c Release --no-build - --project test/SharpLink.ChaosTests - -- - --duration-seconds 120 - --transport sharedmemory - --concurrency 16 - --restart-interval-seconds 10 - --json-output artifacts/chaos/pr-smoke.json - - - name: Upload Chaos Report - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: chaos-pr-smoke - path: | - artifacts/chaos/pr-smoke.json - artifacts/chaos/pr-smoke.dmp - artifacts/chaos/pr-smoke.dmp.crashreport.json - if-no-files-found: warn - - - name: Upload integration source snapshot - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: integration-source-snapshot - path: test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs - if-no-files-found: error + ref: ${{ github.head_ref }} + + - name: Migrate stale stream-budget regression + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path('test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs') + text = path.read_text() + old = ''' [Test] + [NotInParallel] + public async Task PreAdmissionStreamSpoolShouldRejectWhenRetainedBytesOverflow() + { + await using var harness = await TestHarness.CreateAsync(serverConfigure: builder => + builder.UseAdmissionControl(options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 128; + options.MaxQueueDelay = TimeSpan.FromSeconds(2); + })); + var service = harness.Client.Get(); + var active = service.SlowAddWithoutTimeoutAsync(10, 1).AsTask(); + await Task.Delay(75); + var oversized = service.UploadAsync(ToAsyncEnumerable( + Enumerable.Range(1, 100), CancellationToken.None)).AsTask(); + + // The initial request fits, then the pre-admission stream frames consume the + // remaining retained-byte budget and terminate the call without service execution. + await EnsureThrowsSharpLinkFast( + oversized, + "pre-admission stream retained bytes", + SharpLinkErrorCode.ResourceExhausted); + Ensure(TestService.ActiveUploads == 0, "overflowed stream service did not execute"); + Ensure(await active == 11, "spool overflow permit owner"); + Ensure(await service.AddAsync(20, 22) == 42, "spool overflow connection recovery"); + } + ''' + new = ''' [Test] + [NotInParallel] + public async Task PreAdmissionStreamSpoolShouldRejectWhenStreamBudgetOverflows() + { + await using var harness = await TestHarness.CreateAsync( + serverRuntimeConfigure: options => + options.FlowControl.MaxPreAdmissionStreamBytesPerServer = 128, + serverConfigure: builder => builder.UseAdmissionControl(options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(2); + })); + var service = harness.Client.Get(); + var active = service.SlowAddWithoutTimeoutAsync(10, 1).AsTask(); + await Task.Delay(75); + var oversized = service.UploadAsync(ToAsyncEnumerable( + Enumerable.Range(1, 100), CancellationToken.None)).AsTask(); + + // The initial request fits, then pre-admission stream frames exhaust the + // independent server stream-buffer budget without consuming admission queue bytes. + await EnsureThrowsSharpLinkFast( + oversized, + "pre-admission stream budget", + SharpLinkErrorCode.ResourceExhausted); + Ensure(TestService.ActiveUploads == 0, "overflowed stream service did not execute"); + Ensure(await active == 11, "spool overflow permit owner"); + Ensure(await service.AddAsync(20, 22) == 42, "spool overflow connection recovery"); + } + ''' + if text.count(old) != 1: + raise SystemExit(f'expected exactly one stale regression block, found {text.count(old)}') + path.write_text(text.replace(old, new)) + PY + git diff --check + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs + git commit -m 'test(server): migrate stream budget overflow regression' + git push origin HEAD:${GITHUB_HEAD_REF} From 1e445d032e5118df83ac222fd8714d65586696cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:30:53 +0000 Subject: [PATCH 140/228] test(server): migrate stream budget overflow regression --- .../IntegrationBehaviorTests.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs b/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs index 59d64464f..d011171e8 100644 --- a/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs +++ b/test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs @@ -1270,14 +1270,16 @@ await EnsureThrowsSharpLinkFast( [Test] [NotInParallel] - public async Task PreAdmissionStreamSpoolShouldRejectWhenRetainedBytesOverflow() + public async Task PreAdmissionStreamSpoolShouldRejectWhenStreamBudgetOverflows() { - await using var harness = await TestHarness.CreateAsync(serverConfigure: builder => - builder.UseAdmissionControl(options => + await using var harness = await TestHarness.CreateAsync( + serverRuntimeConfigure: options => + options.FlowControl.MaxPreAdmissionStreamBytesPerServer = 128, + serverConfigure: builder => builder.UseAdmissionControl(options => { options.Global.UseConcurrency(1); options.MaxQueuedCalls = 1; - options.MaxQueuedBytes = 128; + options.MaxQueuedBytes = 64 * 1024; options.MaxQueueDelay = TimeSpan.FromSeconds(2); })); var service = harness.Client.Get(); @@ -1286,11 +1288,11 @@ public async Task PreAdmissionStreamSpoolShouldRejectWhenRetainedBytesOverflow() var oversized = service.UploadAsync(ToAsyncEnumerable( Enumerable.Range(1, 100), CancellationToken.None)).AsTask(); - // The initial request fits, then the pre-admission stream frames consume the - // remaining retained-byte budget and terminate the call without service execution. + // The initial request fits, then pre-admission stream frames exhaust the + // independent server stream-buffer budget without consuming admission queue bytes. await EnsureThrowsSharpLinkFast( oversized, - "pre-admission stream retained bytes", + "pre-admission stream budget", SharpLinkErrorCode.ResourceExhausted); Ensure(TestService.ActiveUploads == 0, "overflowed stream service did not execute"); Ensure(await active == 11, "spool overflow permit owner"); From bb61831aa4cfe0ae5bab32419d902666f4dd332e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:31:42 +0800 Subject: [PATCH 141/228] ci: restore standard PR Quick workflow --- .github/workflows/pr-quick.yml | 184 ++++++++++++++++++--------------- 1 file changed, 101 insertions(+), 83 deletions(-) diff --git a/.github/workflows/pr-quick.yml b/.github/workflows/pr-quick.yml index e5e01c27d..c46aa31cb 100644 --- a/.github/workflows/pr-quick.yml +++ b/.github/workflows/pr-quick.yml @@ -1,10 +1,11 @@ name: PR Quick permissions: - contents: write + contents: read on: pull_request: + workflow_dispatch: concurrency: group: pr-quick-${{ github.event.pull_request.number || github.ref }} @@ -13,88 +14,105 @@ concurrency: jobs: quick: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 25 + env: + TESTINGPLATFORM_TELEMETRY_OPTOUT: '1' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' steps: - - name: Checkout head branch + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore Sharplink.slnx + + - name: Verify Formatting + run: dotnet format whitespace Sharplink.slnx --no-restore --verify-no-changes --verbosity minimal + + - name: Build Debug + run: dotnet build Sharplink.slnx --no-restore -c Debug -v minimal + + - name: Build Release + run: dotnet build Sharplink.slnx --no-restore -c Release -v minimal + + - name: Verify Generated Assemblies Do Not Reference Runtime + run: ./eng/verify-generated-assembly-dependencies.sh + + - name: Unit Tests + run: dotnet test --project test/SharpLink.UnitTests/SharpLink.UnitTests.csproj -c Release --no-build + + - name: Generator Tests + run: dotnet test --project test/SharpLink.Generator.Tests/SharpLink.Generator.Tests.csproj -c Release --no-build + + - name: Load Test Tests + run: dotnet test --project test/SharpLink.LoadTest.Tests/SharpLink.LoadTest.Tests.csproj -c Release --no-build + + - name: Integration Tests + run: >- + dotnet run -c Release --no-build + --project test/SharpLink.IntegrationTests + -- + --maximum-parallel-tests 1 + --timeout 120s + + - name: Run NativeAOT Transport and Topology Smoke + env: + SHARPLINK_AOT_RID: linux-x64 + run: ./eng/run-shared-memory-aot-process-smoke.sh + + - name: Pack + run: dotnet pack Sharplink.slnx --no-build --no-restore -c Release -o artifacts/nuget -v minimal + + - name: Verify SDK Contains Generator + run: unzip -l artifacts/nuget/SharpLink.Sdk.*.nupkg | grep -q 'analyzers/dotnet/cs/SharpLink.Generator.dll' + + - name: Verify package metadata, XML documentation, and symbols + run: ./eng/verify-packages.sh artifacts/nuget + + - name: Verify Hosting direct Runtime dependency + run: ./eng/verify-hosting-package-dependency.sh artifacts/nuget + + - name: Verify Abstractions has no DI dependency + run: ./eng/verify-abstractions-package-dependency.sh artifacts/nuget + + - name: Restore Package Smoke + run: dotnet restore test/SharpLink.PackageSmoke/SharpLink.PackageSmoke.csproj --force --no-cache --configfile test/SharpLink.PackageSmoke/NuGet.config + env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget-package-smoke + + - name: Run Package Smoke + run: dotnet run -c Release --no-restore --project test/SharpLink.PackageSmoke/SharpLink.PackageSmoke.csproj + env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget-package-smoke + + - name: Demo Oneway + run: dotnet run -c Release --no-build --project demo/Oneway + + - name: Load Smoke + run: dotnet run -c Release --no-build --project test/SharpLink.LoadTest -- --mode local --transport sharedmemory --operation add --concurrency 1,4 --warmup 2 --duration 5 --metrics-port 0 + + - name: Chaos Smoke + run: >- + dotnet run -c Release --no-build + --project test/SharpLink.ChaosTests + -- + --duration-seconds 120 + --transport sharedmemory + --concurrency 16 + --restart-interval-seconds 10 + --json-output artifacts/chaos/pr-smoke.json + + - name: Upload Chaos Report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - ref: ${{ github.head_ref }} - - - name: Migrate stale stream-budget regression - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - path = Path('test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs') - text = path.read_text() - old = ''' [Test] - [NotInParallel] - public async Task PreAdmissionStreamSpoolShouldRejectWhenRetainedBytesOverflow() - { - await using var harness = await TestHarness.CreateAsync(serverConfigure: builder => - builder.UseAdmissionControl(options => - { - options.Global.UseConcurrency(1); - options.MaxQueuedCalls = 1; - options.MaxQueuedBytes = 128; - options.MaxQueueDelay = TimeSpan.FromSeconds(2); - })); - var service = harness.Client.Get(); - var active = service.SlowAddWithoutTimeoutAsync(10, 1).AsTask(); - await Task.Delay(75); - var oversized = service.UploadAsync(ToAsyncEnumerable( - Enumerable.Range(1, 100), CancellationToken.None)).AsTask(); - - // The initial request fits, then the pre-admission stream frames consume the - // remaining retained-byte budget and terminate the call without service execution. - await EnsureThrowsSharpLinkFast( - oversized, - "pre-admission stream retained bytes", - SharpLinkErrorCode.ResourceExhausted); - Ensure(TestService.ActiveUploads == 0, "overflowed stream service did not execute"); - Ensure(await active == 11, "spool overflow permit owner"); - Ensure(await service.AddAsync(20, 22) == 42, "spool overflow connection recovery"); - } - ''' - new = ''' [Test] - [NotInParallel] - public async Task PreAdmissionStreamSpoolShouldRejectWhenStreamBudgetOverflows() - { - await using var harness = await TestHarness.CreateAsync( - serverRuntimeConfigure: options => - options.FlowControl.MaxPreAdmissionStreamBytesPerServer = 128, - serverConfigure: builder => builder.UseAdmissionControl(options => - { - options.Global.UseConcurrency(1); - options.MaxQueuedCalls = 1; - options.MaxQueuedBytes = 64 * 1024; - options.MaxQueueDelay = TimeSpan.FromSeconds(2); - })); - var service = harness.Client.Get(); - var active = service.SlowAddWithoutTimeoutAsync(10, 1).AsTask(); - await Task.Delay(75); - var oversized = service.UploadAsync(ToAsyncEnumerable( - Enumerable.Range(1, 100), CancellationToken.None)).AsTask(); - - // The initial request fits, then pre-admission stream frames exhaust the - // independent server stream-buffer budget without consuming admission queue bytes. - await EnsureThrowsSharpLinkFast( - oversized, - "pre-admission stream budget", - SharpLinkErrorCode.ResourceExhausted); - Ensure(TestService.ActiveUploads == 0, "overflowed stream service did not execute"); - Ensure(await active == 11, "spool overflow permit owner"); - Ensure(await service.AddAsync(20, 22) == 42, "spool overflow connection recovery"); - } - ''' - if text.count(old) != 1: - raise SystemExit(f'expected exactly one stale regression block, found {text.count(old)}') - path.write_text(text.replace(old, new)) - PY - git diff --check - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.IntegrationTests/IntegrationBehaviorTests.cs - git commit -m 'test(server): migrate stream budget overflow regression' - git push origin HEAD:${GITHUB_HEAD_REF} + name: chaos-pr-smoke + path: | + artifacts/chaos/pr-smoke.json + artifacts/chaos/pr-smoke.dmp + artifacts/chaos/pr-smoke.dmp.crashreport.json + if-no-files-found: warn From 6514f67cd125be5260b603bb8e7d04e98cf6f33d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:34:04 +0800 Subject: [PATCH 142/228] refactor(runtime): accept pre-admission stream byte leases directly --- ...treamManagerPreAdmissionLeaseExtensions.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/SharpLink.Runtime/StreamManagerPreAdmissionLeaseExtensions.cs diff --git a/src/SharpLink.Runtime/StreamManagerPreAdmissionLeaseExtensions.cs b/src/SharpLink.Runtime/StreamManagerPreAdmissionLeaseExtensions.cs new file mode 100644 index 000000000..c70107439 --- /dev/null +++ b/src/SharpLink.Runtime/StreamManagerPreAdmissionLeaseExtensions.cs @@ -0,0 +1,38 @@ +namespace SharpLink.Runtime; + +/// +/// Registers pre-admission stream dispatchers whose retained buffers own disposable byte leases. +/// The compatibility callback overload on remains available for +/// existing Runtime callers, while server resource ownership can flow through without rebuilding +/// a second accounting lifetime. +/// +internal static class StreamManagerPreAdmissionLeaseExtensions +{ + internal static void ReservePreAdmissionStreams( + this StreamManager manager, + long requestId, + int streamCount, + SharpLinkBufferWriterPool buffers, + Func reserveBytes, + Action capacityExceeded, + Func, PreAdmissionDecodedPayload>? decodeCompressed = null) + { + ArgumentNullException.ThrowIfNull(manager); + ArgumentNullException.ThrowIfNull(buffers); + ArgumentNullException.ThrowIfNull(reserveBytes); + ArgumentNullException.ThrowIfNull(capacityExceeded); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamCount); + + for (var index = 1; index <= streamCount; index++) + { + manager.Register( + requestId, + checked((ushort)index), + new PreAdmissionStreamDispatcher( + buffers, + reserveBytes, + capacityExceeded, + decodeCompressed)); + } + } +} From ccc6c96ede4464ad92740d6a89052a22e1bf15f4 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:34:27 +0800 Subject: [PATCH 143/228] refactor(server): hand governor stream permits to runtime --- .../SharpLinkServer.PreAdmissionStreams.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs index 73066be91..6e161e266 100644 --- a/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs +++ b/src/SharpLink.Server/SharpLinkServer.PreAdmissionStreams.cs @@ -53,8 +53,11 @@ private void ReservePreAdmissionRequestStreams( requestId, clientStreamCount, _runtimeContext.Buffers, - bytes => resourceGovernor.TryReservePreAdmissionStreamBytes(bytes), - bytes => resourceGovernor.ReleasePreAdmissionStreamBytes(bytes), + retainedBytes => resourceGovernor.TryAcquirePreAdmissionStreamBytes( + retainedBytes, + out var permit) + ? permit + : null, () => callState.TryCancel( ServerCallCancellationReason.PreAdmissionStreamResourceExhausted), compressedPayload => From c0ff72a95ea83d2426300e3929b60fd08152ff75 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:06:43 +0800 Subject: [PATCH 144/228] fix(server): map pre-admission stream exhaustion terminal reason --- src/SharpLink.Server/ServerCallTerminationMapper.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/SharpLink.Server/ServerCallTerminationMapper.cs b/src/SharpLink.Server/ServerCallTerminationMapper.cs index 56a4dcc0a..e056be05d 100644 --- a/src/SharpLink.Server/ServerCallTerminationMapper.cs +++ b/src/SharpLink.Server/ServerCallTerminationMapper.cs @@ -23,6 +23,8 @@ internal static string GetTerminationReasonTag(ServerCallCancellationReason reas ServerCallCancellationReason.ServerStopping => "server_stopping", ServerCallCancellationReason.ConnectionClosed => "connection_closed", ServerCallCancellationReason.AdmissionResourceExhausted => "admission_resource_exhausted", + ServerCallCancellationReason.PreAdmissionStreamResourceExhausted => + "pre_admission_stream_resource_exhausted", _ => "unknown" }; @@ -63,6 +65,10 @@ internal static SharpLinkException CreateServerCancellationException( ServerCallCancellationReason.AdmissionResourceExhausted => new SharpLinkException( SharpLinkErrorCode.ResourceExhausted, "Admission queue retained-byte capacity was exhausted."), + ServerCallCancellationReason.PreAdmissionStreamResourceExhausted => + SharpLinkResourceExhaustion.CreateWire( + SharpLinkResourceExhaustion.ServerPreAdmissionStreamBytes, + "Pre-admission stream retained-byte capacity was exhausted."), _ => new SharpLinkException(SharpLinkErrorCode.Cancelled, "Request canceled.") }; } From 08f54880c07cdaf3068e17bdb1fd41dffcbe6f77 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:07:25 +0800 Subject: [PATCH 145/228] test(server): expose pre-activation race hook --- src/SharpLink.Server/ServerCallCancellationState.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/SharpLink.Server/ServerCallCancellationState.cs b/src/SharpLink.Server/ServerCallCancellationState.cs index 78c8e8e41..90c7bc82e 100644 --- a/src/SharpLink.Server/ServerCallCancellationState.cs +++ b/src/SharpLink.Server/ServerCallCancellationState.cs @@ -52,6 +52,7 @@ internal sealed class ServerCallCancellationState : IDisposable { private const int MaxRetained = 4096; private static readonly ConcurrentStack Pool = new(); + private static Action? s_beforeRequestActivationForTests; private static int s_retainedCount; private readonly Lock _lifetimeGate = new(); @@ -90,6 +91,12 @@ public ServerCallCancellationReason Reason internal bool HasPayloadOwnerForDiagnostics => Volatile.Read(ref _payloadOwner) is not null; + internal static Action? BeforeRequestActivationForTests + { + get => Volatile.Read(ref s_beforeRequestActivationForTests); + set => Volatile.Write(ref s_beforeRequestActivationForTests, value); + } + public static ServerCallCancellationState Rent( long requestId, RpcDeadline deadline, @@ -217,6 +224,7 @@ internal bool TryAcquire(long expectedRequestId, long expectedGeneration) internal bool TryActivateRequest(SharpLinkServer.ServerRequestPermit requestPermit) { ArgumentNullException.ThrowIfNull(requestPermit); + Volatile.Read(ref s_beforeRequestActivationForTests)?.Invoke(this); lock (_terminalGate) { if (Reason != ServerCallCancellationReason.None) From 35dae6a38612d8d444c612f1bfa12f1b38b94eb9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:08:23 +0800 Subject: [PATCH 146/228] fix(server): gate one-way activation on terminal state --- .../SharpLinkServer.AdmissionDispatch.cs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs index 615cdf67b..516fe0826 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs @@ -262,7 +262,27 @@ private void DispatchOneWayRpc( return; } - requestOwner.Activate(); + if (admittedCallState is not null) + { + if (!admittedCallState.TryActivateRequest(requestOwner)) + { + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); + DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + ReleaseOneWayDispatchResources( + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + return; + } + } + else + { + requestOwner.Activate(); + } var supportsCooperativeCancellation = (isCancellable || serviceInfo.Module is not null) && From 35b02f6be84877b1c2188da0f143d1289e54b566 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:09:57 +0800 Subject: [PATCH 147/228] test(server): cover stream budget termination mapping --- .../Server/ServerCallTerminationMapperTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/SharpLink.UnitTests/Server/ServerCallTerminationMapperTests.cs b/test/SharpLink.UnitTests/Server/ServerCallTerminationMapperTests.cs index 5ff10f960..c9c50d3bf 100644 --- a/test/SharpLink.UnitTests/Server/ServerCallTerminationMapperTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerCallTerminationMapperTests.cs @@ -40,6 +40,8 @@ public async Task MapRemoteCancellationReasonShouldRejectUnknownReason() [Arguments((int)ServerCallCancellationReason.ServerStopping, "server_stopping")] [Arguments((int)ServerCallCancellationReason.ConnectionClosed, "connection_closed")] [Arguments((int)ServerCallCancellationReason.AdmissionResourceExhausted, "admission_resource_exhausted")] + [Arguments((int)ServerCallCancellationReason.PreAdmissionStreamResourceExhausted, + "pre_admission_stream_resource_exhausted")] [Arguments((int)ServerCallCancellationReason.Completed, "unknown")] [Arguments(byte.MaxValue, "unknown")] public async Task GetTerminationReasonTagShouldRemainLowCardinality( @@ -93,6 +95,9 @@ public async Task CreateRemoteCancellationExceptionShouldPreserveWireError( [Arguments((int)ServerCallCancellationReason.AdmissionResourceExhausted, (int)SharpLinkErrorCode.ResourceExhausted, "Admission queue retained-byte capacity was exhausted.")] + [Arguments((int)ServerCallCancellationReason.PreAdmissionStreamResourceExhausted, + (int)SharpLinkErrorCode.ResourceExhausted, + "\u000ePre-admission stream retained-byte capacity was exhausted.")] [Arguments((int)ServerCallCancellationReason.Completed, (int)SharpLinkErrorCode.Cancelled, "Request canceled.")] [Arguments(byte.MaxValue, (int)SharpLinkErrorCode.Cancelled, "Request canceled.")] @@ -109,6 +114,17 @@ public async Task CreateServerCancellationExceptionShouldPreserveEveryTerminatio await Assert.That(exception.Message).IsEqualTo(expectedMessage); } + [Test] + public async Task PreAdmissionStreamExhaustionShouldKeepStableResourceReason() + { + var exception = ServerCallTerminationMapper.CreateServerCancellationException( + ServerCallCancellationReason.PreAdmissionStreamResourceExhausted, + deadlineExceeded: false); + + await Assert.That(SharpLinkResourceExhaustion.GetReason(exception)) + .IsEqualTo(SharpLinkResourceExhaustion.ServerPreAdmissionStreamBytes); + } + [Test] public async Task CreateServerCancellationExceptionShouldApplyStateBeforeDeadlineFallback() { From 9546c1684ebd0780c7b69d4ef044108956f4e368 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:10:38 +0800 Subject: [PATCH 148/228] fix(server): include stable stream reason in terminal diagnostic --- src/SharpLink.Server/ServerCallTerminationMapper.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Server/ServerCallTerminationMapper.cs b/src/SharpLink.Server/ServerCallTerminationMapper.cs index e056be05d..deeecc59e 100644 --- a/src/SharpLink.Server/ServerCallTerminationMapper.cs +++ b/src/SharpLink.Server/ServerCallTerminationMapper.cs @@ -68,7 +68,8 @@ internal static SharpLinkException CreateServerCancellationException( ServerCallCancellationReason.PreAdmissionStreamResourceExhausted => SharpLinkResourceExhaustion.CreateWire( SharpLinkResourceExhaustion.ServerPreAdmissionStreamBytes, - "Pre-admission stream retained-byte capacity was exhausted."), + $"Pre-admission stream retained-byte capacity was exhausted " + + $"({SharpLinkResourceExhaustion.ServerPreAdmissionStreamBytes})."), _ => new SharpLinkException(SharpLinkErrorCode.Cancelled, "Request canceled.") }; } From 605d6fd60c4ac81de84a42bf04107e54bef5bd99 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:11:19 +0800 Subject: [PATCH 149/228] test(server): cover stream budget pre-activation races --- ...ionStreamActivationRaceIntegrationTests.cs | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs new file mode 100644 index 000000000..b8f18cd04 --- /dev/null +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs @@ -0,0 +1,405 @@ +namespace SharpLink.IntegrationTests; + +public class PreAdmissionStreamActivationRaceIntegrationTests +{ + private const int FirstItemBytes = 4 * 1024; + private const int OverflowItemBytes = 16 * 1024; + private const long StreamBudgetBytes = 12L * 1024; + + [Test] + [NotInParallel] + public async Task OneWayStreamBudgetCancellationAfterAdmissionShouldPreventInvocation() + { + PreAdmissionStreamActivationRaceService.Reset(); + TestService.ResetBlockingAdd(); + await using var harness = await RaceHarness.CreateAsync(); + var overflowRelease = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var activationEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var activationRelease = new ManualResetEventSlim(); + Task? target = null; + var active = harness.ClientA.Get() + .BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + target = harness.ClientB.Get() + .NotifyAsync(OneThenOverflowAsync(overflowRelease.Task)).AsTask(); + await WaitUntilAsync( + () => harness.PreAdmissionStreamBytes > 0 && harness.AdmissionQueuedBytes > 0, + "one-way stream is buffered while admission waits"); + + ServerCallCancellationState.BeforeRequestActivationForTests = state => + { + if (activationEntered.TrySetResult(state)) + activationRelease.Wait(TimeSpan.FromSeconds(5)); + }; + + TestService.ReleaseBlockingAdd(); + Ensure(await active.WaitAsync(TimeSpan.FromSeconds(5)) == 2, + "active admission owner completes before target activation"); + var callState = await activationEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Ensure(callState.Reason == ServerCallCancellationReason.None, + "admission must be acquired before the forced stream-budget race"); + + overflowRelease.TrySetResult(); + await WaitUntilAsync( + () => callState.Reason == + ServerCallCancellationReason.PreAdmissionStreamResourceExhausted, + "stream-budget cancellation wins before one-way activation"); + + activationRelease.Set(); + await ObserveTerminalAsync(target); + await WaitUntilAsync( + () => harness.PreAdmissionStreamBytes == 0 && harness.AdmissionQueuedBytes == 0, + "one-way race releases stream and admission ownership"); + await Task.Delay(50); + Ensure(PreAdmissionStreamActivationRaceService.OneWayInvocations == 0, + "one-way user code must not run after stream-budget terminal wins"); + Ensure(await harness.ClientB.Get().AddAsync(20, 22) == 42, + "one-way race leaves the connection usable"); + } + finally + { + ServerCallCancellationState.BeforeRequestActivationForTests = null; + activationRelease.Set(); + overflowRelease.TrySetResult(); + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (target is not null) + await ObserveTerminalAsync(target); + } + } + + [Test] + [NotInParallel] + public async Task TwoWayStreamBudgetCancellationAfterAdmissionShouldKeepStableResourceReason() + { + PreAdmissionStreamActivationRaceService.Reset(); + TestService.ResetBlockingAdd(); + await using var harness = await RaceHarness.CreateAsync(); + var overflowRelease = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var activationEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var activationRelease = new ManualResetEventSlim(); + Task? target = null; + var active = harness.ClientA.Get() + .BlockingAddAsync(3, 4, CancellationToken.None).AsTask(); + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + target = harness.ClientB.Get() + .UploadAsync(OneThenOverflowAsync(overflowRelease.Task)).AsTask(); + await WaitUntilAsync( + () => harness.PreAdmissionStreamBytes > 0 && harness.AdmissionQueuedBytes > 0, + "two-way stream is buffered while admission waits"); + + ServerCallCancellationState.BeforeRequestActivationForTests = state => + { + if (activationEntered.TrySetResult(state)) + activationRelease.Wait(TimeSpan.FromSeconds(5)); + }; + + TestService.ReleaseBlockingAdd(); + Ensure(await active.WaitAsync(TimeSpan.FromSeconds(5)) == 7, + "active admission owner completes before target activation"); + var callState = await activationEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Ensure(callState.Reason == ServerCallCancellationReason.None, + "two-way admission must be acquired before the forced stream-budget race"); + + overflowRelease.TrySetResult(); + await WaitUntilAsync( + () => callState.Reason == + ServerCallCancellationReason.PreAdmissionStreamResourceExhausted, + "stream-budget cancellation wins before two-way activation"); + + activationRelease.Set(); + var failure = await CaptureFailureAsync(target); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted } exhausted && + exhausted.Message.Contains( + "server_pre_admission_stream_bytes", + StringComparison.Ordinal), + "two-way race must surface the stable stream-budget ResourceExhausted reason"); + await WaitUntilAsync( + () => harness.PreAdmissionStreamBytes == 0 && harness.AdmissionQueuedBytes == 0, + "two-way race releases stream and admission ownership"); + Ensure(PreAdmissionStreamActivationRaceService.TwoWayInvocations == 0, + "two-way user code must not run after stream-budget terminal wins"); + Ensure(await harness.ClientB.Get().AddAsync(20, 22) == 42, + "two-way race leaves the connection usable"); + } + finally + { + ServerCallCancellationState.BeforeRequestActivationForTests = null; + activationRelease.Set(); + overflowRelease.TrySetResult(); + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (target is not null) + await ObserveTerminalAsync(target); + } + } + + private static async IAsyncEnumerable OneThenOverflowAsync( + Task releaseOverflow, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return CreateItem(0x51, FirstItemBytes); + await releaseOverflow.WaitAsync(cancellationToken); + yield return CreateItem(0x52, OverflowItemBytes); + } + + private static byte[] CreateItem(byte value, int length) + => Enumerable.Repeat(value, length).ToArray(); + + private static async Task CaptureFailureAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task ObserveTerminalAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class RaceHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCancellation; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + private bool _disposed; + + private RaceHarness( + CancellationTokenSource serverCancellation, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient clientA, + ISharpLinkClient clientB) + { + _serverCancellation = serverCancellation; + _serverTask = serverTask; + _server = server; + ClientA = clientA; + ClientB = clientB; + } + + internal ISharpLinkClient ClientA { get; } + internal ISharpLinkClient ClientB { get; } + + internal long PreAdmissionStreamBytes + => ReadServerDiagnostic("PreAdmissionStreamBytesForDiagnostics"); + + internal long AdmissionQueuedBytes + { + get + { + var controllerField = _server.GetType().GetField( + "_admissionController", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception("cannot find server admission controller field"); + var controller = controllerField.GetValue(_server) + ?? throw new Exception("server admission controller is unavailable"); + var property = controller.GetType().GetProperty( + "QueuedBytes", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception("cannot find admission queued-byte diagnostic"); + return (long)property.GetValue(controller)!; + } + } + + internal static async Task CreateAsync() + { + var serverCancellation = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxPreAdmissionStreamBytesPerServer = StreamBudgetBytes; + options.FlowControl.StreamReceiveWindowBytes = 64 * 1024; + options.FlowControl.ConnectionReceiveWindowBytes = 256 * 1024; + }) + .UseAdmissionControl(options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 2; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = serverBuilder.Build(); + var serverTask = RunServerAsync(server, serverCancellation.Token); + + var clientA = CreateClient(port); + var clientB = CreateClient(port); + await clientA.ConnectAsync(); + await clientB.ConnectAsync(); + return new RaceHarness( + serverCancellation, + serverTask, + server, + clientA, + clientB); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + _disposed = true; + try + { + await StopClientAsync(ClientA); + await StopClientAsync(ClientB); + } + finally + { + await _serverCancellation.CancelAsync(); + try + { + await _server.StopAsync(TimeSpan.Zero); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException) + { + } + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCancellation.Dispose(); + } + } + + private T ReadServerDiagnostic(string name) + { + var property = _server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find server diagnostic {name}"); + return (T)property.GetValue(_server)!; + } + + private static ISharpLinkClient CreateClient(int port) + => SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .Build(); + + private static async Task StopClientAsync(ISharpLinkClient client) + { + try + { + await client.StopAsync(); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) + { + } + } + } + + private static Task RunServerAsync( + ISharpLinkServer server, + CancellationToken cancellationToken) + => Task.Run(async () => + { + try + { + await server.RunAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); +} + +[RpcContract] +public interface IPreAdmissionStreamActivationRaceService : IService +{ + [Oneway] + [NonCancellable] + ValueTask NotifyAsync(IAsyncEnumerable values); + + [NonCancellable] + ValueTask UploadAsync(IAsyncEnumerable values); +} + +public sealed class PreAdmissionStreamActivationRaceService : IPreAdmissionStreamActivationRaceService +{ + private static int s_oneWayInvocations; + private static int s_twoWayInvocations; + + internal static int OneWayInvocations => Volatile.Read(ref s_oneWayInvocations); + internal static int TwoWayInvocations => Volatile.Read(ref s_twoWayInvocations); + + internal static void Reset() + { + Volatile.Write(ref s_oneWayInvocations, 0); + Volatile.Write(ref s_twoWayInvocations, 0); + } + + public async ValueTask NotifyAsync(IAsyncEnumerable values) + { + Interlocked.Increment(ref s_oneWayInvocations); + await foreach (var _ in values) + { + } + } + + public async ValueTask UploadAsync(IAsyncEnumerable values) + { + Interlocked.Increment(ref s_twoWayInvocations); + var total = 0; + await foreach (var value in values) + total += value.Length; + return total; + } +} From 42992d384bdf2768e6a1b46094d8596638177fe1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:11:59 +0800 Subject: [PATCH 150/228] test(server): match stream exhaustion wire diagnostic --- .../Server/ServerCallTerminationMapperTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SharpLink.UnitTests/Server/ServerCallTerminationMapperTests.cs b/test/SharpLink.UnitTests/Server/ServerCallTerminationMapperTests.cs index c9c50d3bf..3d667365f 100644 --- a/test/SharpLink.UnitTests/Server/ServerCallTerminationMapperTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerCallTerminationMapperTests.cs @@ -97,7 +97,7 @@ public async Task CreateRemoteCancellationExceptionShouldPreserveWireError( "Admission queue retained-byte capacity was exhausted.")] [Arguments((int)ServerCallCancellationReason.PreAdmissionStreamResourceExhausted, (int)SharpLinkErrorCode.ResourceExhausted, - "\u000ePre-admission stream retained-byte capacity was exhausted.")] + "\u000ePre-admission stream retained-byte capacity was exhausted (server_pre_admission_stream_bytes).")] [Arguments((int)ServerCallCancellationReason.Completed, (int)SharpLinkErrorCode.Cancelled, "Request canceled.")] [Arguments(byte.MaxValue, (int)SharpLinkErrorCode.Cancelled, "Request canceled.")] From f8590083cd88c5de771edf4e87ffcb15df4ae527 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:21:35 +0800 Subject: [PATCH 151/228] test(server): register deterministic activation race service --- ...ionStreamActivationRaceIntegrationTests.cs | 37 +++++-------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs index b8f18cd04..f0dd43d9a 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs @@ -28,8 +28,8 @@ public async Task OneWayStreamBudgetCancellationAfterAdmissionShouldPreventInvoc target = harness.ClientB.Get() .NotifyAsync(OneThenOverflowAsync(overflowRelease.Task)).AsTask(); await WaitUntilAsync( - () => harness.PreAdmissionStreamBytes > 0 && harness.AdmissionQueuedBytes > 0, - "one-way stream is buffered while admission waits"); + () => harness.PreAdmissionStreamBytes > 0, + "one-way stream is physically buffered while admission waits"); ServerCallCancellationState.BeforeRequestActivationForTests = state => { @@ -53,8 +53,8 @@ await WaitUntilAsync( activationRelease.Set(); await ObserveTerminalAsync(target); await WaitUntilAsync( - () => harness.PreAdmissionStreamBytes == 0 && harness.AdmissionQueuedBytes == 0, - "one-way race releases stream and admission ownership"); + () => harness.PreAdmissionStreamBytes == 0, + "one-way race releases stream-buffer ownership"); await Task.Delay(50); Ensure(PreAdmissionStreamActivationRaceService.OneWayInvocations == 0, "one-way user code must not run after stream-budget terminal wins"); @@ -95,8 +95,8 @@ public async Task TwoWayStreamBudgetCancellationAfterAdmissionShouldKeepStableRe target = harness.ClientB.Get() .UploadAsync(OneThenOverflowAsync(overflowRelease.Task)).AsTask(); await WaitUntilAsync( - () => harness.PreAdmissionStreamBytes > 0 && harness.AdmissionQueuedBytes > 0, - "two-way stream is buffered while admission waits"); + () => harness.PreAdmissionStreamBytes > 0, + "two-way stream is physically buffered while admission waits"); ServerCallCancellationState.BeforeRequestActivationForTests = state => { @@ -125,8 +125,8 @@ await WaitUntilAsync( StringComparison.Ordinal), "two-way race must surface the stable stream-budget ResourceExhausted reason"); await WaitUntilAsync( - () => harness.PreAdmissionStreamBytes == 0 && harness.AdmissionQueuedBytes == 0, - "two-way race releases stream and admission ownership"); + () => harness.PreAdmissionStreamBytes == 0, + "two-way race releases stream-buffer ownership"); Ensure(PreAdmissionStreamActivationRaceService.TwoWayInvocations == 0, "two-way user code must not run after stream-budget terminal wins"); Ensure(await harness.ClientB.Get().AddAsync(20, 22) == 42, @@ -227,26 +227,6 @@ private RaceHarness( internal long PreAdmissionStreamBytes => ReadServerDiagnostic("PreAdmissionStreamBytesForDiagnostics"); - internal long AdmissionQueuedBytes - { - get - { - var controllerField = _server.GetType().GetField( - "_admissionController", - System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.NonPublic) - ?? throw new Exception("cannot find server admission controller field"); - var controller = controllerField.GetValue(_server) - ?? throw new Exception("server admission controller is unavailable"); - var property = controller.GetType().GetProperty( - "QueuedBytes", - System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.NonPublic) - ?? throw new Exception("cannot find admission queued-byte diagnostic"); - return (long)property.GetValue(controller)!; - } - } - internal static async Task CreateAsync() { var serverCancellation = new CancellationTokenSource(); @@ -372,6 +352,7 @@ public interface IPreAdmissionStreamActivationRaceService : IService ValueTask UploadAsync(IAsyncEnumerable values); } +[RpcService] public sealed class PreAdmissionStreamActivationRaceService : IPreAdmissionStreamActivationRaceService { private static int s_oneWayInvocations; From 2b8082a7b6eac5f4fb5d7e30399500b190c96e86 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:47:07 +0800 Subject: [PATCH 152/228] test(server): queue one-way calls in stream activation race --- .../PreAdmissionStreamActivationRaceIntegrationTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs index f0dd43d9a..7a21a00c1 100644 --- a/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs +++ b/test/SharpLink.IntegrationTests/PreAdmissionStreamActivationRaceIntegrationTests.cs @@ -241,6 +241,7 @@ internal static async Task CreateAsync() .UseAdmissionControl(options => { options.Global.UseConcurrency(1); + options.QueueOneWayCalls = true; options.MaxQueuedCalls = 2; options.MaxQueuedBytes = 64 * 1024; options.MaxQueueDelay = TimeSpan.FromSeconds(10); From 88a27139cde33e1f7f63d4331dd3491034435a7a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:27:44 +0800 Subject: [PATCH 153/228] feat(server): add immutable admission program generations --- .../Admission/AdmissionProgram.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/SharpLink.Server/Admission/AdmissionProgram.cs diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs new file mode 100644 index 000000000..b3fd6ba30 --- /dev/null +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -0,0 +1,71 @@ +namespace SharpLink.Server; + +/// +/// Immutable admission-policy publication for one runtime generation. Requests capture one +/// publication at the RequestLoop boundary and never re-read the server's current publication. +/// +internal sealed class AdmissionProgram +{ + private static long s_nextGenerationId; + + private readonly SharpLinkAdmissionController _controller; + private int _activeUses; + private int _duplicateReleaseAttempts; + + internal AdmissionProgram(SharpLinkAdmissionController controller) + { + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + GenerationId = Interlocked.Increment(ref s_nextGenerationId); + } + + internal long GenerationId { get; } + + internal SharpLinkAdmissionController Controller => _controller; + + internal bool QueueOneWayCalls => _controller.QueueOneWayCalls; + + internal int ActiveUses => Volatile.Read(ref _activeUses); + + internal int DuplicateReleaseAttempts => Volatile.Read(ref _duplicateReleaseAttempts); + + internal AdmissionProgramUse AcquireUse() + { + Interlocked.Increment(ref _activeUses); + return new AdmissionProgramUse(this); + } + + internal void ReleaseUse() + { + if (Interlocked.Decrement(ref _activeUses) < 0) + throw new InvalidOperationException("Admission program use count underflowed."); + } + + internal void RecordDuplicateReleaseAttempt() + => Interlocked.Increment(ref _duplicateReleaseAttempts); +} + +/// +/// Exactly-once lifetime token for one captured admission generation. The token may be transferred +/// to the existing server-call lifetime owner without rebuilding policy or routing state. +/// +internal sealed class AdmissionProgramUse : IDisposable +{ + private readonly AdmissionProgram _program; + private int _disposed; + + internal AdmissionProgramUse(AdmissionProgram program) + => _program = program ?? throw new ArgumentNullException(nameof(program)); + + internal AdmissionProgram Program => _program; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + _program.RecordDuplicateReleaseAttempt(); + return; + } + + _program.ReleaseUse(); + } +} From fcb641e4a1cfd0a51ff3eb7e422ca81450e2550e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:27:58 +0800 Subject: [PATCH 154/228] refactor(server): publish initial admission program in composition --- src/SharpLink.Server/ServerRuntimeComposition.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Server/ServerRuntimeComposition.cs b/src/SharpLink.Server/ServerRuntimeComposition.cs index fbc7065db..07093b651 100644 --- a/src/SharpLink.Server/ServerRuntimeComposition.cs +++ b/src/SharpLink.Server/ServerRuntimeComposition.cs @@ -57,7 +57,7 @@ internal ServerRuntimeComposition( Authenticator = authenticator; AuthenticationRequired = authenticationRequired; RpcSessionFlushOptions = rpcSessionFlushOptions; - AdmissionController = admissionController; + AdmissionProgram = admissionController is null ? null : new AdmissionProgram(admissionController); ConnectionAdmission = connectionAdmission ?? throw new ArgumentNullException(nameof(connectionAdmission)); } @@ -91,7 +91,7 @@ internal ServerRuntimeComposition( internal IReadOnlyList StaticManifests => _staticManifests; - internal SharpLinkAdmissionController? AdmissionController { get; } + internal AdmissionProgram? AdmissionProgram { get; } internal ServerConnectionAdmission ConnectionAdmission { get; } From 1950ad5b6a9df673a5021b2ec3a28750a1588c36 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:28:30 +0800 Subject: [PATCH 155/228] refactor(server): bind admission generation use to call lifetime --- src/SharpLink.Server/ServerCallCancellationState.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/SharpLink.Server/ServerCallCancellationState.cs b/src/SharpLink.Server/ServerCallCancellationState.cs index 90c7bc82e..bffadabf5 100644 --- a/src/SharpLink.Server/ServerCallCancellationState.cs +++ b/src/SharpLink.Server/ServerCallCancellationState.cs @@ -67,6 +67,7 @@ internal sealed class ServerCallCancellationState : IDisposable private bool _disposeRequested; private int _externalUsers; private long _leaseGeneration; + private AdmissionProgramUse? _admissionProgramUse; private AdmissionLease? _admissionLease; private SharpLinkBufferWriterPool? _payloadPool; private IRpcByteBufferWriter? _payloadOwner; @@ -134,6 +135,7 @@ public static ServerCallCancellationState Rent( state._reason = (int)ServerCallCancellationReason.None; state._abandonedRecorded = 0; state._moduleDrainResponseClaimed = 0; + state._admissionProgramUse = null; state._admissionLease = null; state._payloadPool = null; state._payloadOwner = null; @@ -176,6 +178,13 @@ public static ServerCallCancellationState Rent( return state; } + internal void AttachAdmissionProgramUse(AdmissionProgramUse admissionProgramUse) + { + ArgumentNullException.ThrowIfNull(admissionProgramUse); + if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgramUse, null) is not null) + throw new InvalidOperationException("An admission program use is already attached to this call."); + } + internal void AttachAdmissionLease(AdmissionLease lease) { ArgumentNullException.ThrowIfNull(lease); @@ -339,6 +348,7 @@ private void ReturnCore() _serverStoppingRegistration.Dispose(); _invocationCancellation?.Dispose(); Interlocked.Exchange(ref _admissionLease, null)?.Dispose(); + Interlocked.Exchange(ref _admissionProgramUse, null)?.Dispose(); var payloadOwner = Interlocked.Exchange(ref _payloadOwner, null); var payloadPool = Interlocked.Exchange(ref _payloadPool, null); var decodedBytesPermit = Interlocked.Exchange(ref _decodedBytesPermit, null); From 19ad32c21c3265505f5c3a7b9a56fc24597e4428 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:29:00 +0800 Subject: [PATCH 156/228] test(server): add deterministic admission publication hook --- .../SharpLinkServer.AdmissionProgram.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs new file mode 100644 index 000000000..2ef7d4ceb --- /dev/null +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs @@ -0,0 +1,38 @@ +namespace SharpLink.Server; + +internal sealed partial class SharpLinkServer +{ + private static Action? s_afterAdmissionCaptureForTests; + + private readonly AdmissionProgram? _ownedAdmissionProgram; + private AdmissionProgram? _admissionProgram; + + internal static Action? AfterAdmissionCaptureForTests + { + get => Volatile.Read(ref s_afterAdmissionCaptureForTests); + set => Volatile.Write(ref s_afterAdmissionCaptureForTests, value); + } + + internal AdmissionProgram? CurrentAdmissionProgramForTests + => Volatile.Read(ref _admissionProgram); + + internal AdmissionProgram? OwnedAdmissionProgramForTests => _ownedAdmissionProgram; + + internal AdmissionProgram? PublishAdmissionProgramForTests(AdmissionProgram? program) + => Interlocked.Exchange(ref _admissionProgram, program); + + private AdmissionProgram? CaptureAdmissionProgram(long requestId) + { + var program = Volatile.Read(ref _admissionProgram); + Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); + return program; + } + + private void StopAdmissionPrograms() + { + var current = Volatile.Read(ref _admissionProgram); + current?.Controller.StopAccepting(); + if (_ownedAdmissionProgram is not null && !ReferenceEquals(current, _ownedAdmissionProgram)) + _ownedAdmissionProgram.Controller.StopAccepting(); + } +} From 5010c19678bdbf26f08ccf8c3690eb80716c6494 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:29:36 +0800 Subject: [PATCH 157/228] fix(server): acquire admission generation before publication hook --- .../SharpLinkServer.AdmissionProgram.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs index 2ef7d4ceb..1fafc05a4 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs @@ -21,11 +21,22 @@ internal AdmissionProgram? CurrentAdmissionProgramForTests internal AdmissionProgram? PublishAdmissionProgramForTests(AdmissionProgram? program) => Interlocked.Exchange(ref _admissionProgram, program); - private AdmissionProgram? CaptureAdmissionProgram(long requestId) + private AdmissionProgramUse? CaptureAdmissionProgram( + long requestId, + out AdmissionProgram? program) { - var program = Volatile.Read(ref _admissionProgram); - Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); - return program; + program = Volatile.Read(ref _admissionProgram); + var use = program?.AcquireUse(); + try + { + Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); + return use; + } + catch + { + use?.Dispose(); + throw; + } } private void StopAdmissionPrograms() From 04d5694ad6791a192417358f585b372078562ba2 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:30:04 +0800 Subject: [PATCH 158/228] refactor(server): capture admission program once in request loop --- .../SharpLinkServer.RequestLoop.cs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs b/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs index 65ac98c9c..6c8d311d4 100644 --- a/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs +++ b/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs @@ -136,15 +136,32 @@ await session.SendPongWithBackpressureAsync( break; } + var admissionProgramUse = CaptureAdmissionProgram( + requestId, + out var admissionProgram); if ((header.Flags & ProtocolV2FrameFlags.OneWay) != 0) { DispatchOneWayRpc( - connection, requestId, header.Flags, payload, requestCancellationMap, ct); + connection, + requestId, + header.Flags, + payload, + requestCancellationMap, + ct, + admissionProgram, + admissionProgramUse); break; } var dispatchTask = DispatchRpcAsync( - connection, requestId, header.Flags, payload, requestCancellationMap, ct); + connection, + requestId, + header.Flags, + payload, + requestCancellationMap, + ct, + admissionProgram, + admissionProgramUse); if (!dispatchTask.IsCompletedSuccessfully) ObserveUserCall(dispatchTask, requestId); break; From 1e4ac6a734ec899239e8aa32a5442a074ffd1fbb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:31:15 +0800 Subject: [PATCH 159/228] refactor(server): dispatch one-way calls with captured admission generation --- .../SharpLinkServer.AdmissionDispatch.cs | 612 +++++++++--------- 1 file changed, 318 insertions(+), 294 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs index 516fe0826..737fefe7b 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs @@ -9,266 +9,217 @@ private void DispatchOneWayRpc( ReadOnlySequence payload, StripedLongMap requestCancellationMap, CancellationToken serverLoopToken, + AdmissionProgram? admissionProgram, + AdmissionProgramUse? admissionProgramUse, ServerCallCancellationState? admittedCallState = null, bool admissionGranted = false, int admittedClientStreamCount = 0, ServerRetainedAdmissionPayload? retainedAdmissionPayload = null) { - var session = connection.Session; - using var requestScope = BeginRequestLogScope(_logger, requestId); - var isCancellable = (flags & ProtocolV2FrameFlags.Cancellable) != 0; - var isCompressed = (flags & ProtocolV2FrameFlags.Compressed) != 0; - var request = ReadRequestEnvelope(session, payload, flags); - if (IsDeadlineExceeded(request.RpcDeadline)) + if (admissionProgram is null && admissionProgramUse is not null) + throw new InvalidOperationException("A captured admission use requires its program generation."); + if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) + throw new InvalidOperationException("An enabled captured admission generation requires one use token."); + + try { - if (admittedCallState is not null) + var session = connection.Session; + using var requestScope = BeginRequestLogScope(_logger, requestId); + var isCancellable = (flags & ProtocolV2FrameFlags.Cancellable) != 0; + var isCompressed = (flags & ProtocolV2FrameFlags.Compressed) != 0; + var request = ReadRequestEnvelope(session, payload, flags); + if (IsDeadlineExceeded(request.RpcDeadline)) { - DrainRejectedOneWayStreams(session, requestId, admittedClientStreamCount); - ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); + if (admittedCallState is not null) + { + DrainRejectedOneWayStreams(session, requestId, admittedClientStreamCount); + ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); + } + return; } - return; - } - if (!Volatile.Read(ref _services).TryGetValue(request.InterfaceHash, out var serviceInfo)) - { - if (admittedCallState is not null) + if (!Volatile.Read(ref _services).TryGetValue(request.InterfaceHash, out var serviceInfo)) { - DrainRejectedOneWayStreams(session, requestId, admittedClientStreamCount); - ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); + if (admittedCallState is not null) + { + DrainRejectedOneWayStreams(session, requestId, admittedClientStreamCount); + ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); + } + return; } - return; - } - if (!serviceInfo.AcceptsCalls) - { - if (admittedCallState is not null) + if (!serviceInfo.AcceptsCalls) { - DrainRejectedOneWayStreams(session, requestId, admittedClientStreamCount); - ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); + if (admittedCallState is not null) + { + DrainRejectedOneWayStreams(session, requestId, admittedClientStreamCount); + ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); + } + return; } - return; - } - var descriptor = GetMethodDescriptor(serviceInfo.Stub, request.MethodHash); + var descriptor = GetMethodDescriptor(serviceInfo.Stub, request.MethodHash); - if (_admissionController is not null && !admissionGranted) - { - admittedCallState = CreateAdmissionWaitState( - connection, - requestId, - request.RpcDeadline, - serverLoopToken, - serviceInfo.ModuleCancellation, - requestCancellationMap); - ValueTask admissionTask; - try - { - admissionTask = _admissionController.AcquireAsync( - CreateAdmissionContext(connection, descriptor, request), - checked((int)payload.Length), - _admissionController.QueueOneWayCalls, - request.RpcDeadline, - admittedCallState.InvocationToken); - } - catch (Exception exception) + if (admissionProgram is not null && !admissionGranted) { - LogOnewayRpcDispatchFailed(_logger, exception); - DrainRejectedOneWayStreams(session, requestId, descriptor.ClientStreamCount); - _ = RejectAdmission( - session, + admittedCallState = CreateAdmissionWaitState( + connection, requestId, - AdmissionDecision.Reject( - "partition_selector", "partition", SharpLinkErrorCode.Internal), - oneWay: true); - ReleaseAdmissionCallState( - requestCancellationMap, requestId, admittedCallState); - return; - } - if (!admissionTask.IsCompletedSuccessfully) - { - if (!TryCopyAdmissionPayload(payload, flags, out var retainedPayload)) + request.RpcDeadline, + serverLoopToken, + serviceInfo.ModuleCancellation, + requestCancellationMap); + admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); + admissionProgramUse = null; + var admissionController = admissionProgram.Controller; + ValueTask admissionTask; + try { - admittedCallState.TryCancel(ServerCallCancellationReason.AdmissionResourceExhausted); + admissionTask = admissionController.AcquireAsync( + CreateAdmissionContext(connection, descriptor, request), + checked((int)payload.Length), + admissionProgram.QueueOneWayCalls, + request.RpcDeadline, + admittedCallState.InvocationToken); + } + catch (Exception exception) + { + LogOnewayRpcDispatchFailed(_logger, exception); + DrainRejectedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + _ = RejectAdmission( + session, + requestId, + AdmissionDecision.Reject( + "partition_selector", "partition", SharpLinkErrorCode.Internal), + oneWay: true); + ReleaseAdmissionCallState( + requestCancellationMap, requestId, admittedCallState); + return; + } + if (!admissionTask.IsCompletedSuccessfully) + { + if (!TryCopyAdmissionPayload(payload, flags, out var retainedPayload)) + { + admittedCallState.TryCancel(ServerCallCancellationReason.AdmissionResourceExhausted); + ObserveUserCall( + RejectQueuedAdmissionForRetainedBudgetAsync( + admissionTask, + connection, + requestId, + requestCancellationMap, + admittedCallState, + oneWay: true, + descriptor.ClientStreamCount), + requestId); + return; + } + + ReservePreAdmissionRequestStreams( + session, + requestId, + descriptor.ClientStreamCount, + admittedCallState); ObserveUserCall( - RejectQueuedAdmissionForRetainedBudgetAsync( + new ValueTask(AwaitOneWayAdmissionAsync( admissionTask, + retainedPayload!, connection, requestId, + flags, requestCancellationMap, + serverLoopToken, + descriptor.ClientStreamCount, admittedCallState, - oneWay: true, - descriptor.ClientStreamCount), + admissionProgram)), requestId); return; } - ReservePreAdmissionRequestStreams( - session, - requestId, - descriptor.ClientStreamCount, - admittedCallState); - ObserveUserCall( - new ValueTask(AwaitOneWayAdmissionAsync( - admissionTask, - retainedPayload!, - connection, - requestId, - flags, - requestCancellationMap, - serverLoopToken, - descriptor.ClientStreamCount, - admittedCallState)), - requestId); - return; + var decision = admissionTask.Result; + if (!decision.IsAcquired) + { + DrainRejectedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + _ = RejectAdmission(connection.Session, requestId, decision, oneWay: true); + ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); + return; + } + admittedCallState.AttachAdmissionLease(decision.Lease!); } - var decision = admissionTask.Result; - if (!decision.IsAcquired) + var admission = TryReserveCall(connection, out var requestPermit); + if (admission != ServerCallAdmissionResult.Acquired || requestPermit is null) { DrainRejectedOneWayStreams(session, requestId, descriptor.ClientStreamCount); - _ = RejectAdmission(connection.Session, requestId, decision, oneWay: true); - ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); + if (admittedCallState is not null) + ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); + Interlocked.Increment(ref _rejectedOneWayCalls); + if (admission is ServerCallAdmissionResult.PerConnectionCapacityExhausted or + ServerCallAdmissionResult.ServerCapacityExhausted) + { + var reason = GetCallCapacityExhaustionReason(admission); + SharpLinkTelemetry.RecordResourceExhausted("server", reason); + LogOnewayRpcResourceExhausted(_logger, reason); + } return; } - admittedCallState.AttachAdmissionLease(decision.Lease!); - } + var requestOwner = requestPermit; - var admission = TryReserveCall(connection, out var requestPermit); - if (admission != ServerCallAdmissionResult.Acquired || requestPermit is null) - { - DrainRejectedOneWayStreams(session, requestId, descriptor.ClientStreamCount); - if (admittedCallState is not null) - ReleaseAdmissionCallState(requestCancellationMap, requestId, admittedCallState); - Interlocked.Increment(ref _rejectedOneWayCalls); - if (admission is ServerCallAdmissionResult.PerConnectionCapacityExhausted or - ServerCallAdmissionResult.ServerCapacityExhausted) + IRpcByteBufferWriter? decodedRequestOwner = null; + try { - var reason = GetCallCapacityExhaustionReason(admission); - SharpLinkTelemetry.RecordResourceExhausted("server", reason); - LogOnewayRpcResourceExhausted(_logger, reason); - } - return; - } - var requestOwner = requestPermit; + if (isCompressed) + { + admittedCallState = EnsurePreDecodeCallState( + connection, + admittedCallState, + requestId, + request.RpcDeadline, + serverLoopToken, + serviceInfo.ModuleCancellation, + requestCancellationMap); + if (!TryPrepareCompressedRequestDecode( + requestOwner, + retainedAdmissionPayload?.RetainedPermit, + flags, + payload, + out var decodePermit, + out var resourceRejection)) + { + retainedAdmissionPayload?.Dispose(); + requestOwner.ReleaseDecodeResources(); + var rejection = resourceRejection ?? throw new InvalidOperationException( + "Compressed one-way decode resource rejection is missing its error."); + var reason = SharpLinkResourceExhaustion.GetReason(rejection); + Interlocked.Increment(ref _rejectedOneWayCalls); + LogOnewayRpcResourceExhausted(_logger, reason); + DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + ReleaseOneWayDispatchResources( + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + return; + } - IRpcByteBufferWriter? decodedRequestOwner = null; - try - { - if (isCompressed) - { - admittedCallState = EnsurePreDecodeCallState( - connection, - admittedCallState, - requestId, - request.RpcDeadline, - serverLoopToken, - serviceInfo.ModuleCancellation, - requestCancellationMap); - if (!TryPrepareCompressedRequestDecode( - requestOwner, - retainedAdmissionPayload?.RetainedPermit, + payload = session.DecodeInboundPayload( + ProtocolV2FrameType.Request, flags, payload, - out var decodePermit, - out var resourceRejection)) - { + admittedCallState.InvocationToken, + out decodedRequestOwner); retainedAdmissionPayload?.Dispose(); - requestOwner.ReleaseDecodeResources(); - var rejection = resourceRejection ?? throw new InvalidOperationException( - "Compressed one-way decode resource rejection is missing its error."); - var reason = SharpLinkResourceExhaustion.GetReason(rejection); - Interlocked.Increment(ref _rejectedOneWayCalls); - LogOnewayRpcResourceExhausted(_logger, reason); - DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); - ReleaseOneWayDispatchResources( - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - return; + decodePermit!.CompleteDecode(); + request = ReadRequestEnvelope(session, payload, flags); } - - payload = session.DecodeInboundPayload( - ProtocolV2FrameType.Request, - flags, - payload, - admittedCallState.InvocationToken, - out decodedRequestOwner); - retainedAdmissionPayload?.Dispose(); - decodePermit!.CompleteDecode(); - request = ReadRequestEnvelope(session, payload, flags); } - } - catch (SharpLinkException exception) when ( - exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) - { - retainedAdmissionPayload?.Dispose(); - session.ReturnDecodedPayload(decodedRequestOwner); - decodedRequestOwner = null; - requestOwner.ReleaseDecodeResources(); - Interlocked.Increment(ref _rejectedOneWayCalls); - LogOnewayRpcDispatchFailed(_logger, exception); - DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); - ReleaseOneWayDispatchResources( - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - return; - } - catch (OperationCanceledException) - { - retainedAdmissionPayload?.Dispose(); - session.ReturnDecodedPayload(decodedRequestOwner); - decodedRequestOwner = null; - requestOwner.ReleaseDecodeResources(); - DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); - ReleaseOneWayDispatchResources( - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - return; - } - catch - { - retainedAdmissionPayload?.Dispose(); - session.ReturnDecodedPayload(decodedRequestOwner); - decodedRequestOwner = null; - requestOwner.ReleaseDecodeResources(); - DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); - ReleaseOneWayDispatchResources( - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - throw; - } - - if (IsDeadlineExceeded(request.RpcDeadline) || serverLoopToken.IsCancellationRequested) - { - session.ReturnDecodedPayload(decodedRequestOwner); - decodedRequestOwner = null; - requestOwner.ReleaseDecodeResources(); - DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); - ReleaseOneWayDispatchResources( - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - return; - } - - if (admittedCallState is not null) - { - if (!admittedCallState.TryActivateRequest(requestOwner)) + catch (SharpLinkException exception) when ( + exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) { + retainedAdmissionPayload?.Dispose(); session.ReturnDecodedPayload(decodedRequestOwner); decodedRequestOwner = null; requestOwner.ReleaseDecodeResources(); + Interlocked.Increment(ref _rejectedOneWayCalls); + LogOnewayRpcDispatchFailed(_logger, exception); DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); ReleaseOneWayDispatchResources( admittedCallState, @@ -278,100 +229,167 @@ private void DispatchOneWayRpc( requestOwner); return; } - } - else - { - requestOwner.Activate(); - } - - var supportsCooperativeCancellation = - (isCancellable || serviceInfo.Module is not null) && - serviceInfo.Stub.SupportsCancellation(request.MethodHash); - var callState = admittedCallState ?? CreateTrackedCallState( - connection, - requestId, - request.RpcDeadline, - serverLoopToken, - serviceInfo.ModuleCancellation, - supportsCooperativeCancellation, - requestCancellationMap); - if (decodedRequestOwner is not null) - { - callState = EnsureTrackedCallState( - connection, callState, requestId, request.RpcDeadline, - serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); - callState.AttachPayloadOwner(_runtimeContext.Buffers, decodedRequestOwner); - decodedRequestOwner = null; - } - var invokeToken = supportsCooperativeCancellation - ? callState!.InvocationToken - : serverLoopToken; - - var callContext = CreateCallContext( - connection, serviceInfo.Stub, request.MethodHash, requestId, - request.Deadline, request.Metadata, invokeToken); - try - { - using var callContextScope = SharpLinkCallContext.Push(callContext); - var invokeTask = InvokeServiceAsync( - serviceInfo, - connection, - session, - request.MethodHash, - requestId, - request.Arguments, - output: null, - invokeToken, - callContext); - if (invokeTask.IsCompletedSuccessfully) + catch (OperationCanceledException) { - if (callContext is SharpLinkServerInvocationContext - { - Status: SharpLinkInvocationStatus.Pending - } interceptorContext) - interceptorContext.Status = SharpLinkInvocationStatus.Succeeded; - TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken); + retainedAdmissionPayload?.Dispose(); + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); + DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); ReleaseOneWayDispatchResources( - callState, + admittedCallState, requestId, requestCancellationMap, connection, requestOwner); return; } + catch + { + retainedAdmissionPayload?.Dispose(); + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); + DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + ReleaseOneWayDispatchResources( + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + throw; + } - callState = EnsureTrackedCallState( - connection, callState, requestId, request.RpcDeadline, - serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); - ObserveUserCall( - new ValueTask(AwaitOneWayDispatchAsync( - invokeTask, - callState, + if (IsDeadlineExceeded(request.RpcDeadline) || serverLoopToken.IsCancellationRequested) + { + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); + DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + ReleaseOneWayDispatchResources( + admittedCallState, requestId, requestCancellationMap, connection, - callContext, + requestOwner); + return; + } + + if (admittedCallState is not null) + { + if (!admittedCallState.TryActivateRequest(requestOwner)) + { + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + requestOwner.ReleaseDecodeResources(); + DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + ReleaseOneWayDispatchResources( + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + return; + } + } + else + { + requestOwner.Activate(); + } + + var supportsCooperativeCancellation = + (isCancellable || serviceInfo.Module is not null) && + serviceInfo.Stub.SupportsCancellation(request.MethodHash); + var callState = admittedCallState ?? CreateTrackedCallState( + connection, + requestId, + request.RpcDeadline, + serverLoopToken, + serviceInfo.ModuleCancellation, + supportsCooperativeCancellation, + requestCancellationMap); + if (decodedRequestOwner is not null) + { + callState = EnsureTrackedCallState( + connection, callState, requestId, request.RpcDeadline, + serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); + callState.AttachPayloadOwner(_runtimeContext.Buffers, decodedRequestOwner); + decodedRequestOwner = null; + } + var invokeToken = supportsCooperativeCancellation + ? callState!.InvocationToken + : serverLoopToken; + + var callContext = CreateCallContext( + connection, serviceInfo.Stub, request.MethodHash, requestId, + request.Deadline, request.Metadata, invokeToken); + try + { + using var callContextScope = SharpLinkCallContext.Push(callContext); + var invokeTask = InvokeServiceAsync( + serviceInfo, + connection, session, - serviceInfo.Stub, request.MethodHash, + requestId, + request.Arguments, + output: null, invokeToken, - requestOwner)), - requestId); - } - catch (Exception ex) - { - DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); - if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) + callContext); + if (invokeTask.IsCompletedSuccessfully) + { + if (callContext is SharpLinkServerInvocationContext + { + Status: SharpLinkInvocationStatus.Pending + } interceptorContext) + interceptorContext.Status = SharpLinkInvocationStatus.Succeeded; + TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken); + ReleaseOneWayDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); + return; + } + + callState = EnsureTrackedCallState( + connection, callState, requestId, request.RpcDeadline, + serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); + ObserveUserCall( + new ValueTask(AwaitOneWayDispatchAsync( + invokeTask, + callState, + requestId, + requestCancellationMap, + connection, + callContext, + session, + serviceInfo.Stub, + request.MethodHash, + invokeToken, + requestOwner)), + requestId); + } + catch (Exception ex) { - LogOnewayRpcDispatchFailed(_logger, MapServiceException( - ex, callContext, session, serviceInfo.Stub, request.MethodHash, requestId, invokeToken)); + DrainFailedOneWayStreams(session, requestId, descriptor.ClientStreamCount); + if (TryClaimCallCompletion(callState, request.RpcDeadline, serverLoopToken)) + { + LogOnewayRpcDispatchFailed(_logger, MapServiceException( + ex, callContext, session, serviceInfo.Stub, request.MethodHash, requestId, invokeToken)); + } + ReleaseOneWayDispatchResources( + callState, + requestId, + requestCancellationMap, + connection, + requestOwner); } - ReleaseOneWayDispatchResources( - callState, - requestId, - requestCancellationMap, - connection, - requestOwner); + } + finally + { + admissionProgramUse?.Dispose(); } } @@ -427,7 +445,8 @@ private async Task AwaitOneWayAdmissionAsync( StripedLongMap requestCancellationMap, CancellationToken serverLoopToken, int clientStreamCount, - ServerCallCancellationState callState) + ServerCallCancellationState callState, + AdmissionProgram admissionProgram) { var transferred = false; try @@ -459,6 +478,8 @@ private async Task AwaitOneWayAdmissionAsync( retainedPayload.Payload, requestCancellationMap, serverLoopToken, + admissionProgram, + admissionProgramUse: null, callState, admissionGranted: true, admittedClientStreamCount: clientStreamCount, @@ -481,7 +502,8 @@ private async ValueTask AwaitRpcAdmissionAsync( ProtocolV2FrameFlags flags, StripedLongMap requestCancellationMap, CancellationToken serverLoopToken, - ServerCallCancellationState callState) + ServerCallCancellationState callState, + AdmissionProgram admissionProgram) { var transferred = false; try @@ -514,6 +536,8 @@ await RejectAdmission( retainedPayload.Payload, requestCancellationMap, serverLoopToken, + admissionProgram, + admissionProgramUse: null, callState, admissionGranted: true, retainedAdmissionPayload: retainedPayload); From b055e9c74c58fe037e3f406505ee712885f44a23 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:32:14 +0800 Subject: [PATCH 160/228] refactor(server): dispatch two-way calls with captured admission generation --- .../SharpLinkServer.InvocationDispatch.cs | 509 +++++++++--------- 1 file changed, 263 insertions(+), 246 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs index f83dfd47f..c351da8d8 100644 --- a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs @@ -9,93 +9,62 @@ private ValueTask DispatchRpcAsync( ReadOnlySequence payload, StripedLongMap requestCancellationMap, CancellationToken serverLoopToken, + AdmissionProgram? admissionProgram, + AdmissionProgramUse? admissionProgramUse, ServerCallCancellationState? admittedCallState = null, bool admissionGranted = false, ServerRetainedAdmissionPayload? retainedAdmissionPayload = null) { - var session = connection.Session; - var isCompressed = (flags & ProtocolV2FrameFlags.Compressed) != 0; + if (admissionProgram is null && admissionProgramUse is not null) + throw new InvalidOperationException("A captured admission use requires its program generation."); + if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) + throw new InvalidOperationException("An enabled captured admission generation requires one use token."); - var request = ReadRequestEnvelope(session, payload, flags); - if (IsDeadlineExceeded(request.RpcDeadline)) - { - ValueTask responseSend; - try - { - responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, - new SharpLinkException( - SharpLinkErrorCode.DeadlineExceeded, - "Request deadline exceeded before dispatch."), - connection.ConnectionToken); - } - finally - { - if (admittedCallState is not null) - ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); - } - return responseSend; - } - if (!Volatile.Read(ref _services).TryGetValue(request.InterfaceHash, out var serviceInfo)) - { - ValueTask responseSend; - try - { - responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, - new SharpLinkException( - SharpLinkErrorCode.Unimplemented, - $"Service {request.InterfaceHash} is not implemented."), - connection.ConnectionToken); - } - finally - { - if (admittedCallState is not null) - ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); - } - return responseSend; - } - if (!serviceInfo.AcceptsCalls) + try { - ValueTask responseSend; - try - { - responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, - new SharpLinkException( - SharpLinkErrorCode.Unavailable, - "RPC module is draining"), - connection.ConnectionToken); - } - finally + var session = connection.Session; + var isCompressed = (flags & ProtocolV2FrameFlags.Compressed) != 0; + + var request = ReadRequestEnvelope(session, payload, flags); + if (IsDeadlineExceeded(request.RpcDeadline)) { - if (admittedCallState is not null) - ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); + ValueTask responseSend; + try + { + responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + new SharpLinkException( + SharpLinkErrorCode.DeadlineExceeded, + "Request deadline exceeded before dispatch."), + connection.ConnectionToken); + } + finally + { + if (admittedCallState is not null) + ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); + } + return responseSend; } - return responseSend; - } - - if (_admissionController is not null && !admissionGranted) - { - admittedCallState = CreateAdmissionWaitState( - connection, - requestId, - request.RpcDeadline, - serverLoopToken, - serviceInfo.ModuleCancellation, - requestCancellationMap); - var descriptor = GetMethodDescriptor(serviceInfo.Stub, request.MethodHash); - ValueTask admissionTask; - try + if (!Volatile.Read(ref _services).TryGetValue(request.InterfaceHash, out var serviceInfo)) { - admissionTask = _admissionController.AcquireAsync( - CreateAdmissionContext(connection, descriptor, request), - checked((int)payload.Length), - allowQueue: true, - deadline: request.RpcDeadline, - cancellationToken: admittedCallState.InvocationToken); + ValueTask responseSend; + try + { + responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + new SharpLinkException( + SharpLinkErrorCode.Unimplemented, + $"Service {request.InterfaceHash} is not implemented."), + connection.ConnectionToken); + } + finally + { + if (admittedCallState is not null) + ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); + } + return responseSend; } - catch (Exception exception) + if (!serviceInfo.AcceptsCalls) { ValueTask responseSend; try @@ -103,220 +72,268 @@ private ValueTask DispatchRpcAsync( responseSend = session.SendRpcErrorWithBackpressureAsync( requestId, new SharpLinkException( - SharpLinkErrorCode.Internal, - "The admission partition selector failed.", - exception), + SharpLinkErrorCode.Unavailable, + "RPC module is draining"), connection.ConnectionToken); - SharpLinkTelemetry.RecordAdmissionRejected("partition", "partition_selector"); } finally { - ReleasePendingAdmissionState( - session, requestCancellationMap, requestId, admittedCallState); + if (admittedCallState is not null) + ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); } return responseSend; } - if (!admissionTask.IsCompletedSuccessfully) + + if (admissionProgram is not null && !admissionGranted) { - if (!TryCopyAdmissionPayload(payload, flags, out var queuedRetainedPayload)) + admittedCallState = CreateAdmissionWaitState( + connection, + requestId, + request.RpcDeadline, + serverLoopToken, + serviceInfo.ModuleCancellation, + requestCancellationMap); + admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); + admissionProgramUse = null; + var descriptor = GetMethodDescriptor(serviceInfo.Stub, request.MethodHash); + ValueTask admissionTask; + try { - admittedCallState.TryCancel(ServerCallCancellationReason.AdmissionResourceExhausted); - return RejectQueuedAdmissionForRetainedBudgetAsync( + admissionTask = admissionProgram.Controller.AcquireAsync( + CreateAdmissionContext(connection, descriptor, request), + checked((int)payload.Length), + allowQueue: true, + deadline: request.RpcDeadline, + cancellationToken: admittedCallState.InvocationToken); + } + catch (Exception exception) + { + ValueTask responseSend; + try + { + responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + new SharpLinkException( + SharpLinkErrorCode.Internal, + "The admission partition selector failed.", + exception), + connection.ConnectionToken); + SharpLinkTelemetry.RecordAdmissionRejected("partition", "partition_selector"); + } + finally + { + ReleasePendingAdmissionState( + session, requestCancellationMap, requestId, admittedCallState); + } + return responseSend; + } + if (!admissionTask.IsCompletedSuccessfully) + { + if (!TryCopyAdmissionPayload(payload, flags, out var queuedRetainedPayload)) + { + admittedCallState.TryCancel(ServerCallCancellationReason.AdmissionResourceExhausted); + return RejectQueuedAdmissionForRetainedBudgetAsync( + admissionTask, + connection, + requestId, + requestCancellationMap, + admittedCallState, + oneWay: false); + } + + ReservePreAdmissionRequestStreams( + session, + requestId, + descriptor.ClientStreamCount, + admittedCallState); + return AwaitRpcAdmissionAsync( admissionTask, + queuedRetainedPayload!, connection, requestId, + flags, requestCancellationMap, + serverLoopToken, admittedCallState, - oneWay: false); + admissionProgram); } - ReservePreAdmissionRequestStreams( - session, - requestId, - descriptor.ClientStreamCount, - admittedCallState); - return AwaitRpcAdmissionAsync( - admissionTask, - queuedRetainedPayload!, + var decision = admissionTask.Result; + if (!decision.IsAcquired) + { + ValueTask rejectionSend; + try + { + rejectionSend = RejectAdmission( + connection.Session, + requestId, + decision, + oneWay: false, + connection.ConnectionToken); + } + finally + { + ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); + } + return rejectionSend; + } + admittedCallState.AttachAdmissionLease(decision.Lease!); + } + + var admission = TryReserveCall(connection, out var requestPermit); + if (admission != ServerCallAdmissionResult.Acquired || requestPermit is null) + { + if (admittedCallState is not null) + ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); + SharpLinkException rejection; + if (admission is ServerCallAdmissionResult.PerConnectionCapacityExhausted or + ServerCallAdmissionResult.ServerCapacityExhausted) + { + var reason = GetCallCapacityExhaustionReason(admission); + SharpLinkTelemetry.RecordResourceExhausted("server", reason); + rejection = SharpLinkResourceExhaustion.CreateWire( + reason, + $"Server call capacity is exhausted ({reason})."); + } + else + { + rejection = new SharpLinkException( + SharpLinkErrorCode.Unavailable, + "Server is draining."); + } + return session.SendRpcErrorWithBackpressureAsync( + requestId, rejection, connection.ConnectionToken); + } + var requestOwner = requestPermit; + + if (isCompressed && ShouldUsePersistentDecode(flags, serviceInfo, request, payload)) + { + return DispatchRpcWithPersistentDecodeAsync( connection, requestId, flags, + payload, + request, + serviceInfo, requestCancellationMap, serverLoopToken, - admittedCallState); + admittedCallState, + requestOwner, + retainedAdmissionPayload); } - var decision = admissionTask.Result; - if (!decision.IsAcquired) + IRpcByteBufferWriter? decodedRequestOwner = null; + try { - ValueTask rejectionSend; - try + if (isCompressed) { - rejectionSend = RejectAdmission( - connection.Session, + admittedCallState = EnsurePreDecodeCallState( + connection, + admittedCallState, requestId, - decision, - oneWay: false, - connection.ConnectionToken); - } - finally - { - ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); + request.RpcDeadline, + serverLoopToken, + serviceInfo.ModuleCancellation, + requestCancellationMap); + if (!TryPrepareCompressedRequestDecode( + requestOwner, + retainedAdmissionPayload?.RetainedPermit, + flags, + payload, + out var decodePermit, + out var resourceRejection)) + { + retainedAdmissionPayload?.Dispose(); + var rejection = resourceRejection ?? throw new InvalidOperationException( + "Compressed request decode resource rejection is missing its error."); + CompleteFailedRequestStreams(session, requestId, rejection); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, rejection, connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + + payload = session.DecodeInboundPayload( + ProtocolV2FrameType.Request, + flags, + payload, + admittedCallState.InvocationToken, + out decodedRequestOwner); + retainedAdmissionPayload?.Dispose(); + decodePermit!.CompleteDecode(); + request = ReadRequestEnvelope(session, payload, flags); } - return rejectionSend; } - admittedCallState.AttachAdmissionLease(decision.Lease!); - } - - var admission = TryReserveCall(connection, out var requestPermit); - if (admission != ServerCallAdmissionResult.Acquired || requestPermit is null) - { - if (admittedCallState is not null) - ReleasePendingAdmissionState(session, requestCancellationMap, requestId, admittedCallState); - SharpLinkException rejection; - if (admission is ServerCallAdmissionResult.PerConnectionCapacityExhausted or - ServerCallAdmissionResult.ServerCapacityExhausted) + catch (SharpLinkException exception) when ( + exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) { - var reason = GetCallCapacityExhaustionReason(admission); - SharpLinkTelemetry.RecordResourceExhausted("server", reason); - rejection = SharpLinkResourceExhaustion.CreateWire( - reason, - $"Server call capacity is exhausted ({reason})."); + retainedAdmissionPayload?.Dispose(); + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + CompleteFailedRequestStreams(session, requestId, exception); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, exception, connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); } - else + catch (OperationCanceledException exception) { - rejection = new SharpLinkException( - SharpLinkErrorCode.Unavailable, - "Server is draining."); + retainedAdmissionPayload?.Dispose(); + session.ReturnDecodedPayload(decodedRequestOwner); + decodedRequestOwner = null; + CompleteFailedRequestStreams(session, requestId, exception); + var responseSend = session.SendRpcErrorWithBackpressureAsync( + requestId, + MapServerCancellationException(admittedCallState, request.RpcDeadline), + connection.ConnectionToken); + return ReleaseDispatchResourcesAfterResponseAsync( + responseSend, + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + } + catch (Exception exception) + { + retainedAdmissionPayload?.Dispose(); + session.ReturnDecodedPayload(decodedRequestOwner); + CompleteFailedRequestStreams(session, requestId, exception); + ReleaseDispatchResources( + admittedCallState, + requestId, + requestCancellationMap, + connection, + requestOwner); + throw; } - return session.SendRpcErrorWithBackpressureAsync( - requestId, rejection, connection.ConnectionToken); - } - var requestOwner = requestPermit; - if (isCompressed && ShouldUsePersistentDecode(flags, serviceInfo, request, payload)) - { - return DispatchRpcWithPersistentDecodeAsync( + return ContinueRpcDispatch( connection, requestId, flags, - payload, request, serviceInfo, requestCancellationMap, serverLoopToken, admittedCallState, requestOwner, - retainedAdmissionPayload); + decodedRequestOwner); } - - IRpcByteBufferWriter? decodedRequestOwner = null; - try - { - if (isCompressed) - { - admittedCallState = EnsurePreDecodeCallState( - connection, - admittedCallState, - requestId, - request.RpcDeadline, - serverLoopToken, - serviceInfo.ModuleCancellation, - requestCancellationMap); - if (!TryPrepareCompressedRequestDecode( - requestOwner, - retainedAdmissionPayload?.RetainedPermit, - flags, - payload, - out var decodePermit, - out var resourceRejection)) - { - retainedAdmissionPayload?.Dispose(); - var rejection = resourceRejection ?? throw new InvalidOperationException( - "Compressed request decode resource rejection is missing its error."); - CompleteFailedRequestStreams(session, requestId, rejection); - var responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, rejection, connection.ConnectionToken); - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - - payload = session.DecodeInboundPayload( - ProtocolV2FrameType.Request, - flags, - payload, - admittedCallState.InvocationToken, - out decodedRequestOwner); - retainedAdmissionPayload?.Dispose(); - decodePermit!.CompleteDecode(); - request = ReadRequestEnvelope(session, payload, flags); - } - } - catch (SharpLinkException exception) when ( - exception.Code is SharpLinkErrorCode.DataLoss or SharpLinkErrorCode.Internal) - { - retainedAdmissionPayload?.Dispose(); - session.ReturnDecodedPayload(decodedRequestOwner); - decodedRequestOwner = null; - CompleteFailedRequestStreams(session, requestId, exception); - var responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, exception, connection.ConnectionToken); - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - catch (OperationCanceledException exception) - { - retainedAdmissionPayload?.Dispose(); - session.ReturnDecodedPayload(decodedRequestOwner); - decodedRequestOwner = null; - CompleteFailedRequestStreams(session, requestId, exception); - var responseSend = session.SendRpcErrorWithBackpressureAsync( - requestId, - MapServerCancellationException(admittedCallState, request.RpcDeadline), - connection.ConnectionToken); - return ReleaseDispatchResourcesAfterResponseAsync( - responseSend, - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - } - catch (Exception exception) + finally { - retainedAdmissionPayload?.Dispose(); - session.ReturnDecodedPayload(decodedRequestOwner); - CompleteFailedRequestStreams(session, requestId, exception); - ReleaseDispatchResources( - admittedCallState, - requestId, - requestCancellationMap, - connection, - requestOwner); - throw; + admissionProgramUse?.Dispose(); } - - return ContinueRpcDispatch( - connection, - requestId, - flags, - request, - serviceInfo, - requestCancellationMap, - serverLoopToken, - admittedCallState, - requestOwner, - decodedRequestOwner); } private async ValueTask AwaitDispatchRpcNoReturnAsync( From de06fd7e7ae58021ee6aaeb5b71871f6124e9151 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:34:58 +0800 Subject: [PATCH 161/228] refactor(server): retain build admission publication identity --- src/SharpLink.Server/Admission/AdmissionProgram.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs index b3fd6ba30..9df5b0d8f 100644 --- a/src/SharpLink.Server/Admission/AdmissionProgram.cs +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -6,6 +6,7 @@ namespace SharpLink.Server; /// internal sealed class AdmissionProgram { + private static readonly ConditionalWeakTable ProgramsByController = new(); private static long s_nextGenerationId; private readonly SharpLinkAdmissionController _controller; @@ -16,6 +17,7 @@ internal AdmissionProgram(SharpLinkAdmissionController controller) { _controller = controller ?? throw new ArgumentNullException(nameof(controller)); GenerationId = Interlocked.Increment(ref s_nextGenerationId); + ProgramsByController.Add(controller, this); } internal long GenerationId { get; } @@ -28,6 +30,14 @@ internal AdmissionProgram(SharpLinkAdmissionController controller) internal int DuplicateReleaseAttempts => Volatile.Read(ref _duplicateReleaseAttempts); + internal static AdmissionProgram FromController(SharpLinkAdmissionController controller) + { + ArgumentNullException.ThrowIfNull(controller); + return ProgramsByController.TryGetValue(controller, out var program) + ? program + : throw new InvalidOperationException("Admission controller has no published program generation."); + } + internal AdmissionProgramUse AcquireUse() { Interlocked.Increment(ref _activeUses); From 17877641d03bf73ec82372ed752cc6441c01a2bb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:35:48 +0800 Subject: [PATCH 162/228] refactor(server): preserve controller lifecycle compatibility --- src/SharpLink.Server/ServerRuntimeComposition.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/SharpLink.Server/ServerRuntimeComposition.cs b/src/SharpLink.Server/ServerRuntimeComposition.cs index 07093b651..2728865a1 100644 --- a/src/SharpLink.Server/ServerRuntimeComposition.cs +++ b/src/SharpLink.Server/ServerRuntimeComposition.cs @@ -93,6 +93,8 @@ internal ServerRuntimeComposition( internal AdmissionProgram? AdmissionProgram { get; } + internal SharpLinkAdmissionController? AdmissionController => AdmissionProgram?.Controller; + internal ServerConnectionAdmission ConnectionAdmission { get; } internal ServerShutdownPlan ShutdownPlan { get; } From 6f5532383b0b68195342654f13f947e734edb2bf Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:35:59 +0800 Subject: [PATCH 163/228] fix(server): qualify admission publication registry type --- src/SharpLink.Server/Admission/AdmissionProgram.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs index 9df5b0d8f..b5c25ada7 100644 --- a/src/SharpLink.Server/Admission/AdmissionProgram.cs +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -6,7 +6,9 @@ namespace SharpLink.Server; /// internal sealed class AdmissionProgram { - private static readonly ConditionalWeakTable ProgramsByController = new(); + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< + SharpLinkAdmissionController, + AdmissionProgram> ProgramsByController = new(); private static long s_nextGenerationId; private readonly SharpLinkAdmissionController _controller; From a3167bd0c51787a31d551f074b39427b05b9140d Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:36:24 +0800 Subject: [PATCH 164/228] refactor(server): model disabled admission publication without request ownership --- .../Admission/AdmissionProgram.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs index b5c25ada7..626d30d8e 100644 --- a/src/SharpLink.Server/Admission/AdmissionProgram.cs +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -11,10 +11,13 @@ private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< AdmissionProgram> ProgramsByController = new(); private static long s_nextGenerationId; - private readonly SharpLinkAdmissionController _controller; + private readonly SharpLinkAdmissionController? _controller; private int _activeUses; private int _duplicateReleaseAttempts; + private AdmissionProgram(long sentinelGenerationId) + => GenerationId = sentinelGenerationId; + internal AdmissionProgram(SharpLinkAdmissionController controller) { _controller = controller ?? throw new ArgumentNullException(nameof(controller)); @@ -22,11 +25,18 @@ internal AdmissionProgram(SharpLinkAdmissionController controller) ProgramsByController.Add(controller, this); } + internal static AdmissionProgram Uninitialized { get; } = new(long.MinValue); + + internal static AdmissionProgram Disabled { get; } = new(0); + internal long GenerationId { get; } - internal SharpLinkAdmissionController Controller => _controller; + internal bool IsEnabled => _controller is not null; + + internal SharpLinkAdmissionController Controller + => _controller ?? throw new InvalidOperationException("Disabled admission has no controller."); - internal bool QueueOneWayCalls => _controller.QueueOneWayCalls; + internal bool QueueOneWayCalls => Controller.QueueOneWayCalls; internal int ActiveUses => Volatile.Read(ref _activeUses); @@ -42,6 +52,8 @@ internal static AdmissionProgram FromController(SharpLinkAdmissionController con internal AdmissionProgramUse AcquireUse() { + if (!IsEnabled) + throw new InvalidOperationException("Disabled admission does not acquire generation uses."); Interlocked.Increment(ref _activeUses); return new AdmissionProgramUse(this); } From 54a1bdbe6b117d2f155dfd40d6a6d66d3416de3e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:36:38 +0800 Subject: [PATCH 165/228] refactor(server): initialize and capture admission publication once --- .../SharpLinkServer.AdmissionProgram.cs | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs index 1fafc05a4..0822a426f 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs @@ -4,8 +4,7 @@ internal sealed partial class SharpLinkServer { private static Action? s_afterAdmissionCaptureForTests; - private readonly AdmissionProgram? _ownedAdmissionProgram; - private AdmissionProgram? _admissionProgram; + private AdmissionProgram _admissionProgram = AdmissionProgram.Uninitialized; internal static Action? AfterAdmissionCaptureForTests { @@ -14,18 +13,38 @@ internal sealed partial class SharpLinkServer } internal AdmissionProgram? CurrentAdmissionProgramForTests - => Volatile.Read(ref _admissionProgram); + { + get + { + var publication = ReadAdmissionPublication(); + return publication.IsEnabled ? publication : null; + } + } - internal AdmissionProgram? OwnedAdmissionProgramForTests => _ownedAdmissionProgram; + internal AdmissionProgram? OwnedAdmissionProgramForTests + => _admissionController is null + ? null + : AdmissionProgram.FromController(_admissionController); internal AdmissionProgram? PublishAdmissionProgramForTests(AdmissionProgram? program) - => Interlocked.Exchange(ref _admissionProgram, program); + { + var replacement = program ?? AdmissionProgram.Disabled; + var previous = Interlocked.Exchange(ref _admissionProgram, replacement); + if (ReferenceEquals(previous, AdmissionProgram.Uninitialized)) + { + previous = _admissionController is null + ? AdmissionProgram.Disabled + : AdmissionProgram.FromController(_admissionController); + } + return previous.IsEnabled ? previous : null; + } private AdmissionProgramUse? CaptureAdmissionProgram( long requestId, out AdmissionProgram? program) { - program = Volatile.Read(ref _admissionProgram); + var publication = ReadAdmissionPublication(); + program = publication.IsEnabled ? publication : null; var use = program?.AcquireUse(); try { @@ -39,11 +58,21 @@ internal AdmissionProgram? CurrentAdmissionProgramForTests } } - private void StopAdmissionPrograms() + private AdmissionProgram ReadAdmissionPublication() { - var current = Volatile.Read(ref _admissionProgram); - current?.Controller.StopAccepting(); - if (_ownedAdmissionProgram is not null && !ReferenceEquals(current, _ownedAdmissionProgram)) - _ownedAdmissionProgram.Controller.StopAccepting(); + var publication = Volatile.Read(ref _admissionProgram); + if (!ReferenceEquals(publication, AdmissionProgram.Uninitialized)) + return publication; + + var initial = _admissionController is null + ? AdmissionProgram.Disabled + : AdmissionProgram.FromController(_admissionController); + var observed = Interlocked.CompareExchange( + ref _admissionProgram, + initial, + AdmissionProgram.Uninitialized); + return ReferenceEquals(observed, AdmissionProgram.Uninitialized) + ? initial + : observed; } } From 76b590a6f9bf49c267ad5898c8bdb66651adc4ea Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:40:44 +0800 Subject: [PATCH 166/228] test(server): cover captured admission generations and release matrix --- .../DynamicAdmissionGenerationTests.cs | 725 ++++++++++++++++++ 1 file changed, 725 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs new file mode 100644 index 000000000..e9da597b9 --- /dev/null +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs @@ -0,0 +1,725 @@ +namespace SharpLink.IntegrationTests; + +public class DynamicAdmissionGenerationTests +{ + [Test] + [NotInParallel] + [Arguments(true)] + [Arguments(false)] + public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bool oneWay) + { + TestService.ResetNotify(); + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => options.Global.UseConcurrency(1)); + var program = harness.OwnedProgram + ?? throw new Exception("enabled server must expose its initial admission program"); + var held = await program.Controller.AcquireAsync( + CreateAdmissionContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired, "test must occupy the captured generation before the request"); + AdmissionProgram? captured = null; + var hookCount = 0; + + try + { + SharpLinkServer.AfterAdmissionCaptureForTests = (server, _, observed) => + { + if (!ReferenceEquals(server, harness.Server) || + Interlocked.Exchange(ref hookCount, 1) != 0) + return; + captured = observed; + server.PublishAdmissionProgramForTests(null); + }; + + var service = harness.ClientA.Get(); + if (oneWay) + { + await service.NotifyAsync("captured-enabled"); + SharpLinkServer.AfterAdmissionCaptureForTests = null; + Ensure(await service.AddAsync(20, 22) == 42, + "the new disabled publication must be usable by the next request"); + Ensure(TestService.NotifyCount == 0, + "the already-captured enabled one-way request must still be rejected"); + } + else + { + var failure = await CaptureFailureAsync(service.AddAsync(20, 22).AsTask()); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted }, + "the already-captured enabled two-way request must still be rejected"); + } + + Ensure(ReferenceEquals(captured, program), + "request must retain the exact enabled generation captured before publication change"); + await WaitUntilAsync(() => program.ActiveUses == 0, + "enabled capture use returns to zero after rejection"); + Ensure(program.DuplicateReleaseAttempts == 0, + "enabled capture must not be released twice"); + } + finally + { + SharpLinkServer.AfterAdmissionCaptureForTests = null; + held.Lease?.Dispose(); + } + } + + [Test] + [NotInParallel] + public async Task EnabledCaptureShouldRemainOnGenerationNWhenCurrentBecomesNPlusOne() + { + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => options.Global.UseConcurrency(1)); + var original = harness.OwnedProgram + ?? throw new Exception("enabled server must expose its initial admission program"); + var held = await original.Controller.AcquireAsync( + CreateAdmissionContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired, "test must occupy generation N"); + var replacementController = CreateController(options => options.Global.UseConcurrency(1)); + var replacement = new AdmissionProgram(replacementController); + AdmissionProgram? captured = null; + var hookCount = 0; + + try + { + SharpLinkServer.AfterAdmissionCaptureForTests = (server, _, observed) => + { + if (!ReferenceEquals(server, harness.Server) || + Interlocked.Exchange(ref hookCount, 1) != 0) + return; + captured = observed; + server.PublishAdmissionProgramForTests(replacement); + }; + + var service = harness.ClientA.Get(); + var failure = await CaptureFailureAsync(service.AddAsync(1, 2).AsTask()); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted }, + "request captured from N must not switch to the available N+1 generation"); + SharpLinkServer.AfterAdmissionCaptureForTests = null; + Ensure(await service.AddAsync(20, 22) == 42, + "the next request must observe the replacement generation"); + Ensure(ReferenceEquals(captured, original) && + captured!.GenerationId != replacement.GenerationId, + "the in-flight request must retain generation N identity"); + await WaitUntilAsync(() => original.ActiveUses == 0 && replacement.ActiveUses == 0, + "both generations return to zero after their requests finish"); + Ensure(original.DuplicateReleaseAttempts == 0 && + replacement.DuplicateReleaseAttempts == 0, + "generation replacement must not double-release either generation"); + } + finally + { + SharpLinkServer.AfterAdmissionCaptureForTests = null; + harness.Server.PublishAdmissionProgramForTests(original); + held.Lease?.Dispose(); + await replacementController.DisposeAsync(); + } + } + + [Test] + [NotInParallel] + [Arguments(true)] + [Arguments(false)] + public async Task DisabledCaptureShouldRemainDisabledWhenCurrentBecomesEnabled(bool oneWay) + { + TestService.ResetNotify(); + await using var harness = await Harness.CreateAsync(); + var replacementController = CreateController(options => options.Global.UseConcurrency(1)); + var replacement = new AdmissionProgram(replacementController); + var held = await replacementController.AcquireAsync( + CreateAdmissionContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired, "test must occupy the replacement enabled generation"); + AdmissionProgram? captured = replacement; + var hookCount = 0; + + try + { + SharpLinkServer.AfterAdmissionCaptureForTests = (server, _, observed) => + { + if (!ReferenceEquals(server, harness.Server) || + Interlocked.Exchange(ref hookCount, 1) != 0) + return; + captured = observed; + server.PublishAdmissionProgramForTests(replacement); + }; + + var service = harness.ClientA.Get(); + if (oneWay) + { + await service.NotifyAsync("captured-disabled"); + await TestService.WaitForNotifyAsync().WaitAsync(TimeSpan.FromSeconds(2)); + Ensure(TestService.NotifyCount == 1, + "one-way request captured while disabled must bypass the later enabled publication"); + } + else + { + Ensure(await service.AddAsync(20, 22) == 42, + "two-way request captured while disabled must bypass the later enabled publication"); + } + + Ensure(captured is null, "disabled capture must remain represented as disabled"); + SharpLinkServer.AfterAdmissionCaptureForTests = null; + var nextFailure = await CaptureFailureAsync(service.AddAsync(1, 2).AsTask()); + Ensure(nextFailure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted }, + "the next request must observe the new enabled publication"); + await WaitUntilAsync(() => replacement.ActiveUses == 0, + "replacement generation use returns to zero after rejection"); + Ensure(replacement.DuplicateReleaseAttempts == 0, + "replacement generation must not be released twice"); + } + finally + { + SharpLinkServer.AfterAdmissionCaptureForTests = null; + harness.Server.PublishAdmissionProgramForTests(null); + held.Lease?.Dispose(); + await replacementController.DisposeAsync(); + } + } + + [Test] + [NotInParallel] + [Arguments(true)] + [Arguments(false)] + public async Task QueuedRequestShouldRetainCapturedGenerationAcrossAwait(bool oneWay) + { + TestService.ResetBlockingAdd(); + TestService.ResetNotify(); + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => + { + options.Global.UseConcurrency(1); + options.QueueOneWayCalls = true; + options.MaxQueuedCalls = 2; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }); + var program = harness.OwnedProgram + ?? throw new Exception("enabled server must expose its initial admission program"); + var service = harness.ClientA.Get(); + var active = service.BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + Task? queuedTwoWay = null; + var hookCount = 0; + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + SharpLinkServer.AfterAdmissionCaptureForTests = (server, _, observed) => + { + if (!ReferenceEquals(server, harness.Server) || + Interlocked.Exchange(ref hookCount, 1) != 0) + return; + Ensure(ReferenceEquals(observed, program), + "queued request must capture the original generation before publication change"); + server.PublishAdmissionProgramForTests(null); + }; + + if (oneWay) + await service.NotifyAsync("queued-generation"); + else + queuedTwoWay = service.AddAsync(20, 22).AsTask(); + + await WaitUntilAsync(() => program.Controller.QueuedCalls == 1, + "target request reaches the captured generation queue"); + Ensure(program.ActiveUses == 2, + "active owner and queued target must each retain one generation use"); + if (oneWay) + Ensure(TestService.NotifyCount == 0, + "queued one-way request must not bypass after current publication becomes disabled"); + else + Ensure(!queuedTwoWay!.IsCompleted, + "queued two-way request must remain queued on its captured generation"); + + TestService.ReleaseBlockingAdd(); + Ensure(await active.WaitAsync(TimeSpan.FromSeconds(5)) == 2, + "active admission owner completes"); + if (oneWay) + await TestService.WaitForNotifyAsync().WaitAsync(TimeSpan.FromSeconds(5)); + else + Ensure(await queuedTwoWay!.WaitAsync(TimeSpan.FromSeconds(5)) == 42, + "queued two-way request executes after the captured generation releases a permit"); + + await WaitUntilAsync( + () => program.Controller.QueuedCalls == 0 && program.ActiveUses == 0, + "queued generation accounting returns to zero"); + Ensure(program.DuplicateReleaseAttempts == 0, + "queued request generation must release exactly once"); + } + finally + { + SharpLinkServer.AfterAdmissionCaptureForTests = null; + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (queuedTwoWay is not null) + await ObserveTerminalAsync(queuedTwoWay); + } + } + + [Test] + [NotInParallel] + public async Task AdmissionRejectShouldReleaseGenerationExactlyOnce() + { + TestService.ResetBlockingAdd(); + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => options.Global.UseConcurrency(1)); + var program = harness.OwnedProgram!; + var service = harness.ClientA.Get(); + var active = service.BlockingAddAsync(1, 1).AsTask(); + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + var failure = await CaptureFailureAsync(service.AddAsync(2, 2).AsTask()); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted }, + "contender must be rejected by admission"); + await WaitUntilAsync(() => program.ActiveUses == 1, + "admission reject releases only the rejected request generation use"); + Ensure(program.DuplicateReleaseAttempts == 0, + "admission reject must not double-release generation use"); + } + finally + { + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + } + await AssertProgramReleasedAsync(program, "admission reject terminal cleanup"); + } + + [Test] + [NotInParallel] + public async Task CallCapacityRejectShouldReleaseGenerationExactlyOnce() + { + TestService.ResetBlockingAdd(); + await using var harness = await Harness.CreateAsync( + serverRuntimeConfigure: options => options.FlowControl.MaxConcurrentCallsPerServer = 1, + admissionConfigure: options => options.Global.UseConcurrency(2)); + var program = harness.OwnedProgram!; + var service = harness.ClientA.Get(); + var active = service.BlockingAddAsync(1, 1).AsTask(); + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + var failure = await CaptureFailureAsync(service.AddAsync(2, 2).AsTask()); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted }, + "contender must be rejected by server call capacity after admission succeeds"); + await WaitUntilAsync(() => program.ActiveUses == 1, + "call-capacity rejection releases the target generation use"); + Ensure(program.DuplicateReleaseAttempts == 0, + "call-capacity rejection must not double-release generation use"); + } + finally + { + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + } + await AssertProgramReleasedAsync(program, "call-capacity rejection terminal cleanup"); + } + + [Test] + [NotInParallel] + public async Task RetainedRequestBudgetRejectShouldReleaseGenerationExactlyOnce() + { + TestService.ResetBlockingAdd(); + await using var harness = await Harness.CreateAsync( + serverRuntimeConfigure: options => + { + options.FlowControl.MaxRetainedCompressedBytesPerServer = 1; + options.Compression.Providers.Add(SharpLinkCompressionProviders.CreateBrotli()); + }, + clientRuntimeConfigure: options => + options.Compression.Providers.Add(SharpLinkCompressionProviders.CreateBrotli()), + admissionConfigure: options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }); + var program = harness.OwnedProgram!; + var active = harness.ClientA.Get() + .BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + Task? target = null; + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + var payload = Enumerable.Repeat((byte)0x2a, 16 * 1024).ToArray(); + target = harness.ClientA.Get().EchoBytesAsync(payload).AsTask(); + await WaitUntilAsync(() => program.Controller.QueuedCalls == 1, + "compressed target enters admission queue before retained-budget cleanup"); + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + var failure = await CaptureFailureAsync(target); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted } exhausted && + exhausted.Message.Contains( + SharpLinkResourceExhaustion.ServerRetainedCompressedBytes, + StringComparison.Ordinal), + "retained compressed request budget must reject with its stable reason"); + } + finally + { + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (target is not null) + await ObserveTerminalAsync(target); + } + await AssertProgramReleasedAsync(program, "retained request budget rejection cleanup"); + } + + [Test] + [NotInParallel] + public async Task QueuedCancellationShouldReleaseGenerationExactlyOnce() + { + TestService.ResetBlockingAdd(); + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }); + var program = harness.OwnedProgram!; + var service = harness.ClientA.Get(); + var active = service.BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + using var cancellation = new CancellationTokenSource(); + Task? target = null; + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + target = service.BlockingAddAsync(2, 2, cancellation.Token).AsTask(); + await WaitUntilAsync(() => program.Controller.QueuedCalls == 1, + "cancellable request enters admission queue"); + cancellation.Cancel(); + await CaptureFailureAsync(target); + await WaitUntilAsync( + () => program.Controller.QueuedCalls == 0 && program.ActiveUses == 1, + "queued cancellation releases only the cancelled generation use"); + Ensure(program.DuplicateReleaseAttempts == 0, + "queued cancellation must not double-release generation use"); + } + finally + { + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (target is not null) + await ObserveTerminalAsync(target); + } + await AssertProgramReleasedAsync(program, "queued cancellation terminal cleanup"); + } + + [Test] + [NotInParallel] + public async Task QueuedConnectionCloseShouldReleaseGenerationExactlyOnce() + { + TestService.ResetBlockingAdd(); + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }); + var program = harness.OwnedProgram!; + var active = harness.ClientA.Get() + .BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + Task? target = null; + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + target = harness.ClientB.Get().AddAsync(2, 2).AsTask(); + await WaitUntilAsync(() => program.Controller.QueuedCalls == 1, + "second-connection request enters admission queue"); + await harness.StopClientBAsync(); + await CaptureFailureAsync(target); + await WaitUntilAsync( + () => program.Controller.QueuedCalls == 0 && program.ActiveUses == 1, + "connection close releases the queued request generation use"); + Ensure(program.DuplicateReleaseAttempts == 0, + "connection close must not double-release generation use"); + } + finally + { + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (target is not null) + await ObserveTerminalAsync(target); + } + await AssertProgramReleasedAsync(program, "connection-close terminal cleanup"); + } + + [Test] + [NotInParallel] + public async Task DecodeFailureShouldReleaseGenerationAndKeepConnectionReusable() + { + var throwingProvider = new ThrowingDecompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await Harness.CreateAsync( + serverRuntimeConfigure: options => + options.Compression.Providers.Add(throwingProvider), + clientRuntimeConfigure: options => + options.Compression.Providers.Add(SharpLinkCompressionProviders.CreateBrotli()), + admissionConfigure: options => options.Global.UseConcurrency(2)); + var program = harness.OwnedProgram!; + var payload = Enumerable.Repeat((byte)0x35, 16 * 1024).ToArray(); + + var failure = await CaptureFailureAsync( + harness.ClientA.Get().EchoBytesAsync(payload).AsTask()); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.Internal }, + "provider decode failure must remain call-scoped"); + await AssertProgramReleasedAsync(program, "decode failure cleanup"); + Ensure(await harness.ClientA.Get().AddAsync(20, 22) == 42, + "connection must remain reusable after controlled decode failure"); + await AssertProgramReleasedAsync(program, "post-decode-failure connection reuse"); + } + + [Test] + [NotInParallel] + public async Task ActivationFailureShouldReleaseGenerationAndKeepConnectionReusable() + { + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => options.Global.UseConcurrency(2)); + var program = harness.OwnedProgram!; + try + { + ServerCallCancellationState.BeforeRequestActivationForTests = state => + state.TryCancel(ServerCallCancellationReason.RemoteCancel); + var failure = await CaptureFailureAsync( + harness.ClientA.Get().AddAsync(1, 2).AsTask()); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.Cancelled }, + "activation terminal winner must prevent invocation and surface cancellation"); + } + finally + { + ServerCallCancellationState.BeforeRequestActivationForTests = null; + } + + await AssertProgramReleasedAsync(program, "activation failure cleanup"); + Ensure(await harness.ClientA.Get().AddAsync(20, 22) == 42, + "connection must remain reusable after controlled activation failure"); + await AssertProgramReleasedAsync(program, "post-activation-failure connection reuse"); + } + + [Test] + [NotInParallel] + public async Task SuccessfulTerminalCompletionShouldReleaseGenerationExactlyOnce() + { + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => options.Global.UseConcurrency(2)); + var program = harness.OwnedProgram!; + Ensure(await harness.ClientA.Get().AddAsync(20, 22) == 42, + "successful admitted request result"); + await AssertProgramReleasedAsync(program, "successful request terminal cleanup"); + } + + private static SharpLinkAdmissionController CreateController( + Action configure) + { + var options = new SharpLinkAdmissionControlOptions(); + configure(options); + return SharpLinkAdmissionController.Create(options, []); + } + + private static SharpLinkAdmissionContext CreateAdmissionContext() + => new(1, 2, RpcMethodKind.Unary, "generation-test", null, null, null); + + private static async Task AssertProgramReleasedAsync( + AdmissionProgram program, + string scenario) + { + await WaitUntilAsync(() => program.ActiveUses == 0, scenario); + Ensure(program.DuplicateReleaseAttempts == 0, + $"{scenario}: generation use must be released exactly once"); + } + + private static async Task CaptureFailureAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task ObserveTerminalAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class ThrowingDecompressionProvider(ISharpLinkCompressionProvider inner) + : ISharpLinkCompressionProvider + { + public string WireProfile => inner.WireProfile; + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => throw new InvalidOperationException("forced generation-test decompression failure"); + } + + private sealed class Harness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCancellation; + private readonly Task _serverTask; + private bool _clientBStopped; + private bool _disposed; + + private Harness( + CancellationTokenSource serverCancellation, + Task serverTask, + SharpLinkServer server, + ISharpLinkClient clientA, + ISharpLinkClient clientB) + { + _serverCancellation = serverCancellation; + _serverTask = serverTask; + Server = server; + ClientA = clientA; + ClientB = clientB; + } + + internal SharpLinkServer Server { get; } + internal ISharpLinkClient ClientA { get; } + internal ISharpLinkClient ClientB { get; } + internal AdmissionProgram? OwnedProgram => Server.OwnedAdmissionProgramForTests; + + internal static async Task CreateAsync( + Action? serverRuntimeConfigure = null, + Action? clientRuntimeConfigure = null, + Action? admissionConfigure = null) + { + var serverCancellation = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); + if (serverRuntimeConfigure is not null) + serverBuilder.UseRuntime(serverRuntimeConfigure); + if (admissionConfigure is not null) + serverBuilder.UseAdmissionControl(admissionConfigure); + serverBuilder.UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = (SharpLinkServer)serverBuilder.Build(); + var serverTask = RunServerAsync(server, serverCancellation.Token); + + var clientA = CreateClient(port, clientRuntimeConfigure); + var clientB = CreateClient(port, clientRuntimeConfigure); + await clientA.ConnectAsync(); + await clientB.ConnectAsync(); + return new Harness(serverCancellation, serverTask, server, clientA, clientB); + } + + internal async Task StopClientBAsync() + { + if (_clientBStopped) + return; + _clientBStopped = true; + await StopClientAsync(ClientB); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + _disposed = true; + try + { + await StopClientAsync(ClientA); + if (!_clientBStopped) + await StopClientAsync(ClientB); + } + finally + { + await _serverCancellation.CancelAsync(); + try + { + await Server.StopAsync(TimeSpan.Zero); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException) + { + } + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCancellation.Dispose(); + } + } + + private static ISharpLinkClient CreateClient( + int port, + Action? runtimeConfigure) + { + var builder = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); + if (runtimeConfigure is not null) + builder.UseRuntime(runtimeConfigure); + return builder.UseTcp(IPAddress.Loopback.ToString(), port).Build(); + } + + private static async Task StopClientAsync(ISharpLinkClient client) + { + try + { + await client.StopAsync(); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) + { + } + } + + private static Task RunServerAsync( + ISharpLinkServer server, + CancellationToken cancellationToken) + => Task.Run(async () => + { + try + { + await server.RunAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + } +} From a2f08ba2659eb00f61107036d9ba4ef3d6610717 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:42:42 +0800 Subject: [PATCH 167/228] test(server): keep retained-budget reason local to integration probe --- .../DynamicAdmissionGenerationTestReasons.cs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTestReasons.cs diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTestReasons.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTestReasons.cs new file mode 100644 index 000000000..3e2b39bd5 --- /dev/null +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTestReasons.cs @@ -0,0 +1,6 @@ +namespace SharpLink.IntegrationTests; + +internal static class SharpLinkResourceExhaustion +{ + internal const string ServerRetainedCompressedBytes = "server_retained_compressed_bytes"; +} From c44198d14bf54d818d03825b8074ec6db3f450b7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:49:13 +0800 Subject: [PATCH 168/228] chore(ci): apply scoped issue 322 harness patch --- .github/workflows/issue-322-patch-harness.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/issue-322-patch-harness.yml diff --git a/.github/workflows/issue-322-patch-harness.yml b/.github/workflows/issue-322-patch-harness.yml new file mode 100644 index 000000000..ccd94d446 --- /dev/null +++ b/.github/workflows/issue-322-patch-harness.yml @@ -0,0 +1,58 @@ +name: Issue 322 Patch Harness + +on: + push: + branches: + - issue-322-admission-program-generation + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: issue-322-admission-program-generation + fetch-depth: 0 + + - name: Patch reflection harness exactly + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path('test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs') + text = path.read_text() + old = ''' Connection.CallCancellations, + CancellationToken.None, + null, + (flags & ProtocolV2FrameFlags.Cancellable) != 0, + null + ''' + new = ''' Connection.CallCancellations, + CancellationToken.None, + null, + null, + null, + (flags & ProtocolV2FrameFlags.Cancellable) != 0, + null + ''' + count = text.count(old) + if count != 1: + raise SystemExit(f'expected exactly one DispatchRpcAsync argument block, found {count}') + path.write_text(text.replace(old, new)) + PY + + - name: Commit patch and remove helper workflow + shell: bash + run: | + rm .github/workflows/issue-322-patch-harness.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs .github/workflows/issue-322-patch-harness.yml + git commit -m "test(server): pass captured admission generation in dispatch harness" + git push origin HEAD:issue-322-admission-program-generation From 0b6be96e761c1af022ac224db390e146893eeb76 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:50:16 +0800 Subject: [PATCH 169/228] chore(ci): trigger scoped issue 322 harness patch --- .github/workflows/issue-322-patch-harness.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issue-322-patch-harness.yml b/.github/workflows/issue-322-patch-harness.yml index ccd94d446..c49ce9be5 100644 --- a/.github/workflows/issue-322-patch-harness.yml +++ b/.github/workflows/issue-322-patch-harness.yml @@ -1,16 +1,14 @@ name: Issue 322 Patch Harness on: - push: - branches: - - issue-322-admission-program-generation + pull_request: permissions: contents: write jobs: patch: - if: github.actor != 'github-actions[bot]' + if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' runs-on: ubuntu-latest steps: - name: Checkout branch From 275366f09a3df8bbae70c2cf72fd8906add0c070 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:51:36 +0800 Subject: [PATCH 170/228] chore(ci): make issue 322 harness patch deterministic --- .github/workflows/issue-322-patch-harness.yml | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/.github/workflows/issue-322-patch-harness.yml b/.github/workflows/issue-322-patch-harness.yml index c49ce9be5..637eda0be 100644 --- a/.github/workflows/issue-322-patch-harness.yml +++ b/.github/workflows/issue-322-patch-harness.yml @@ -24,25 +24,31 @@ jobs: from pathlib import Path path = Path('test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs') - text = path.read_text() - old = ''' Connection.CallCancellations, - CancellationToken.None, - null, - (flags & ProtocolV2FrameFlags.Cancellable) != 0, - null - ''' - new = ''' Connection.CallCancellations, - CancellationToken.None, - null, - null, - null, - (flags & ProtocolV2FrameFlags.Cancellable) != 0, - null - ''' - count = text.count(old) - if count != 1: - raise SystemExit(f'expected exactly one DispatchRpcAsync argument block, found {count}') - path.write_text(text.replace(old, new)) + lines = path.read_text().splitlines(keepends=True) + starts = [ + i for i, line in enumerate(lines) + if 'return (ValueTask)DispatchMethod.Invoke(Server,' in line + ] + if len(starts) != 1: + raise SystemExit(f'expected one dispatch reflection call, found {len(starts)}') + start = starts[0] + end = next( + (i for i in range(start, min(start + 32, len(lines))) if '])!;' in lines[i]), + None) + if end is None: + raise SystemExit('dispatch reflection call terminator not found') + matches = [ + i for i in range(start, end + 1) + if lines[i].strip() == 'CancellationToken.None,' + ] + if len(matches) != 1: + raise SystemExit(f'expected one serverLoopToken argument, found {len(matches)}') + index = matches[0] + if lines[index + 1].strip() != 'null,': + raise SystemExit('expected existing admittedCallState null after serverLoopToken') + indent = lines[index + 1][:-len(lines[index + 1].lstrip())] + lines[index + 1:index + 1] = [f'{indent}null,\n', f'{indent}null,\n'] + path.write_text(''.join(lines)) PY - name: Commit patch and remove helper workflow From 973054754326bb49732ee511c6e9d94ef6614c0c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:51:46 +0000 Subject: [PATCH 171/228] test(server): pass captured admission generation in dispatch harness --- .github/workflows/issue-322-patch-harness.yml | 62 ------------------- .../Server/SharpLinkServerInvocationTests.cs | 2 + 2 files changed, 2 insertions(+), 62 deletions(-) delete mode 100644 .github/workflows/issue-322-patch-harness.yml diff --git a/.github/workflows/issue-322-patch-harness.yml b/.github/workflows/issue-322-patch-harness.yml deleted file mode 100644 index 637eda0be..000000000 --- a/.github/workflows/issue-322-patch-harness.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Issue 322 Patch Harness - -on: - pull_request: - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: issue-322-admission-program-generation - fetch-depth: 0 - - - name: Patch reflection harness exactly - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - path = Path('test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs') - lines = path.read_text().splitlines(keepends=True) - starts = [ - i for i, line in enumerate(lines) - if 'return (ValueTask)DispatchMethod.Invoke(Server,' in line - ] - if len(starts) != 1: - raise SystemExit(f'expected one dispatch reflection call, found {len(starts)}') - start = starts[0] - end = next( - (i for i in range(start, min(start + 32, len(lines))) if '])!;' in lines[i]), - None) - if end is None: - raise SystemExit('dispatch reflection call terminator not found') - matches = [ - i for i in range(start, end + 1) - if lines[i].strip() == 'CancellationToken.None,' - ] - if len(matches) != 1: - raise SystemExit(f'expected one serverLoopToken argument, found {len(matches)}') - index = matches[0] - if lines[index + 1].strip() != 'null,': - raise SystemExit('expected existing admittedCallState null after serverLoopToken') - indent = lines[index + 1][:-len(lines[index + 1].lstrip())] - lines[index + 1:index + 1] = [f'{indent}null,\n', f'{indent}null,\n'] - path.write_text(''.join(lines)) - PY - - - name: Commit patch and remove helper workflow - shell: bash - run: | - rm .github/workflows/issue-322-patch-harness.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs .github/workflows/issue-322-patch-harness.yml - git commit -m "test(server): pass captured admission generation in dispatch harness" - git push origin HEAD:issue-322-admission-program-generation diff --git a/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs b/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs index eddaa96f3..b0b7940d7 100644 --- a/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs +++ b/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs @@ -1352,6 +1352,8 @@ internal ValueTask Dispatch(long requestId, ProtocolV2FrameFlags flags) Connection.CallCancellations, CancellationToken.None, null, + null, + null, (flags & ProtocolV2FrameFlags.Cancellable) != 0, null ])!; From be9acaca84b45708d90f216858513fd4b5432852 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:52:20 +0800 Subject: [PATCH 172/228] chore: trigger exact-head issue 322 validation From 0a2105d4141972f441f7111dc28c30a939653f38 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:55:05 +0800 Subject: [PATCH 173/228] chore(ci): apply issue 322 zero-allocation ownership refinement --- .../issue-322-remove-use-allocation.yml | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 .github/workflows/issue-322-remove-use-allocation.yml diff --git a/.github/workflows/issue-322-remove-use-allocation.yml b/.github/workflows/issue-322-remove-use-allocation.yml new file mode 100644 index 000000000..3c602c660 --- /dev/null +++ b/.github/workflows/issue-322-remove-use-allocation.yml @@ -0,0 +1,291 @@ +name: Issue 322 Remove Use Allocation + +on: + pull_request: + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: issue-322-admission-program-generation + fetch-depth: 0 + + - name: Remove enabled-path generation token allocation + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(path, old, new): + p = Path(path) + text = p.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected one replacement, found {count}: {old[:80]!r}') + p.write_text(text.replace(old, new, 1)) + + Path('src/SharpLink.Server/Admission/AdmissionProgram.cs').write_text('''namespace SharpLink.Server; + +/// +/// Immutable admission-policy publication for one runtime generation. Requests capture one +/// publication at the RequestLoop boundary and never re-read the server's current publication. +/// +internal sealed class AdmissionProgram +{ + private static long s_nextGenerationId; + + private readonly SharpLinkAdmissionController _controller; + private int _activeUses; + private int _duplicateReleaseAttempts; + + internal AdmissionProgram(SharpLinkAdmissionController controller) + { + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + GenerationId = Interlocked.Increment(ref s_nextGenerationId); + } + + internal long GenerationId { get; } + + internal SharpLinkAdmissionController Controller => _controller; + + internal bool QueueOneWayCalls => _controller.QueueOneWayCalls; + + internal int ActiveUses => Volatile.Read(ref _activeUses); + + internal int DuplicateReleaseAttempts => Volatile.Read(ref _duplicateReleaseAttempts); + + internal void AcquireUse() => Interlocked.Increment(ref _activeUses); + + internal void ReleaseUse() + { + if (Interlocked.Decrement(ref _activeUses) >= 0) + return; + + // Restore accounting before surfacing an ownership bug so diagnostics remain stable. + Interlocked.Increment(ref _activeUses); + Interlocked.Increment(ref _duplicateReleaseAttempts); + throw new InvalidOperationException("Admission program use count underflowed."); + } +} +''') + + replace_once( + 'src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs', + ''' private AdmissionProgramUse? CaptureAdmissionProgram( + long requestId, + out AdmissionProgram? program) + { + program = Volatile.Read(ref _admissionProgram); + var use = program?.AcquireUse(); + try + { + Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); + return use; + } + catch + { + use?.Dispose(); + throw; + } + } +''', + ''' private AdmissionProgram? CaptureAdmissionProgram(long requestId) + { + var program = Volatile.Read(ref _admissionProgram); + program?.AcquireUse(); + try + { + Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); + return program; + } + catch + { + program?.ReleaseUse(); + throw; + } + } +''') + + replace_once( + 'src/SharpLink.Server/SharpLinkServer.RequestLoop.cs', + ''' var admissionProgramUse = CaptureAdmissionProgram( + requestId, + out var admissionProgram); +''', + ''' var admissionProgram = CaptureAdmissionProgram(requestId); +''') + p = Path('src/SharpLink.Server/SharpLinkServer.RequestLoop.cs') + text = p.read_text() + count = text.count(''' admissionProgram, + admissionProgramUse);''') + if count != 2: + raise SystemExit(f'RequestLoop: expected two dispatch captures, found {count}') + p.write_text(text.replace( + ''' admissionProgram, + admissionProgramUse);''', + ''' admissionProgram);''')) + + replace_once( + 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', + ''' AdmissionProgram? admissionProgram, + AdmissionProgramUse? admissionProgramUse, + ServerCallCancellationState? admittedCallState = null, +''', + ''' AdmissionProgram? admissionProgram, + ServerCallCancellationState? admittedCallState = null, +''') + replace_once( + 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', + ''' { + if (admissionProgram is null && admissionProgramUse is not null) + throw new InvalidOperationException("A captured admission use requires its program generation."); + if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) + throw new InvalidOperationException("An enabled captured admission generation requires one use token."); + + try +''', + ''' { + var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; + try +''') + replace_once( + 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', + ''' admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); + admissionProgramUse = null; +''', + ''' admittedCallState.AttachAdmissionProgramUse(admissionProgram); + ownsAdmissionProgramUse = false; +''') + replace_once( + 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', + ''' admissionProgram, + admissionProgramUse: null, + callState, +''', + ''' admissionProgram, + callState, +''') + replace_once( + 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', + ''' finally + { + admissionProgramUse?.Dispose(); + } +''', + ''' finally + { + if (ownsAdmissionProgramUse) + admissionProgram!.ReleaseUse(); + } +''') + + replace_once( + 'src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs', + ''' AdmissionProgram? admissionProgram, + AdmissionProgramUse? admissionProgramUse, + ServerCallCancellationState? admittedCallState = null, +''', + ''' AdmissionProgram? admissionProgram, + ServerCallCancellationState? admittedCallState = null, +''') + replace_once( + 'src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs', + ''' { + if (admissionProgram is null && admissionProgramUse is not null) + throw new InvalidOperationException("A captured admission use requires its program generation."); + if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) + throw new InvalidOperationException("An enabled captured admission generation requires one use token."); + + try +''', + ''' { + var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; + try +''') + replace_once( + 'src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs', + ''' admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); + admissionProgramUse = null; +''', + ''' admittedCallState.AttachAdmissionProgramUse(admissionProgram); + ownsAdmissionProgramUse = false; +''') + replace_once( + 'src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs', + ''' finally + { + admissionProgramUse?.Dispose(); + } +''', + ''' finally + { + if (ownsAdmissionProgramUse) + admissionProgram!.ReleaseUse(); + } +''') + + p = Path('src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs') + text = p.read_text() + extra = ''' admissionProgramUse: null,\n''' + count = text.count(extra) + if count != 1: + raise SystemExit(f'AdmissionDispatch: expected one remaining admissionProgramUse named argument, found {count}') + p.write_text(text.replace(extra, '', 1)) + + replace_once( + 'src/SharpLink.Server/ServerCallCancellationState.cs', + ' private AdmissionProgramUse? _admissionProgramUse;\n', + ' private AdmissionProgram? _admissionProgramUse;\n') + replace_once( + 'src/SharpLink.Server/ServerCallCancellationState.cs', + ''' internal void AttachAdmissionProgramUse(AdmissionProgramUse admissionProgramUse) + { + ArgumentNullException.ThrowIfNull(admissionProgramUse); + if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgramUse, null) is not null) + throw new InvalidOperationException("An admission program use is already attached to this call."); + } +''', + ''' internal void AttachAdmissionProgramUse(AdmissionProgram admissionProgram) + { + ArgumentNullException.ThrowIfNull(admissionProgram); + if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgram, null) is not null) + throw new InvalidOperationException("An admission program use is already attached to this call."); + } +''') + replace_once( + 'src/SharpLink.Server/ServerCallCancellationState.cs', + ' Interlocked.Exchange(ref _admissionProgramUse, null)?.Dispose();\n', + ' Interlocked.Exchange(ref _admissionProgramUse, null)?.ReleaseUse();\n') + + p = Path('test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs') + lines = p.read_text().splitlines(keepends=True) + start = next(i for i, line in enumerate(lines) if 'return (ValueTask)DispatchMethod.Invoke(Server,' in line) + end = next(i for i in range(start, start + 32) if '])!;' in lines[i]) + token = next(i for i in range(start, end + 1) if lines[i].strip() == 'CancellationToken.None,') + nulls = [] + i = token + 1 + while lines[i].strip() == 'null,': + nulls.append(i) + i += 1 + if len(nulls) != 3: + raise SystemExit(f'unit harness: expected three nulls before admissionGranted, found {len(nulls)}') + del lines[nulls[-1]] + p.write_text(''.join(lines)) + PY + + - name: Commit optimized ownership and remove helper workflow + shell: bash + run: | + rm .github/workflows/issue-322-remove-use-allocation.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add src/SharpLink.Server test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs .github/workflows/issue-322-remove-use-allocation.yml + git commit -m "perf(server): remove admission generation use allocation" + git push origin HEAD:issue-322-admission-program-generation From 7e2be179ea63a8845f63e401e3840565af428fef Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:55:29 +0800 Subject: [PATCH 174/228] chore: trigger admission use ownership refinement From cd4ab9a4cbc249b99a5d54f4cf9d4fc632b575b3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:56:21 +0800 Subject: [PATCH 175/228] chore(ci): trigger issue 322 zero-allocation refinement --- .github/workflows/issue-322-remove-use-allocation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue-322-remove-use-allocation.yml b/.github/workflows/issue-322-remove-use-allocation.yml index 3c602c660..621161109 100644 --- a/.github/workflows/issue-322-remove-use-allocation.yml +++ b/.github/workflows/issue-322-remove-use-allocation.yml @@ -1,4 +1,4 @@ -name: Issue 322 Remove Use Allocation +name: Issue 322 Remove Use Allocation v2 on: pull_request: From 48ca06117532062447c997f0041df1e77f3251db Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:57:16 +0800 Subject: [PATCH 176/228] chore(ci): stage issue 322 zero-allocation patch script --- eng/issue322-zeroalloc-patch.py | 253 ++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 eng/issue322-zeroalloc-patch.py diff --git a/eng/issue322-zeroalloc-patch.py b/eng/issue322-zeroalloc-patch.py new file mode 100644 index 000000000..bb577f47b --- /dev/null +++ b/eng/issue322-zeroalloc-patch.py @@ -0,0 +1,253 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one replacement, found {count}: {old[:80]!r}") + target.write_text(text.replace(old, new, 1)) + + +Path("src/SharpLink.Server/Admission/AdmissionProgram.cs").write_text("""namespace SharpLink.Server; + +/// +/// Immutable admission-policy publication for one runtime generation. Requests capture one +/// publication at the RequestLoop boundary and never re-read the server's current publication. +/// +internal sealed class AdmissionProgram +{ + private static long s_nextGenerationId; + + private readonly SharpLinkAdmissionController _controller; + private int _activeUses; + private int _duplicateReleaseAttempts; + + internal AdmissionProgram(SharpLinkAdmissionController controller) + { + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + GenerationId = Interlocked.Increment(ref s_nextGenerationId); + } + + internal long GenerationId { get; } + + internal SharpLinkAdmissionController Controller => _controller; + + internal bool QueueOneWayCalls => _controller.QueueOneWayCalls; + + internal int ActiveUses => Volatile.Read(ref _activeUses); + + internal int DuplicateReleaseAttempts => Volatile.Read(ref _duplicateReleaseAttempts); + + internal void AcquireUse() => Interlocked.Increment(ref _activeUses); + + internal void ReleaseUse() + { + if (Interlocked.Decrement(ref _activeUses) >= 0) + return; + + Interlocked.Increment(ref _activeUses); + Interlocked.Increment(ref _duplicateReleaseAttempts); + throw new InvalidOperationException("Admission program use count underflowed."); + } +} +""") + +replace_once( + "src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs", + """ private AdmissionProgramUse? CaptureAdmissionProgram( + long requestId, + out AdmissionProgram? program) + { + program = Volatile.Read(ref _admissionProgram); + var use = program?.AcquireUse(); + try + { + Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); + return use; + } + catch + { + use?.Dispose(); + throw; + } + } +""", + """ private AdmissionProgram? CaptureAdmissionProgram(long requestId) + { + var program = Volatile.Read(ref _admissionProgram); + program?.AcquireUse(); + try + { + Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); + return program; + } + catch + { + program?.ReleaseUse(); + throw; + } + } +""") + +replace_once( + "src/SharpLink.Server/SharpLinkServer.RequestLoop.cs", + """ var admissionProgramUse = CaptureAdmissionProgram( + requestId, + out var admissionProgram); +""", + """ var admissionProgram = CaptureAdmissionProgram(requestId); +""") +request_loop = Path("src/SharpLink.Server/SharpLinkServer.RequestLoop.cs") +text = request_loop.read_text() +old = """ admissionProgram, + admissionProgramUse);""" +if text.count(old) != 2: + raise SystemExit(f"RequestLoop: expected two captured dispatch arguments, found {text.count(old)}") +request_loop.write_text(text.replace(old, """ admissionProgram);""")) + +replace_once( + "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", + """ AdmissionProgram? admissionProgram, + AdmissionProgramUse? admissionProgramUse, + ServerCallCancellationState? admittedCallState = null, +""", + """ AdmissionProgram? admissionProgram, + ServerCallCancellationState? admittedCallState = null, +""") +replace_once( + "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", + """ { + if (admissionProgram is null && admissionProgramUse is not null) + throw new InvalidOperationException("A captured admission use requires its program generation."); + if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) + throw new InvalidOperationException("An enabled captured admission generation requires one use token."); + + try +""", + """ { + var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; + try +""") +replace_once( + "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", + """ admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); + admissionProgramUse = null; +""", + """ admittedCallState.AttachAdmissionProgramUse(admissionProgram); + ownsAdmissionProgramUse = false; +""") +replace_once( + "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", + """ admissionProgram, + admissionProgramUse: null, + callState, +""", + """ admissionProgram, + callState, +""") +replace_once( + "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", + """ finally + { + admissionProgramUse?.Dispose(); + } +""", + """ finally + { + if (ownsAdmissionProgramUse) + admissionProgram!.ReleaseUse(); + } +""") +admission_dispatch = Path("src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs") +text = admission_dispatch.read_text() +extra = " admissionProgramUse: null,\n" +if text.count(extra) != 1: + raise SystemExit(f"AdmissionDispatch: expected one remaining named use argument, found {text.count(extra)}") +admission_dispatch.write_text(text.replace(extra, "", 1)) + +replace_once( + "src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs", + """ AdmissionProgram? admissionProgram, + AdmissionProgramUse? admissionProgramUse, + ServerCallCancellationState? admittedCallState = null, +""", + """ AdmissionProgram? admissionProgram, + ServerCallCancellationState? admittedCallState = null, +""") +replace_once( + "src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs", + """ { + if (admissionProgram is null && admissionProgramUse is not null) + throw new InvalidOperationException("A captured admission use requires its program generation."); + if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) + throw new InvalidOperationException("An enabled captured admission generation requires one use token."); + + try +""", + """ { + var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; + try +""") +replace_once( + "src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs", + """ admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); + admissionProgramUse = null; +""", + """ admittedCallState.AttachAdmissionProgramUse(admissionProgram); + ownsAdmissionProgramUse = false; +""") +replace_once( + "src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs", + """ finally + { + admissionProgramUse?.Dispose(); + } +""", + """ finally + { + if (ownsAdmissionProgramUse) + admissionProgram!.ReleaseUse(); + } +""") + +replace_once( + "src/SharpLink.Server/ServerCallCancellationState.cs", + " private AdmissionProgramUse? _admissionProgramUse;\n", + " private AdmissionProgram? _admissionProgramUse;\n") +replace_once( + "src/SharpLink.Server/ServerCallCancellationState.cs", + """ internal void AttachAdmissionProgramUse(AdmissionProgramUse admissionProgramUse) + { + ArgumentNullException.ThrowIfNull(admissionProgramUse); + if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgramUse, null) is not null) + throw new InvalidOperationException("An admission program use is already attached to this call."); + } +""", + """ internal void AttachAdmissionProgramUse(AdmissionProgram admissionProgram) + { + ArgumentNullException.ThrowIfNull(admissionProgram); + if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgram, null) is not null) + throw new InvalidOperationException("An admission program use is already attached to this call."); + } +""") +replace_once( + "src/SharpLink.Server/ServerCallCancellationState.cs", + " Interlocked.Exchange(ref _admissionProgramUse, null)?.Dispose();\n", + " Interlocked.Exchange(ref _admissionProgramUse, null)?.ReleaseUse();\n") + +unit = Path("test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs") +lines = unit.read_text().splitlines(keepends=True) +start = next(i for i, line in enumerate(lines) if "return (ValueTask)DispatchMethod.Invoke(Server," in line) +end = next(i for i in range(start, start + 32) if "])!;" in lines[i]) +token = next(i for i in range(start, end + 1) if lines[i].strip() == "CancellationToken.None,") +nulls = [] +i = token + 1 +while lines[i].strip() == "null,": + nulls.append(i) + i += 1 +if len(nulls) != 3: + raise SystemExit(f"unit harness: expected three nulls before admissionGranted, found {len(nulls)}") +del lines[nulls[-1]] +unit.write_text("".join(lines)) From 79f6e70091298b82c03d69311fa867fcd2d7f289 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:57:25 +0800 Subject: [PATCH 177/228] chore(ci): arm issue 322 ownership patch helper --- .../issue-322-remove-use-allocation.yml | 277 +----------------- 1 file changed, 9 insertions(+), 268 deletions(-) diff --git a/.github/workflows/issue-322-remove-use-allocation.yml b/.github/workflows/issue-322-remove-use-allocation.yml index 621161109..a3b891c8c 100644 --- a/.github/workflows/issue-322-remove-use-allocation.yml +++ b/.github/workflows/issue-322-remove-use-allocation.yml @@ -1,14 +1,15 @@ -name: Issue 322 Remove Use Allocation v2 +name: Issue 322 Remove Use Allocation on: - pull_request: + push: + branches: + - issue-322-admission-program-generation permissions: contents: write jobs: patch: - if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' runs-on: ubuntu-latest steps: - name: Checkout branch @@ -17,275 +18,15 @@ jobs: ref: issue-322-admission-program-generation fetch-depth: 0 - - name: Remove enabled-path generation token allocation - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path, old, new): - p = Path(path) - text = p.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected one replacement, found {count}: {old[:80]!r}') - p.write_text(text.replace(old, new, 1)) - - Path('src/SharpLink.Server/Admission/AdmissionProgram.cs').write_text('''namespace SharpLink.Server; - -/// -/// Immutable admission-policy publication for one runtime generation. Requests capture one -/// publication at the RequestLoop boundary and never re-read the server's current publication. -/// -internal sealed class AdmissionProgram -{ - private static long s_nextGenerationId; - - private readonly SharpLinkAdmissionController _controller; - private int _activeUses; - private int _duplicateReleaseAttempts; - - internal AdmissionProgram(SharpLinkAdmissionController controller) - { - _controller = controller ?? throw new ArgumentNullException(nameof(controller)); - GenerationId = Interlocked.Increment(ref s_nextGenerationId); - } - - internal long GenerationId { get; } - - internal SharpLinkAdmissionController Controller => _controller; - - internal bool QueueOneWayCalls => _controller.QueueOneWayCalls; - - internal int ActiveUses => Volatile.Read(ref _activeUses); - - internal int DuplicateReleaseAttempts => Volatile.Read(ref _duplicateReleaseAttempts); - - internal void AcquireUse() => Interlocked.Increment(ref _activeUses); - - internal void ReleaseUse() - { - if (Interlocked.Decrement(ref _activeUses) >= 0) - return; - - // Restore accounting before surfacing an ownership bug so diagnostics remain stable. - Interlocked.Increment(ref _activeUses); - Interlocked.Increment(ref _duplicateReleaseAttempts); - throw new InvalidOperationException("Admission program use count underflowed."); - } -} -''') - - replace_once( - 'src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs', - ''' private AdmissionProgramUse? CaptureAdmissionProgram( - long requestId, - out AdmissionProgram? program) - { - program = Volatile.Read(ref _admissionProgram); - var use = program?.AcquireUse(); - try - { - Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); - return use; - } - catch - { - use?.Dispose(); - throw; - } - } -''', - ''' private AdmissionProgram? CaptureAdmissionProgram(long requestId) - { - var program = Volatile.Read(ref _admissionProgram); - program?.AcquireUse(); - try - { - Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); - return program; - } - catch - { - program?.ReleaseUse(); - throw; - } - } -''') - - replace_once( - 'src/SharpLink.Server/SharpLinkServer.RequestLoop.cs', - ''' var admissionProgramUse = CaptureAdmissionProgram( - requestId, - out var admissionProgram); -''', - ''' var admissionProgram = CaptureAdmissionProgram(requestId); -''') - p = Path('src/SharpLink.Server/SharpLinkServer.RequestLoop.cs') - text = p.read_text() - count = text.count(''' admissionProgram, - admissionProgramUse);''') - if count != 2: - raise SystemExit(f'RequestLoop: expected two dispatch captures, found {count}') - p.write_text(text.replace( - ''' admissionProgram, - admissionProgramUse);''', - ''' admissionProgram);''')) - - replace_once( - 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', - ''' AdmissionProgram? admissionProgram, - AdmissionProgramUse? admissionProgramUse, - ServerCallCancellationState? admittedCallState = null, -''', - ''' AdmissionProgram? admissionProgram, - ServerCallCancellationState? admittedCallState = null, -''') - replace_once( - 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', - ''' { - if (admissionProgram is null && admissionProgramUse is not null) - throw new InvalidOperationException("A captured admission use requires its program generation."); - if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) - throw new InvalidOperationException("An enabled captured admission generation requires one use token."); - - try -''', - ''' { - var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; - try -''') - replace_once( - 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', - ''' admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); - admissionProgramUse = null; -''', - ''' admittedCallState.AttachAdmissionProgramUse(admissionProgram); - ownsAdmissionProgramUse = false; -''') - replace_once( - 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', - ''' admissionProgram, - admissionProgramUse: null, - callState, -''', - ''' admissionProgram, - callState, -''') - replace_once( - 'src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs', - ''' finally - { - admissionProgramUse?.Dispose(); - } -''', - ''' finally - { - if (ownsAdmissionProgramUse) - admissionProgram!.ReleaseUse(); - } -''') - - replace_once( - 'src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs', - ''' AdmissionProgram? admissionProgram, - AdmissionProgramUse? admissionProgramUse, - ServerCallCancellationState? admittedCallState = null, -''', - ''' AdmissionProgram? admissionProgram, - ServerCallCancellationState? admittedCallState = null, -''') - replace_once( - 'src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs', - ''' { - if (admissionProgram is null && admissionProgramUse is not null) - throw new InvalidOperationException("A captured admission use requires its program generation."); - if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) - throw new InvalidOperationException("An enabled captured admission generation requires one use token."); - - try -''', - ''' { - var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; - try -''') - replace_once( - 'src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs', - ''' admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); - admissionProgramUse = null; -''', - ''' admittedCallState.AttachAdmissionProgramUse(admissionProgram); - ownsAdmissionProgramUse = false; -''') - replace_once( - 'src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs', - ''' finally - { - admissionProgramUse?.Dispose(); - } -''', - ''' finally - { - if (ownsAdmissionProgramUse) - admissionProgram!.ReleaseUse(); - } -''') - - p = Path('src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs') - text = p.read_text() - extra = ''' admissionProgramUse: null,\n''' - count = text.count(extra) - if count != 1: - raise SystemExit(f'AdmissionDispatch: expected one remaining admissionProgramUse named argument, found {count}') - p.write_text(text.replace(extra, '', 1)) - - replace_once( - 'src/SharpLink.Server/ServerCallCancellationState.cs', - ' private AdmissionProgramUse? _admissionProgramUse;\n', - ' private AdmissionProgram? _admissionProgramUse;\n') - replace_once( - 'src/SharpLink.Server/ServerCallCancellationState.cs', - ''' internal void AttachAdmissionProgramUse(AdmissionProgramUse admissionProgramUse) - { - ArgumentNullException.ThrowIfNull(admissionProgramUse); - if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgramUse, null) is not null) - throw new InvalidOperationException("An admission program use is already attached to this call."); - } -''', - ''' internal void AttachAdmissionProgramUse(AdmissionProgram admissionProgram) - { - ArgumentNullException.ThrowIfNull(admissionProgram); - if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgram, null) is not null) - throw new InvalidOperationException("An admission program use is already attached to this call."); - } -''') - replace_once( - 'src/SharpLink.Server/ServerCallCancellationState.cs', - ' Interlocked.Exchange(ref _admissionProgramUse, null)?.Dispose();\n', - ' Interlocked.Exchange(ref _admissionProgramUse, null)?.ReleaseUse();\n') - - p = Path('test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs') - lines = p.read_text().splitlines(keepends=True) - start = next(i for i, line in enumerate(lines) if 'return (ValueTask)DispatchMethod.Invoke(Server,' in line) - end = next(i for i in range(start, start + 32) if '])!;' in lines[i]) - token = next(i for i in range(start, end + 1) if lines[i].strip() == 'CancellationToken.None,') - nulls = [] - i = token + 1 - while lines[i].strip() == 'null,': - nulls.append(i) - i += 1 - if len(nulls) != 3: - raise SystemExit(f'unit harness: expected three nulls before admissionGranted, found {len(nulls)}') - del lines[nulls[-1]] - p.write_text(''.join(lines)) - PY + - name: Apply zero-allocation ownership patch + run: python eng/issue322-zeroalloc-patch.py - - name: Commit optimized ownership and remove helper workflow - shell: bash + - name: Commit patch and remove helpers run: | + rm eng/issue322-zeroalloc-patch.py rm .github/workflows/issue-322-remove-use-allocation.yml git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add src/SharpLink.Server test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs .github/workflows/issue-322-remove-use-allocation.yml + git add src/SharpLink.Server test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs eng/issue322-zeroalloc-patch.py .github/workflows/issue-322-remove-use-allocation.yml git commit -m "perf(server): remove admission generation use allocation" git push origin HEAD:issue-322-admission-program-generation From 4498944c302a2fdb8d5693bf4df029c0b14c5b56 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:57:39 +0800 Subject: [PATCH 178/228] chore(ci): trigger issue 322 ownership patch helper --- .github/workflows/issue-322-remove-use-allocation.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-322-remove-use-allocation.yml b/.github/workflows/issue-322-remove-use-allocation.yml index a3b891c8c..4d8659ff6 100644 --- a/.github/workflows/issue-322-remove-use-allocation.yml +++ b/.github/workflows/issue-322-remove-use-allocation.yml @@ -1,15 +1,14 @@ name: Issue 322 Remove Use Allocation on: - push: - branches: - - issue-322-admission-program-generation + pull_request: permissions: contents: write jobs: patch: + if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' runs-on: ubuntu-latest steps: - name: Checkout branch From ffe6155ebf901417b7008502fec7b2adcabad27a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:59:01 +0800 Subject: [PATCH 179/228] chore(ci): correct issue 322 zero-allocation patch script --- eng/issue322-zeroalloc-patch.py | 44 +++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/eng/issue322-zeroalloc-patch.py b/eng/issue322-zeroalloc-patch.py index bb577f47b..4f047ada1 100644 --- a/eng/issue322-zeroalloc-patch.py +++ b/eng/issue322-zeroalloc-patch.py @@ -6,7 +6,7 @@ def replace_once(path: str, old: str, new: str) -> None: text = target.read_text() count = text.count(old) if count != 1: - raise SystemExit(f"{path}: expected one replacement, found {count}: {old[:80]!r}") + raise SystemExit(f"{path}: expected one replacement, found {count}: {old[:100]!r}") target.write_text(text.replace(old, new, 1)) @@ -18,35 +18,63 @@ def replace_once(path: str, old: str, new: str) -> None: /// internal sealed class AdmissionProgram { + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< + SharpLinkAdmissionController, + AdmissionProgram> ProgramsByController = new(); private static long s_nextGenerationId; - private readonly SharpLinkAdmissionController _controller; + private readonly SharpLinkAdmissionController? _controller; private int _activeUses; private int _duplicateReleaseAttempts; + private AdmissionProgram(long sentinelGenerationId) + => GenerationId = sentinelGenerationId; + internal AdmissionProgram(SharpLinkAdmissionController controller) { _controller = controller ?? throw new ArgumentNullException(nameof(controller)); GenerationId = Interlocked.Increment(ref s_nextGenerationId); + ProgramsByController.Add(controller, this); } + internal static AdmissionProgram Uninitialized { get; } = new(long.MinValue); + + internal static AdmissionProgram Disabled { get; } = new(0); + internal long GenerationId { get; } - internal SharpLinkAdmissionController Controller => _controller; + internal bool IsEnabled => _controller is not null; - internal bool QueueOneWayCalls => _controller.QueueOneWayCalls; + internal SharpLinkAdmissionController Controller + => _controller ?? throw new InvalidOperationException("Disabled admission has no controller."); + + internal bool QueueOneWayCalls => Controller.QueueOneWayCalls; internal int ActiveUses => Volatile.Read(ref _activeUses); internal int DuplicateReleaseAttempts => Volatile.Read(ref _duplicateReleaseAttempts); - internal void AcquireUse() => Interlocked.Increment(ref _activeUses); + internal static AdmissionProgram FromController(SharpLinkAdmissionController controller) + { + ArgumentNullException.ThrowIfNull(controller); + return ProgramsByController.TryGetValue(controller, out var program) + ? program + : throw new InvalidOperationException("Admission controller has no published program generation."); + } + + internal void AcquireUse() + { + if (!IsEnabled) + throw new InvalidOperationException("Disabled admission does not acquire generation uses."); + Interlocked.Increment(ref _activeUses); + } internal void ReleaseUse() { if (Interlocked.Decrement(ref _activeUses) >= 0) return; + // Restore accounting before surfacing an ownership bug so diagnostics stay stable. Interlocked.Increment(ref _activeUses); Interlocked.Increment(ref _duplicateReleaseAttempts); throw new InvalidOperationException("Admission program use count underflowed."); @@ -60,7 +88,8 @@ def replace_once(path: str, old: str, new: str) -> None: long requestId, out AdmissionProgram? program) { - program = Volatile.Read(ref _admissionProgram); + var publication = ReadAdmissionPublication(); + program = publication.IsEnabled ? publication : null; var use = program?.AcquireUse(); try { @@ -76,7 +105,8 @@ def replace_once(path: str, old: str, new: str) -> None: """, """ private AdmissionProgram? CaptureAdmissionProgram(long requestId) { - var program = Volatile.Read(ref _admissionProgram); + var publication = ReadAdmissionPublication(); + var program = publication.IsEnabled ? publication : null; program?.AcquireUse(); try { From f39649b612dd0aeb82c6e8c19ec03d8cab2b9498 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:00:27 +0800 Subject: [PATCH 180/228] chore(ci): harden issue 322 zero-allocation patch matching --- eng/issue322-zeroalloc-patch.py | 71 ++++++++++++--------------------- 1 file changed, 26 insertions(+), 45 deletions(-) diff --git a/eng/issue322-zeroalloc-patch.py b/eng/issue322-zeroalloc-patch.py index 4f047ada1..9b80c16b6 100644 --- a/eng/issue322-zeroalloc-patch.py +++ b/eng/issue322-zeroalloc-patch.py @@ -1,4 +1,5 @@ from pathlib import Path +import re def replace_once(path: str, old: str, new: str) -> None: @@ -10,6 +11,15 @@ def replace_once(path: str, old: str, new: str) -> None: target.write_text(text.replace(old, new, 1)) +def regex_once(path: str, pattern: str, replacement: str) -> None: + target = Path(path) + text = target.read_text() + updated, count = re.subn(pattern, replacement, text, count=1, flags=re.S | re.M) + if count != 1: + raise SystemExit(f"{path}: expected one regex replacement, found {count}: {pattern[:100]!r}") + target.write_text(updated) + + Path("src/SharpLink.Server/Admission/AdmissionProgram.cs").write_text("""namespace SharpLink.Server; /// @@ -82,27 +92,9 @@ def replace_once(path: str, old: str, new: str) -> None: } """) -replace_once( +regex_once( "src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs", - """ private AdmissionProgramUse? CaptureAdmissionProgram( - long requestId, - out AdmissionProgram? program) - { - var publication = ReadAdmissionPublication(); - program = publication.IsEnabled ? publication : null; - var use = program?.AcquireUse(); - try - { - Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); - return use; - } - catch - { - use?.Dispose(); - throw; - } - } -""", + r" private AdmissionProgramUse\? CaptureAdmissionProgram\(.*?\n \}\n\n(?= private AdmissionProgram ReadAdmissionPublication)", """ private AdmissionProgram? CaptureAdmissionProgram(long requestId) { var publication = ReadAdmissionPublication(); @@ -119,23 +111,20 @@ def replace_once(path: str, old: str, new: str) -> None: throw; } } + """) -replace_once( +regex_once( "src/SharpLink.Server/SharpLinkServer.RequestLoop.cs", - """ var admissionProgramUse = CaptureAdmissionProgram( - requestId, - out var admissionProgram); -""", - """ var admissionProgram = CaptureAdmissionProgram(requestId); -""") + r" var admissionProgramUse = CaptureAdmissionProgram\(\s*requestId,\s*out var admissionProgram\);", + " var admissionProgram = CaptureAdmissionProgram(requestId);") request_loop = Path("src/SharpLink.Server/SharpLinkServer.RequestLoop.cs") text = request_loop.read_text() -old = """ admissionProgram, - admissionProgramUse);""" -if text.count(old) != 2: - raise SystemExit(f"RequestLoop: expected two captured dispatch arguments, found {text.count(old)}") -request_loop.write_text(text.replace(old, """ admissionProgram);""")) +pattern = r"(?m)^(\s*)admissionProgram,\n\1admissionProgramUse\);$" +text, count = re.subn(pattern, r"\1admissionProgram);", text) +if count != 2: + raise SystemExit(f"RequestLoop: expected two captured dispatch argument removals, found {count}") +request_loop.write_text(text) replace_once( "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", @@ -168,15 +157,6 @@ def replace_once(path: str, old: str, new: str) -> None: """ admittedCallState.AttachAdmissionProgramUse(admissionProgram); ownsAdmissionProgramUse = false; """) -replace_once( - "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", - """ admissionProgram, - admissionProgramUse: null, - callState, -""", - """ admissionProgram, - callState, -""") replace_once( "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", """ finally @@ -192,10 +172,11 @@ def replace_once(path: str, old: str, new: str) -> None: """) admission_dispatch = Path("src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs") text = admission_dispatch.read_text() -extra = " admissionProgramUse: null,\n" -if text.count(extra) != 1: - raise SystemExit(f"AdmissionDispatch: expected one remaining named use argument, found {text.count(extra)}") -admission_dispatch.write_text(text.replace(extra, "", 1)) +use_arg = r"(?m)^\s*admissionProgramUse: null,\n" +text, count = re.subn(use_arg, "", text) +if count != 2: + raise SystemExit(f"AdmissionDispatch: expected two resumed-use named arguments, found {count}") +admission_dispatch.write_text(text) replace_once( "src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs", From 8daea1438e70eb6eeed05f51ad456b722329a6f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:00:37 +0000 Subject: [PATCH 181/228] perf(server): remove admission generation use allocation --- .../issue-322-remove-use-allocation.yml | 31 -- eng/issue322-zeroalloc-patch.py | 264 ------------------ .../Admission/AdmissionProgram.cs | 37 +-- .../ServerCallCancellationState.cs | 10 +- .../SharpLinkServer.AdmissionDispatch.cs | 16 +- .../SharpLinkServer.AdmissionProgram.cs | 12 +- .../SharpLinkServer.InvocationDispatch.cs | 14 +- .../SharpLinkServer.RequestLoop.cs | 10 +- .../Server/SharpLinkServerInvocationTests.cs | 1 - 9 files changed, 29 insertions(+), 366 deletions(-) delete mode 100644 .github/workflows/issue-322-remove-use-allocation.yml delete mode 100644 eng/issue322-zeroalloc-patch.py diff --git a/.github/workflows/issue-322-remove-use-allocation.yml b/.github/workflows/issue-322-remove-use-allocation.yml deleted file mode 100644 index 4d8659ff6..000000000 --- a/.github/workflows/issue-322-remove-use-allocation.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Issue 322 Remove Use Allocation - -on: - pull_request: - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: issue-322-admission-program-generation - fetch-depth: 0 - - - name: Apply zero-allocation ownership patch - run: python eng/issue322-zeroalloc-patch.py - - - name: Commit patch and remove helpers - run: | - rm eng/issue322-zeroalloc-patch.py - rm .github/workflows/issue-322-remove-use-allocation.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add src/SharpLink.Server test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs eng/issue322-zeroalloc-patch.py .github/workflows/issue-322-remove-use-allocation.yml - git commit -m "perf(server): remove admission generation use allocation" - git push origin HEAD:issue-322-admission-program-generation diff --git a/eng/issue322-zeroalloc-patch.py b/eng/issue322-zeroalloc-patch.py deleted file mode 100644 index 9b80c16b6..000000000 --- a/eng/issue322-zeroalloc-patch.py +++ /dev/null @@ -1,264 +0,0 @@ -from pathlib import Path -import re - - -def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one replacement, found {count}: {old[:100]!r}") - target.write_text(text.replace(old, new, 1)) - - -def regex_once(path: str, pattern: str, replacement: str) -> None: - target = Path(path) - text = target.read_text() - updated, count = re.subn(pattern, replacement, text, count=1, flags=re.S | re.M) - if count != 1: - raise SystemExit(f"{path}: expected one regex replacement, found {count}: {pattern[:100]!r}") - target.write_text(updated) - - -Path("src/SharpLink.Server/Admission/AdmissionProgram.cs").write_text("""namespace SharpLink.Server; - -/// -/// Immutable admission-policy publication for one runtime generation. Requests capture one -/// publication at the RequestLoop boundary and never re-read the server's current publication. -/// -internal sealed class AdmissionProgram -{ - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< - SharpLinkAdmissionController, - AdmissionProgram> ProgramsByController = new(); - private static long s_nextGenerationId; - - private readonly SharpLinkAdmissionController? _controller; - private int _activeUses; - private int _duplicateReleaseAttempts; - - private AdmissionProgram(long sentinelGenerationId) - => GenerationId = sentinelGenerationId; - - internal AdmissionProgram(SharpLinkAdmissionController controller) - { - _controller = controller ?? throw new ArgumentNullException(nameof(controller)); - GenerationId = Interlocked.Increment(ref s_nextGenerationId); - ProgramsByController.Add(controller, this); - } - - internal static AdmissionProgram Uninitialized { get; } = new(long.MinValue); - - internal static AdmissionProgram Disabled { get; } = new(0); - - internal long GenerationId { get; } - - internal bool IsEnabled => _controller is not null; - - internal SharpLinkAdmissionController Controller - => _controller ?? throw new InvalidOperationException("Disabled admission has no controller."); - - internal bool QueueOneWayCalls => Controller.QueueOneWayCalls; - - internal int ActiveUses => Volatile.Read(ref _activeUses); - - internal int DuplicateReleaseAttempts => Volatile.Read(ref _duplicateReleaseAttempts); - - internal static AdmissionProgram FromController(SharpLinkAdmissionController controller) - { - ArgumentNullException.ThrowIfNull(controller); - return ProgramsByController.TryGetValue(controller, out var program) - ? program - : throw new InvalidOperationException("Admission controller has no published program generation."); - } - - internal void AcquireUse() - { - if (!IsEnabled) - throw new InvalidOperationException("Disabled admission does not acquire generation uses."); - Interlocked.Increment(ref _activeUses); - } - - internal void ReleaseUse() - { - if (Interlocked.Decrement(ref _activeUses) >= 0) - return; - - // Restore accounting before surfacing an ownership bug so diagnostics stay stable. - Interlocked.Increment(ref _activeUses); - Interlocked.Increment(ref _duplicateReleaseAttempts); - throw new InvalidOperationException("Admission program use count underflowed."); - } -} -""") - -regex_once( - "src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs", - r" private AdmissionProgramUse\? CaptureAdmissionProgram\(.*?\n \}\n\n(?= private AdmissionProgram ReadAdmissionPublication)", - """ private AdmissionProgram? CaptureAdmissionProgram(long requestId) - { - var publication = ReadAdmissionPublication(); - var program = publication.IsEnabled ? publication : null; - program?.AcquireUse(); - try - { - Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); - return program; - } - catch - { - program?.ReleaseUse(); - throw; - } - } - -""") - -regex_once( - "src/SharpLink.Server/SharpLinkServer.RequestLoop.cs", - r" var admissionProgramUse = CaptureAdmissionProgram\(\s*requestId,\s*out var admissionProgram\);", - " var admissionProgram = CaptureAdmissionProgram(requestId);") -request_loop = Path("src/SharpLink.Server/SharpLinkServer.RequestLoop.cs") -text = request_loop.read_text() -pattern = r"(?m)^(\s*)admissionProgram,\n\1admissionProgramUse\);$" -text, count = re.subn(pattern, r"\1admissionProgram);", text) -if count != 2: - raise SystemExit(f"RequestLoop: expected two captured dispatch argument removals, found {count}") -request_loop.write_text(text) - -replace_once( - "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", - """ AdmissionProgram? admissionProgram, - AdmissionProgramUse? admissionProgramUse, - ServerCallCancellationState? admittedCallState = null, -""", - """ AdmissionProgram? admissionProgram, - ServerCallCancellationState? admittedCallState = null, -""") -replace_once( - "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", - """ { - if (admissionProgram is null && admissionProgramUse is not null) - throw new InvalidOperationException("A captured admission use requires its program generation."); - if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) - throw new InvalidOperationException("An enabled captured admission generation requires one use token."); - - try -""", - """ { - var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; - try -""") -replace_once( - "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", - """ admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); - admissionProgramUse = null; -""", - """ admittedCallState.AttachAdmissionProgramUse(admissionProgram); - ownsAdmissionProgramUse = false; -""") -replace_once( - "src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs", - """ finally - { - admissionProgramUse?.Dispose(); - } -""", - """ finally - { - if (ownsAdmissionProgramUse) - admissionProgram!.ReleaseUse(); - } -""") -admission_dispatch = Path("src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs") -text = admission_dispatch.read_text() -use_arg = r"(?m)^\s*admissionProgramUse: null,\n" -text, count = re.subn(use_arg, "", text) -if count != 2: - raise SystemExit(f"AdmissionDispatch: expected two resumed-use named arguments, found {count}") -admission_dispatch.write_text(text) - -replace_once( - "src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs", - """ AdmissionProgram? admissionProgram, - AdmissionProgramUse? admissionProgramUse, - ServerCallCancellationState? admittedCallState = null, -""", - """ AdmissionProgram? admissionProgram, - ServerCallCancellationState? admittedCallState = null, -""") -replace_once( - "src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs", - """ { - if (admissionProgram is null && admissionProgramUse is not null) - throw new InvalidOperationException("A captured admission use requires its program generation."); - if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) - throw new InvalidOperationException("An enabled captured admission generation requires one use token."); - - try -""", - """ { - var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; - try -""") -replace_once( - "src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs", - """ admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); - admissionProgramUse = null; -""", - """ admittedCallState.AttachAdmissionProgramUse(admissionProgram); - ownsAdmissionProgramUse = false; -""") -replace_once( - "src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs", - """ finally - { - admissionProgramUse?.Dispose(); - } -""", - """ finally - { - if (ownsAdmissionProgramUse) - admissionProgram!.ReleaseUse(); - } -""") - -replace_once( - "src/SharpLink.Server/ServerCallCancellationState.cs", - " private AdmissionProgramUse? _admissionProgramUse;\n", - " private AdmissionProgram? _admissionProgramUse;\n") -replace_once( - "src/SharpLink.Server/ServerCallCancellationState.cs", - """ internal void AttachAdmissionProgramUse(AdmissionProgramUse admissionProgramUse) - { - ArgumentNullException.ThrowIfNull(admissionProgramUse); - if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgramUse, null) is not null) - throw new InvalidOperationException("An admission program use is already attached to this call."); - } -""", - """ internal void AttachAdmissionProgramUse(AdmissionProgram admissionProgram) - { - ArgumentNullException.ThrowIfNull(admissionProgram); - if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgram, null) is not null) - throw new InvalidOperationException("An admission program use is already attached to this call."); - } -""") -replace_once( - "src/SharpLink.Server/ServerCallCancellationState.cs", - " Interlocked.Exchange(ref _admissionProgramUse, null)?.Dispose();\n", - " Interlocked.Exchange(ref _admissionProgramUse, null)?.ReleaseUse();\n") - -unit = Path("test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs") -lines = unit.read_text().splitlines(keepends=True) -start = next(i for i, line in enumerate(lines) if "return (ValueTask)DispatchMethod.Invoke(Server," in line) -end = next(i for i in range(start, start + 32) if "])!;" in lines[i]) -token = next(i for i in range(start, end + 1) if lines[i].strip() == "CancellationToken.None,") -nulls = [] -i = token + 1 -while lines[i].strip() == "null,": - nulls.append(i) - i += 1 -if len(nulls) != 3: - raise SystemExit(f"unit harness: expected three nulls before admissionGranted, found {len(nulls)}") -del lines[nulls[-1]] -unit.write_text("".join(lines)) diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs index 626d30d8e..6a8b17b73 100644 --- a/src/SharpLink.Server/Admission/AdmissionProgram.cs +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -50,46 +50,21 @@ internal static AdmissionProgram FromController(SharpLinkAdmissionController con : throw new InvalidOperationException("Admission controller has no published program generation."); } - internal AdmissionProgramUse AcquireUse() + internal void AcquireUse() { if (!IsEnabled) throw new InvalidOperationException("Disabled admission does not acquire generation uses."); Interlocked.Increment(ref _activeUses); - return new AdmissionProgramUse(this); } internal void ReleaseUse() { - if (Interlocked.Decrement(ref _activeUses) < 0) - throw new InvalidOperationException("Admission program use count underflowed."); - } - - internal void RecordDuplicateReleaseAttempt() - => Interlocked.Increment(ref _duplicateReleaseAttempts); -} - -/// -/// Exactly-once lifetime token for one captured admission generation. The token may be transferred -/// to the existing server-call lifetime owner without rebuilding policy or routing state. -/// -internal sealed class AdmissionProgramUse : IDisposable -{ - private readonly AdmissionProgram _program; - private int _disposed; - - internal AdmissionProgramUse(AdmissionProgram program) - => _program = program ?? throw new ArgumentNullException(nameof(program)); - - internal AdmissionProgram Program => _program; - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - _program.RecordDuplicateReleaseAttempt(); + if (Interlocked.Decrement(ref _activeUses) >= 0) return; - } - _program.ReleaseUse(); + // Restore accounting before surfacing an ownership bug so diagnostics stay stable. + Interlocked.Increment(ref _activeUses); + Interlocked.Increment(ref _duplicateReleaseAttempts); + throw new InvalidOperationException("Admission program use count underflowed."); } } diff --git a/src/SharpLink.Server/ServerCallCancellationState.cs b/src/SharpLink.Server/ServerCallCancellationState.cs index bffadabf5..9b5c3de2d 100644 --- a/src/SharpLink.Server/ServerCallCancellationState.cs +++ b/src/SharpLink.Server/ServerCallCancellationState.cs @@ -67,7 +67,7 @@ internal sealed class ServerCallCancellationState : IDisposable private bool _disposeRequested; private int _externalUsers; private long _leaseGeneration; - private AdmissionProgramUse? _admissionProgramUse; + private AdmissionProgram? _admissionProgramUse; private AdmissionLease? _admissionLease; private SharpLinkBufferWriterPool? _payloadPool; private IRpcByteBufferWriter? _payloadOwner; @@ -178,10 +178,10 @@ public static ServerCallCancellationState Rent( return state; } - internal void AttachAdmissionProgramUse(AdmissionProgramUse admissionProgramUse) + internal void AttachAdmissionProgramUse(AdmissionProgram admissionProgram) { - ArgumentNullException.ThrowIfNull(admissionProgramUse); - if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgramUse, null) is not null) + ArgumentNullException.ThrowIfNull(admissionProgram); + if (Interlocked.CompareExchange(ref _admissionProgramUse, admissionProgram, null) is not null) throw new InvalidOperationException("An admission program use is already attached to this call."); } @@ -348,7 +348,7 @@ private void ReturnCore() _serverStoppingRegistration.Dispose(); _invocationCancellation?.Dispose(); Interlocked.Exchange(ref _admissionLease, null)?.Dispose(); - Interlocked.Exchange(ref _admissionProgramUse, null)?.Dispose(); + Interlocked.Exchange(ref _admissionProgramUse, null)?.ReleaseUse(); var payloadOwner = Interlocked.Exchange(ref _payloadOwner, null); var payloadPool = Interlocked.Exchange(ref _payloadPool, null); var decodedBytesPermit = Interlocked.Exchange(ref _decodedBytesPermit, null); diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs index 737fefe7b..9a1033d63 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionDispatch.cs @@ -10,17 +10,12 @@ private void DispatchOneWayRpc( StripedLongMap requestCancellationMap, CancellationToken serverLoopToken, AdmissionProgram? admissionProgram, - AdmissionProgramUse? admissionProgramUse, ServerCallCancellationState? admittedCallState = null, bool admissionGranted = false, int admittedClientStreamCount = 0, ServerRetainedAdmissionPayload? retainedAdmissionPayload = null) { - if (admissionProgram is null && admissionProgramUse is not null) - throw new InvalidOperationException("A captured admission use requires its program generation."); - if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) - throw new InvalidOperationException("An enabled captured admission generation requires one use token."); - + var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; try { var session = connection.Session; @@ -67,8 +62,8 @@ private void DispatchOneWayRpc( serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); - admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); - admissionProgramUse = null; + admittedCallState.AttachAdmissionProgramUse(admissionProgram); + ownsAdmissionProgramUse = false; var admissionController = admissionProgram.Controller; ValueTask admissionTask; try @@ -389,7 +384,8 @@ private void DispatchOneWayRpc( } finally { - admissionProgramUse?.Dispose(); + if (ownsAdmissionProgramUse) + admissionProgram!.ReleaseUse(); } } @@ -479,7 +475,6 @@ private async Task AwaitOneWayAdmissionAsync( requestCancellationMap, serverLoopToken, admissionProgram, - admissionProgramUse: null, callState, admissionGranted: true, admittedClientStreamCount: clientStreamCount, @@ -537,7 +532,6 @@ await RejectAdmission( requestCancellationMap, serverLoopToken, admissionProgram, - admissionProgramUse: null, callState, admissionGranted: true, retainedAdmissionPayload: retainedPayload); diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs index 0822a426f..5ee6ae693 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs @@ -39,21 +39,19 @@ internal AdmissionProgram? OwnedAdmissionProgramForTests return previous.IsEnabled ? previous : null; } - private AdmissionProgramUse? CaptureAdmissionProgram( - long requestId, - out AdmissionProgram? program) + private AdmissionProgram? CaptureAdmissionProgram(long requestId) { var publication = ReadAdmissionPublication(); - program = publication.IsEnabled ? publication : null; - var use = program?.AcquireUse(); + var program = publication.IsEnabled ? publication : null; + program?.AcquireUse(); try { Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); - return use; + return program; } catch { - use?.Dispose(); + program?.ReleaseUse(); throw; } } diff --git a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs index c351da8d8..2be7252a8 100644 --- a/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs +++ b/src/SharpLink.Server/SharpLinkServer.InvocationDispatch.cs @@ -10,16 +10,11 @@ private ValueTask DispatchRpcAsync( StripedLongMap requestCancellationMap, CancellationToken serverLoopToken, AdmissionProgram? admissionProgram, - AdmissionProgramUse? admissionProgramUse, ServerCallCancellationState? admittedCallState = null, bool admissionGranted = false, ServerRetainedAdmissionPayload? retainedAdmissionPayload = null) { - if (admissionProgram is null && admissionProgramUse is not null) - throw new InvalidOperationException("A captured admission use requires its program generation."); - if (!admissionGranted && admissionProgram is not null && admissionProgramUse is null) - throw new InvalidOperationException("An enabled captured admission generation requires one use token."); - + var ownsAdmissionProgramUse = admissionProgram is not null && !admissionGranted; try { var session = connection.Session; @@ -93,8 +88,8 @@ private ValueTask DispatchRpcAsync( serverLoopToken, serviceInfo.ModuleCancellation, requestCancellationMap); - admittedCallState.AttachAdmissionProgramUse(admissionProgramUse!); - admissionProgramUse = null; + admittedCallState.AttachAdmissionProgramUse(admissionProgram); + ownsAdmissionProgramUse = false; var descriptor = GetMethodDescriptor(serviceInfo.Stub, request.MethodHash); ValueTask admissionTask; try @@ -332,7 +327,8 @@ private ValueTask DispatchRpcAsync( } finally { - admissionProgramUse?.Dispose(); + if (ownsAdmissionProgramUse) + admissionProgram!.ReleaseUse(); } } diff --git a/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs b/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs index 6c8d311d4..020c9ccd1 100644 --- a/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs +++ b/src/SharpLink.Server/SharpLinkServer.RequestLoop.cs @@ -136,9 +136,7 @@ await session.SendPongWithBackpressureAsync( break; } - var admissionProgramUse = CaptureAdmissionProgram( - requestId, - out var admissionProgram); + var admissionProgram = CaptureAdmissionProgram(requestId); if ((header.Flags & ProtocolV2FrameFlags.OneWay) != 0) { DispatchOneWayRpc( @@ -148,8 +146,7 @@ await session.SendPongWithBackpressureAsync( payload, requestCancellationMap, ct, - admissionProgram, - admissionProgramUse); + admissionProgram); break; } @@ -160,8 +157,7 @@ await session.SendPongWithBackpressureAsync( payload, requestCancellationMap, ct, - admissionProgram, - admissionProgramUse); + admissionProgram); if (!dispatchTask.IsCompletedSuccessfully) ObserveUserCall(dispatchTask, requestId); break; diff --git a/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs b/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs index b0b7940d7..da3a3c73d 100644 --- a/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs +++ b/test/SharpLink.UnitTests/Server/SharpLinkServerInvocationTests.cs @@ -1353,7 +1353,6 @@ internal ValueTask Dispatch(long requestId, ProtocolV2FrameFlags flags) CancellationToken.None, null, null, - null, (flags & ProtocolV2FrameFlags.Cancellable) != 0, null ])!; From dcf7c9e15ea77a620085c1af1496c4d0d2f8e285 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:01:41 +0800 Subject: [PATCH 182/228] refactor(server): make admission use release diagnostic race-safe --- .../Admission/AdmissionProgram.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs index 6a8b17b73..f1ab27b0e 100644 --- a/src/SharpLink.Server/Admission/AdmissionProgram.cs +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -59,12 +59,17 @@ internal void AcquireUse() internal void ReleaseUse() { - if (Interlocked.Decrement(ref _activeUses) >= 0) - return; - - // Restore accounting before surfacing an ownership bug so diagnostics stay stable. - Interlocked.Increment(ref _activeUses); - Interlocked.Increment(ref _duplicateReleaseAttempts); - throw new InvalidOperationException("Admission program use count underflowed."); + while (true) + { + var activeUses = Volatile.Read(ref _activeUses); + if (activeUses <= 0) + { + Interlocked.Increment(ref _duplicateReleaseAttempts); + throw new InvalidOperationException("Admission program use count underflowed."); + } + + if (Interlocked.CompareExchange(ref _activeUses, activeUses - 1, activeUses) == activeUses) + return; + } } } From 3badf2a4ddbb7671a7594be1ac63d338e60b0e61 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:07:18 +0800 Subject: [PATCH 183/228] chore(ci): stage deterministic one-way generation test fix --- .../issue-322-fix-oneway-generation-test.yml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/issue-322-fix-oneway-generation-test.yml diff --git a/.github/workflows/issue-322-fix-oneway-generation-test.yml b/.github/workflows/issue-322-fix-oneway-generation-test.yml new file mode 100644 index 000000000..63e87ca3e --- /dev/null +++ b/.github/workflows/issue-322-fix-oneway-generation-test.yml @@ -0,0 +1,63 @@ +name: Issue 322 Fix Oneway Generation Test + +on: + pull_request: + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: issue-322-admission-program-generation + fetch-depth: 0 + + - name: Add deterministic capture barrier + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path('test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs') + text = path.read_text() + start = text.index(' public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bool oneWay)') + end = text.index('\n [Test]\n [NotInParallel]\n public async Task EnabledCaptureShouldRemainOnGenerationNWhenCurrentBecomesNPlusOne()', start) + block = text[start:end] + + replacements = [ + ( + ' var hookCount = 0;\n', + ' var hookCount = 0;\n var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n' + ), + ( + ' server.PublishAdmissionProgramForTests(null);\n', + ' server.PublishAdmissionProgramForTests(null);\n captureCompleted.TrySetResult();\n' + ), + ( + ' await service.NotifyAsync("captured-enabled");\n', + ' await service.NotifyAsync("captured-enabled");\n await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n' + ) + ] + for old, new in replacements: + count = block.count(old) + if count != 1: + raise SystemExit(f'expected one scoped replacement, found {count}: {old!r}') + block = block.replace(old, new, 1) + + path.write_text(text[:start] + block + text[end:]) + PY + + - name: Commit test fix and remove helper + shell: bash + run: | + rm .github/workflows/issue-322-fix-oneway-generation-test.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs .github/workflows/issue-322-fix-oneway-generation-test.yml + git commit -m "test(server): synchronize one-way admission publication race" + git push origin HEAD:issue-322-admission-program-generation From f61860c91ef77bffd58c2de63fd0ce35e58195a6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:07:29 +0000 Subject: [PATCH 184/228] test(server): synchronize one-way admission publication race --- .../issue-322-fix-oneway-generation-test.yml | 63 ------------------- .../DynamicAdmissionGenerationTests.cs | 4 ++ 2 files changed, 4 insertions(+), 63 deletions(-) delete mode 100644 .github/workflows/issue-322-fix-oneway-generation-test.yml diff --git a/.github/workflows/issue-322-fix-oneway-generation-test.yml b/.github/workflows/issue-322-fix-oneway-generation-test.yml deleted file mode 100644 index 63e87ca3e..000000000 --- a/.github/workflows/issue-322-fix-oneway-generation-test.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Issue 322 Fix Oneway Generation Test - -on: - pull_request: - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: issue-322-admission-program-generation - fetch-depth: 0 - - - name: Add deterministic capture barrier - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - path = Path('test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs') - text = path.read_text() - start = text.index(' public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bool oneWay)') - end = text.index('\n [Test]\n [NotInParallel]\n public async Task EnabledCaptureShouldRemainOnGenerationNWhenCurrentBecomesNPlusOne()', start) - block = text[start:end] - - replacements = [ - ( - ' var hookCount = 0;\n', - ' var hookCount = 0;\n var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n' - ), - ( - ' server.PublishAdmissionProgramForTests(null);\n', - ' server.PublishAdmissionProgramForTests(null);\n captureCompleted.TrySetResult();\n' - ), - ( - ' await service.NotifyAsync("captured-enabled");\n', - ' await service.NotifyAsync("captured-enabled");\n await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n' - ) - ] - for old, new in replacements: - count = block.count(old) - if count != 1: - raise SystemExit(f'expected one scoped replacement, found {count}: {old!r}') - block = block.replace(old, new, 1) - - path.write_text(text[:start] + block + text[end:]) - PY - - - name: Commit test fix and remove helper - shell: bash - run: | - rm .github/workflows/issue-322-fix-oneway-generation-test.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs .github/workflows/issue-322-fix-oneway-generation-test.yml - git commit -m "test(server): synchronize one-way admission publication race" - git push origin HEAD:issue-322-admission-program-generation diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs index e9da597b9..6ce21ab08 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs @@ -18,6 +18,8 @@ public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bo Ensure(held.IsAcquired, "test must occupy the captured generation before the request"); AdmissionProgram? captured = null; var hookCount = 0; + var captureCompleted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); try { @@ -28,12 +30,14 @@ public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bo return; captured = observed; server.PublishAdmissionProgramForTests(null); + captureCompleted.TrySetResult(); }; var service = harness.ClientA.Get(); if (oneWay) { await service.NotifyAsync("captured-enabled"); + await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); SharpLinkServer.AfterAdmissionCaptureForTests = null; Ensure(await service.AddAsync(20, 22) == 42, "the new disabled publication must be usable by the next request"); From bb6f135e0ba74f68769191a12cc9f30cd6106ff9 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:07:37 +0800 Subject: [PATCH 185/228] chore(ci): trigger deterministic one-way generation test fix --- .../issue-322-fix-oneway-generation-test.yml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/issue-322-fix-oneway-generation-test.yml diff --git a/.github/workflows/issue-322-fix-oneway-generation-test.yml b/.github/workflows/issue-322-fix-oneway-generation-test.yml new file mode 100644 index 000000000..f636ceed0 --- /dev/null +++ b/.github/workflows/issue-322-fix-oneway-generation-test.yml @@ -0,0 +1,63 @@ +name: Issue 322 Fix Oneway Generation Test v2 + +on: + pull_request: + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: issue-322-admission-program-generation + fetch-depth: 0 + + - name: Add deterministic capture barrier + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path('test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs') + text = path.read_text() + start = text.index(' public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bool oneWay)') + end = text.index('\n [Test]\n [NotInParallel]\n public async Task EnabledCaptureShouldRemainOnGenerationNWhenCurrentBecomesNPlusOne()', start) + block = text[start:end] + + replacements = [ + ( + ' var hookCount = 0;\n', + ' var hookCount = 0;\n var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n' + ), + ( + ' server.PublishAdmissionProgramForTests(null);\n', + ' server.PublishAdmissionProgramForTests(null);\n captureCompleted.TrySetResult();\n' + ), + ( + ' await service.NotifyAsync("captured-enabled");\n', + ' await service.NotifyAsync("captured-enabled");\n await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n' + ) + ] + for old, new in replacements: + count = block.count(old) + if count != 1: + raise SystemExit(f'expected one scoped replacement, found {count}: {old!r}') + block = block.replace(old, new, 1) + + path.write_text(text[:start] + block + text[end:]) + PY + + - name: Commit test fix and remove helper + shell: bash + run: | + rm .github/workflows/issue-322-fix-oneway-generation-test.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs .github/workflows/issue-322-fix-oneway-generation-test.yml + git commit -m "test(server): synchronize one-way admission publication race" + git push origin HEAD:issue-322-admission-program-generation From 20c46b767591a265df67a2d043c5286b8c86b2cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:07:47 +0000 Subject: [PATCH 186/228] test(server): synchronize one-way admission publication race --- .../issue-322-fix-oneway-generation-test.yml | 63 ------------------- .../DynamicAdmissionGenerationTests.cs | 4 ++ 2 files changed, 4 insertions(+), 63 deletions(-) delete mode 100644 .github/workflows/issue-322-fix-oneway-generation-test.yml diff --git a/.github/workflows/issue-322-fix-oneway-generation-test.yml b/.github/workflows/issue-322-fix-oneway-generation-test.yml deleted file mode 100644 index f636ceed0..000000000 --- a/.github/workflows/issue-322-fix-oneway-generation-test.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Issue 322 Fix Oneway Generation Test v2 - -on: - pull_request: - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: issue-322-admission-program-generation - fetch-depth: 0 - - - name: Add deterministic capture barrier - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - path = Path('test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs') - text = path.read_text() - start = text.index(' public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bool oneWay)') - end = text.index('\n [Test]\n [NotInParallel]\n public async Task EnabledCaptureShouldRemainOnGenerationNWhenCurrentBecomesNPlusOne()', start) - block = text[start:end] - - replacements = [ - ( - ' var hookCount = 0;\n', - ' var hookCount = 0;\n var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n' - ), - ( - ' server.PublishAdmissionProgramForTests(null);\n', - ' server.PublishAdmissionProgramForTests(null);\n captureCompleted.TrySetResult();\n' - ), - ( - ' await service.NotifyAsync("captured-enabled");\n', - ' await service.NotifyAsync("captured-enabled");\n await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n' - ) - ] - for old, new in replacements: - count = block.count(old) - if count != 1: - raise SystemExit(f'expected one scoped replacement, found {count}: {old!r}') - block = block.replace(old, new, 1) - - path.write_text(text[:start] + block + text[end:]) - PY - - - name: Commit test fix and remove helper - shell: bash - run: | - rm .github/workflows/issue-322-fix-oneway-generation-test.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs .github/workflows/issue-322-fix-oneway-generation-test.yml - git commit -m "test(server): synchronize one-way admission publication race" - git push origin HEAD:issue-322-admission-program-generation diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs index 6ce21ab08..24542568b 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs @@ -20,6 +20,8 @@ public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bo var hookCount = 0; var captureCompleted = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); + var captureCompleted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); try { @@ -31,6 +33,7 @@ public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bo captured = observed; server.PublishAdmissionProgramForTests(null); captureCompleted.TrySetResult(); + captureCompleted.TrySetResult(); }; var service = harness.ClientA.Get(); @@ -38,6 +41,7 @@ public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bo { await service.NotifyAsync("captured-enabled"); await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); SharpLinkServer.AfterAdmissionCaptureForTests = null; Ensure(await service.AddAsync(20, 22) == 42, "the new disabled publication must be usable by the next request"); From 9a6117322f1aaaa3207c132e0e141b231b1e42c0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:08:07 +0800 Subject: [PATCH 187/228] chore: trigger exact-head issue 322 validation From 0cf98711cb96056730116366a4cf58afc880f093 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:09:46 +0800 Subject: [PATCH 188/228] chore(ci): stage generation test barrier dedupe --- .../issue-322-dedupe-generation-test.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/issue-322-dedupe-generation-test.yml diff --git a/.github/workflows/issue-322-dedupe-generation-test.yml b/.github/workflows/issue-322-dedupe-generation-test.yml new file mode 100644 index 000000000..05ed82f97 --- /dev/null +++ b/.github/workflows/issue-322-dedupe-generation-test.yml @@ -0,0 +1,58 @@ +name: Issue 322 Dedupe Generation Test + +on: + push: + branches: + - issue-322-admission-program-generation + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: issue-322-admission-program-generation + fetch-depth: 0 + + - name: Remove duplicated barrier insertion + shell: bash + run: | + python - <<'PY' + from pathlib import Path + path = Path('test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs') + text = path.read_text() + replacements = [ + ( + ' var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n', + ' var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n' + ), + ( + ' captureCompleted.TrySetResult();\n captureCompleted.TrySetResult();\n', + ' captureCompleted.TrySetResult();\n' + ), + ( + ' await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n', + ' await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n' + ) + ] + for old, new in replacements: + count = text.count(old) + if count != 1: + raise SystemExit(f'expected one duplicate pair, found {count}: {old!r}') + text = text.replace(old, new, 1) + path.write_text(text) + PY + + - name: Commit dedupe and remove helper + shell: bash + run: | + rm .github/workflows/issue-322-dedupe-generation-test.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs .github/workflows/issue-322-dedupe-generation-test.yml + git commit -m "test(server): dedupe admission publication barrier" + git push origin HEAD:issue-322-admission-program-generation From eea77712cacdd8fdfe7d13d59f98328af5064e0c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:09:55 +0000 Subject: [PATCH 189/228] test(server): dedupe admission publication barrier --- .../issue-322-dedupe-generation-test.yml | 58 ------------------- .../DynamicAdmissionGenerationTests.cs | 4 -- 2 files changed, 62 deletions(-) delete mode 100644 .github/workflows/issue-322-dedupe-generation-test.yml diff --git a/.github/workflows/issue-322-dedupe-generation-test.yml b/.github/workflows/issue-322-dedupe-generation-test.yml deleted file mode 100644 index 05ed82f97..000000000 --- a/.github/workflows/issue-322-dedupe-generation-test.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Issue 322 Dedupe Generation Test - -on: - push: - branches: - - issue-322-admission-program-generation - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: issue-322-admission-program-generation - fetch-depth: 0 - - - name: Remove duplicated barrier insertion - shell: bash - run: | - python - <<'PY' - from pathlib import Path - path = Path('test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs') - text = path.read_text() - replacements = [ - ( - ' var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n', - ' var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n' - ), - ( - ' captureCompleted.TrySetResult();\n captureCompleted.TrySetResult();\n', - ' captureCompleted.TrySetResult();\n' - ), - ( - ' await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n', - ' await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n' - ) - ] - for old, new in replacements: - count = text.count(old) - if count != 1: - raise SystemExit(f'expected one duplicate pair, found {count}: {old!r}') - text = text.replace(old, new, 1) - path.write_text(text) - PY - - - name: Commit dedupe and remove helper - shell: bash - run: | - rm .github/workflows/issue-322-dedupe-generation-test.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs .github/workflows/issue-322-dedupe-generation-test.yml - git commit -m "test(server): dedupe admission publication barrier" - git push origin HEAD:issue-322-admission-program-generation diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs index 24542568b..6ce21ab08 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs @@ -20,8 +20,6 @@ public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bo var hookCount = 0; var captureCompleted = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); - var captureCompleted = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); try { @@ -33,7 +31,6 @@ public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bo captured = observed; server.PublishAdmissionProgramForTests(null); captureCompleted.TrySetResult(); - captureCompleted.TrySetResult(); }; var service = harness.ClientA.Get(); @@ -41,7 +38,6 @@ public async Task EnabledCaptureShouldRemainEnabledWhenCurrentBecomesDisabled(bo { await service.NotifyAsync("captured-enabled"); await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); - await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); SharpLinkServer.AfterAdmissionCaptureForTests = null; Ensure(await service.AddAsync(20, 22) == 42, "the new disabled publication must be usable by the next request"); From 20856d0b7cc374064c602b2a2dbe520c4e721a2c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:10:12 +0800 Subject: [PATCH 190/228] chore(ci): trigger generation test barrier dedupe --- .../issue-322-dedupe-generation-test.yml | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/issue-322-dedupe-generation-test.yml diff --git a/.github/workflows/issue-322-dedupe-generation-test.yml b/.github/workflows/issue-322-dedupe-generation-test.yml new file mode 100644 index 000000000..b2f9809ba --- /dev/null +++ b/.github/workflows/issue-322-dedupe-generation-test.yml @@ -0,0 +1,57 @@ +name: Issue 322 Dedupe Generation Test + +on: + pull_request: + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: issue-322-admission-program-generation + fetch-depth: 0 + + - name: Remove duplicated barrier insertion + shell: bash + run: | + python - <<'PY' + from pathlib import Path + path = Path('test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs') + text = path.read_text() + replacements = [ + ( + ' var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n', + ' var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n' + ), + ( + ' captureCompleted.TrySetResult();\n captureCompleted.TrySetResult();\n', + ' captureCompleted.TrySetResult();\n' + ), + ( + ' await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n', + ' await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n' + ) + ] + for old, new in replacements: + count = text.count(old) + if count != 1: + raise SystemExit(f'expected one duplicate pair, found {count}: {old!r}') + text = text.replace(old, new, 1) + path.write_text(text) + PY + + - name: Commit dedupe and remove helper + shell: bash + run: | + rm .github/workflows/issue-322-dedupe-generation-test.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs .github/workflows/issue-322-dedupe-generation-test.yml + git commit -m "test(server): dedupe admission publication barrier" + git push origin HEAD:issue-322-admission-program-generation From 0711faa11500b22f08efb9a5225b53bd008ac479 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:10:55 +0800 Subject: [PATCH 191/228] chore(ci): remove temporary issue 322 test helper --- .../issue-322-dedupe-generation-test.yml | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 .github/workflows/issue-322-dedupe-generation-test.yml diff --git a/.github/workflows/issue-322-dedupe-generation-test.yml b/.github/workflows/issue-322-dedupe-generation-test.yml deleted file mode 100644 index b2f9809ba..000000000 --- a/.github/workflows/issue-322-dedupe-generation-test.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Issue 322 Dedupe Generation Test - -on: - pull_request: - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.head.ref == 'issue-322-admission-program-generation' - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: issue-322-admission-program-generation - fetch-depth: 0 - - - name: Remove duplicated barrier insertion - shell: bash - run: | - python - <<'PY' - from pathlib import Path - path = Path('test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs') - text = path.read_text() - replacements = [ - ( - ' var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n', - ' var captureCompleted = new TaskCompletionSource(\n TaskCreationOptions.RunContinuationsAsynchronously);\n' - ), - ( - ' captureCompleted.TrySetResult();\n captureCompleted.TrySetResult();\n', - ' captureCompleted.TrySetResult();\n' - ), - ( - ' await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n', - ' await captureCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));\n' - ) - ] - for old, new in replacements: - count = text.count(old) - if count != 1: - raise SystemExit(f'expected one duplicate pair, found {count}: {old!r}') - text = text.replace(old, new, 1) - path.write_text(text) - PY - - - name: Commit dedupe and remove helper - shell: bash - run: | - rm .github/workflows/issue-322-dedupe-generation-test.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs .github/workflows/issue-322-dedupe-generation-test.yml - git commit -m "test(server): dedupe admission publication barrier" - git push origin HEAD:issue-322-admission-program-generation From 1f8d0f2460958ac774ebd19de3f4d2b66ac2be03 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:24:47 +0800 Subject: [PATCH 192/228] refactor: add admission program retire lifecycle --- .../Admission/AdmissionProgram.cs | 100 +++++++++++++++--- 1 file changed, 83 insertions(+), 17 deletions(-) diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs index f1ab27b0e..a9f034a74 100644 --- a/src/SharpLink.Server/Admission/AdmissionProgram.cs +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -3,17 +3,20 @@ namespace SharpLink.Server; /// /// Immutable admission-policy publication for one runtime generation. Requests capture one /// publication at the RequestLoop boundary and never re-read the server's current publication. +/// Mutable limiter/accounting state is owned by the server-scoped . /// internal sealed class AdmissionProgram { - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< - SharpLinkAdmissionController, - AdmissionProgram> ProgramsByController = new(); + private const int RetiredMask = int.MinValue; + private const int UseCountMask = int.MaxValue; private static long s_nextGenerationId; private readonly SharpLinkAdmissionController? _controller; - private int _activeUses; + private readonly AdmissionStateKernel? _kernel; + private int _useState; private int _duplicateReleaseAttempts; + private int _reclaimed; + private int _reclaimCount; private AdmissionProgram(long sentinelGenerationId) => GenerationId = sentinelGenerationId; @@ -21,8 +24,12 @@ private AdmissionProgram(long sentinelGenerationId) internal AdmissionProgram(SharpLinkAdmissionController controller) { _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + if (!controller.IsEnabled) + throw new InvalidOperationException("Disabled admission does not create a program generation."); + _kernel = controller.Kernel; GenerationId = Interlocked.Increment(ref s_nextGenerationId); - ProgramsByController.Add(controller, this); + controller.AttachProgram(this); + _kernel.RegisterProgram(this); } internal static AdmissionProgram Uninitialized { get; } = new(long.MinValue); @@ -36,40 +43,99 @@ internal AdmissionProgram(SharpLinkAdmissionController controller) internal SharpLinkAdmissionController Controller => _controller ?? throw new InvalidOperationException("Disabled admission has no controller."); + internal AdmissionStateKernel Kernel + => _kernel ?? throw new InvalidOperationException("Disabled admission has no state kernel."); + internal bool QueueOneWayCalls => Controller.QueueOneWayCalls; - internal int ActiveUses => Volatile.Read(ref _activeUses); + internal int ActiveUses => Volatile.Read(ref _useState) & UseCountMask; + + internal bool IsRetired => (Volatile.Read(ref _useState) & RetiredMask) != 0; + + internal bool IsReclaimed => Volatile.Read(ref _reclaimed) != 0; + + internal int ReclaimCount => Volatile.Read(ref _reclaimCount); internal int DuplicateReleaseAttempts => Volatile.Read(ref _duplicateReleaseAttempts); - internal static AdmissionProgram FromController(SharpLinkAdmissionController controller) + /// + /// Acquires one generation use only while this program is current. The retired bit and use + /// count share one CAS word so retirement cannot become visible between the lifecycle check + /// and the increment. + /// + internal bool TryAcquireUse() { - ArgumentNullException.ThrowIfNull(controller); - return ProgramsByController.TryGetValue(controller, out var program) - ? program - : throw new InvalidOperationException("Admission controller has no published program generation."); + if (!IsEnabled) + return false; + + while (true) + { + var state = Volatile.Read(ref _useState); + if ((state & RetiredMask) != 0) + return false; + if ((state & UseCountMask) == UseCountMask) + throw new InvalidOperationException("Admission program use count overflowed."); + if (Interlocked.CompareExchange(ref _useState, state + 1, state) == state) + return true; + } } internal void AcquireUse() + { + if (!TryAcquireUse()) + throw new InvalidOperationException("Retired admission program cannot acquire new generation uses."); + } + + /// Transitions this publication to retired exactly once without cancelling existing users. + internal bool Retire() { if (!IsEnabled) - throw new InvalidOperationException("Disabled admission does not acquire generation uses."); - Interlocked.Increment(ref _activeUses); + return false; + + while (true) + { + var state = Volatile.Read(ref _useState); + if ((state & RetiredMask) != 0) + return false; + var retired = state | RetiredMask; + if (Interlocked.CompareExchange(ref _useState, retired, state) != state) + continue; + + Kernel.OnProgramRetired(this); + if ((state & UseCountMask) == 0) + Kernel.TryReclaimProgram(this); + return true; + } } internal void ReleaseUse() { while (true) { - var activeUses = Volatile.Read(ref _activeUses); - if (activeUses <= 0) + var state = Volatile.Read(ref _useState); + var activeUses = state & UseCountMask; + if (activeUses == 0) { Interlocked.Increment(ref _duplicateReleaseAttempts); throw new InvalidOperationException("Admission program use count underflowed."); } - if (Interlocked.CompareExchange(ref _activeUses, activeUses - 1, activeUses) == activeUses) - return; + var next = (state & RetiredMask) | (activeUses - 1); + if (Interlocked.CompareExchange(ref _useState, next, state) != state) + continue; + if (next == RetiredMask) + Kernel.TryReclaimProgram(this); + return; } } + + internal bool TryMarkReclaimed() + { + if (!IsRetired || ActiveUses != 0) + return false; + if (Interlocked.CompareExchange(ref _reclaimed, 1, 0) != 0) + return false; + Interlocked.Increment(ref _reclaimCount); + return true; + } } From 649efbb9cff2ba68c9a8bd0efdd9fbc2501f375f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:25:36 +0800 Subject: [PATCH 193/228] refactor: add stable admission state kernel --- .../Admission/AdmissionStateKernel.cs | 574 ++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 src/SharpLink.Server/Admission/AdmissionStateKernel.cs diff --git a/src/SharpLink.Server/Admission/AdmissionStateKernel.cs b/src/SharpLink.Server/Admission/AdmissionStateKernel.cs new file mode 100644 index 000000000..5df3579e3 --- /dev/null +++ b/src/SharpLink.Server/Admission/AdmissionStateKernel.cs @@ -0,0 +1,574 @@ +namespace SharpLink.Server; + +/// +/// Stable server-scoped owner for mutable admission accounting and limiter state. Programs are +/// immutable publications that hold references into this kernel; ordinary program retirement does +/// not cancel queued or active work. +/// +internal sealed class AdmissionStateKernel : IAsyncDisposable +{ + private readonly Lock _accountingGate = new(); + private readonly Lock _registryGate = new(); + private readonly Dictionary _ruleStates = []; + private readonly Dictionary _partitionStates = []; + private readonly HashSet _programs = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _retiredPrograms = new(ReferenceEqualityComparer.Instance); + private readonly CancellationTokenSource _draining = new(); + private readonly TimeProvider _timeProvider; + private TaskCompletionSource _queueDrained = CompletedSignal(); + private TaskCompletionSource _permitsDrained = CompletedSignal(); + private TaskCompletionSource _programsDrained = CompletedSignal(); + private int _queuedCalls; + private long _queuedBytes; + private int _activePermits; + private int _disposed; + + internal AdmissionStateKernel(TimeProvider timeProvider) + => _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + + internal TimeProvider TimeProvider => _timeProvider; + + internal CancellationToken DrainingToken => _draining.Token; + + internal bool IsDraining => _draining.IsCancellationRequested || Volatile.Read(ref _disposed) != 0; + + internal int QueuedCalls => Volatile.Read(ref _queuedCalls); + + internal long QueuedBytes => Volatile.Read(ref _queuedBytes); + + internal int ActivePermits => Volatile.Read(ref _activePermits); + + internal int RetiredProgramCount + { + get + { + lock (_registryGate) + return _retiredPrograms.Count; + } + } + + internal int LiveProgramCount + { + get + { + lock (_registryGate) + return _programs.Count; + } + } + + internal int RuleStateCount + { + get + { + lock (_registryGate) + return _ruleStates.Count; + } + } + + internal int PartitionStateCount + { + get + { + lock (_registryGate) + return _partitionStates.Count; + } + } + + internal AdmissionProgram CreateProgram( + SharpLinkAdmissionControlOptions options, + IReadOnlyList manifests) + { + if (IsDraining) + throw new InvalidOperationException("Admission state is sealed for shutdown."); + var controller = SharpLinkAdmissionController.Create( + this, + options, + manifests, + _timeProvider, + ownsKernel: false); + try + { + return new AdmissionProgram(controller); + } + catch + { + ReleaseUnpublishedBindings(controller); + throw; + } + } + + internal AdmissionRuleStateBinding AcquireRuleState( + AdmissionRuleStateKey key, + SharpLinkAdmissionRuleOptions options, + int queueLimit, + string scope) + { + lock (_registryGate) + { + ThrowIfDisposed(); + if (_ruleStates.TryGetValue(key, out var existing)) + { + existing.ProgramReferences++; + return new AdmissionRuleStateBinding(key, existing.Runtime); + } + + var runtime = AdmissionRuleRuntime.Create(options, queueLimit, scope); + _ruleStates.Add(key, new RuleStateEntry(runtime, 1)); + return new AdmissionRuleStateBinding(key, runtime); + } + } + + internal AdmissionPartitionStateBinding AcquirePartitionState( + AdmissionPartitionStateKey key, + Func selector, + SharpLinkPartitionAdmissionOptions options, + int queueLimit) + { + lock (_registryGate) + { + ThrowIfDisposed(); + if (_partitionStates.TryGetValue(key, out var existing)) + { + existing.ProgramReferences++; + return new AdmissionPartitionStateBinding(key, existing.Pool); + } + + var pool = new AdmissionPartitionPool(selector, options, queueLimit, _timeProvider); + _partitionStates.Add(key, new PartitionStateEntry(pool, 1)); + return new AdmissionPartitionStateBinding(key, pool); + } + } + + internal void RegisterProgram(AdmissionProgram program) + { + lock (_registryGate) + { + ThrowIfDisposed(); + if (!_programs.Add(program)) + throw new InvalidOperationException("Admission program was registered twice."); + if (_programs.Count == 1) + { + _programsDrained = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + } + } + } + + internal void OnProgramRetired(AdmissionProgram program) + { + lock (_registryGate) + { + if (_programs.Contains(program)) + _retiredPrograms.Add(program); + } + } + + internal void TryReclaimProgram(AdmissionProgram program) + { + if (!program.TryMarkReclaimed()) + return; + + List? dispose = null; + TaskCompletionSource? programsDrained = null; + lock (_registryGate) + { + if (!_programs.Remove(program)) + return; + _retiredPrograms.Remove(program); + ReleaseBindingsLocked(program.Controller, ref dispose); + if (_programs.Count == 0) + programsDrained = _programsDrained; + } + programsDrained?.TrySetResult(true); + DisposeStates(dispose); + } + + internal void ReleaseUnpublishedBindings(SharpLinkAdmissionController controller) + { + List? dispose = null; + lock (_registryGate) + ReleaseBindingsLocked(controller, ref dispose); + DisposeStates(dispose); + } + + internal bool TryReserveQueue( + int retainedBytes, + int maxQueuedCalls, + long maxQueuedBytes, + out string reason) + { + lock (_accountingGate) + { + if (IsDraining) + { + reason = "draining"; + return false; + } + if (_queuedCalls >= maxQueuedCalls) + { + reason = "queue_count"; + return false; + } + if (retainedBytes > maxQueuedBytes - _queuedBytes) + { + reason = "queue_bytes"; + return false; + } + if (_queuedCalls++ == 0) + { + _queueDrained = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + } + _queuedBytes += retainedBytes; + } + SharpLinkTelemetry.AddAdmissionQueuedCalls(1); + reason = string.Empty; + return true; + } + + internal void ReleaseQueue(int retainedBytes) + { + TaskCompletionSource? drained = null; + var shouldReclaim = false; + lock (_accountingGate) + { + if (--_queuedCalls < 0) + throw new InvalidOperationException("Admission queued call accounting underflowed."); + _queuedBytes -= retainedBytes; + if (_queuedBytes < 0) + throw new InvalidOperationException("Admission queued byte accounting underflowed."); + if (_queuedCalls == 0) + { + drained = _queueDrained; + shouldReclaim = _activePermits == 0; + } + } + drained?.TrySetResult(true); + SharpLinkTelemetry.AddAdmissionQueuedCalls(-1); + if (shouldReclaim) + ReclaimUnreferencedStatesIfIdle(); + } + + internal bool TryReserveAdditionalQueuedBytes(int retainedBytes, long maxQueuedBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(retainedBytes); + lock (_accountingGate) + { + if (IsDraining || retainedBytes > maxQueuedBytes - _queuedBytes) + return false; + _queuedBytes += retainedBytes; + return true; + } + } + + internal void ReleaseAdditionalQueuedBytes(int retainedBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(retainedBytes); + lock (_accountingGate) + { + _queuedBytes -= retainedBytes; + if (_queuedBytes < 0) + throw new InvalidOperationException("Admission queued byte accounting underflowed."); + } + } + + internal void OnLeaseCreated() + { + lock (_accountingGate) + { + if (_activePermits++ == 0) + { + _permitsDrained = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + } + } + SharpLinkTelemetry.AddAdmissionActivePermits(1); + } + + internal void OnLeaseDisposed() + { + TaskCompletionSource? drained = null; + var shouldReclaim = false; + lock (_accountingGate) + { + if (--_activePermits < 0) + throw new InvalidOperationException("Admission active permit accounting underflowed."); + if (_activePermits == 0) + { + drained = _permitsDrained; + shouldReclaim = _queuedCalls == 0; + } + } + drained?.TrySetResult(true); + SharpLinkTelemetry.AddAdmissionActivePermits(-1); + if (shouldReclaim) + ReclaimUnreferencedStatesIfIdle(); + } + + /// Shutdown-only cancellation. Ordinary program retirement never calls this method. + internal void StopAccepting() + { + try + { + _draining.Cancel(); + } + catch (ObjectDisposedException) + { + } + + AdmissionProgram[] programs; + lock (_registryGate) + programs = [.. _programs]; + foreach (var program in programs) + program.Retire(); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + StopAccepting(); + while (true) + { + Task queueDrained; + Task permitsDrained; + Task programsDrained; + lock (_accountingGate) + { + queueDrained = _queueDrained.Task; + permitsDrained = _permitsDrained.Task; + } + lock (_registryGate) + programsDrained = _programsDrained.Task; + + await Task.WhenAll(queueDrained, permitsDrained, programsDrained).ConfigureAwait(false); + + lock (_accountingGate) + { + if (_queuedCalls != 0 || _activePermits != 0) + continue; + } + lock (_registryGate) + { + if (_programs.Count == 0) + break; + } + } + + List dispose = []; + lock (_registryGate) + { + foreach (var entry in _ruleStates.Values) + dispose.Add(entry.Runtime); + foreach (var entry in _partitionStates.Values) + dispose.Add(entry.Pool); + _ruleStates.Clear(); + _partitionStates.Clear(); + _retiredPrograms.Clear(); + } + DisposeStates(dispose); + _draining.Dispose(); + } + + private void ReleaseBindingsLocked( + SharpLinkAdmissionController controller, + ref List? dispose) + { + foreach (var binding in controller.RuleStateBindings) + { + if (!_ruleStates.TryGetValue(binding.Key, out var entry) || + !ReferenceEquals(entry.Runtime, binding.Runtime)) + { + continue; + } + if (--entry.ProgramReferences < 0) + throw new InvalidOperationException("Admission rule state reference count underflowed."); + if (entry.ProgramReferences == 0 && !HasOutstandingActivity()) + { + _ruleStates.Remove(binding.Key); + (dispose ??= []).Add(entry.Runtime); + } + } + + if (controller.PartitionStateBinding is { } partitionBinding && + _partitionStates.TryGetValue(partitionBinding.Key, out var partitionEntry) && + ReferenceEquals(partitionEntry.Pool, partitionBinding.Pool)) + { + if (--partitionEntry.ProgramReferences < 0) + throw new InvalidOperationException("Admission partition state reference count underflowed."); + if (partitionEntry.ProgramReferences == 0 && !HasOutstandingActivity()) + { + _partitionStates.Remove(partitionBinding.Key); + (dispose ??= []).Add(partitionEntry.Pool); + } + } + } + + private void ReclaimUnreferencedStatesIfIdle() + { + if (HasOutstandingActivity()) + return; + + List? dispose = null; + lock (_registryGate) + { + foreach (var pair in _ruleStates.Where(static pair => pair.Value.ProgramReferences == 0).ToArray()) + { + _ruleStates.Remove(pair.Key); + (dispose ??= []).Add(pair.Value.Runtime); + } + foreach (var pair in _partitionStates.Where(static pair => pair.Value.ProgramReferences == 0).ToArray()) + { + _partitionStates.Remove(pair.Key); + (dispose ??= []).Add(pair.Value.Pool); + } + } + DisposeStates(dispose); + } + + private bool HasOutstandingActivity() + => Volatile.Read(ref _queuedCalls) != 0 || Volatile.Read(ref _activePermits) != 0; + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(AdmissionStateKernel)); + } + + private static void DisposeStates(List? states) + { + if (states is null) + return; + foreach (var state in states) + state.Dispose(); + } + + private static TaskCompletionSource CompletedSignal() + { + var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + signal.SetResult(true); + return signal; + } + + private sealed class RuleStateEntry(AdmissionRuleRuntime runtime, int programReferences) + { + internal AdmissionRuleRuntime Runtime { get; } = runtime; + internal int ProgramReferences = programReferences; + } + + private sealed class PartitionStateEntry(AdmissionPartitionPool pool, int programReferences) + { + internal AdmissionPartitionPool Pool { get; } = pool; + internal int ProgramReferences = programReferences; + } +} + +internal enum AdmissionRuleStateScope : byte +{ + Global, + Contract, + Method +} + +internal enum AdmissionRateStateKind : byte +{ + None, + TokenBucket, + FixedWindow, + SlidingWindow +} + +internal readonly record struct AdmissionRateStateDefinition( + AdmissionRateStateKind Kind, + int Limit, + int Secondary, + long PeriodTicks, + int Segments) +{ + internal static AdmissionRateStateDefinition Create(object? options) + => options switch + { + SharpLinkTokenBucketLimitOptions value => new( + AdmissionRateStateKind.TokenBucket, + value.TokenLimit, + value.TokensPerPeriod, + value.ReplenishmentPeriod.Ticks, + 0), + SharpLinkFixedWindowLimitOptions value => new( + AdmissionRateStateKind.FixedWindow, + value.PermitLimit, + 0, + value.Window.Ticks, + 0), + SharpLinkSlidingWindowLimitOptions value => new( + AdmissionRateStateKind.SlidingWindow, + value.PermitLimit, + 0, + value.Window.Ticks, + value.SegmentsPerWindow), + _ => default + }; +} + +internal readonly record struct AdmissionRuleStateDefinition( + int ConcurrencyPermitLimit, + AdmissionRateStateDefinition Rate, + int QueueLimit) +{ + internal static AdmissionRuleStateDefinition Create( + SharpLinkAdmissionRuleOptions options, + int queueLimit) + => new( + options.Concurrency?.PermitLimit ?? 0, + AdmissionRateStateDefinition.Create(options.RateLimit), + queueLimit); +} + +internal readonly record struct AdmissionRuleStateKey( + AdmissionRuleStateScope Scope, + long ContractId, + long MethodId, + AdmissionRuleStateDefinition Definition) +{ + internal static AdmissionRuleStateKey Global(SharpLinkAdmissionRuleOptions options, int queueLimit) + => new(AdmissionRuleStateScope.Global, 0, 0, AdmissionRuleStateDefinition.Create(options, queueLimit)); + + internal static AdmissionRuleStateKey Contract( + long contractId, + SharpLinkAdmissionRuleOptions options, + int queueLimit) + => new(AdmissionRuleStateScope.Contract, contractId, 0, AdmissionRuleStateDefinition.Create(options, queueLimit)); + + internal static AdmissionRuleStateKey Method( + long contractId, + long methodId, + SharpLinkAdmissionRuleOptions options, + int queueLimit) + => new(AdmissionRuleStateScope.Method, contractId, methodId, AdmissionRuleStateDefinition.Create(options, queueLimit)); +} + +internal readonly record struct AdmissionPartitionStateKey( + Func Selector, + AdmissionRuleStateDefinition Definition, + int MaxPartitions, + long IdleTimeoutTicks) +{ + internal static AdmissionPartitionStateKey Create( + Func selector, + SharpLinkPartitionAdmissionOptions options, + int queueLimit) + => new( + selector, + AdmissionRuleStateDefinition.Create(options, queueLimit), + options.MaxPartitions, + options.IdleTimeout.Ticks); +} + +internal readonly record struct AdmissionRuleStateBinding( + AdmissionRuleStateKey Key, + AdmissionRuleRuntime Runtime); + +internal readonly record struct AdmissionPartitionStateBinding( + AdmissionPartitionStateKey Key, + AdmissionPartitionPool Pool); From d128c229c37654cd7908316299ccc17f427077e7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:26:45 +0800 Subject: [PATCH 194/228] refactor: bind admission programs to kernel state --- .../Admission/SharpLinkAdmissionController.cs | 394 +++++++++--------- 1 file changed, 189 insertions(+), 205 deletions(-) diff --git a/src/SharpLink.Server/Admission/SharpLinkAdmissionController.cs b/src/SharpLink.Server/Admission/SharpLinkAdmissionController.cs index bdb6daa0d..463d52d48 100644 --- a/src/SharpLink.Server/Admission/SharpLinkAdmissionController.cs +++ b/src/SharpLink.Server/Admission/SharpLinkAdmissionController.cs @@ -2,8 +2,13 @@ namespace SharpLink.Server; +/// +/// Immutable admission policy/binding for one program generation. Mutable limiter, queue, permit, +/// and partition state is owned by the stable server-scoped . +/// internal sealed class SharpLinkAdmissionController : IAsyncDisposable { + private readonly AdmissionStateKernel _kernel; private readonly AdmissionRuleRuntime? _global; private readonly FrozenDictionary _contracts; private readonly FrozenDictionary<(long ContractId, long MethodId), AdmissionRuleRuntime> _methods; @@ -13,32 +18,59 @@ internal sealed class SharpLinkAdmissionController : IAsyncDisposable private readonly bool _queueOneWayCalls; private readonly TimeProvider _timeProvider; private readonly AdmissionPartitionPool? _partitions; - private readonly CancellationTokenSource _draining = new(); - private readonly Lock _queueGate = new(); - private int _queuedCalls; - private long _queuedBytes; - private int _activePermits; - private int _disposed; - private TaskCompletionSource _queueDrained = CompletedSignal(); - private TaskCompletionSource _permitsDrained = CompletedSignal(); + private readonly AdmissionRuleStateBinding[] _ruleStateBindings; + private readonly AdmissionPartitionStateBinding? _partitionStateBinding; + private readonly bool _ownsKernel; + private AdmissionProgram? _program; private SharpLinkAdmissionController( - SharpLinkAdmissionControlOptions options, + AdmissionStateKernel kernel, + int maxQueuedCalls, + long maxQueuedBytes, + TimeSpan maxQueueDelay, + bool queueOneWayCalls, AdmissionRuleRuntime? global, FrozenDictionary contracts, FrozenDictionary<(long ContractId, long MethodId), AdmissionRuleRuntime> methods, AdmissionPartitionPool? partitions, - TimeProvider timeProvider) + AdmissionRuleStateBinding[] ruleStateBindings, + AdmissionPartitionStateBinding? partitionStateBinding, + TimeProvider timeProvider, + bool ownsKernel) { - _maxQueuedCalls = options.MaxQueuedCalls; - _maxQueuedBytes = options.MaxQueuedBytes; - _maxQueueDelay = options.MaxQueueDelay; - _queueOneWayCalls = options.QueueOneWayCalls; + _kernel = kernel; + _maxQueuedCalls = maxQueuedCalls; + _maxQueuedBytes = maxQueuedBytes; + _maxQueueDelay = maxQueueDelay; + _queueOneWayCalls = queueOneWayCalls; _timeProvider = timeProvider; _global = global; _contracts = contracts; _methods = methods; _partitions = partitions; + _ruleStateBindings = ruleStateBindings; + _partitionStateBinding = partitionStateBinding; + _ownsKernel = ownsKernel; + } + + internal static SharpLinkAdmissionController CreateDisabled(TimeProvider? timeProvider = null) + { + timeProvider ??= TimeProvider.System; + var kernel = new AdmissionStateKernel(timeProvider); + return new SharpLinkAdmissionController( + kernel, + 0, + 0, + TimeSpan.Zero, + queueOneWayCalls: false, + global: null, + FrozenDictionary.Empty, + FrozenDictionary<(long ContractId, long MethodId), AdmissionRuleRuntime>.Empty, + partitions: null, + [], + partitionStateBinding: null, + timeProvider, + ownsKernel: true); } internal static SharpLinkAdmissionController Create( @@ -49,7 +81,31 @@ internal static SharpLinkAdmissionController Create( ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(manifests); timeProvider ??= TimeProvider.System; + var kernel = new AdmissionStateKernel(timeProvider); + try + { + return Create(kernel, options, manifests, timeProvider, ownsKernel: true); + } + catch + { + SharpLinkAsyncCleanup.DisposeSynchronously(kernel); + throw; + } + } + + internal static SharpLinkAdmissionController Create( + AdmissionStateKernel kernel, + SharpLinkAdmissionControlOptions options, + IReadOnlyList manifests, + TimeProvider timeProvider, + bool ownsKernel) + { + ArgumentNullException.ThrowIfNull(kernel); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(manifests); + ArgumentNullException.ThrowIfNull(timeProvider); options.Validate(); + var contractsByType = new Dictionary(); foreach (var manifest in manifests) { @@ -113,49 +169,119 @@ internal static SharpLinkAdmissionController Create( AdmissionRuleRuntime? global = null; var contractRules = new Dictionary(contractOptions.Count); var methodRules = new Dictionary<(long, long), AdmissionRuleRuntime>(methodOptions.Count); + var bindings = new List(1 + contractOptions.Count + methodOptions.Count); + AdmissionPartitionStateBinding? partitionBinding = null; try { - global = options.Global.HasLimit - ? AdmissionRuleRuntime.Create(options.Global, options.MaxQueuedCalls, "global") - : null; + if (options.Global.HasLimit) + { + var binding = kernel.AcquireRuleState( + AdmissionRuleStateKey.Global(options.Global, options.MaxQueuedCalls), + options.Global, + options.MaxQueuedCalls, + "global"); + bindings.Add(binding); + global = binding.Runtime; + } + foreach (var pair in contractOptions) { - contractRules.Add( - pair.Key, - AdmissionRuleRuntime.Create(pair.Value, options.MaxQueuedCalls, "contract")); + var binding = kernel.AcquireRuleState( + AdmissionRuleStateKey.Contract(pair.Key, pair.Value, options.MaxQueuedCalls), + pair.Value, + options.MaxQueuedCalls, + "contract"); + bindings.Add(binding); + contractRules.Add(pair.Key, binding.Runtime); } + foreach (var pair in methodOptions) { - methodRules.Add( - pair.Key, - AdmissionRuleRuntime.Create(pair.Value, options.MaxQueuedCalls, "method")); + var binding = kernel.AcquireRuleState( + AdmissionRuleStateKey.Method(pair.Key.Item1, pair.Key.Item2, pair.Value, options.MaxQueuedCalls), + pair.Value, + options.MaxQueuedCalls, + "method"); + bindings.Add(binding); + methodRules.Add(pair.Key, binding.Runtime); } - var partitions = options.Partition is { } partition - ? new AdmissionPartitionPool( - options.PartitionSelector!, + + AdmissionPartitionPool? partitions = null; + if (options.Partition is { } partition) + { + var selector = options.PartitionSelector!; + partitionBinding = kernel.AcquirePartitionState( + AdmissionPartitionStateKey.Create(selector, partition, options.MaxQueuedCalls), + selector, partition, - options.MaxQueuedCalls, - timeProvider) - : null; + options.MaxQueuedCalls); + partitions = partitionBinding.Value.Pool; + } + return new SharpLinkAdmissionController( - options, + kernel, + options.MaxQueuedCalls, + options.MaxQueuedBytes, + options.MaxQueueDelay, + options.QueueOneWayCalls, global, contractRules.ToFrozenDictionary(), methodRules.ToFrozenDictionary(), partitions, - timeProvider); + [.. bindings], + partitionBinding, + timeProvider, + ownsKernel); } catch { - global?.Dispose(); - foreach (var rule in contractRules.Values) - rule.Dispose(); - foreach (var rule in methodRules.Values) - rule.Dispose(); + var rollback = new SharpLinkAdmissionController( + kernel, + options.MaxQueuedCalls, + options.MaxQueuedBytes, + options.MaxQueueDelay, + options.QueueOneWayCalls, + global, + contractRules.ToFrozenDictionary(), + methodRules.ToFrozenDictionary(), + partitionBinding?.Pool, + [.. bindings], + partitionBinding, + timeProvider, + ownsKernel: false); + kernel.ReleaseUnpublishedBindings(rollback); throw; } } + internal AdmissionStateKernel Kernel => _kernel; + + internal AdmissionProgram? Program => Volatile.Read(ref _program); + + internal bool IsEnabled + => _global is not null || _contracts.Count != 0 || _methods.Count != 0 || _partitions is not null; + + internal IReadOnlyList RuleStateBindings => _ruleStateBindings; + + internal AdmissionPartitionStateBinding? PartitionStateBinding => _partitionStateBinding; + + internal AdmissionRuleRuntime? GlobalStateForTests => _global; + + internal AdmissionRuleRuntime? ContractStateForTests(long contractId) + => _contracts.GetValueOrDefault(contractId); + + internal AdmissionRuleRuntime? MethodStateForTests(long contractId, long methodId) + => _methods.GetValueOrDefault((contractId, methodId)); + + internal AdmissionPartitionPool? PartitionStateForTests => _partitions; + + internal void AttachProgram(AdmissionProgram program) + { + ArgumentNullException.ThrowIfNull(program); + if (Interlocked.CompareExchange(ref _program, program, null) is not null) + throw new InvalidOperationException("Admission policy binding already belongs to a program generation."); + } + internal ValueTask AcquireAsync( SharpLinkAdmissionContext context, int retainedBytes, @@ -179,7 +305,7 @@ internal ValueTask AcquireAsync( { ArgumentNullException.ThrowIfNull(context); ArgumentOutOfRangeException.ThrowIfNegative(retainedBytes); - if (_draining.IsCancellationRequested || Volatile.Read(ref _disposed) != 0) + if (_kernel.IsDraining) return ValueTask.FromResult(AdmissionDecision.Reject("draining", SharpLinkErrorCode.Unavailable)); AdmissionPartitionLease? partitionLease = null; @@ -191,7 +317,7 @@ internal ValueTask AcquireAsync( } var request = CreateRequest(context, partitionLease); - if (request.TryAcquire(this, out var lease, out var failedSlot)) + if (request.TryAcquire(_kernel, out var lease, out var failedSlot)) return ValueTask.FromResult(AdmissionDecision.Accept(lease!)); if (!allowQueue || _maxQueuedCalls == 0) @@ -200,7 +326,7 @@ internal ValueTask AcquireAsync( return ValueTask.FromResult(AdmissionDecision.Reject(failedSlot.Reason, failedSlot.Scope)); } - if (!TryReserveQueue(retainedBytes, out var queueReason)) + if (!_kernel.TryReserveQueue(retainedBytes, _maxQueuedCalls, _maxQueuedBytes, out var queueReason)) { request.Dispose(); return ValueTask.FromResult(queueReason == "draining" @@ -215,16 +341,7 @@ internal ValueTask AcquireAsync( cancellationToken); } - internal void StopAccepting() - { - try - { - _draining.Cancel(); - } - catch (ObjectDisposedException) - { - } - } + internal void StopAccepting() => _kernel.StopAccepting(); private AdmissionRequest CreateRequest( SharpLinkAdmissionContext context, @@ -267,7 +384,7 @@ private async ValueTask WaitForAdmissionAsync( timeoutCancellation.Cancel(); using var waitCancellation = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, - _draining.Token, + _kernel.DrainingToken, timeoutCancellation.Token); try @@ -282,16 +399,12 @@ private async ValueTask WaitForAdmissionAsync( .ConfigureAwait(false); } catch (OperationCanceledException) when ( - _draining.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + _kernel.IsDraining && !cancellationToken.IsCancellationRequested) { return AdmissionDecision.Reject("draining", SharpLinkErrorCode.Unavailable); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - // The admission timer and the server deadline scheduler intentionally race. - // Preserve the deadline result when this local bounded-wait timer wins; - // otherwise identical calls could surface ResourceExhausted or - // DeadlineExceeded depending on scheduler timing. return deadlineLimitsWait ? AdmissionDecision.Reject("deadline", SharpLinkErrorCode.DeadlineExceeded) : AdmissionDecision.Reject(failedSlot.Reason, failedSlot.Scope); @@ -303,7 +416,7 @@ private async ValueTask WaitForAdmissionAsync( return AdmissionDecision.Reject(failedSlot.Reason, failedSlot.Scope); } if (request.TryAcquireUsing( - this, + _kernel, failedSlot.Limiter, waitedLease, out var lease, @@ -315,153 +428,26 @@ private async ValueTask WaitForAdmissionAsync( } finally { - ReleaseQueue(retainedBytes); - SharpLinkTelemetry.RecordAdmissionQueueDuration( - _timeProvider.GetElapsedTime(started)); + _kernel.ReleaseQueue(retainedBytes); + SharpLinkTelemetry.RecordAdmissionQueueDuration(_timeProvider.GetElapsedTime(started)); request.Dispose(); } } - private bool TryReserveQueue(int retainedBytes, out string reason) - { - lock (_queueGate) - { - if (_draining.IsCancellationRequested) - { - reason = "draining"; - return false; - } - if (_queuedCalls >= _maxQueuedCalls) - { - reason = "queue_count"; - return false; - } - if (retainedBytes > _maxQueuedBytes - _queuedBytes) - { - reason = "queue_bytes"; - return false; - } - if (_queuedCalls++ == 0) - { - _queueDrained = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - } - _queuedBytes += retainedBytes; - } - SharpLinkTelemetry.AddAdmissionQueuedCalls(1); - reason = string.Empty; - return true; - } - - private void ReleaseQueue(int retainedBytes) - { - TaskCompletionSource? drained = null; - lock (_queueGate) - { - _queuedCalls--; - _queuedBytes -= retainedBytes; - if (_queuedCalls == 0) - drained = _queueDrained; - } - drained?.TrySetResult(true); - SharpLinkTelemetry.AddAdmissionQueuedCalls(-1); - } - internal bool TryReserveAdditionalQueuedBytes(int retainedBytes) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(retainedBytes); - lock (_queueGate) - { - if (_draining.IsCancellationRequested || - retainedBytes > _maxQueuedBytes - _queuedBytes) - { - return false; - } - _queuedBytes += retainedBytes; - return true; - } - } + => _kernel.TryReserveAdditionalQueuedBytes(retainedBytes, _maxQueuedBytes); internal void ReleaseAdditionalQueuedBytes(int retainedBytes) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(retainedBytes); - lock (_queueGate) - { - _queuedBytes -= retainedBytes; - if (_queuedBytes < 0) - throw new InvalidOperationException("Admission queued byte accounting underflowed."); - } - } + => _kernel.ReleaseAdditionalQueuedBytes(retainedBytes); - internal void OnLeaseCreated() - { - lock (_queueGate) - { - if (_activePermits++ == 0) - { - _permitsDrained = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - } - } - SharpLinkTelemetry.AddAdmissionActivePermits(1); - } - - internal void OnLeaseDisposed() - { - TaskCompletionSource? drained = null; - lock (_queueGate) - { - if (--_activePermits == 0) - drained = _permitsDrained; - } - drained?.TrySetResult(true); - SharpLinkTelemetry.AddAdmissionActivePermits(-1); - } - - internal int ActivePermits => Volatile.Read(ref _activePermits); - internal int QueuedCalls => Volatile.Read(ref _queuedCalls); - internal long QueuedBytes => Volatile.Read(ref _queuedBytes); + internal int ActivePermits => _kernel.ActivePermits; + internal int QueuedCalls => _kernel.QueuedCalls; + internal long QueuedBytes => _kernel.QueuedBytes; internal int ActivePartitions => _partitions?.Count ?? 0; internal bool QueueOneWayCalls => _queueOneWayCalls; - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - return; - StopAccepting(); - while (true) - { - Task queueDrained; - Task permitsDrained; - lock (_queueGate) - { - queueDrained = _queueDrained.Task; - permitsDrained = _permitsDrained.Task; - } - await Task.WhenAll(queueDrained, permitsDrained).ConfigureAwait(false); - lock (_queueGate) - { - if (_queuedCalls == 0 && _activePermits == 0) - break; - } - } - _global?.Dispose(); - foreach (var rule in _contracts.Values) - rule.Dispose(); - foreach (var rule in _methods.Values) - rule.Dispose(); - _partitions?.Dispose(); - _draining.Dispose(); - await ValueTask.CompletedTask; - } - - private static TaskCompletionSource CompletedSignal() - { - var signal = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - signal.SetResult(true); - return signal; - } + public ValueTask DisposeAsync() + => _ownsKernel ? _kernel.DisposeAsync() : ValueTask.CompletedTask; } internal readonly record struct AdmissionDecision( @@ -484,13 +470,13 @@ internal static AdmissionDecision Reject(string reason, SharpLinkErrorCode error internal sealed class AdmissionLease : IDisposable { - private SharpLinkAdmissionController? _owner; + private AdmissionStateKernel? _owner; private RateLimitLease? _singleLease; private RateLimitLease[]? _leases; private AdmissionPartitionLease? _partition; internal AdmissionLease( - SharpLinkAdmissionController owner, + AdmissionStateKernel owner, RateLimitLease singleLease, AdmissionPartitionLease? partition) { @@ -501,7 +487,7 @@ internal AdmissionLease( } internal AdmissionLease( - SharpLinkAdmissionController owner, + AdmissionStateKernel owner, RateLimitLease[] leases, AdmissionPartitionLease? partition) { @@ -538,26 +524,21 @@ internal sealed class AdmissionRequest( HasRetainedSlot(slots, slotCount) ? new RateLimitLease?[slotCount] : null; internal bool TryAcquire( - SharpLinkAdmissionController owner, + AdmissionStateKernel owner, out AdmissionLease? admissionLease, out AdmissionLimiterSlot failedSlot) => TryAcquireCore(owner, null, null, out admissionLease, out failedSlot); internal bool TryAcquireUsing( - SharpLinkAdmissionController owner, + AdmissionStateKernel owner, RateLimiter suppliedLimiter, RateLimitLease suppliedLease, out AdmissionLease? admissionLease, out AdmissionLimiterSlot failedSlot) - => TryAcquireCore( - owner, - suppliedLimiter, - suppliedLease, - out admissionLease, - out failedSlot); + => TryAcquireCore(owner, suppliedLimiter, suppliedLease, out admissionLease, out failedSlot); private bool TryAcquireCore( - SharpLinkAdmissionController owner, + AdmissionStateKernel owner, RateLimiter? suppliedLimiter, RateLimitLease? suppliedLease, out AdmissionLease? admissionLease, @@ -662,6 +643,7 @@ internal readonly record struct AdmissionLimiterSlot( string Reason, bool RetainOnFailure); +/// Kernel-owned mutable limiter state for one explicit structural rule identity. internal sealed class AdmissionRuleRuntime : IDisposable { private readonly AdmissionLimiterSlot[] _slots; @@ -741,6 +723,7 @@ public void Dispose() } } +/// Kernel-owned partition namespace/state shared by compatible program generations. internal sealed class AdmissionPartitionPool : IDisposable { private readonly Func _selector; @@ -800,7 +783,8 @@ internal void Release(AdmissionPartitionEntry entry) List? evicted; lock (_gate) { - entry.References--; + if (--entry.References < 0) + throw new InvalidOperationException("Admission partition reference count underflowed."); if (entry.References == 0) { entry.IdleSince = _timeProvider.GetTimestamp(); From 4ebbef7ad819ca62c630cf7f68d7f9b8b2c514ed Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:27:11 +0800 Subject: [PATCH 195/228] refactor: compose stable admission lifecycle owner --- src/SharpLink.Server/ServerRuntimeComposition.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/SharpLink.Server/ServerRuntimeComposition.cs b/src/SharpLink.Server/ServerRuntimeComposition.cs index 2728865a1..0916baa63 100644 --- a/src/SharpLink.Server/ServerRuntimeComposition.cs +++ b/src/SharpLink.Server/ServerRuntimeComposition.cs @@ -26,7 +26,7 @@ internal ServerRuntimeComposition( ServerServiceCleanup serviceCleanup, IServiceProvider serviceProvider, IReadOnlyList staticManifests, - SharpLinkAdmissionController? admissionController, + SharpLinkAdmissionController admissionController, ServerConnectionAdmission connectionAdmission, ServerShutdownPlan shutdownPlan, FrameworkTaskSupervisor frameworkTasks) @@ -47,6 +47,7 @@ internal ServerRuntimeComposition( ArgumentNullException.ThrowIfNull(staticManifests); ShutdownPlan = shutdownPlan ?? throw new ArgumentNullException(nameof(shutdownPlan)); FrameworkTasks = frameworkTasks ?? throw new ArgumentNullException(nameof(frameworkTasks)); + AdmissionController = admissionController ?? throw new ArgumentNullException(nameof(admissionController)); _interceptors = [.. interceptors]; for (var index = 0; index < staticManifests.Count; index++) @@ -57,7 +58,7 @@ internal ServerRuntimeComposition( Authenticator = authenticator; AuthenticationRequired = authenticationRequired; RpcSessionFlushOptions = rpcSessionFlushOptions; - AdmissionProgram = admissionController is null ? null : new AdmissionProgram(admissionController); + AdmissionProgram = admissionController.IsEnabled ? new AdmissionProgram(admissionController) : null; ConnectionAdmission = connectionAdmission ?? throw new ArgumentNullException(nameof(connectionAdmission)); } @@ -93,7 +94,7 @@ internal ServerRuntimeComposition( internal AdmissionProgram? AdmissionProgram { get; } - internal SharpLinkAdmissionController? AdmissionController => AdmissionProgram?.Controller; + internal SharpLinkAdmissionController AdmissionController { get; } internal ServerConnectionAdmission ConnectionAdmission { get; } From fd12f71dd7460f6f22086a32f2f84427f96c31a8 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:27:42 +0800 Subject: [PATCH 196/228] refactor: retire and retry admission publication capture --- .../SharpLinkServer.AdmissionProgram.cs | 102 +++++++++++++----- 1 file changed, 78 insertions(+), 24 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs index 5ee6ae693..111eeaedc 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs @@ -2,10 +2,21 @@ namespace SharpLink.Server; internal sealed partial class SharpLinkServer { + private static Action? s_afterAdmissionPublicationReadForTests; private static Action? s_afterAdmissionCaptureForTests; private AdmissionProgram _admissionProgram = AdmissionProgram.Uninitialized; + /// + /// Deterministic stale-read probe. It runs after the current publication pointer is read and + /// before TryAcquireUse performs the retired-bit/use-count CAS. + /// + internal static Action? AfterAdmissionPublicationReadForTests + { + get => Volatile.Read(ref s_afterAdmissionPublicationReadForTests); + set => Volatile.Write(ref s_afterAdmissionPublicationReadForTests, value); + } + internal static Action? AfterAdmissionCaptureForTests { get => Volatile.Read(ref s_afterAdmissionCaptureForTests); @@ -21,38 +32,83 @@ internal AdmissionProgram? CurrentAdmissionProgramForTests } } - internal AdmissionProgram? OwnedAdmissionProgramForTests - => _admissionController is null - ? null - : AdmissionProgram.FromController(_admissionController); + internal AdmissionProgram? OwnedAdmissionProgramForTests => _admissionController?.Program; + + internal AdmissionStateKernel? AdmissionStateKernelForTests => _admissionController?.Kernel; + + internal AdmissionProgram CreateAdmissionProgramForTests( + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var controller = _admissionController ?? + throw new InvalidOperationException("Server admission lifecycle owner is unavailable."); + var options = new SharpLinkAdmissionControlOptions(); + configure(options); + options.Validate(); + return controller.Kernel.CreateProgram(options, _staticManifests); + } internal AdmissionProgram? PublishAdmissionProgramForTests(AdmissionProgram? program) { - var replacement = program ?? AdmissionProgram.Disabled; - var previous = Interlocked.Exchange(ref _admissionProgram, replacement); - if (ReferenceEquals(previous, AdmissionProgram.Uninitialized)) + var lifecycle = _admissionController ?? + throw new InvalidOperationException("Server admission lifecycle owner is unavailable."); + if (program is not null && !ReferenceEquals(program.Kernel, lifecycle.Kernel)) + throw new InvalidOperationException("Admission program belongs to a different server state kernel."); + if (program is { IsRetired: true }) + throw new InvalidOperationException("A retired admission program cannot be published again."); + + AdmissionProgram previous; + lock (_registryGate) { - previous = _admissionController is null - ? AdmissionProgram.Disabled - : AdmissionProgram.FromController(_admissionController); + if (CurrentState is ServerState.Draining or ServerState.Stopped or ServerState.Faulted) + { + program?.Retire(); + throw new InvalidOperationException("Admission publication is sealed because the server is stopping."); + } + + var replacement = program ?? AdmissionProgram.Disabled; + previous = ReadAdmissionPublication(); + if (ReferenceEquals(previous, replacement)) + return previous.IsEnabled ? previous : null; + Volatile.Write(ref _admissionProgram, replacement); + if (previous.IsEnabled) + previous.Retire(); } return previous.IsEnabled ? previous : null; } private AdmissionProgram? CaptureAdmissionProgram(long requestId) { - var publication = ReadAdmissionPublication(); - var program = publication.IsEnabled ? publication : null; - program?.AcquireUse(); - try - { - Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, program); - return program; - } - catch + while (true) { - program?.ReleaseUse(); - throw; + var publication = ReadAdmissionPublication(); + if (!publication.IsEnabled) + { + Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, null); + return null; + } + + Volatile.Read(ref s_afterAdmissionPublicationReadForTests)?.Invoke(this, requestId, publication); + if (!publication.TryAcquireUse()) + { + // Shutdown retires every live program after the server state has been sealed. The + // publication pointer may still name that retired object, but no admitted Request + // may attach to it and there is no reason to spin once shutdown cancellation is live. + if (_admissionController?.Kernel.IsDraining == true) + return null; + continue; + } + + try + { + Volatile.Read(ref s_afterAdmissionCaptureForTests)?.Invoke(this, requestId, publication); + return publication; + } + catch + { + publication.ReleaseUse(); + throw; + } } } @@ -62,9 +118,7 @@ private AdmissionProgram ReadAdmissionPublication() if (!ReferenceEquals(publication, AdmissionProgram.Uninitialized)) return publication; - var initial = _admissionController is null - ? AdmissionProgram.Disabled - : AdmissionProgram.FromController(_admissionController); + var initial = _admissionController?.Program ?? AdmissionProgram.Disabled; var observed = Interlocked.CompareExchange( ref _admissionProgram, initial, From a4681c0255a13dffdd5c31bd154d249ba12ba722 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:28:44 +0800 Subject: [PATCH 197/228] refactor: always materialize server admission state kernel --- src/SharpLink.Server/SharpLinkServerBuilder.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServerBuilder.cs b/src/SharpLink.Server/SharpLinkServerBuilder.cs index d840ae24c..8216eb8dc 100644 --- a/src/SharpLink.Server/SharpLinkServerBuilder.cs +++ b/src/SharpLink.Server/SharpLinkServerBuilder.cs @@ -553,18 +553,16 @@ private ISharpLinkServer Materialize(ServerBuildPlan plan) metadata: SynchronousBuildResourceMetadata.CallerOwned("Server caller service provider")); } - SharpLinkAdmissionController? admissionController = null; var staticManifests = plan.CreateStaticManifestSnapshot(); - if (plan.AdmissionControlOptions is not null) - { - admissionController = transaction.Own( - SharpLinkAdmissionController.Create( - plan.AdmissionControlOptions, + var admissionController = transaction.Own( + plan.AdmissionControlOptions is { } admissionOptions + ? SharpLinkAdmissionController.Create( + admissionOptions, staticManifests, - runtimeContext.TimeProvider), - static controller => SharpLinkAsyncCleanup.DisposeSynchronously(controller), - SynchronousBuildResourceMetadata.FrameworkOwned("Server admission controller")); - } + runtimeContext.TimeProvider) + : SharpLinkAdmissionController.CreateDisabled(runtimeContext.TimeProvider), + static controller => SharpLinkAsyncCleanup.DisposeSynchronously(controller), + SynchronousBuildResourceMetadata.FrameworkOwned("Server admission state kernel")); var registrationsByContract = new Dictionary(plan.ServiceCount); for (var index = 0; index < plan.ServiceCount; index++) From 6601fea36bb06fa05c4e747a80d51ea0a4229f1c Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:32:10 +0800 Subject: [PATCH 198/228] test: adapt admission request owner assertions to kernel --- .../Server/AdmissionRequestTestExtensions.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/AdmissionRequestTestExtensions.cs diff --git a/test/SharpLink.UnitTests/Server/AdmissionRequestTestExtensions.cs b/test/SharpLink.UnitTests/Server/AdmissionRequestTestExtensions.cs new file mode 100644 index 000000000..dba7657dd --- /dev/null +++ b/test/SharpLink.UnitTests/Server/AdmissionRequestTestExtensions.cs @@ -0,0 +1,28 @@ +using System.Threading.RateLimiting; +using SharpLink.Server; + +namespace SharpLink.UnitTests.Server; + +internal static class AdmissionRequestTestExtensions +{ + internal static bool TryAcquire( + this AdmissionRequest request, + SharpLinkAdmissionController owner, + out AdmissionLease? admissionLease, + out AdmissionLimiterSlot failedSlot) + => request.TryAcquire(owner.Kernel, out admissionLease, out failedSlot); + + internal static bool TryAcquireUsing( + this AdmissionRequest request, + SharpLinkAdmissionController owner, + RateLimiter suppliedLimiter, + RateLimitLease suppliedLease, + out AdmissionLease? admissionLease, + out AdmissionLimiterSlot failedSlot) + => request.TryAcquireUsing( + owner.Kernel, + suppliedLimiter, + suppliedLease, + out admissionLease, + out failedSlot); +} From e0d0659e315168c98ea9b6c604b0c70f650f12b5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:32:24 +0800 Subject: [PATCH 199/228] fix: close admission program registration shutdown race --- src/SharpLink.Server/Admission/AdmissionProgram.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs index a9f034a74..73f892405 100644 --- a/src/SharpLink.Server/Admission/AdmissionProgram.cs +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -30,6 +30,12 @@ internal AdmissionProgram(SharpLinkAdmissionController controller) GenerationId = Interlocked.Increment(ref s_nextGenerationId); controller.AttachProgram(this); _kernel.RegisterProgram(this); + + // Close the narrow CreateProgram-vs-Stop race where shutdown seals the kernel after the + // caller's pre-check but before this program registers. Stop either observes this program + // in its registry snapshot, or this post-registration check retires it itself. + if (_kernel.IsDraining) + Retire(); } internal static AdmissionProgram Uninitialized { get; } = new(long.MinValue); From f258179b53c27004a9e4eb07b908cde48a7fb47f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:33:25 +0800 Subject: [PATCH 200/228] test: cover admission kernel state reuse and reclamation --- .../Server/AdmissionStateKernelTests.cs | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/AdmissionStateKernelTests.cs diff --git a/test/SharpLink.UnitTests/Server/AdmissionStateKernelTests.cs b/test/SharpLink.UnitTests/Server/AdmissionStateKernelTests.cs new file mode 100644 index 000000000..c414cee9c --- /dev/null +++ b/test/SharpLink.UnitTests/Server/AdmissionStateKernelTests.cs @@ -0,0 +1,311 @@ +using SharpLink.Server; + +namespace SharpLink.UnitTests.Server; + +public sealed class AdmissionStateKernelTests +{ + [Test] + public async Task PreRetireUseShouldRemainValidAndReclaimExactlyOnceOnLastRelease() + { + await using var kernel = new AdmissionStateKernel(TimeProvider.System); + var program = CreateProgram(kernel, options => options.Global.UseConcurrency(1)); + + Ensure(program.TryAcquireUse(), "current generation must acquire its pre-retire use"); + Ensure(program.Retire(), "first retirement must win"); + Ensure(program.IsRetired && !program.IsReclaimed && program.ActiveUses == 1, + "retirement must preserve the existing use until its terminal release"); + Ensure(!program.TryAcquireUse(), "retired generation must reject every new use"); + Ensure(!program.Retire(), "duplicate retirement must be idempotent"); + + program.ReleaseUse(); + + Ensure(program.IsReclaimed && program.ReclaimCount == 1, + "last release must reclaim the retired generation exactly once"); + kernel.TryReclaimProgram(program); + Ensure(program.ReclaimCount == 1 && kernel.RetiredProgramCount == 0 && kernel.LiveProgramCount == 0, + "duplicate reclaim attempts must not double-reclaim or retain history"); + } + + [Test] + public async Task CompatibleGlobalConcurrencyShouldReuseStateAndConstrainNextGeneration() + { + await using var kernel = new AdmissionStateKernel(TimeProvider.System); + var original = CreateProgram(kernel, options => options.Global.UseConcurrency(1)); + var held = await original.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired, "generation N must hold the sole global permit"); + + var replacement = CreateProgram(kernel, options => options.Global.UseConcurrency(1)); + Ensure(ReferenceEquals( + original.Controller.GlobalStateForTests, + replacement.Controller.GlobalStateForTests), + "identical global concurrency structure must reuse one mutable state object"); + Ensure(kernel.RuleStateCount == 1 && kernel.ActivePermits == 1, + "compatible overlap must not duplicate limiter state or permit accounting"); + + original.Retire(); + var blocked = await replacement.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(!blocked.IsAcquired && blocked.Reason == "concurrency", + "an active permit acquired under N must constrain compatible N+1"); + + held.Lease!.Dispose(); + var admitted = await replacement.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(admitted.IsAcquired, "N+1 must acquire after the shared N permit releases"); + admitted.Lease!.Dispose(); + Ensure(kernel.ActivePermits == 0, "shared active-permit accounting must drain to zero"); + replacement.Retire(); + } + + [Test] + public async Task CompatibleContractAndMethodRulesShouldReuseStableIdentityState() + { + await using var kernel = new AdmissionStateKernel(TimeProvider.System); + var original = CreateScopedProgram(kernel); + var replacement = CreateScopedProgram(kernel); + + Ensure(ReferenceEquals( + original.Controller.ContractStateForTests(101), + replacement.Controller.ContractStateForTests(101)), + "contract state identity must be stable contract ID plus limiter structure"); + Ensure(ReferenceEquals( + original.Controller.MethodStateForTests(101, 202), + replacement.Controller.MethodStateForTests(101, 202)), + "method state identity must be stable contract/method IDs plus limiter structure"); + Ensure(kernel.RuleStateCount == 3, + "global, contract, and method identities must each have one shared state entry"); + + original.Retire(); + replacement.Retire(); + Ensure(kernel.RuleStateCount == 0, + "shared static rule state must be reclaimed when no generation references it"); + } + + [Test] + public async Task CompatibleRateStateShouldNotResetConsumedQuota() + { + await using var kernel = new AdmissionStateKernel(TimeProvider.System); + var original = CreateProgram(kernel, ConfigureRate); + var first = await original.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(first.IsAcquired, "generation N must consume the only current rate token"); + first.Lease!.Dispose(); + + var replacement = CreateProgram(kernel, ConfigureRate); + Ensure(ReferenceEquals( + original.Controller.GlobalStateForTests, + replacement.Controller.GlobalStateForTests), + "compatible rate policy must reuse one rate-limiter state object"); + original.Retire(); + + var exhausted = await replacement.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(!exhausted.IsAcquired && exhausted.Reason == "rate", + "publication replacement must not reset already-consumed rate quota"); + replacement.Retire(); + } + + [Test] + public async Task CompatiblePartitionPolicyShouldReuseNamespaceAndActivePartitionState() + { + await using var kernel = new AdmissionStateKernel(TimeProvider.System); + Func selector = static _ => "tenant-a"; + var original = CreateProgram(kernel, options => ConfigurePartition(options, selector)); + var held = await original.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired && original.Controller.ActivePartitions == 1, + "generation N must materialize one tenant partition"); + + var replacement = CreateProgram(kernel, options => ConfigurePartition(options, selector)); + Ensure(ReferenceEquals( + original.Controller.PartitionStateForTests, + replacement.Controller.PartitionStateForTests), + "compatible partition generations must share one namespace/pool"); + Ensure(kernel.PartitionStateCount == 1 && replacement.Controller.ActivePartitions == 1, + "compatible publication must not duplicate active partition state"); + original.Retire(); + + var blocked = await replacement.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(!blocked.IsAcquired && blocked.Reason == "concurrency", + "partition permit acquired under N must constrain N+1 in the same namespace"); + held.Lease!.Dispose(); + replacement.Retire(); + } + + [Test] + [Arguments(1, 8, "queue_count")] + [Arguments(2, 3, "queue_bytes")] + public async Task OverlappingGenerationsShouldShareQueueBoundsAndRetainedBytes( + int maxQueuedCalls, + long maxQueuedBytes, + string expectedReason) + { + await using var kernel = new AdmissionStateKernel(TimeProvider.System); + var original = CreateProgram(kernel, options => ConfigureQueue( + options, maxQueuedCalls, maxQueuedBytes)); + var held = await original.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired, "generation N must hold the shared concurrency permit"); + + var queued = original.Controller.AcquireAsync( + CreateContext(), retainedBytes: 2, allowQueue: true, CancellationToken.None).AsTask(); + await WaitUntilAsync(() => kernel.QueuedCalls == 1, + "generation N request enters shared queue accounting"); + Ensure(kernel.QueuedBytes == 2, "queued retained bytes must be owned by the stable kernel"); + + var replacement = CreateProgram(kernel, options => ConfigureQueue( + options, maxQueuedCalls, maxQueuedBytes)); + original.Retire(); + Ensure(original.IsReclaimed, + "ordinary retirement may reclaim the policy publication while shared state stays alive for N+1"); + + var rejected = await replacement.Controller.AcquireAsync( + CreateContext(), retainedBytes: 2, allowQueue: true, CancellationToken.None); + Ensure(!rejected.IsAcquired && rejected.Reason == expectedReason, + "N and N+1 must enforce one server-wide queue count/byte budget"); + Ensure(kernel.QueuedCalls == 1 && kernel.QueuedBytes == 2, + "rejected N+1 enqueue must not perturb N queue accounting"); + + held.Lease!.Dispose(); + var admitted = await queued.WaitAsync(TimeSpan.FromSeconds(2)); + Ensure(admitted.IsAcquired, "old-generation queued work must survive ordinary retirement without disposal"); + admitted.Lease!.Dispose(); + await WaitUntilAsync( + () => kernel.QueuedCalls == 0 && kernel.QueuedBytes == 0 && kernel.ActivePermits == 0, + "shared queue, retained bytes, and permits drain after old-generation completion"); + replacement.Retire(); + } + + [Test] + public async Task RepeatedCompatibleGenerationCyclesShouldKeepRegistryBounded() + { + await using var kernel = new AdmissionStateKernel(TimeProvider.System); + var current = CreateProgram(kernel, options => options.Global.UseConcurrency(4)); + var shared = current.Controller.GlobalStateForTests; + + for (var index = 0; index < 64; index++) + { + var next = CreateProgram(kernel, options => options.Global.UseConcurrency(4)); + Ensure(ReferenceEquals(shared, next.Controller.GlobalStateForTests), + "identical republish must keep reusing the original static state entry"); + current.Retire(); + current = next; + Ensure(kernel.LiveProgramCount == 1 && kernel.RetiredProgramCount == 0, + "retired generation history must be reclaimed each cycle"); + Ensure(kernel.RuleStateCount == 1, + "identical republish must not grow the static state registry"); + } + + current.Retire(); + Ensure(kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0 && kernel.RuleStateCount == 0, + "final retirement must leave no generation history or unreferenced compatible state"); + } + + [Test] + public async Task IncompatibleStateShouldRemainUntilRetiredUseReleasesThenReclaim() + { + await using var kernel = new AdmissionStateKernel(TimeProvider.System); + var original = CreateProgram(kernel, options => options.Global.UseConcurrency(1)); + Ensure(original.TryAcquireUse(), "test must hold one generation-N use"); + var replacement = CreateProgram(kernel, options => options.Global.UseConcurrency(2)); + Ensure(!ReferenceEquals( + original.Controller.GlobalStateForTests, + replacement.Controller.GlobalStateForTests), + "incompatible limiter structure must not alias mutable state"); + Ensure(kernel.RuleStateCount == 2, "overlapping incompatible structures require two bounded entries"); + + original.Retire(); + Ensure(!original.IsReclaimed && kernel.RetiredProgramCount == 1 && kernel.RuleStateCount == 2, + "retired generation and its incompatible state must stay alive while one use remains"); + original.ReleaseUse(); + Ensure(original.IsReclaimed && kernel.RetiredProgramCount == 0 && kernel.RuleStateCount == 1, + "last use must reclaim the retired generation and its unreferenced incompatible state"); + + replacement.Retire(); + Ensure(kernel.RuleStateCount == 0, "replacement state must eventually reclaim too"); + } + + [Test] + public async Task EmptyKernelShouldHaveNoProgramOrAccountingState() + { + await using var kernel = new AdmissionStateKernel(TimeProvider.System); + Ensure(kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0, + "disabled admission must not create generation refcount state"); + Ensure(kernel.RuleStateCount == 0 && kernel.PartitionStateCount == 0, + "disabled admission must not materialize limiter or partition registry state"); + Ensure(kernel.QueuedCalls == 0 && kernel.QueuedBytes == 0 && kernel.ActivePermits == 0, + "disabled admission kernel must have zero request accounting"); + } + + private static AdmissionProgram CreateProgram( + AdmissionStateKernel kernel, + Action configure) + { + var options = new SharpLinkAdmissionControlOptions(); + configure(options); + options.Validate(); + return kernel.CreateProgram(options, []); + } + + private static AdmissionProgram CreateScopedProgram(AdmissionStateKernel kernel) + => CreateProgram(kernel, options => + { + options.Global.UseConcurrency(4); + options.AddContract(101, rule => rule.UseConcurrency(3)); + options.AddMethod(101, 202, rule => rule.UseConcurrency(2)); + }); + + private static void ConfigureRate(SharpLinkAdmissionControlOptions options) + => options.Global.UseTokenBucket(rate => + { + rate.TokenLimit = 1; + rate.TokensPerPeriod = 1; + rate.ReplenishmentPeriod = TimeSpan.FromHours(1); + }); + + private static void ConfigurePartition( + SharpLinkAdmissionControlOptions options, + Func selector) + => options.UsePartition(selector, partition => + { + partition.MaxPartitions = 8; + partition.IdleTimeout = TimeSpan.FromHours(1); + partition.UseConcurrency(1); + }); + + private static void ConfigureQueue( + SharpLinkAdmissionControlOptions options, + int maxQueuedCalls, + long maxQueuedBytes) + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = maxQueuedCalls; + options.MaxQueuedBytes = maxQueuedBytes; + options.MaxQueueDelay = TimeSpan.FromSeconds(5); + } + + private static SharpLinkAdmissionContext CreateContext() + => new(101, 202, RpcMethodKind.Unary, "kernel-test", null, null, null); + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } +} From 3f761265ee12f0466cb51820bec72c85dede7be3 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:34:50 +0800 Subject: [PATCH 201/228] test: keep generation regression on server-scoped kernel --- .../DynamicAdmissionGenerationTests.cs | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs index 6ce21ab08..9bb821573 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs @@ -76,8 +76,8 @@ public async Task EnabledCaptureShouldRemainOnGenerationNWhenCurrentBecomesNPlus var held = await original.Controller.AcquireAsync( CreateAdmissionContext(), 1, allowQueue: false, CancellationToken.None); Ensure(held.IsAcquired, "test must occupy generation N"); - var replacementController = CreateController(options => options.Global.UseConcurrency(1)); - var replacement = new AdmissionProgram(replacementController); + var replacement = harness.Server.CreateAdmissionProgramForTests( + options => options.Global.UseConcurrency(2)); AdmissionProgram? captured = null; var hookCount = 0; @@ -111,9 +111,8 @@ await WaitUntilAsync(() => original.ActiveUses == 0 && replacement.ActiveUses == finally { SharpLinkServer.AfterAdmissionCaptureForTests = null; - harness.Server.PublishAdmissionProgramForTests(original); + harness.Server.PublishAdmissionProgramForTests(null); held.Lease?.Dispose(); - await replacementController.DisposeAsync(); } } @@ -125,9 +124,9 @@ public async Task DisabledCaptureShouldRemainDisabledWhenCurrentBecomesEnabled(b { TestService.ResetNotify(); await using var harness = await Harness.CreateAsync(); - var replacementController = CreateController(options => options.Global.UseConcurrency(1)); - var replacement = new AdmissionProgram(replacementController); - var held = await replacementController.AcquireAsync( + var replacement = harness.Server.CreateAdmissionProgramForTests( + options => options.Global.UseConcurrency(1)); + var held = await replacement.Controller.AcquireAsync( CreateAdmissionContext(), 1, allowQueue: false, CancellationToken.None); Ensure(held.IsAcquired, "test must occupy the replacement enabled generation"); AdmissionProgram? captured = replacement; @@ -173,7 +172,6 @@ await WaitUntilAsync(() => replacement.ActiveUses == 0, SharpLinkServer.AfterAdmissionCaptureForTests = null; harness.Server.PublishAdmissionProgramForTests(null); held.Lease?.Dispose(); - await replacementController.DisposeAsync(); } } @@ -511,14 +509,6 @@ public async Task SuccessfulTerminalCompletionShouldReleaseGenerationExactlyOnce await AssertProgramReleasedAsync(program, "successful request terminal cleanup"); } - private static SharpLinkAdmissionController CreateController( - Action configure) - { - var options = new SharpLinkAdmissionControlOptions(); - configure(options); - return SharpLinkAdmissionController.Create(options, []); - } - private static SharpLinkAdmissionContext CreateAdmissionContext() => new(1, 2, RpcMethodKind.Unary, "generation-test", null, null, null); From a6d62059cffa31578f5c189d158a4d5a2943d103 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:35:09 +0800 Subject: [PATCH 202/228] test: expose allocation-free admission capture probe --- src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs index 111eeaedc..f0130749b 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs @@ -36,6 +36,9 @@ internal AdmissionProgram? CurrentAdmissionProgramForTests internal AdmissionStateKernel? AdmissionStateKernelForTests => _admissionController?.Kernel; + internal AdmissionProgram? CaptureAdmissionProgramForTests(long requestId = 0) + => CaptureAdmissionProgram(requestId); + internal AdmissionProgram CreateAdmissionProgramForTests( Action configure) { From dee56b3f633edba04c1164ceee8bc0dd25ca4f78 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:36:07 +0800 Subject: [PATCH 203/228] test: cover admission capture retire and stop races --- ...micAdmissionStateKernelIntegrationTests.cs | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs new file mode 100644 index 000000000..775acf41d --- /dev/null +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionStateKernelIntegrationTests.cs @@ -0,0 +1,485 @@ +namespace SharpLink.IntegrationTests; + +public sealed class DynamicAdmissionStateKernelIntegrationTests +{ + [Test] + [NotInParallel] + public async Task StalePublicationReadShouldRetryAndNeverAttachRetiredGeneration() + { + await using var harness = await Harness.CreateAsync( + options => options.Global.UseConcurrency(1)); + var original = harness.Server.OwnedAdmissionProgramForTests!; + var replacement = harness.Server.CreateAdmissionProgramForTests( + options => options.Global.UseConcurrency(2)); + AdmissionProgram? captured = null; + var readHookCount = 0; + + try + { + SharpLinkServer.AfterAdmissionPublicationReadForTests = (server, _, observed) => + { + if (!ReferenceEquals(server, harness.Server) || + !ReferenceEquals(observed, original) || + Interlocked.Exchange(ref readHookCount, 1) != 0) + { + return; + } + server.PublishAdmissionProgramForTests(replacement); + }; + SharpLinkServer.AfterAdmissionCaptureForTests = (server, _, observed) => + { + if (ReferenceEquals(server, harness.Server)) + captured = observed; + }; + + Ensure(await harness.Client.Get().AddAsync(20, 22) == 42, + "request must retry to N+1 after its stale N read loses the retire/use CAS race"); + await WaitUntilAsync(() => original.IsReclaimed && replacement.ActiveUses == 0, + "stale N publication reclaims and N+1 request releases its use"); + Ensure(ReferenceEquals(captured, replacement), + "stale read must never attach a new use to retired generation N"); + Ensure(original.ActiveUses == 0 && original.ReclaimCount == 1, + "retired stale generation must have no post-retire users and reclaim exactly once"); + } + finally + { + SharpLinkServer.AfterAdmissionPublicationReadForTests = null; + SharpLinkServer.AfterAdmissionCaptureForTests = null; + TryDisableAdmission(harness.Server); + } + } + + [Test] + [NotInParallel] + public async Task DisabledCaptureShouldRemainAllocationAndRefcountFree() + { + await using var harness = await Harness.CreateAsync(); + var kernel = harness.Server.AdmissionStateKernelForTests!; + Ensure(harness.Server.CaptureAdmissionProgramForTests() is null, + "disabled capture warmup must return the disabled sentinel without a generation use"); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var index = 0; index < 4096; index++) + { + if (harness.Server.CaptureAdmissionProgramForTests(index) is not null) + throw new Exception("assert failed: disabled capture unexpectedly produced a program"); + } + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Ensure(allocated == 0, + $"disabled capture fast path must allocate zero bytes; observed {allocated}"); + Ensure(kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 0 && kernel.PartitionStateCount == 0, + "disabled capture must not create generation refcounts or mutable admission state"); + Ensure(await harness.Client.Get().AddAsync(20, 22) == 42, + "disabled request path remains functional"); + Ensure(kernel.LiveProgramCount == 0 && kernel.QueuedCalls == 0 && + kernel.QueuedBytes == 0 && kernel.ActivePermits == 0, + "disabled request path must leave admission accounting untouched"); + } + + [Test] + [NotInParallel] + public async Task QueuedOneWayShouldUseCapturedPolicyAcrossCompatiblePublication() + { + TestService.ResetBlockingAdd(); + TestService.ResetNotify(); + await using var harness = await Harness.CreateAsync(ConfigureQueuedOneWay); + var original = harness.Server.OwnedAdmissionProgramForTests!; + var replacement = harness.Server.CreateAdmissionProgramForTests(options => + { + ConfigureQueuedOneWay(options); + options.QueueOneWayCalls = false; + }); + var service = harness.Client.Get(); + var active = service.BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + var hookCount = 0; + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + SharpLinkServer.AfterAdmissionCaptureForTests = (server, _, observed) => + { + if (!ReferenceEquals(server, harness.Server) || + !ReferenceEquals(observed, original) || + Interlocked.Exchange(ref hookCount, 1) != 0) + { + return; + } + server.PublishAdmissionProgramForTests(replacement); + }; + + await service.NotifyAsync("captured-queue-one-way"); + await WaitUntilAsync(() => original.Kernel.QueuedCalls == 1, + "one-way request captured under N must queue under N policy after N+1 publication"); + Ensure(TestService.NotifyCount == 0, + "captured queue-one-way request must not be reinterpreted by N+1 QueueOneWayCalls=false"); + Ensure(ReferenceEquals( + original.Controller.GlobalStateForTests, + replacement.Controller.GlobalStateForTests), + "policy-only QueueOneWay change must still share compatible limiter state"); + + TestService.ReleaseBlockingAdd(); + Ensure(await active.WaitAsync(TimeSpan.FromSeconds(5)) == 2, + "active owner completes and releases shared permit"); + await TestService.WaitForNotifyAsync().WaitAsync(TimeSpan.FromSeconds(5)); + await WaitUntilAsync( + () => original.Kernel.QueuedCalls == 0 && original.Kernel.QueuedBytes == 0 && + original.Kernel.ActivePermits == 0, + "captured one-way queue accounting drains through stable kernel"); + Ensure(TestService.NotifyCount == 1, + "captured N one-way request executes exactly once"); + } + finally + { + SharpLinkServer.AfterAdmissionCaptureForTests = null; + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + TryDisableAdmission(harness.Server); + } + } + + [Test] + [NotInParallel] + public async Task StopRacingStaleCaptureShouldNotAttachRetiredProgram() + { + await using var harness = await Harness.CreateAsync( + options => options.Global.UseConcurrency(2)); + var original = harness.Server.OwnedAdmissionProgramForTests!; + var kernel = original.Kernel; + Task? stopTask = null; + var hookCount = 0; + + try + { + SharpLinkServer.AfterAdmissionPublicationReadForTests = (server, _, observed) => + { + if (!ReferenceEquals(server, harness.Server) || + !ReferenceEquals(observed, original) || + Interlocked.Exchange(ref hookCount, 1) != 0) + { + return; + } + stopTask = server.StopAsync(TimeSpan.Zero).AsTask(); + Ensure(kernel.IsDraining, "Stop must seal and cancel admission before stale capture resumes"); + }; + + var failure = await CaptureFailureAsync( + harness.Client.Get().AddAsync(20, 22).AsTask()); + Ensure(failure is not ObjectDisposedException, + "Stop-vs-capture must terminate through controlled shutdown, never disposed limiter state"); + Ensure(stopTask is not null, "deterministic capture hook must start Stop"); + await stopTask!.WaitAsync(TimeSpan.FromSeconds(5)); + Ensure(original.IsRetired && original.IsReclaimed && original.ActiveUses == 0, + "stale capture must not add a use after Stop retires the publication"); + AssertKernelDrained(kernel, "Stop-vs-capture"); + } + finally + { + SharpLinkServer.AfterAdmissionPublicationReadForTests = null; + } + } + + [Test] + [NotInParallel] + public async Task StopSealShouldRejectPublicationAndRetireUnpublishedCandidate() + { + await using var harness = await Harness.CreateAsync( + options => options.Global.UseConcurrency(1)); + var original = harness.Server.OwnedAdmissionProgramForTests!; + var candidate = harness.Server.CreateAdmissionProgramForTests( + options => options.Global.UseConcurrency(2)); + var kernel = original.Kernel; + + var stopTask = harness.Server.StopAsync(TimeSpan.Zero).AsTask(); + await WaitUntilAsync(() => kernel.IsDraining, + "Stop seals admission publication/control plane"); + + var publicationFailure = CaptureSynchronousFailure( + () => harness.Server.PublishAdmissionProgramForTests(candidate)); + Ensure(publicationFailure is InvalidOperationException, + "no admission publication may succeed after Stop seals the control plane"); + await stopTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Ensure(original.IsRetired && original.ReclaimCount == 1, + "Stop must retire and reclaim the current program exactly once"); + Ensure(candidate.IsRetired && candidate.ReclaimCount == 1, + "Stop must also retire an already-built live candidate exactly once"); + AssertKernelDrained(kernel, "Stop-vs-publication"); + } + + [Test] + [NotInParallel] + public async Task StopShouldWaitForActiveUseOfAlreadyRetiredGeneration() + { + await using var harness = await Harness.CreateAsync( + options => options.Global.UseConcurrency(1)); + var original = harness.Server.OwnedAdmissionProgramForTests!; + var replacement = harness.Server.CreateAdmissionProgramForTests( + options => options.Global.UseConcurrency(2)); + var kernel = original.Kernel; + + Ensure(original.TryAcquireUse(), "test must hold one pre-retire generation use"); + harness.Server.PublishAdmissionProgramForTests(replacement); + Ensure(original.IsRetired && !original.IsReclaimed && original.ActiveUses == 1, + "ordinary replacement retires N without invalidating its active use"); + + var stopTask = harness.Server.StopAsync(TimeSpan.Zero).AsTask(); + await WaitUntilAsync(() => kernel.IsDraining && replacement.IsRetired, + "Stop retires the current replacement while old use remains live"); + Ensure(!original.IsReclaimed, + "retired N must remain alive until its pre-retire use reaches terminal release"); + + original.ReleaseUse(); + await stopTask.WaitAsync(TimeSpan.FromSeconds(5)); + Ensure(original.IsReclaimed && original.ReclaimCount == 1, + "last old-generation use release must unblock exact-once reclamation"); + Ensure(replacement.IsReclaimed && replacement.ReclaimCount == 1, + "current generation also reclaims exactly once during Stop"); + AssertKernelDrained(kernel, "Stop with active retired generation use"); + } + + [Test] + [NotInParallel] + public async Task StopShouldDrainQueuedOldGenerationWithoutDisposedState() + { + TestService.ResetBlockingAdd(); + await using var harness = await Harness.CreateAsync(options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }); + var original = harness.Server.OwnedAdmissionProgramForTests!; + var replacement = harness.Server.CreateAdmissionProgramForTests(options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }); + var kernel = original.Kernel; + var service = harness.Client.Get(); + var active = service.BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + Task? queued = null; + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + queued = service.AddAsync(20, 22).AsTask(); + await WaitUntilAsync(() => kernel.QueuedCalls == 1 && original.ActiveUses == 2, + "generation N owns both active and queued requests before replacement"); + harness.Server.PublishAdmissionProgramForTests(replacement); + Ensure(original.IsRetired && !original.IsReclaimed, + "queued/active N requests must retain retired program ownership"); + + var stopTask = harness.Server.StopAsync(TimeSpan.Zero).AsTask(); + var queuedFailure = await CaptureFailureAsync(queued); + Ensure(queuedFailure is not ObjectDisposedException, + "Stop must cancel old-generation queue work without disposing state underneath it"); + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + await stopTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Ensure(original.IsReclaimed && original.ReclaimCount == 1, + "old queued/active generation reclaims once after both requests terminate"); + Ensure(replacement.IsReclaimed && replacement.ReclaimCount == 1, + "replacement generation reclaims once during Stop"); + AssertKernelDrained(kernel, "Stop with queued old generation"); + } + finally + { + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (queued is not null) + await ObserveTerminalAsync(queued); + } + } + + private static void ConfigureQueuedOneWay(SharpLinkAdmissionControlOptions options) + { + options.Global.UseConcurrency(1); + options.QueueOneWayCalls = true; + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + } + + private static void AssertKernelDrained(AdmissionStateKernel kernel, string scenario) + => Ensure( + kernel.QueuedCalls == 0 && kernel.QueuedBytes == 0 && kernel.ActivePermits == 0 && + kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 0 && kernel.PartitionStateCount == 0, + $"{scenario}: Stop must drain all admission diagnostics and registries to zero"); + + private static void TryDisableAdmission(SharpLinkServer server) + { + try + { + server.PublishAdmissionProgramForTests(null); + } + catch (InvalidOperationException) when (server.AdmissionStateKernelForTests?.IsDraining == true) + { + } + } + + private static Exception? CaptureSynchronousFailure(Action action) + { + try + { + action(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task CaptureFailureAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task ObserveTerminalAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class Harness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCancellation; + private readonly Task _serverTask; + private bool _disposed; + + private Harness( + CancellationTokenSource serverCancellation, + Task serverTask, + SharpLinkServer server, + ISharpLinkClient client) + { + _serverCancellation = serverCancellation; + _serverTask = serverTask; + Server = server; + Client = client; + } + + internal SharpLinkServer Server { get; } + internal ISharpLinkClient Client { get; } + + internal static async Task CreateAsync( + Action? admissionConfigure = null) + { + var serverCancellation = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); + if (admissionConfigure is not null) + serverBuilder.UseAdmissionControl(admissionConfigure); + serverBuilder.UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = (SharpLinkServer)serverBuilder.Build(); + var serverTask = RunServerAsync(server, serverCancellation.Token); + var client = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .Build(); + await client.ConnectAsync(); + return new Harness(serverCancellation, serverTask, server, client); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + _disposed = true; + try + { + await StopClientAsync(Client); + } + finally + { + await _serverCancellation.CancelAsync(); + try + { + await Server.StopAsync(TimeSpan.Zero); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException) + { + } + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCancellation.Dispose(); + } + } + + private static async Task StopClientAsync(ISharpLinkClient client) + { + try + { + await client.StopAsync(); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) + { + } + } + + private static Task RunServerAsync( + ISharpLinkServer server, + CancellationToken cancellationToken) + => Task.Run(async () => + { + try + { + await server.RunAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + } +} From 6654ecfb79fdaec1b97e66ffe3af7258bb82e621 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:37:14 +0800 Subject: [PATCH 204/228] fix: reclaim unreferenced admission state without unrelated traffic delay --- .../Admission/AdmissionStateKernel.cs | 41 +------------------ 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/src/SharpLink.Server/Admission/AdmissionStateKernel.cs b/src/SharpLink.Server/Admission/AdmissionStateKernel.cs index 5df3579e3..03cb73fd7 100644 --- a/src/SharpLink.Server/Admission/AdmissionStateKernel.cs +++ b/src/SharpLink.Server/Admission/AdmissionStateKernel.cs @@ -229,7 +229,6 @@ internal bool TryReserveQueue( internal void ReleaseQueue(int retainedBytes) { TaskCompletionSource? drained = null; - var shouldReclaim = false; lock (_accountingGate) { if (--_queuedCalls < 0) @@ -238,15 +237,10 @@ internal void ReleaseQueue(int retainedBytes) if (_queuedBytes < 0) throw new InvalidOperationException("Admission queued byte accounting underflowed."); if (_queuedCalls == 0) - { drained = _queueDrained; - shouldReclaim = _activePermits == 0; - } } drained?.TrySetResult(true); SharpLinkTelemetry.AddAdmissionQueuedCalls(-1); - if (shouldReclaim) - ReclaimUnreferencedStatesIfIdle(); } internal bool TryReserveAdditionalQueuedBytes(int retainedBytes, long maxQueuedBytes) @@ -288,21 +282,15 @@ internal void OnLeaseCreated() internal void OnLeaseDisposed() { TaskCompletionSource? drained = null; - var shouldReclaim = false; lock (_accountingGate) { if (--_activePermits < 0) throw new InvalidOperationException("Admission active permit accounting underflowed."); if (_activePermits == 0) - { drained = _permitsDrained; - shouldReclaim = _queuedCalls == 0; - } } drained?.TrySetResult(true); SharpLinkTelemetry.AddAdmissionActivePermits(-1); - if (shouldReclaim) - ReclaimUnreferencedStatesIfIdle(); } /// Shutdown-only cancellation. Ordinary program retirement never calls this method. @@ -384,7 +372,7 @@ private void ReleaseBindingsLocked( } if (--entry.ProgramReferences < 0) throw new InvalidOperationException("Admission rule state reference count underflowed."); - if (entry.ProgramReferences == 0 && !HasOutstandingActivity()) + if (entry.ProgramReferences == 0) { _ruleStates.Remove(binding.Key); (dispose ??= []).Add(entry.Runtime); @@ -397,7 +385,7 @@ private void ReleaseBindingsLocked( { if (--partitionEntry.ProgramReferences < 0) throw new InvalidOperationException("Admission partition state reference count underflowed."); - if (partitionEntry.ProgramReferences == 0 && !HasOutstandingActivity()) + if (partitionEntry.ProgramReferences == 0) { _partitionStates.Remove(partitionBinding.Key); (dispose ??= []).Add(partitionEntry.Pool); @@ -405,31 +393,6 @@ private void ReleaseBindingsLocked( } } - private void ReclaimUnreferencedStatesIfIdle() - { - if (HasOutstandingActivity()) - return; - - List? dispose = null; - lock (_registryGate) - { - foreach (var pair in _ruleStates.Where(static pair => pair.Value.ProgramReferences == 0).ToArray()) - { - _ruleStates.Remove(pair.Key); - (dispose ??= []).Add(pair.Value.Runtime); - } - foreach (var pair in _partitionStates.Where(static pair => pair.Value.ProgramReferences == 0).ToArray()) - { - _partitionStates.Remove(pair.Key); - (dispose ??= []).Add(pair.Value.Pool); - } - } - DisposeStates(dispose); - } - - private bool HasOutstandingActivity() - => Volatile.Read(ref _queuedCalls) != 0 || Volatile.Read(ref _activePermits) != 0; - private void ThrowIfDisposed() { if (Volatile.Read(ref _disposed) != 0) From 319cf0d90b8f8c64c1a2edcc05fcc9b885a01fd0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:40:11 +0800 Subject: [PATCH 205/228] test: import threading primitives for kernel matrix --- test/SharpLink.UnitTests/Server/AdmissionStateKernelTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/SharpLink.UnitTests/Server/AdmissionStateKernelTests.cs b/test/SharpLink.UnitTests/Server/AdmissionStateKernelTests.cs index c414cee9c..5c18f9938 100644 --- a/test/SharpLink.UnitTests/Server/AdmissionStateKernelTests.cs +++ b/test/SharpLink.UnitTests/Server/AdmissionStateKernelTests.cs @@ -1,3 +1,4 @@ +using System.Threading; using SharpLink.Server; namespace SharpLink.UnitTests.Server; From 3b4a88b1b540b6918089576ed70a02eaabd2d18a Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:52:42 +0800 Subject: [PATCH 206/228] test(server): make retained-budget generation assertion deterministic --- .../DynamicAdmissionGenerationTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs index 9bb821573..94aee1640 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionGenerationTests.cs @@ -341,16 +341,17 @@ public async Task RetainedRequestBudgetRejectShouldReleaseGenerationExactlyOnce( await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); var payload = Enumerable.Repeat((byte)0x2a, 16 * 1024).ToArray(); target = harness.ClientA.Get().EchoBytesAsync(payload).AsTask(); - await WaitUntilAsync(() => program.Controller.QueuedCalls == 1, - "compressed target enters admission queue before retained-budget cleanup"); - TestService.ReleaseBlockingAdd(); - await ObserveTerminalAsync(active); var failure = await CaptureFailureAsync(target); Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted } exhausted && exhausted.Message.Contains( SharpLinkResourceExhaustion.ServerRetainedCompressedBytes, StringComparison.Ordinal), "retained compressed request budget must reject with its stable reason"); + await WaitUntilAsync( + () => program.Controller.QueuedCalls == 0 && program.ActiveUses == 1, + "retained-budget rejection releases only the rejected generation use and queue accounting"); + Ensure(program.DuplicateReleaseAttempts == 0, + "retained-budget rejection must not double-release generation use"); } finally { From 21a3ebef8e05b97dffd49cc13c5cf1209153d657 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:12:40 +0800 Subject: [PATCH 207/228] fix(server): complete admission reclamation before drain --- .../Admission/AdmissionProgram.cs | 16 +- .../Admission/AdmissionStateKernel.cs | 13 +- .../Admission/SharpLinkAdmissionController.cs | 29 ++- .../AdmissionReclamationRegressionTests.cs | 165 ++++++++++++++++++ 4 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 test/SharpLink.UnitTests/Server/AdmissionReclamationRegressionTests.cs diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs index 73f892405..a102bff8f 100644 --- a/src/SharpLink.Server/Admission/AdmissionProgram.cs +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -15,7 +15,7 @@ internal sealed class AdmissionProgram private readonly AdmissionStateKernel? _kernel; private int _useState; private int _duplicateReleaseAttempts; - private int _reclaimed; + private int _reclaimState; private int _reclaimCount; private AdmissionProgram(long sentinelGenerationId) @@ -58,7 +58,7 @@ internal AdmissionStateKernel Kernel internal bool IsRetired => (Volatile.Read(ref _useState) & RetiredMask) != 0; - internal bool IsReclaimed => Volatile.Read(ref _reclaimed) != 0; + internal bool IsReclaimed => Volatile.Read(ref _reclaimState) == 2; internal int ReclaimCount => Volatile.Read(ref _reclaimCount); @@ -135,13 +135,17 @@ internal void ReleaseUse() } } - internal bool TryMarkReclaimed() + internal bool TryBeginReclaim() { if (!IsRetired || ActiveUses != 0) return false; - if (Interlocked.CompareExchange(ref _reclaimed, 1, 0) != 0) - return false; + return Interlocked.CompareExchange(ref _reclaimState, 1, 0) == 0; + } + + internal void CompleteReclaim() + { + if (Interlocked.CompareExchange(ref _reclaimState, 2, 1) != 1) + throw new InvalidOperationException("Admission program reclamation did not own the completion transition."); Interlocked.Increment(ref _reclaimCount); - return true; } } diff --git a/src/SharpLink.Server/Admission/AdmissionStateKernel.cs b/src/SharpLink.Server/Admission/AdmissionStateKernel.cs index 03cb73fd7..046b54943 100644 --- a/src/SharpLink.Server/Admission/AdmissionStateKernel.cs +++ b/src/SharpLink.Server/Admission/AdmissionStateKernel.cs @@ -32,6 +32,8 @@ internal AdmissionStateKernel(TimeProvider timeProvider) internal bool IsDraining => _draining.IsCancellationRequested || Volatile.Read(ref _disposed) != 0; + internal Action? BeforeReclaimedStateDisposalForTests { get; set; } + internal int QueuedCalls => Volatile.Read(ref _queuedCalls); internal long QueuedBytes => Volatile.Read(ref _queuedBytes); @@ -165,7 +167,7 @@ internal void OnProgramRetired(AdmissionProgram program) internal void TryReclaimProgram(AdmissionProgram program) { - if (!program.TryMarkReclaimed()) + if (!program.TryBeginReclaim()) return; List? dispose = null; @@ -173,14 +175,19 @@ internal void TryReclaimProgram(AdmissionProgram program) lock (_registryGate) { if (!_programs.Remove(program)) - return; + throw new InvalidOperationException("Admission program reclamation lost its registered program."); _retiredPrograms.Remove(program); ReleaseBindingsLocked(program.Controller, ref dispose); if (_programs.Count == 0) programsDrained = _programsDrained; } - programsDrained?.TrySetResult(true); + + if (dispose is { Count: > 0 }) + BeforeReclaimedStateDisposalForTests?.Invoke(); DisposeStates(dispose); + program.Controller.DetachReclaimedState(program); + program.CompleteReclaim(); + programsDrained?.TrySetResult(true); } internal void ReleaseUnpublishedBindings(SharpLinkAdmissionController controller) diff --git a/src/SharpLink.Server/Admission/SharpLinkAdmissionController.cs b/src/SharpLink.Server/Admission/SharpLinkAdmissionController.cs index 463d52d48..2e65c1d93 100644 --- a/src/SharpLink.Server/Admission/SharpLinkAdmissionController.cs +++ b/src/SharpLink.Server/Admission/SharpLinkAdmissionController.cs @@ -9,17 +9,17 @@ namespace SharpLink.Server; internal sealed class SharpLinkAdmissionController : IAsyncDisposable { private readonly AdmissionStateKernel _kernel; - private readonly AdmissionRuleRuntime? _global; - private readonly FrozenDictionary _contracts; - private readonly FrozenDictionary<(long ContractId, long MethodId), AdmissionRuleRuntime> _methods; + private AdmissionRuleRuntime? _global; + private FrozenDictionary _contracts; + private FrozenDictionary<(long ContractId, long MethodId), AdmissionRuleRuntime> _methods; private readonly int _maxQueuedCalls; private readonly long _maxQueuedBytes; private readonly TimeSpan _maxQueueDelay; private readonly bool _queueOneWayCalls; private readonly TimeProvider _timeProvider; - private readonly AdmissionPartitionPool? _partitions; - private readonly AdmissionRuleStateBinding[] _ruleStateBindings; - private readonly AdmissionPartitionStateBinding? _partitionStateBinding; + private AdmissionPartitionPool? _partitions; + private AdmissionRuleStateBinding[] _ruleStateBindings; + private AdmissionPartitionStateBinding? _partitionStateBinding; private readonly bool _ownsKernel; private AdmissionProgram? _program; @@ -282,6 +282,23 @@ internal void AttachProgram(AdmissionProgram program) throw new InvalidOperationException("Admission policy binding already belongs to a program generation."); } + internal void DetachReclaimedState(AdmissionProgram program) + { + ArgumentNullException.ThrowIfNull(program); + if (!ReferenceEquals(Interlocked.CompareExchange(ref _program, null, program), program)) + { + throw new InvalidOperationException( + "Admission program/controller ownership was not intact during reclamation."); + } + + _global = null; + _contracts = FrozenDictionary.Empty; + _methods = FrozenDictionary<(long ContractId, long MethodId), AdmissionRuleRuntime>.Empty; + _partitions = null; + _ruleStateBindings = []; + _partitionStateBinding = null; + } + internal ValueTask AcquireAsync( SharpLinkAdmissionContext context, int retainedBytes, diff --git a/test/SharpLink.UnitTests/Server/AdmissionReclamationRegressionTests.cs b/test/SharpLink.UnitTests/Server/AdmissionReclamationRegressionTests.cs new file mode 100644 index 000000000..90ab0f855 --- /dev/null +++ b/test/SharpLink.UnitTests/Server/AdmissionReclamationRegressionTests.cs @@ -0,0 +1,165 @@ +using System.Runtime.CompilerServices; +using System.Threading; +using SharpLink.Server; + +namespace SharpLink.UnitTests.Server; + +public sealed class AdmissionReclamationRegressionTests +{ + [Test] + public async Task KernelDisposeShouldWaitUntilFinalReclaimedStateIsDisposed() + { + var kernel = new AdmissionStateKernel(TimeProvider.System); + using var disposalEntered = new ManualResetEventSlim(); + using var allowDisposal = new ManualResetEventSlim(); + Task? disposeTask = null; + Task? releaseTask = null; + + try + { + var program = CreateProgram(kernel, options => options.Global.UseConcurrency(1)); + Ensure(program.TryAcquireUse(), "test must hold one generation use before retirement"); + Ensure(program.Retire(), "test retirement must win exactly once"); + Ensure(!program.IsReclaimed && program.ActiveUses == 1, + "active retired use must defer reclamation"); + + kernel.BeforeReclaimedStateDisposalForTests = () => + { + disposalEntered.Set(); + if (!allowDisposal.Wait(TimeSpan.FromSeconds(5))) + throw new Exception("assert failed: timed out waiting to release reclaimed-state disposal"); + }; + + disposeTask = kernel.DisposeAsync().AsTask(); + releaseTask = Task.Run(program.ReleaseUse); + + Ensure(disposalEntered.Wait(TimeSpan.FromSeconds(5)), + "last release must reach deterministic reclaimed-state disposal probe"); + Ensure(kernel.LiveProgramCount == 0 && kernel.RuleStateCount == 0, + "registry entries may already be detached while physical state disposal is blocked"); + Ensure(!program.IsReclaimed, + "program must not report reclaimed before detached state is physically disposed"); + Ensure(!disposeTask.IsCompleted, + "kernel Dispose must not complete while final reclaimed-state disposal is blocked"); + + allowDisposal.Set(); + await releaseTask.WaitAsync(TimeSpan.FromSeconds(5)); + await disposeTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Ensure(program.IsReclaimed && program.ReclaimCount == 1, + "reclamation completes exactly once only after state disposal finishes"); + Ensure(kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 0 && kernel.PartitionStateCount == 0, + "kernel must be fully drained after final state disposal completes"); + } + finally + { + allowDisposal.Set(); + kernel.BeforeReclaimedStateDisposalForTests = null; + if (releaseTask is not null) + await ObserveTerminalAsync(releaseTask); + if (disposeTask is not null) + await ObserveTerminalAsync(disposeTask); + else + await kernel.DisposeAsync(); + } + } + + [Test] + public async Task ReclaimedInitialControllerOwnerShouldNotRootOldProgramOrState() + { + var roots = CreateReclaimedReplacementScenario(); + try + { + ForceFullCollection(); + + Ensure(!roots.OldProgram.TryGetTarget(out _), + "server-lifecycle controller root must not retain the reclaimed initial program"); + Ensure(!roots.OldState.TryGetTarget(out _), + "server-lifecycle controller root must not retain the reclaimed initial limiter state"); + Ensure(roots.LifecycleOwner.Program is null, + "reclamation must sever the controller-to-program back-reference"); + Ensure(roots.Kernel.LiveProgramCount == 1 && roots.Kernel.RetiredProgramCount == 0 && + roots.Kernel.RuleStateCount == 1, + "only the incompatible replacement generation/state may remain registered"); + + GC.KeepAlive(roots.LifecycleOwner); + GC.KeepAlive(roots.Replacement); + GC.KeepAlive(roots.Kernel); + } + finally + { + roots.Replacement.Retire(); + await roots.Kernel.DisposeAsync(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static ReclaimedReplacementRoots CreateReclaimedReplacementScenario() + { + var kernel = new AdmissionStateKernel(TimeProvider.System); + var original = CreateProgram(kernel, options => options.Global.UseConcurrency(1)); + var lifecycleOwner = original.Controller; + var oldState = lifecycleOwner.GlobalStateForTests ?? + throw new Exception("assert failed: initial global state was not created"); + var replacement = CreateProgram(kernel, options => options.Global.UseConcurrency(2)); + var oldProgram = new WeakReference(original); + var oldStateReference = new WeakReference(oldState); + + Ensure(original.Retire(), "replacement must retire the initial generation"); + Ensure(original.IsReclaimed && original.ReclaimCount == 1, + "initial generation must synchronously reclaim when it has no active uses"); + Ensure(lifecycleOwner.Program is null, + "reclaim must detach the lifecycle owner's initial-program back-reference"); + Ensure(kernel.LiveProgramCount == 1 && kernel.RuleStateCount == 1, + "incompatible replacement must be the only remaining program/state entry"); + + return new ReclaimedReplacementRoots( + kernel, + lifecycleOwner, + replacement, + oldProgram, + oldStateReference); + } + + private static AdmissionProgram CreateProgram( + AdmissionStateKernel kernel, + Action configure) + { + var options = new SharpLinkAdmissionControlOptions(); + configure(options); + options.Validate(); + return kernel.CreateProgram(options, []); + } + + private static void ForceFullCollection() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + private static async Task ObserveTerminalAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private readonly record struct ReclaimedReplacementRoots( + AdmissionStateKernel Kernel, + SharpLinkAdmissionController LifecycleOwner, + AdmissionProgram Replacement, + WeakReference OldProgram, + WeakReference OldState); +} From e893291a6d03c894d98f99d75879e15a0cdba383 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:53:16 +0800 Subject: [PATCH 208/228] Add runtime admission control API --- ...arpLinkServerAdmissionControlExtensions.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/SharpLink.Server/SharpLinkServerAdmissionControlExtensions.cs diff --git a/src/SharpLink.Server/SharpLinkServerAdmissionControlExtensions.cs b/src/SharpLink.Server/SharpLinkServerAdmissionControlExtensions.cs new file mode 100644 index 000000000..4ae2ffdc7 --- /dev/null +++ b/src/SharpLink.Server/SharpLinkServerAdmissionControlExtensions.cs @@ -0,0 +1,55 @@ +namespace SharpLink.Server; + +/// Runtime admission-control operations for SharpLink servers. +public static class SharpLinkServerAdmissionControlExtensions +{ + /// + /// Atomically enables admission control for requests that capture admission after this call returns. + /// + /// The server whose admission policy is enabled. + /// Builds the complete admission policy before publication. + /// or is null. + /// Admission is already enabled, or the server is stopping. + /// The server implementation does not support runtime admission control. + public static void EnableAdmissionControl( + this ISharpLinkServer server, + Action configure) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(configure); + if (server is not ISharpLinkAdmissionRuntimeControl runtimeControl) + { + throw new NotSupportedException( + "This ISharpLinkServer implementation does not support runtime admission control."); + } + + runtimeControl.EnableAdmissionControl(configure); + } + + /// + /// Atomically disables admission control for requests that capture admission after this call returns. + /// Requests that already captured an enabled generation retain it until terminal completion. + /// + /// The server whose admission policy is disabled. + /// is null. + /// The server is stopping. + /// The server implementation does not support runtime admission control. + public static void DisableAdmissionControl(this ISharpLinkServer server) + { + ArgumentNullException.ThrowIfNull(server); + if (server is not ISharpLinkAdmissionRuntimeControl runtimeControl) + { + throw new NotSupportedException( + "This ISharpLinkServer implementation does not support runtime admission control."); + } + + runtimeControl.DisableAdmissionControl(); + } +} + +internal interface ISharpLinkAdmissionRuntimeControl +{ + void EnableAdmissionControl(Action configure); + + void DisableAdmissionControl(); +} From 7a3fb5c187ce4fbc58a2815a1ef2c9f227143fbf Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:53:33 +0800 Subject: [PATCH 209/228] Centralize admission publication lifecycle --- .../SharpLinkServer.AdmissionProgram.cs | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs index f0130749b..2f6b425be 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs @@ -1,9 +1,10 @@ namespace SharpLink.Server; -internal sealed partial class SharpLinkServer +internal sealed partial class SharpLinkServer : ISharpLinkAdmissionRuntimeControl { private static Action? s_afterAdmissionPublicationReadForTests; private static Action? s_afterAdmissionCaptureForTests; + private static Action? s_afterAdmissionCandidateBuiltForTests; private AdmissionProgram _admissionProgram = AdmissionProgram.Uninitialized; @@ -23,6 +24,16 @@ internal static Action? AfterAdmissionP set => Volatile.Write(ref s_afterAdmissionCaptureForTests, value); } + /// + /// Deterministic control-plane probe. It runs after a public enable candidate is fully built and + /// before the lifecycle writer lock is entered. + /// + internal static Action? AfterAdmissionCandidateBuiltForTests + { + get => Volatile.Read(ref s_afterAdmissionCandidateBuiltForTests); + set => Volatile.Write(ref s_afterAdmissionCandidateBuiltForTests, value); + } + internal AdmissionProgram? CurrentAdmissionProgramForTests { get @@ -43,6 +54,35 @@ internal AdmissionProgram CreateAdmissionProgramForTests( Action configure) { ArgumentNullException.ThrowIfNull(configure); + return CreateAdmissionProgram(configure); + } + + internal AdmissionProgram? PublishAdmissionProgramForTests(AdmissionProgram? program) + => PublishAdmissionProgram(program, AdmissionPublicationIntent.TestReplacement); + + void ISharpLinkAdmissionRuntimeControl.EnableAdmissionControl( + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var candidate = CreateAdmissionProgram(configure); + try + { + Volatile.Read(ref s_afterAdmissionCandidateBuiltForTests)?.Invoke(this, candidate); + PublishAdmissionProgram(candidate, AdmissionPublicationIntent.Enable); + } + catch + { + candidate.Retire(); + throw; + } + } + + void ISharpLinkAdmissionRuntimeControl.DisableAdmissionControl() + => PublishAdmissionProgram(null, AdmissionPublicationIntent.Disable); + + private AdmissionProgram CreateAdmissionProgram( + Action configure) + { var controller = _admissionController ?? throw new InvalidOperationException("Server admission lifecycle owner is unavailable."); var options = new SharpLinkAdmissionControlOptions(); @@ -51,14 +91,14 @@ internal AdmissionProgram CreateAdmissionProgramForTests( return controller.Kernel.CreateProgram(options, _staticManifests); } - internal AdmissionProgram? PublishAdmissionProgramForTests(AdmissionProgram? program) + private AdmissionProgram? PublishAdmissionProgram( + AdmissionProgram? program, + AdmissionPublicationIntent intent) { var lifecycle = _admissionController ?? throw new InvalidOperationException("Server admission lifecycle owner is unavailable."); if (program is not null && !ReferenceEquals(program.Kernel, lifecycle.Kernel)) throw new InvalidOperationException("Admission program belongs to a different server state kernel."); - if (program is { IsRetired: true }) - throw new InvalidOperationException("A retired admission program cannot be published again."); AdmissionProgram previous; lock (_registryGate) @@ -68,11 +108,23 @@ internal AdmissionProgram CreateAdmissionProgramForTests( program?.Retire(); throw new InvalidOperationException("Admission publication is sealed because the server is stopping."); } + if (program is { IsRetired: true }) + { + throw new InvalidOperationException("A retired admission program cannot be published again."); + } var replacement = program ?? AdmissionProgram.Disabled; previous = ReadAdmissionPublication(); + if (intent == AdmissionPublicationIntent.Enable && previous.IsEnabled) + { + program!.Retire(); + throw new InvalidOperationException("Admission control is already enabled."); + } + if (intent == AdmissionPublicationIntent.Disable && !previous.IsEnabled) + return null; if (ReferenceEquals(previous, replacement)) return previous.IsEnabled ? previous : null; + Volatile.Write(ref _admissionProgram, replacement); if (previous.IsEnabled) previous.Retire(); @@ -130,4 +182,11 @@ private AdmissionProgram ReadAdmissionPublication() ? initial : observed; } + + private enum AdmissionPublicationIntent + { + Enable, + Disable, + TestReplacement + } } From 18d25c629d52a335650696d328a8b12cd7369aa5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:56:18 +0800 Subject: [PATCH 210/228] Test admission runtime control transactions --- .../Server/AdmissionRuntimeControlTests.cs | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs diff --git a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs new file mode 100644 index 000000000..b7b2c0606 --- /dev/null +++ b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs @@ -0,0 +1,343 @@ +using System.Net; +using System.Reflection; +using System.Threading; +using SharpLink.Server; +using SharpLink.Sdk; + +namespace SharpLink.UnitTests.Server; + +public sealed class AdmissionRuntimeControlTests +{ + private static readonly Func TenantSelector = + static _ => "tenant-a"; + + [Test] + public void UnsupportedServerShouldRejectRuntimeAdmissionControl() + { + ISharpLinkServer server = new UnsupportedServer(); + + Ensure(CaptureFailure(() => server.EnableAdmissionControl( + options => options.Global.UseConcurrency(1))) is NotSupportedException, + "unsupported server must reject public enable"); + Ensure(CaptureFailure(server.DisableAdmissionControl) is NotSupportedException, + "unsupported server must reject public disable"); + } + + [Test] + [NotInParallel] + public async Task PublicEnableFailuresShouldBeTransactionalAndEnabledUpdateShouldBeRejected() + { + await using var server = CreateServer(); + var kernel = server.AdmissionStateKernelForTests!; + + var callbackFailure = CaptureFailure(() => + ((ISharpLinkServer)server).EnableAdmissionControl( + _ => throw new TestConfigurationException())); + Ensure(callbackFailure is TestConfigurationException, + "configuration callback failure must escape unchanged"); + AssertDisabledAndEmpty(server, kernel, "callback failure"); + + var validationFailure = CaptureFailure(() => + ((ISharpLinkServer)server).EnableAdmissionControl(_ => { })); + Ensure(validationFailure is InvalidOperationException, + "invalid empty policy must fail validation before publication"); + AssertDisabledAndEmpty(server, kernel, "validation failure"); + + var resolutionFailure = CaptureFailure(() => + ((ISharpLinkServer)server).EnableAdmissionControl(options => + options.AddContract( + rule => rule.UseConcurrency(1)))); + Ensure(resolutionFailure is InvalidOperationException, + "missing generated contract must fail candidate resolution"); + AssertDisabledAndEmpty(server, kernel, "resolution failure"); + + SharpLinkConcurrencyLimitOptions? leaked = null; + ((ISharpLinkServer)server).EnableAdmissionControl(options => + { + options.Global.UseConcurrency(1); + leaked = options.Global.Concurrency; + }); + var published = server.CurrentAdmissionProgramForTests!; + leaked!.PermitLimit = 2; + + var held = await published.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + var blocked = await published.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired && !blocked.IsAcquired && blocked.Reason == "concurrency", + "post-return option mutation must not alter the published program"); + held.Lease!.Dispose(); + + var enabledUpdateFailure = CaptureFailure(() => + ((ISharpLinkServer)server).EnableAdmissionControl( + options => options.Global.UseConcurrency(2))); + Ensure(enabledUpdateFailure is InvalidOperationException, + "enabled-to-enabled policy update must be rejected"); + Ensure(ReferenceEquals(server.CurrentAdmissionProgramForTests, published), + "rejected enabled update must leave the current publication unchanged"); + Ensure(kernel.LiveProgramCount == 1 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 1, + "rejected candidate must reclaim without growing generation or state registries"); + + ((ISharpLinkServer)server).DisableAdmissionControl(); + AssertDisabledAndEmpty(server, kernel, "final disable"); + } + + [Test] + [NotInParallel] + public async Task PublicReEnableShouldReuseCompatibleRateAndPartitionStateDuringOverlap() + { + await using var server = CreateServer(); + var publicServer = (ISharpLinkServer)server; + publicServer.EnableAdmissionControl(ConfigureRateAndPartition); + var original = server.CurrentAdmissionProgramForTests!; + var kernel = original.Kernel; + Ensure(original.TryAcquireUse(), "test must retain generation N across public disable"); + + var first = await original.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(first.IsAcquired, "generation N must consume the shared rate token and create partition state"); + first.Lease!.Dispose(); + Ensure(kernel.RuleStateCount == 1 && kernel.PartitionStateCount == 1, + "generation N must own one global rule state and one partition namespace"); + + publicServer.DisableAdmissionControl(); + Ensure(original.IsRetired && !original.IsReclaimed && original.ActiveUses == 1, + "public disable must retire N without invalidating a captured use"); + publicServer.EnableAdmissionControl(ConfigureRateAndPartition); + var replacement = server.CurrentAdmissionProgramForTests!; + + Ensure(ReferenceEquals( + original.Controller.GlobalStateForTests, + replacement.Controller.GlobalStateForTests), + "compatible public re-enable must reuse global limiter state"); + Ensure(ReferenceEquals( + original.Controller.PartitionStateForTests, + replacement.Controller.PartitionStateForTests), + "compatible public re-enable must reuse the partition namespace"); + Ensure(kernel.LiveProgramCount == 2 && kernel.RetiredProgramCount == 1 && + kernel.RuleStateCount == 1 && kernel.PartitionStateCount == 1, + "overlap must not duplicate compatible state registries"); + + var exhausted = await replacement.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(!exhausted.IsAcquired && exhausted.Reason == "rate", + "public re-enable must preserve already-consumed rate quota"); + + original.ReleaseUse(); + Ensure(original.IsReclaimed && original.ReclaimCount == 1, + "last old-generation use must reclaim exactly once"); + publicServer.DisableAdmissionControl(); + AssertDisabledAndEmpty(server, kernel, "overlap cleanup"); + } + + [Test] + [NotInParallel] + public async Task ConcurrentPublicEnablesShouldPublishExactlyOneCandidate() + { + await using var server = CreateServer(); + var publicServer = (ISharpLinkServer)server; + var kernel = server.AdmissionStateKernelForTests!; + using var bothBuilt = new CountdownEvent(2); + using var release = new ManualResetEventSlim(); + + try + { + SharpLinkServer.AfterAdmissionCandidateBuiltForTests = (owner, _) => + { + if (!ReferenceEquals(owner, server)) + return; + bothBuilt.Signal(); + if (!release.Wait(TimeSpan.FromSeconds(5))) + throw new TimeoutException("concurrent enable release timed out"); + }; + + var first = Task.Run(() => CaptureFailure(() => + publicServer.EnableAdmissionControl(options => options.Global.UseConcurrency(1)))); + var second = Task.Run(() => CaptureFailure(() => + publicServer.EnableAdmissionControl(options => options.Global.UseConcurrency(1)))); + Ensure(bothBuilt.Wait(TimeSpan.FromSeconds(5)), + "both fully-built candidates must reach the pre-publication seam"); + release.Set(); + + var failures = await Task.WhenAll(first, second); + Ensure(failures.Count(failure => failure is null) == 1 && + failures.Count(failure => failure is InvalidOperationException) == 1, + "exactly one concurrent enable must win publication"); + Ensure(server.CurrentAdmissionProgramForTests is not null && + kernel.LiveProgramCount == 1 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 1, + "losing candidate must reclaim completely while the winner remains current"); + } + finally + { + SharpLinkServer.AfterAdmissionCandidateBuiltForTests = null; + release.Set(); + } + + publicServer.DisableAdmissionControl(); + AssertDisabledAndEmpty(server, kernel, "concurrent enable cleanup"); + } + + [Test] + [NotInParallel] + public async Task CandidateBuiltBeforeStopShouldBeRejectedAndReclaimed() + { + await using var server = CreateServer(); + var publicServer = (ISharpLinkServer)server; + var kernel = server.AdmissionStateKernelForTests!; + AdmissionProgram? candidate = null; + Task? stopTask = null; + + try + { + SharpLinkServer.AfterAdmissionCandidateBuiltForTests = (owner, observed) => + { + if (!ReferenceEquals(owner, server)) + return; + candidate = observed; + stopTask = owner.StopAsync(TimeSpan.Zero).AsTask(); + Ensure(SpinWait.SpinUntil(() => kernel.IsDraining, TimeSpan.FromSeconds(5)), + "Stop must seal the admission control plane before candidate publication resumes"); + }; + + var failure = CaptureFailure(() => publicServer.EnableAdmissionControl( + options => options.Global.UseConcurrency(1))); + Ensure(failure is InvalidOperationException, + "candidate publication after Stop seal must be rejected"); + Ensure(candidate is not null, "candidate-built seam must observe the complete candidate"); + Ensure(stopTask is not null, "candidate-built seam must start Stop"); + await stopTask!.WaitAsync(TimeSpan.FromSeconds(5)); + Ensure(candidate!.IsRetired && candidate.IsReclaimed && candidate.ReclaimCount == 1, + "Stop-racing candidate must retire and reclaim exactly once"); + AssertKernelDrained(kernel, "candidate-vs-Stop"); + } + finally + { + SharpLinkServer.AfterAdmissionCandidateBuiltForTests = null; + } + } + + [Test] + [NotInParallel] + public async Task RepeatedEnableDisableCyclesShouldKeepRegistriesBounded() + { + await using var server = CreateServer(); + var publicServer = (ISharpLinkServer)server; + var kernel = server.AdmissionStateKernelForTests!; + + publicServer.DisableAdmissionControl(); + publicServer.DisableAdmissionControl(); + AssertDisabledAndEmpty(server, kernel, "repeated initial disable"); + + for (var index = 0; index < 64; index++) + { + publicServer.EnableAdmissionControl(options => options.Global.UseConcurrency(2)); + Ensure(kernel.LiveProgramCount == 1 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 1, + "each enabled cycle must have exactly one current generation and state entry"); + publicServer.DisableAdmissionControl(); + publicServer.DisableAdmissionControl(); + AssertDisabledAndEmpty(server, kernel, $"cycle {index}"); + } + } + + private static SharpLinkServer CreateServer() + { + var builder = SharpLinkServerBuilder.Create().UseTcp(0, IPAddress.Loopback.ToString()); + return (SharpLinkServer)builder.Build(); + } + + private static void ConfigureRateAndPartition(SharpLinkAdmissionControlOptions options) + { + options.Global.UseTokenBucket(rate => + { + rate.TokenLimit = 1; + rate.TokensPerPeriod = 1; + rate.ReplenishmentPeriod = TimeSpan.FromHours(1); + }); + options.UsePartition(TenantSelector, partition => + { + partition.MaxPartitions = 8; + partition.IdleTimeout = TimeSpan.FromHours(1); + partition.UseConcurrency(1); + }); + } + + private static SharpLinkAdmissionContext CreateContext() + => new(101, 202, RpcMethodKind.Unary, "runtime-control-test", null, null, null); + + private static Exception? CaptureFailure(Action action) + { + try + { + action(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static void AssertDisabledAndEmpty( + SharpLinkServer server, + AdmissionStateKernel kernel, + string scenario) + { + Ensure(server.CurrentAdmissionProgramForTests is null, + $"{scenario}: publication must remain disabled"); + Ensure(kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 0 && kernel.PartitionStateCount == 0 && + kernel.QueuedCalls == 0 && kernel.QueuedBytes == 0 && kernel.ActivePermits == 0, + $"{scenario}: candidate/state/accounting registries must be empty"); + } + + private static void AssertKernelDrained(AdmissionStateKernel kernel, string scenario) + => Ensure( + kernel.IsDraining && kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 0 && kernel.PartitionStateCount == 0 && + kernel.QueuedCalls == 0 && kernel.QueuedBytes == 0 && kernel.ActivePermits == 0, + $"{scenario}: Stop must drain all admission state"); + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class TestConfigurationException : Exception; + + private interface IMissingAdmissionContract : IService; + + private sealed class UnsupportedServer : ISharpLinkServer + { + public SharpLinkHealthStatus HealthStatus => default; + + public SharpLinkAssemblyRegistrationResult RegisterAssembly(Assembly assembly) + => throw new NotSupportedException(); + + public ValueTask UnregisterAssemblyAsync( + Assembly assembly, + TimeSpan gracefulTimeout, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public ValueTask ReplaceAssemblyAsync( + Assembly oldAssembly, + Assembly newAssembly, + TimeSpan gracefulTimeout, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public ValueTask RunAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + public ValueTask StopAsync( + TimeSpan gracefulTimeout, + CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} From 29dc71f9a519846baa849ef71ee06a6590c4e28e Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:57:34 +0800 Subject: [PATCH 211/228] Test runtime admission request semantics --- .../DynamicAdmissionRuntimeControlTests.cs | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs new file mode 100644 index 000000000..390b83fc5 --- /dev/null +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs @@ -0,0 +1,399 @@ +namespace SharpLink.IntegrationTests; + +public sealed class DynamicAdmissionRuntimeControlTests +{ + [Test] + [NotInParallel] + public async Task InitiallyDisabledPublicEnableShouldGovernNextRequest() + { + await using var harness = await Harness.CreateAsync(); + var publicServer = (ISharpLinkServer)harness.Server; + publicServer.EnableAdmissionControl(options => options.Global.UseConcurrency(1)); + var program = harness.Server.CurrentAdmissionProgramForTests + ?? throw new Exception("public enable must publish an admission program"); + var held = await program.Controller.AcquireAsync( + CreateAdmissionContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired, "test must occupy the newly enabled global permit"); + + try + { + var failure = await CaptureFailureAsync( + harness.ClientA.Get().AddAsync(20, 22).AsTask()); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted }, + "request captured after public enable returns must be governed by the new program"); + } + finally + { + held.Lease!.Dispose(); + } + + Ensure(await harness.ClientA.Get().AddAsync(20, 22) == 42, + "connection must remain reusable after controlled admission rejection"); + publicServer.DisableAdmissionControl(); + await WaitUntilAsync(() => program.IsReclaimed, + "disabled public generation reclaims after its final request releases"); + AssertKernelEmpty(harness.Server.AdmissionStateKernelForTests!, "enable/disable request path"); + } + + [Test] + [NotInParallel] + public async Task RequestCapturedDisabledShouldRemainBypassWhenPublicEnablePublishes() + { + await using var harness = await Harness.CreateAsync(); + var publicServer = (ISharpLinkServer)harness.Server; + AdmissionDecision held = default; + AdmissionProgram? replacement = null; + var hookCount = 0; + + try + { + SharpLinkServer.AfterAdmissionCaptureForTests = (owner, _, observed) => + { + if (!ReferenceEquals(owner, harness.Server) || observed is not null || + Interlocked.Exchange(ref hookCount, 1) != 0) + return; + + publicServer.EnableAdmissionControl(options => options.Global.UseConcurrency(1)); + replacement = owner.CurrentAdmissionProgramForTests + ?? throw new Exception("public enable must publish inside the capture seam"); + held = replacement.Controller.AcquireAsync( + CreateAdmissionContext(), 1, allowQueue: false, CancellationToken.None) + .GetAwaiter().GetResult(); + Ensure(held.IsAcquired, "test must occupy the newly published permit"); + }; + + Ensure(await harness.ClientA.Get().AddAsync(20, 22) == 42, + "request that captured disabled must bypass the later public enable"); + Ensure(replacement is not null, + "capture seam must have published the public enabled generation"); + SharpLinkServer.AfterAdmissionCaptureForTests = null; + + var nextFailure = await CaptureFailureAsync( + harness.ClientA.Get().AddAsync(20, 22).AsTask()); + Ensure(nextFailure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted }, + "next request must observe the public enabled publication"); + } + finally + { + SharpLinkServer.AfterAdmissionCaptureForTests = null; + held.Lease?.Dispose(); + publicServer.DisableAdmissionControl(); + } + + if (replacement is not null) + { + await WaitUntilAsync(() => replacement.IsReclaimed, + "public replacement must reclaim after disable and final use release"); + } + AssertKernelEmpty(harness.Server.AdmissionStateKernelForTests!, "disabled-capture transition"); + } + + [Test] + [NotInParallel] + public async Task PublicDisableShouldBypassNextRequestWhileCapturedActiveRequestCompletes() + { + TestService.ResetBlockingAdd(); + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => options.Global.UseConcurrency(1)); + var publicServer = (ISharpLinkServer)harness.Server; + var original = harness.Server.CurrentAdmissionProgramForTests!; + var service = harness.ClientA.Get(); + var active = service.BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + publicServer.DisableAdmissionControl(); + publicServer.DisableAdmissionControl(); + Ensure(original.IsRetired && !original.IsReclaimed && original.ActiveUses == 1, + "disable must retire the current program without cancelling its captured active request"); + Ensure(await harness.ClientB.Get().AddAsync(20, 22) == 42, + "request captured after disable returns must bypass admission immediately"); + Ensure(!active.IsCompleted, + "public disable must not cancel the already-admitted active request"); + } + finally + { + TestService.ReleaseBlockingAdd(); + } + + Ensure(await active.WaitAsync(TimeSpan.FromSeconds(5)) == 2, + "old-generation active request must complete normally after disable"); + await WaitUntilAsync(() => original.IsReclaimed, + "old active generation reclaims on terminal release"); + Ensure(original.ReclaimCount == 1 && original.DuplicateReleaseAttempts == 0, + "old active generation must reclaim and release exactly once"); + AssertKernelEmpty(harness.Server.AdmissionStateKernelForTests!, "active disable"); + } + + [Test] + [NotInParallel] + [Arguments(false)] + [Arguments(true)] + public async Task QueuedOldGenerationShouldContinueAfterPublicDisable(bool oneWay) + { + TestService.ResetBlockingAdd(); + TestService.ResetNotify(); + await using var harness = await Harness.CreateAsync( + admissionConfigure: options => + { + options.Global.UseConcurrency(1); + options.QueueOneWayCalls = true; + options.MaxQueuedCalls = 2; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }); + var publicServer = (ISharpLinkServer)harness.Server; + var original = harness.Server.CurrentAdmissionProgramForTests!; + var service = harness.ClientA.Get(); + var active = service.BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + Task? queuedTwoWay = null; + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + if (oneWay) + await service.NotifyAsync("runtime-disable-queued"); + else + queuedTwoWay = service.AddAsync(20, 22).AsTask(); + + await WaitUntilAsync(() => original.Controller.QueuedCalls == 1, + "target request must enter the enabled generation queue before disable"); + publicServer.DisableAdmissionControl(); + Ensure(original.IsRetired && !original.IsReclaimed && original.ActiveUses == 2, + "active and queued captures must keep the retired generation alive"); + Ensure(await harness.ClientB.Get().AddAsync(3, 4) == 7, + "new request must bypass admission while old queued work remains retained"); + + TestService.ReleaseBlockingAdd(); + Ensure(await active.WaitAsync(TimeSpan.FromSeconds(5)) == 2, + "old-generation active owner completes"); + if (oneWay) + { + await TestService.WaitForNotifyAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Ensure(TestService.NotifyCount == 1, + "queued one-way capture must execute after its old permit becomes available"); + } + else + { + Ensure(await queuedTwoWay!.WaitAsync(TimeSpan.FromSeconds(5)) == 42, + "queued two-way capture must execute after its old permit becomes available"); + } + + await WaitUntilAsync(() => original.IsReclaimed, + "retired queued generation must reclaim after final queued completion"); + Ensure(original.ReclaimCount == 1 && original.DuplicateReleaseAttempts == 0, + "queued retirement must reclaim and release exactly once"); + AssertKernelEmpty(harness.Server.AdmissionStateKernelForTests!, "queued disable"); + } + finally + { + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (queuedTwoWay is not null) + await ObserveTerminalAsync(queuedTwoWay); + } + } + + [Test] + [NotInParallel] + public async Task ServerCallCapacityShouldRemainEnforcedAfterRuntimeAdmissionDisable() + { + TestService.ResetBlockingAdd(); + await using var harness = await Harness.CreateAsync( + serverRuntimeConfigure: options => options.FlowControl.MaxConcurrentCallsPerServer = 1, + admissionConfigure: options => options.Global.UseConcurrency(2)); + var publicServer = (ISharpLinkServer)harness.Server; + var service = harness.ClientA.Get(); + var active = service.BlockingAddAsync(1, 1, CancellationToken.None).AsTask(); + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + publicServer.DisableAdmissionControl(); + var failure = await CaptureFailureAsync( + harness.ClientB.Get().AddAsync(20, 22).AsTask()); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted }, + "ServerResourceGovernor call capacity must remain enforced while admission is runtime-disabled"); + } + finally + { + TestService.ReleaseBlockingAdd(); + } + + Ensure(await active.WaitAsync(TimeSpan.FromSeconds(5)) == 2, + "resource-governor owner must complete normally"); + Ensure(await harness.ClientB.Get().AddAsync(20, 22) == 42, + "controlled call-capacity rejection must keep the connection reusable"); + AssertKernelEmpty(harness.Server.AdmissionStateKernelForTests!, "runtime-disabled resource governor"); + } + + private static SharpLinkAdmissionContext CreateAdmissionContext() + => new(1, 2, RpcMethodKind.Unary, "runtime-control-integration", null, null, null); + + private static async Task CaptureFailureAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task ObserveTerminalAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void AssertKernelEmpty(AdmissionStateKernel kernel, string scenario) + => Ensure( + kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 0 && kernel.PartitionStateCount == 0 && + kernel.QueuedCalls == 0 && kernel.QueuedBytes == 0 && kernel.ActivePermits == 0, + $"{scenario}: admission lifecycle diagnostics must return to zero"); + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class Harness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCancellation; + private readonly Task _serverTask; + private bool _disposed; + + private Harness( + CancellationTokenSource serverCancellation, + Task serverTask, + SharpLinkServer server, + ISharpLinkClient clientA, + ISharpLinkClient clientB) + { + _serverCancellation = serverCancellation; + _serverTask = serverTask; + Server = server; + ClientA = clientA; + ClientB = clientB; + } + + internal SharpLinkServer Server { get; } + internal ISharpLinkClient ClientA { get; } + internal ISharpLinkClient ClientB { get; } + + internal static async Task CreateAsync( + Action? serverRuntimeConfigure = null, + Action? admissionConfigure = null) + { + var serverCancellation = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); + if (serverRuntimeConfigure is not null) + serverBuilder.UseRuntime(serverRuntimeConfigure); + if (admissionConfigure is not null) + serverBuilder.UseAdmissionControl(admissionConfigure); + serverBuilder.UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = (SharpLinkServer)serverBuilder.Build(); + var serverTask = RunServerAsync(server, serverCancellation.Token); + + var clientA = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .Build(); + var clientB = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .Build(); + await clientA.ConnectAsync(); + await clientB.ConnectAsync(); + return new Harness(serverCancellation, serverTask, server, clientA, clientB); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + _disposed = true; + try + { + await StopClientAsync(ClientA); + await StopClientAsync(ClientB); + } + finally + { + await _serverCancellation.CancelAsync(); + try + { + await Server.StopAsync(TimeSpan.Zero); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException) + { + } + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCancellation.Dispose(); + } + } + + private static async Task StopClientAsync(ISharpLinkClient client) + { + try + { + await client.StopAsync(); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) + { + } + } + + private static Task RunServerAsync( + ISharpLinkServer server, + CancellationToken cancellationToken) + => Task.Run(async () => + { + try + { + await server.RunAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + } +} From e78d0a162cd82aa34e69bdd47b848f725551efbe Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:57:55 +0800 Subject: [PATCH 212/228] Document runtime admission enable disable --- doc/admission-control.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/doc/admission-control.md b/doc/admission-control.md index 7800fc9f2..93410a364 100644 --- a/doc/admission-control.md +++ b/doc/admission-control.md @@ -23,6 +23,34 @@ serverBuilder.UseAdmissionControl(options => }); ``` +## 运行时启用和停用 + +Server 包提供运行时控制入口,可在最初未启用接入控制的服务上原子启用,也可停用当前策略并在之后重新启用: + +```csharp +ISharpLinkServer server = serverBuilder.Build(); + +server.EnableAdmissionControl(options => +{ + options.Global.UseConcurrency(256); +}); + +server.DisableAdmissionControl(); + +server.EnableAdmissionControl(options => +{ + options.Global.UseConcurrency(256); +}); +``` + +`EnableAdmissionControl` 会先在发布锁之外构造、校验并解析完整候选策略;只有候选完全可用后才原子发布。回调失败、配置校验失败、生成清单解析失败或并发启用失败都不会改变当前发布状态。回调只用于构造候选配置;方法返回后继续修改调用方保留的 options 对象不会改变已发布策略。 + +支持的状态转换只有 Disabled → Enabled、Enabled → Disabled 和停用后的再次 Disabled → Enabled。已启用时再次调用 `EnableAdmissionControl` 不表示在线修改策略,而会抛出 `InvalidOperationException`;如需切换策略,先显式停用,再重新启用。对已停用状态重复调用 `DisableAdmissionControl` 是幂等操作。不支持这些运行时入口的自定义 `ISharpLinkServer` 实现会抛出 `NotSupportedException`。 + +停用只影响之后捕获接入状态的请求,不会取消已经捕获旧 generation 的活动或排队请求,也不会等待这些请求结束。旧 generation 会按正常 retire/reclaim 生命周期完成;在旧 generation 尚未回收时以兼容配置重新启用,会复用稳定 kernel 中兼容的并发、速率、队列和 partition 状态,因此不会重置已消费配额或复制全局记账。 + +运行时停用 Admission 不会停用 `ServerResourceGovernor`。调用容量、解码/预接入预算、保留字节和流式字节等服务器资源限制始终独立生效。 + ## 排队 只有 `MaxQueuedCalls`、`MaxQueuedBytes` 和 `MaxQueueDelay` 都允许时才等待;任何一个边界耗尽都会立即拒绝。排队仍受调用 deadline 和取消 token 约束。队列保留已解码请求字节,因此 count 与 byte 两个边界都必须配置。 From dfcf39769f95f03b8109757615b251abfbf3ef8b Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:58:53 +0800 Subject: [PATCH 213/228] Fix runtime control test type declarations --- .../Server/AdmissionRuntimeControlTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs index b7b2c0606..a9d751ed8 100644 --- a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs +++ b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs @@ -306,9 +306,13 @@ private static void Ensure(bool condition, string scenario) throw new Exception($"assert failed: {scenario}"); } - private sealed class TestConfigurationException : Exception; + private sealed class TestConfigurationException : Exception + { + } - private interface IMissingAdmissionContract : IService; + private interface IMissingAdmissionContract : IService + { + } private sealed class UnsupportedServer : ISharpLinkServer { From df5cf0fd64b8236cd8aa702ce27394f5846e7699 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:01:52 +0800 Subject: [PATCH 214/228] Complete admission writer and overlap tests --- .../Server/AdmissionRuntimeControlTests.cs | 137 +++++++++++++++++- 1 file changed, 134 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs index a9d751ed8..9280e514f 100644 --- a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs +++ b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Net; using System.Reflection; using System.Threading; @@ -85,7 +86,7 @@ public async Task PublicEnableFailuresShouldBeTransactionalAndEnabledUpdateShoul [Test] [NotInParallel] - public async Task PublicReEnableShouldReuseCompatibleRateAndPartitionStateDuringOverlap() + public async Task PublicReEnableShouldReuseCompatibleConcurrencyRateAndPartitionStateDuringOverlap() { await using var server = CreateServer(); var publicServer = (ISharpLinkServer)server; @@ -96,8 +97,7 @@ public async Task PublicReEnableShouldReuseCompatibleRateAndPartitionStateDuring var first = await original.Controller.AcquireAsync( CreateContext(), 1, allowQueue: false, CancellationToken.None); - Ensure(first.IsAcquired, "generation N must consume the shared rate token and create partition state"); - first.Lease!.Dispose(); + Ensure(first.IsAcquired, "generation N must consume shared permits/rate and create partition state"); Ensure(kernel.RuleStateCount == 1 && kernel.PartitionStateCount == 1, "generation N must own one global rule state and one partition namespace"); @@ -119,6 +119,12 @@ public async Task PublicReEnableShouldReuseCompatibleRateAndPartitionStateDuring kernel.RuleStateCount == 1 && kernel.PartitionStateCount == 1, "overlap must not duplicate compatible state registries"); + var blockedByOldPermit = await replacement.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(!blockedByOldPermit.IsAcquired && blockedByOldPermit.Reason == "concurrency", + "old concurrency permit must constrain the compatible re-enabled generation"); + + first.Lease!.Dispose(); var exhausted = await replacement.Controller.AcquireAsync( CreateContext(), 1, allowQueue: false, CancellationToken.None); Ensure(!exhausted.IsAcquired && exhausted.Reason == "rate", @@ -131,6 +137,57 @@ public async Task PublicReEnableShouldReuseCompatibleRateAndPartitionStateDuring AssertDisabledAndEmpty(server, kernel, "overlap cleanup"); } + [Test] + [NotInParallel] + public async Task PublicReEnableShouldShareOldQueueAccountingDuringOverlap() + { + await using var server = CreateServer(); + var publicServer = (ISharpLinkServer)server; + publicServer.EnableAdmissionControl(ConfigureQueue); + var original = server.CurrentAdmissionProgramForTests!; + var kernel = original.Kernel; + Ensure(original.TryAcquireUse() && original.TryAcquireUse(), + "test must retain active and queued generation-N uses"); + var held = await original.Controller.AcquireAsync( + CreateContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired, "generation N must hold the shared concurrency permit"); + var queued = original.Controller.AcquireAsync( + CreateContext(), retainedBytes: 2, allowQueue: true, CancellationToken.None).AsTask(); + await WaitUntilAsync(() => kernel.QueuedCalls == 1, + "generation N must reserve one shared queue slot"); + + publicServer.DisableAdmissionControl(); + publicServer.EnableAdmissionControl(ConfigureQueue); + var replacement = server.CurrentAdmissionProgramForTests!; + Ensure(ReferenceEquals( + original.Controller.GlobalStateForTests, + replacement.Controller.GlobalStateForTests), + "re-enabled queue policy must share compatible global state"); + Ensure(kernel.QueuedCalls == 1 && kernel.QueuedBytes == 2 && kernel.RuleStateCount == 1, + "old queued call and re-enabled generation must use one queue accounting kernel"); + + var rejected = await replacement.Controller.AcquireAsync( + CreateContext(), retainedBytes: 2, allowQueue: true, CancellationToken.None); + Ensure(!rejected.IsAcquired && rejected.Reason == "queue_count", + "re-enabled generation must observe old generation queue occupancy"); + Ensure(kernel.QueuedCalls == 1 && kernel.QueuedBytes == 2, + "rejected re-enabled enqueue must not underflow shared queue accounting"); + + held.Lease!.Dispose(); + var admitted = await queued.WaitAsync(TimeSpan.FromSeconds(2)); + Ensure(admitted.IsAcquired, "old queued call must survive disable/re-enable overlap"); + admitted.Lease!.Dispose(); + original.ReleaseUse(); + original.ReleaseUse(); + Ensure(original.IsReclaimed && original.ReclaimCount == 1, + "old queued generation must reclaim exactly once after simulated captures release"); + await WaitUntilAsync( + () => kernel.QueuedCalls == 0 && kernel.QueuedBytes == 0 && kernel.ActivePermits == 0, + "shared queue and permit accounting must drain without underflow"); + publicServer.DisableAdmissionControl(); + AssertDisabledAndEmpty(server, kernel, "queue overlap cleanup"); + } + [Test] [NotInParallel] public async Task ConcurrentPublicEnablesShouldPublishExactlyOneCandidate() @@ -179,6 +236,55 @@ public async Task ConcurrentPublicEnablesShouldPublishExactlyOneCandidate() AssertDisabledAndEmpty(server, kernel, "concurrent enable cleanup"); } + [Test] + [NotInParallel] + public async Task EnableRacingDisableShouldLinearizeInWriterOrder() + { + await using var server = CreateServer(); + var publicServer = (ISharpLinkServer)server; + var kernel = server.AdmissionStateKernelForTests!; + publicServer.EnableAdmissionControl(options => options.Global.UseConcurrency(1)); + var original = server.CurrentAdmissionProgramForTests!; + using var candidateBuilt = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + + try + { + SharpLinkServer.AfterAdmissionCandidateBuiltForTests = (owner, _) => + { + if (!ReferenceEquals(owner, server)) + return; + candidateBuilt.Set(); + if (!release.Wait(TimeSpan.FromSeconds(5))) + throw new TimeoutException("enable-vs-disable release timed out"); + }; + + var enable = Task.Run(() => CaptureFailure(() => + publicServer.EnableAdmissionControl(options => options.Global.UseConcurrency(1)))); + Ensure(candidateBuilt.Wait(TimeSpan.FromSeconds(5)), + "enable candidate must be fully built before the competing disable wins"); + publicServer.DisableAdmissionControl(); + Ensure(server.CurrentAdmissionProgramForTests is null, + "disable must be visible before the blocked enable is released"); + release.Set(); + Ensure(await enable is null, + "enable that linearizes after disable must succeed as a re-enable"); + Ensure(server.CurrentAdmissionProgramForTests is not null && + original.IsRetired && original.IsReclaimed && + kernel.LiveProgramCount == 1 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 1, + "final state must match disable-then-enable publication order without registry growth"); + } + finally + { + SharpLinkServer.AfterAdmissionCandidateBuiltForTests = null; + release.Set(); + } + + publicServer.DisableAdmissionControl(); + AssertDisabledAndEmpty(server, kernel, "enable-vs-disable cleanup"); + } + [Test] [NotInParallel] public async Task CandidateBuiltBeforeStopShouldBeRejectedAndReclaimed() @@ -210,6 +316,8 @@ public async Task CandidateBuiltBeforeStopShouldBeRejectedAndReclaimed() await stopTask!.WaitAsync(TimeSpan.FromSeconds(5)); Ensure(candidate!.IsRetired && candidate.IsReclaimed && candidate.ReclaimCount == 1, "Stop-racing candidate must retire and reclaim exactly once"); + Ensure(CaptureFailure(publicServer.DisableAdmissionControl) is InvalidOperationException, + "disable after lifecycle sealing must deterministically reject without publishing"); AssertKernelDrained(kernel, "candidate-vs-Stop"); } finally @@ -250,6 +358,7 @@ private static SharpLinkServer CreateServer() private static void ConfigureRateAndPartition(SharpLinkAdmissionControlOptions options) { + options.Global.UseConcurrency(1); options.Global.UseTokenBucket(rate => { rate.TokenLimit = 1; @@ -264,9 +373,31 @@ private static void ConfigureRateAndPartition(SharpLinkAdmissionControlOptions o }); } + private static void ConfigureQueue(SharpLinkAdmissionControlOptions options) + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 8; + options.MaxQueueDelay = TimeSpan.FromSeconds(5); + } + private static SharpLinkAdmissionContext CreateContext() => new(101, 202, RpcMethodKind.Unary, "runtime-control-test", null, null, null); + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + private static Exception? CaptureFailure(Action action) { try From c17af5ecf95a4b3c4703850d80bfb5c2d39551e5 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:06:10 +0800 Subject: [PATCH 215/228] Add deterministic admission writer race seam --- .../SharpLinkServer.AdmissionProgram.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs index 2f6b425be..5e6afc273 100644 --- a/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs +++ b/src/SharpLink.Server/SharpLinkServer.AdmissionProgram.cs @@ -5,6 +5,7 @@ internal sealed partial class SharpLinkServer : ISharpLinkAdmissionRuntimeContro private static Action? s_afterAdmissionPublicationReadForTests; private static Action? s_afterAdmissionCaptureForTests; private static Action? s_afterAdmissionCandidateBuiltForTests; + private static Action? s_beforeAdmissionPublicationLockForTests; private AdmissionProgram _admissionProgram = AdmissionProgram.Uninitialized; @@ -34,6 +35,15 @@ internal static Action? AfterAdmissionCandida set => Volatile.Write(ref s_afterAdmissionCandidateBuiltForTests, value); } + /// + /// Deterministic writer probe. A null program represents disable publication. + /// + internal static Action? BeforeAdmissionPublicationLockForTests + { + get => Volatile.Read(ref s_beforeAdmissionPublicationLockForTests); + set => Volatile.Write(ref s_beforeAdmissionPublicationLockForTests, value); + } + internal AdmissionProgram? CurrentAdmissionProgramForTests { get @@ -100,6 +110,8 @@ private AdmissionProgram CreateAdmissionProgram( if (program is not null && !ReferenceEquals(program.Kernel, lifecycle.Kernel)) throw new InvalidOperationException("Admission program belongs to a different server state kernel."); + Volatile.Read(ref s_beforeAdmissionPublicationLockForTests)?.Invoke(this, program); + AdmissionProgram previous; lock (_registryGate) { From a274a2ad7f62681a003969ebcb47c5d0057bbcd0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:06:38 +0800 Subject: [PATCH 216/228] Cover runtime disable stop race --- .../AdmissionRuntimeControlRaceTests.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/AdmissionRuntimeControlRaceTests.cs diff --git a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlRaceTests.cs b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlRaceTests.cs new file mode 100644 index 000000000..62c237ae1 --- /dev/null +++ b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlRaceTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Threading; +using SharpLink.Server; + +namespace SharpLink.UnitTests.Server; + +public sealed class AdmissionRuntimeControlRaceTests +{ + [Test] + [NotInParallel] + public async Task DisableRacingStopShouldNotDeadlockOrPublishAfterSeal() + { + await using var server = CreateServer(); + var publicServer = (ISharpLinkServer)server; + publicServer.EnableAdmissionControl(options => options.Global.UseConcurrency(1)); + var original = server.CurrentAdmissionProgramForTests + ?? throw new Exception("test requires an enabled publication"); + var kernel = original.Kernel; + using var disableAtWriter = new ManualResetEventSlim(); + using var releaseDisable = new ManualResetEventSlim(); + + try + { + SharpLinkServer.BeforeAdmissionPublicationLockForTests = (owner, candidate) => + { + if (!ReferenceEquals(owner, server) || candidate is not null) + return; + disableAtWriter.Set(); + if (!releaseDisable.Wait(TimeSpan.FromSeconds(5))) + throw new TimeoutException("disable-vs-Stop writer release timed out"); + }; + + var disableTask = Task.Run(() => CaptureFailure(publicServer.DisableAdmissionControl)); + Ensure(disableAtWriter.Wait(TimeSpan.FromSeconds(5)), + "Disable must reach the deterministic pre-writer barrier"); + + var stopTask = server.StopAsync(TimeSpan.Zero).AsTask(); + await WaitUntilAsync(() => kernel.IsDraining, + "Stop must seal the stable admission kernel before Disable resumes"); + releaseDisable.Set(); + + var disableFailure = await disableTask.WaitAsync(TimeSpan.FromSeconds(5)); + Ensure(disableFailure is InvalidOperationException, + "Disable linearized after Stop seal must reject deterministically"); + await stopTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Ensure(original.IsRetired && original.IsReclaimed && original.ReclaimCount == 1, + "Stop must retire and reclaim the pre-seal generation exactly once"); + Ensure(kernel.IsDraining && kernel.LiveProgramCount == 0 && + kernel.RetiredProgramCount == 0 && kernel.RuleStateCount == 0 && + kernel.PartitionStateCount == 0 && kernel.QueuedCalls == 0 && + kernel.QueuedBytes == 0 && kernel.ActivePermits == 0, + "Disable-vs-Stop must finish without deadlock, publication, or residual accounting"); + } + finally + { + SharpLinkServer.BeforeAdmissionPublicationLockForTests = null; + releaseDisable.Set(); + } + } + + private static SharpLinkServer CreateServer() + { + var builder = SharpLinkServerBuilder.Create().UseTcp(0, IPAddress.Loopback.ToString()); + return (SharpLinkServer)builder.Build(); + } + + private static Exception? CaptureFailure(Action action) + { + try + { + action(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } +} From 622093fe354e65cea51adf0e2a100b917b763584 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:07:23 +0800 Subject: [PATCH 217/228] Add deterministic admission construction fault seam --- src/SharpLink.Server/Admission/AdmissionProgram.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/SharpLink.Server/Admission/AdmissionProgram.cs b/src/SharpLink.Server/Admission/AdmissionProgram.cs index a102bff8f..36f20bed9 100644 --- a/src/SharpLink.Server/Admission/AdmissionProgram.cs +++ b/src/SharpLink.Server/Admission/AdmissionProgram.cs @@ -10,6 +10,7 @@ internal sealed class AdmissionProgram private const int RetiredMask = int.MinValue; private const int UseCountMask = int.MaxValue; private static long s_nextGenerationId; + private static Action? s_beforeProgramAttachForTests; private readonly SharpLinkAdmissionController? _controller; private readonly AdmissionStateKernel? _kernel; @@ -28,6 +29,7 @@ internal AdmissionProgram(SharpLinkAdmissionController controller) throw new InvalidOperationException("Disabled admission does not create a program generation."); _kernel = controller.Kernel; GenerationId = Interlocked.Increment(ref s_nextGenerationId); + Volatile.Read(ref s_beforeProgramAttachForTests)?.Invoke(); controller.AttachProgram(this); _kernel.RegisterProgram(this); @@ -42,6 +44,16 @@ internal AdmissionProgram(SharpLinkAdmissionController controller) internal static AdmissionProgram Disabled { get; } = new(0); + /// + /// Deterministic candidate-construction fault seam after state bindings are acquired but before + /// the candidate attaches/registers. The kernel must release those unpublished bindings. + /// + internal static Action? BeforeProgramAttachForTests + { + get => Volatile.Read(ref s_beforeProgramAttachForTests); + set => Volatile.Write(ref s_beforeProgramAttachForTests, value); + } + internal long GenerationId { get; } internal bool IsEnabled => _controller is not null; From b32b84727e7ca4f074b7bb79a26bb15e3330730f Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:07:39 +0800 Subject: [PATCH 218/228] Cover candidate rollback and stop writer race --- .../AdmissionRuntimeControlRaceTests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlRaceTests.cs b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlRaceTests.cs index 62c237ae1..022a04768 100644 --- a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlRaceTests.cs +++ b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlRaceTests.cs @@ -6,6 +6,38 @@ namespace SharpLink.UnitTests.Server; public sealed class AdmissionRuntimeControlRaceTests { + [Test] + [NotInParallel] + public async Task PartialCandidateConstructionFailureShouldReleaseAcquiredBindings() + { + await using var server = CreateServer(); + var publicServer = (ISharpLinkServer)server; + var kernel = server.AdmissionStateKernelForTests!; + + try + { + AdmissionProgram.BeforeProgramAttachForTests = static () => + throw new CandidateConstructionException(); + + var failure = CaptureFailure(() => publicServer.EnableAdmissionControl(options => + options.Global.UseConcurrency(1))); + + Ensure(failure is CandidateConstructionException, + "deterministic construction fault must escape unchanged"); + Ensure(server.CurrentAdmissionProgramForTests is null, + "partial candidate failure must not publish"); + Ensure(kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 0 && kernel.PartitionStateCount == 0 && + kernel.QueuedCalls == 0 && kernel.QueuedBytes == 0 && + kernel.ActivePermits == 0, + "partial candidate failure must release every acquired binding and accounting entry"); + } + finally + { + AdmissionProgram.BeforeProgramAttachForTests = null; + } + } + [Test] [NotInParallel] public async Task DisableRacingStopShouldNotDeadlockOrPublishAfterSeal() @@ -97,4 +129,8 @@ private static void Ensure(bool condition, string scenario) if (!condition) throw new Exception($"assert failed: {scenario}"); } + + private sealed class CandidateConstructionException : Exception + { + } } From 85cd2cccfc6f8f371e9207d95321ef626d17af08 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:08:26 +0800 Subject: [PATCH 219/228] Cover runtime admission resource regressions --- ...AdmissionRuntimeResourceRegressionTests.cs | 410 ++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs new file mode 100644 index 000000000..8e3ce4913 --- /dev/null +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs @@ -0,0 +1,410 @@ +using System.Buffers; +using System.Runtime.CompilerServices; + +namespace SharpLink.IntegrationTests; + +public sealed class DynamicAdmissionRuntimeResourceRegressionTests +{ + private const int StreamItemBytes = 4 * 1024; + private const long StreamBudgetBytes = 12L * 1024; + + [Test] + [NotInParallel] + public async Task RuntimeEnableShouldApplyConfiguredOneWayQueueBehavior() + { + TestService.ResetNotify(); + await using var harness = await RunningHarness.CreateAsync(); + var publicServer = (ISharpLinkServer)harness.Server; + publicServer.EnableAdmissionControl(options => + { + options.Global.UseConcurrency(1); + options.QueueOneWayCalls = true; + options.MaxQueuedCalls = 1; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(5); + }); + var program = harness.Server.CurrentAdmissionProgramForTests + ?? throw new Exception("runtime enable must publish a program"); + var held = await program.Controller.AcquireAsync( + CreateAdmissionContext(), 1, allowQueue: false, CancellationToken.None); + Ensure(held.IsAcquired, "test must occupy the runtime-enabled concurrency permit"); + + try + { + await harness.ClientA.Get().NotifyAsync("runtime-enabled-oneway"); + await WaitUntilAsync(() => program.Controller.QueuedCalls == 1, + "runtime-enabled one-way request must queue under the configured policy"); + Ensure(!TestService.WaitForNotifyAsync().IsCompleted, + "queued runtime-enabled one-way request must not execute before a permit is available"); + + held.Lease!.Dispose(); + held = default; + await TestService.WaitForNotifyAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Ensure(TestService.NotifyCount == 1, + "runtime-enabled queued one-way request must execute after the permit is released"); + await WaitUntilAsync( + () => program.Controller.QueuedCalls == 0 && program.Kernel.ActivePermits == 0, + "runtime-enabled one-way queue accounting must drain"); + } + finally + { + held.Lease?.Dispose(); + publicServer.DisableAdmissionControl(); + } + + await WaitUntilAsync(() => program.IsReclaimed, + "runtime-enabled one-way generation must reclaim after disable"); + AssertAdmissionKernelEmpty(program.Kernel, "runtime-enabled one-way queue"); + } + + [Test] + [NotInParallel] + public async Task RuntimeDisabledCapacityRejectionShouldNotDecompressOrAcquireDecodedBudget() + { + TestService.ResetBlockingAdd(); + var serverProvider = new CountingCompressionProvider( + SharpLinkCompressionProviders.CreateBrotli()); + await using var harness = await RunningHarness.CreateAsync( + serverRuntimeConfigure: options => + { + options.FlowControl.MaxConcurrentCallsPerServer = 1; + options.Compression.Providers.Add(serverProvider); + }, + admissionConfigure: options => options.Global.UseConcurrency(2), + clientRuntimeConfigure: options => options.Compression.Providers.Add( + SharpLinkCompressionProviders.CreateBrotli())); + var publicServer = (ISharpLinkServer)harness.Server; + var blocker = harness.ClientA.Get() + .BlockingAddAsync(1, 2, CancellationToken.None).AsTask(); + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + publicServer.DisableAdmissionControl(); + var decompressionsBeforeRejectedRequest = serverProvider.DecompressCount; + var payload = Enumerable.Repeat((byte)0x51, 32 * 1024).ToArray(); + + var failure = await CaptureFailureAsync( + harness.ClientB.Get().EchoBytesAsync(payload).AsTask()); + Ensure(failure is SharpLinkException { Code: SharpLinkErrorCode.ResourceExhausted }, + "runtime-disabled admission must not bypass ServerResourceGovernor call capacity"); + Ensure(serverProvider.DecompressCount == decompressionsBeforeRejectedRequest, + "capacity-rejected compressed request must perform zero provider decompression"); + await WaitUntilAsync( + () => harness.Server.ActiveDecodeCountForDiagnostics == 0 && + harness.Server.DecodedBytesInFlightForDiagnostics == 0, + "capacity rejection must leave zero decoded execution/rent accounting"); + + TestService.ReleaseBlockingAdd(); + Ensure(await blocker.WaitAsync(TimeSpan.FromSeconds(5)) == 3, + "capacity owner must complete normally"); + + var response = await harness.ClientB.Get() + .EchoBytesAsync(payload).AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + Ensure(response.SequenceEqual(payload), + "controlled capacity rejection must leave the connection reusable"); + Ensure(serverProvider.DecompressCount == decompressionsBeforeRejectedRequest + 1, + "accepted compressed request must decompress exactly once after capacity is available"); + } + finally + { + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(blocker); + } + + AssertAdmissionKernelEmpty( + harness.Server.AdmissionStateKernelForTests!, + "runtime-disabled compressed capacity rejection"); + } + + [Test] + [NotInParallel] + public async Task PreAdmissionStreamAccountingShouldRemainCorrectAcrossRuntimeDisable() + { + TestService.ResetBlockingAdd(); + await using var harness = await RunningHarness.CreateAsync( + serverRuntimeConfigure: options => + { + options.FlowControl.MaxPreAdmissionStreamBytesPerServer = StreamBudgetBytes; + options.FlowControl.StreamReceiveWindowBytes = 64 * 1024; + options.FlowControl.ConnectionReceiveWindowBytes = 256 * 1024; + }, + admissionConfigure: options => + { + options.Global.UseConcurrency(1); + options.MaxQueuedCalls = 2; + options.MaxQueuedBytes = 64 * 1024; + options.MaxQueueDelay = TimeSpan.FromSeconds(10); + }); + var publicServer = (ISharpLinkServer)harness.Server; + var original = harness.Server.CurrentAdmissionProgramForTests + ?? throw new Exception("stream regression requires an initially enabled generation"); + var producerRelease = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var active = harness.ClientA.Get() + .BlockingAddAsync(3, 4, CancellationToken.None).AsTask(); + Task? queued = null; + + try + { + await TestService.WaitForBlockingAddStartedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + queued = harness.ClientB.Get() + .UploadBytesAsync(TwoStreamItemsThenWaitAsync(producerRelease.Task)).AsTask(); + await WaitUntilAsync( + () => harness.Server.PreAdmissionStreamBytesForDiagnostics > StreamItemBytes * 2 && + original.Kernel.QueuedBytes > 0, + "queued old generation must own both pre-admission stream bytes and admission queue bytes"); + + var retainedStreamBytes = harness.Server.PreAdmissionStreamBytesForDiagnostics; + var retainedAdmissionBytes = original.Kernel.QueuedBytes; + publicServer.DisableAdmissionControl(); + + Ensure(original.IsRetired && !original.IsReclaimed, + "runtime disable must retire but retain the generation owning queued stream work"); + Ensure(harness.Server.PreAdmissionStreamBytesForDiagnostics == retainedStreamBytes, + "runtime disable must not drop physical pre-admission stream ownership"); + Ensure(original.Kernel.QueuedBytes == retainedAdmissionBytes, + "runtime disable must not alter old-generation admission queue byte ownership"); + + TestService.ReleaseBlockingAdd(); + Ensure(await active.WaitAsync(TimeSpan.FromSeconds(5)) == 7, + "old active request must complete after runtime disable"); + producerRelease.TrySetResult(); + Ensure(await queued.WaitAsync(TimeSpan.FromSeconds(5)) == StreamItemBytes * 2, + "queued old-generation stream must replay and complete after runtime disable"); + + await WaitUntilAsync( + () => harness.Server.PreAdmissionStreamBytesForDiagnostics == 0 && + original.Kernel.QueuedBytes == 0 && original.IsReclaimed, + "stream and admission ownership plus retired generation must drain to zero"); + Ensure(original.ReclaimCount == 1 && original.DuplicateReleaseAttempts == 0, + "queued stream generation must reclaim exactly once without release underflow"); + Ensure(await harness.ClientB.Get().AddAsync(20, 22) == 42, + "queued-stream transition must leave the connection reusable"); + } + finally + { + producerRelease.TrySetResult(); + TestService.ReleaseBlockingAdd(); + await ObserveTerminalAsync(active); + if (queued is not null) + await ObserveTerminalAsync(queued); + } + + AssertAdmissionKernelEmpty(original.Kernel, "runtime-disabled queued stream"); + } + + private static SharpLinkAdmissionContext CreateAdmissionContext() + => new(11, 22, RpcMethodKind.OneWay, "runtime-enable-oneway", null, null, null); + + private static byte[] CreateStreamItem(byte value) + => Enumerable.Repeat(value, StreamItemBytes).ToArray(); + + private static async IAsyncEnumerable TwoStreamItemsThenWaitAsync( + Task release, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return CreateStreamItem(0x61); + await Task.Yield(); + yield return CreateStreamItem(0x62); + await release.WaitAsync(cancellationToken); + } + + private static async Task CaptureFailureAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task ObserveTerminalAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void AssertAdmissionKernelEmpty(AdmissionStateKernel kernel, string scenario) + => Ensure( + kernel.LiveProgramCount == 0 && kernel.RetiredProgramCount == 0 && + kernel.RuleStateCount == 0 && kernel.PartitionStateCount == 0 && + kernel.QueuedCalls == 0 && kernel.QueuedBytes == 0 && kernel.ActivePermits == 0, + $"{scenario}: admission lifecycle diagnostics must return to zero"); + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class CountingCompressionProvider(ISharpLinkCompressionProvider inner) + : ISharpLinkCompressionProvider + { + private int _decompressCount; + + public string WireProfile => inner.WireProfile; + internal int DecompressCount => Volatile.Read(ref _decompressCount); + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => inner.Compress(input, output, maxOutputBytes, cancellationToken); + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _decompressCount); + return inner.Decompress(input, output, maxOutputBytes, cancellationToken); + } + } + + private sealed class RunningHarness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCancellation; + private readonly Task _serverTask; + private bool _disposed; + + private RunningHarness( + CancellationTokenSource serverCancellation, + Task serverTask, + SharpLinkServer server, + ISharpLinkClient clientA, + ISharpLinkClient clientB) + { + _serverCancellation = serverCancellation; + _serverTask = serverTask; + Server = server; + ClientA = clientA; + ClientB = clientB; + } + + internal SharpLinkServer Server { get; } + internal ISharpLinkClient ClientA { get; } + internal ISharpLinkClient ClientB { get; } + + internal static async Task CreateAsync( + Action? serverRuntimeConfigure = null, + Action? admissionConfigure = null, + Action? clientRuntimeConfigure = null) + { + var serverCancellation = new CancellationTokenSource(); + var serverBuilder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); + if (serverRuntimeConfigure is not null) + serverBuilder.UseRuntime(serverRuntimeConfigure); + if (admissionConfigure is not null) + serverBuilder.UseAdmissionControl(admissionConfigure); + serverBuilder.UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)serverBuilder.Transport!.LocalEndPoint!).Port; + var server = (SharpLinkServer)serverBuilder.Build(); + var serverTask = RunServerAsync(server, serverCancellation.Token); + + var clientA = CreateClient(port, clientRuntimeConfigure); + var clientB = CreateClient(port, clientRuntimeConfigure); + await clientA.ConnectAsync(); + await clientB.ConnectAsync(); + return new RunningHarness(serverCancellation, serverTask, server, clientA, clientB); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + _disposed = true; + try + { + await StopClientAsync(ClientA); + await StopClientAsync(ClientB); + } + finally + { + await _serverCancellation.CancelAsync(); + try + { + await Server.StopAsync(TimeSpan.Zero); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException) + { + } + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCancellation.Dispose(); + } + } + + private static ISharpLinkClient CreateClient( + int port, + Action? runtimeConfigure) + { + var builder = SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)); + if (runtimeConfigure is not null) + builder.UseRuntime(runtimeConfigure); + return builder.UseTcp(IPAddress.Loopback.ToString(), port).Build(); + } + + private static async Task StopClientAsync(ISharpLinkClient client) + { + try + { + await client.StopAsync(); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) + { + } + } + + private static Task RunServerAsync( + ISharpLinkServer server, + CancellationToken cancellationToken) + => Task.Run(async () => + { + try + { + await server.RunAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (IOException) + { + } + catch (SocketException) + { + } + }, CancellationToken.None); + } +} From c1c68580e3337bf640c5c677cce829cae592f423 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:08:49 +0800 Subject: [PATCH 220/228] Clarify runtime admission stop boundary --- doc/admission-control.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/admission-control.md b/doc/admission-control.md index 93410a364..cecef09a2 100644 --- a/doc/admission-control.md +++ b/doc/admission-control.md @@ -49,6 +49,8 @@ server.EnableAdmissionControl(options => 停用只影响之后捕获接入状态的请求,不会取消已经捕获旧 generation 的活动或排队请求,也不会等待这些请求结束。旧 generation 会按正常 retire/reclaim 生命周期完成;在旧 generation 尚未回收时以兼容配置重新启用,会复用稳定 kernel 中兼容的并发、速率、队列和 partition 状态,因此不会重置已消费配额或复制全局记账。 +普通的 `DisableAdmissionControl` 不是 Server Stop:它只切换 Admission publication,不触发 `StopAccepting`,也不取消或等待旧 generation。反过来,一旦 Server 已进入 Draining、Stopped 或 Faulted,Admission control plane 就已封口;之后的 `EnableAdmissionControl` 或 `DisableAdmissionControl` 都会抛出 `InvalidOperationException`,且不会再发布任何 program。与 Stop 并发时,结果按同一生命周期 writer lock 的线性化顺序决定。 + 运行时停用 Admission 不会停用 `ServerResourceGovernor`。调用容量、解码/预接入预算、保留字节和流式字节等服务器资源限制始终独立生效。 ## 排队 From 97bc954f0f8dcde67d7ba63ff16aad9fa48717cb Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:14:10 +0800 Subject: [PATCH 221/228] fix(server): coordinate decode scheduler wake ownership --- src/SharpLink.Server/ServerDecodeExecutor.cs | 153 ++++++++++++++----- 1 file changed, 112 insertions(+), 41 deletions(-) diff --git a/src/SharpLink.Server/ServerDecodeExecutor.cs b/src/SharpLink.Server/ServerDecodeExecutor.cs index b3b0c4488..8bae3015a 100644 --- a/src/SharpLink.Server/ServerDecodeExecutor.cs +++ b/src/SharpLink.Server/ServerDecodeExecutor.cs @@ -19,11 +19,14 @@ internal sealed class ServerDecodeExecutor : IAsyncDisposable private readonly SemaphoreSlim _readySignal = new(0); private readonly SemaphoreSlim _compatibilitySlots; private readonly CancellationTokenSource _compatibilityStop = new(); + private readonly TaskCompletionSource _compatibilityOperationsDrained = + new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Task[] _workers; private readonly Task _completion; private readonly int _queueCapacity; private int _completionRequested; private int _disposeRequested; + private int _compatibilityOperations; private int _queueReservations; private int _queueDepth; private int _skippedBeforeStart; @@ -56,6 +59,8 @@ internal ServerDecodeExecutor(int workerCount, int queueCapacity) /// internal int QueueReservations => Volatile.Read(ref _queueReservations); + internal int ReadySignalCount => _readySignal.CurrentCount; + internal int SkippedBeforeStart => Volatile.Read(ref _skippedBeforeStart); internal int StartedWorkItems => Volatile.Read(ref _startedWorkItems); @@ -128,12 +133,11 @@ internal ValueTask EnqueueReservedAsync( () => RemoveCancelledBeforeStart(entry)); var published = false; - var signalWorker = false; lock (_schedulerGate) { if (Volatile.Read(ref _completionRequested) == 0 && !workItem.IsCancelledBeforeStart) { - PublishEntryLocked(entry, out signalWorker); + PublishEntryLocked(entry); published = true; } } @@ -148,8 +152,6 @@ internal ValueTask EnqueueReservedAsync( return ValueTask.FromException(new ServerDecodeExecutorClosedException()); } - if (signalWorker) - _readySignal.Release(); return new ValueTask(workItem.Completion); } @@ -170,29 +172,44 @@ internal ValueTask EnqueueAsync( { ArgumentNullException.ThrowIfNull(schedulingKey); ArgumentNullException.ThrowIfNull(workItem); - if (Volatile.Read(ref _completionRequested) != 0) + + lock (_schedulerGate) { - return cancellationToken.IsCancellationRequested - ? ValueTask.FromCanceled(cancellationToken) - : ValueTask.FromException(new ServerDecodeExecutorClosedException()); + if (Volatile.Read(ref _completionRequested) != 0) + { + return cancellationToken.IsCancellationRequested + ? ValueTask.FromCanceled(cancellationToken) + : ValueTask.FromException(new ServerDecodeExecutorClosedException()); + } + + _compatibilityOperations++; } - return EnqueueCoreAsync(schedulingKey, workItem, cancellationToken); + return EnqueueTrackedCompatibilityAsync(schedulingKey, workItem, cancellationToken); } internal void StopAccepting() { - if (Interlocked.Exchange(ref _completionRequested, 1) != 0) - return; + var compatibilityDrained = false; + lock (_schedulerGate) + { + if (Volatile.Read(ref _completionRequested) != 0) + return; + + Volatile.Write(ref _completionRequested, 1); + compatibilityDrained = _compatibilityOperations == 0; + } _compatibilityStop.Cancel(); + if (compatibilityDrained) + _compatibilityOperationsDrained.TrySetResult(); _readySignal.Release(_workers.Length); } internal async ValueTask CompleteAsync() { StopAccepting(); - await _completion.ConfigureAwait(false); + await Task.WhenAll(_completion, _compatibilityOperationsDrained.Task).ConfigureAwait(false); } public async ValueTask DisposeAsync() @@ -216,6 +233,21 @@ internal void ReleaseQueueReservation() throw new InvalidOperationException("Server decode queue reservation accounting underflowed."); } + private async ValueTask EnqueueTrackedCompatibilityAsync( + object schedulingKey, + ServerDecodeWorkItem workItem, + CancellationToken cancellationToken) + { + try + { + await EnqueueCoreAsync(schedulingKey, workItem, cancellationToken).ConfigureAwait(false); + } + finally + { + CompleteCompatibilityOperation(); + } + } + private async ValueTask EnqueueCoreAsync( object schedulingKey, ServerDecodeWorkItem workItem, @@ -241,12 +273,11 @@ private async ValueTask EnqueueCoreAsync( cancellationToken, () => RemoveCancelledBeforeStart(entry)); - var signalWorker = false; lock (_schedulerGate) { if (Volatile.Read(ref _completionRequested) == 0 && !workItem.IsCancelledBeforeStart) { - PublishEntryLocked(entry, out signalWorker, queueDepthAlreadyOwned: true); + PublishEntryLocked(entry, queueDepthAlreadyOwned: true); published = true; } } @@ -259,8 +290,6 @@ private async ValueTask EnqueueCoreAsync( throw new ServerDecodeExecutorClosedException(); } - if (signalWorker) - _readySignal.Release(); await workItem.Completion.ConfigureAwait(false); } catch (OperationCanceledException) when (!published) @@ -274,9 +303,9 @@ private async ValueTask EnqueueCoreAsync( { if (!published) { + DecrementQueueDepth(); if (slotAcquired) _compatibilitySlots.Release(); - DecrementQueueDepth(); } } } @@ -297,7 +326,6 @@ private async Task WorkerLoopAsync() continue; } - ReleaseQueuedOwnership(entry); var workItem = entry.WorkItem; if (!workItem.TryStart()) { @@ -315,7 +343,6 @@ private async Task WorkerLoopAsync() private void PublishEntryLocked( ServerDecodeQueueEntry entry, - out bool signalWorker, bool queueDepthAlreadyOwned = false) { if (!_connectionQueues.TryGetValue(entry.SchedulingKey, out var queue)) @@ -329,17 +356,23 @@ private void PublishEntryLocked( if (!queueDepthAlreadyOwned) Interlocked.Increment(ref _queueDepth); - signalWorker = false; if (queue.ReadyNode is null) - { - queue.ReadyNode = _readyConnections.AddLast(queue); - signalWorker = true; - } + AddReadyConnectionLocked(queue); + } + + private void AddReadyConnectionLocked(ConnectionQueue queue) + { + if (queue.ReadyNode is not null) + throw new InvalidOperationException("A decode connection can only have one ready node."); + + queue.ReadyNode = _readyConnections.AddLast(queue); + // Publish the wake while holding the same gate that protects the ready node. Cancellation can + // then either retire an unconsumed permit or observe that a worker has already claimed it. + _readySignal.Release(); } private bool TryTakeNextEntry(out ServerDecodeQueueEntry entry) { - var signalAnotherWorker = false; lock (_schedulerGate) { while (_readyConnections.First is { } readyNode) @@ -357,7 +390,6 @@ private bool TryTakeNextEntry(out ServerDecodeQueueEntry entry) entry = pendingNode.Value; queue.Pending.Remove(pendingNode); entry.PendingNode = null; - entry.Owner = null; DecrementQueueDepth(); if (queue.Pending.Count == 0) @@ -366,21 +398,20 @@ private bool TryTakeNextEntry(out ServerDecodeQueueEntry entry) } else { - queue.ReadyNode = _readyConnections.AddLast(queue); - signalAnotherWorker = true; + AddReadyConnectionLocked(queue); } - goto Found; + // Release bounded queue ownership before publishing that a cancellation-completed + // work item is terminal. A cancellation callback that lost the dequeue race blocks on + // this same gate until the release below has happened. + ReleaseQueuedOwnership(entry); + entry.Owner = null; + return true; } entry = null!; return false; } - - Found: - if (signalAnotherWorker) - _readySignal.Release(); - return true; } private void RemoveCancelledBeforeStart(ServerDecodeQueueEntry entry) @@ -400,11 +431,7 @@ private void RemoveCancelledBeforeStart(ServerDecodeQueueEntry entry) if (queue.Pending.Count == 0) { - if (queue.ReadyNode is { } readyNode && readyNode.List is not null) - { - _readyConnections.Remove(readyNode); - queue.ReadyNode = null; - } + RemoveReadyConnectionLocked(queue); _connectionQueues.Remove(queue.SchedulingKey); } @@ -419,6 +446,23 @@ private void RemoveCancelledBeforeStart(ServerDecodeQueueEntry entry) entry.WorkItem.CompleteRemovedBeforeStart(); } + private void RemoveReadyConnectionLocked(ConnectionQueue queue) + { + var readyNode = queue.ReadyNode; + if (readyNode is null || readyNode.List is null) + return; + + _readyConnections.Remove(readyNode); + queue.ReadyNode = null; + + // While accepting, every ready node has exactly one coordinated wake. If the permit is still + // in the semaphore, retire it. If Wait(0) fails, a worker already consumed that wake and will + // observe the updated ready ring after acquiring _schedulerGate. Stop wakes are deliberately + // not retired because they are needed to let idle workers exit after drain. + if (Volatile.Read(ref _completionRequested) == 0) + _readySignal.Wait(0); + } + private void ReleaseQueuedOwnership(ServerDecodeQueueEntry entry) { if (entry.QueuePermit is not null) @@ -427,6 +471,26 @@ private void ReleaseQueuedOwnership(ServerDecodeQueueEntry entry) _compatibilitySlots.Release(); } + private void CompleteCompatibilityOperation() + { + var drained = false; + lock (_schedulerGate) + { + _compatibilityOperations--; + if (_compatibilityOperations < 0) + { + _compatibilityOperations++; + throw new InvalidOperationException("Compatibility decode operation accounting underflowed."); + } + + drained = _compatibilityOperations == 0 && + Volatile.Read(ref _completionRequested) != 0; + } + + if (drained) + _compatibilityOperationsDrained.TrySetResult(); + } + private void DecrementQueueDepth() { var remaining = Interlocked.Decrement(ref _queueDepth); @@ -633,7 +697,14 @@ private void CancelBeforeStart() { if (Interlocked.CompareExchange(ref _state, CancelledBeforeStart, Queued) != Queued) return; - _completion.TrySetCanceled(_cancellationToken); - _cancelledBeforeStart?.Invoke(); + + try + { + _cancelledBeforeStart?.Invoke(); + } + finally + { + _completion.TrySetCanceled(_cancellationToken); + } } } From 3e7b6b4a243d085dcb3dd9b85b36e8853df4de70 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:14:42 +0800 Subject: [PATCH 222/228] test(server): cover stale ready wakes with four workers --- .../ServerDecodeExecutorSchedulerRaceTests.cs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs new file mode 100644 index 000000000..4204269c8 --- /dev/null +++ b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs @@ -0,0 +1,114 @@ +using SharpLink.Server; +using System.Threading; + +namespace SharpLink.UnitTests.Server; + +public class ServerDecodeExecutorSchedulerRaceTests +{ + [Test] + public async Task CancellingLastReadyItemsShouldRetireWakePermitsWithAllWorkersBusy() + { + const int workerCount = 4; + const int cancellationCycles = 128; + + await using var executor = new ServerDecodeExecutor(workerCount, queueCapacity: 8); + var releaseWorkers = NewSignal(); + var startedSignals = new TaskCompletionSource[workerCount]; + var blockers = new Task[workerCount]; + + for (var index = 0; index < workerCount; index++) + { + var started = NewSignal(); + startedSignals[index] = started; + blockers[index] = executor.EnqueueAsync( + new object(), + new ServerDecodeWorkItem(async _ => + { + started.TrySetResult(); + await releaseWorkers.Task.ConfigureAwait(false); + }), + CancellationToken.None).AsTask(); + } + + for (var index = 0; index < workerCount; index++) + await startedSignals[index].Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(executor.QueueDepth == 0, "all worker blockers must be running rather than queued"); + Ensure(executor.ScheduledConnectionCount == 0, + "running blockers must leave no ready connection metadata behind"); + Ensure(executor.ReadySignalCount == 0, + "all blocker wake permits must already be owned by the busy workers"); + + for (var cycle = 0; cycle < cancellationCycles; cycle++) + { + using var cancellation = new CancellationTokenSource(); + var cancelled = executor.EnqueueAsync( + new object(), + new ServerDecodeWorkItem(_ => ValueTask.CompletedTask), + cancellation.Token).AsTask(); + + await WaitUntilAsync( + () => executor.QueueDepth == 1 && executor.ScheduledConnectionCount == 1, + $"cycle {cycle} ready item publication"); + + cancellation.Cancel(); + await EnsureCancelledAsync(cancelled, $"cycle {cycle} queued cancellation"); + Ensure(executor.QueueDepth == 0, + $"cycle {cycle} cancellation must release queue depth"); + Ensure(executor.ScheduledConnectionCount == 0, + $"cycle {cycle} cancellation must remove the last ready connection"); + } + + Ensure(executor.ReadySignalCount == 0, + "repeated publish/cancel cycles must not accumulate historical ready permits"); + Ensure(executor.SkippedBeforeStart == cancellationCycles, + "every cancelled ready item must be removed before provider start"); + + releaseWorkers.TrySetResult(); + await Task.WhenAll(blockers).WaitAsync(TimeSpan.FromSeconds(5)); + await executor.CompleteAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(executor.QueueDepth == 0, "multi-worker cancellation stress must drain queue depth"); + Ensure(executor.ScheduledConnectionCount == 0, + "multi-worker cancellation stress must reclaim scheduler metadata"); + } + + private static TaskCompletionSource NewSignal() + => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static async Task EnsureCancelledAsync(Task task, string scenario) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(2)); + throw new Exception($"assert failed: {scenario} should cancel"); + } + catch (OperationCanceledException) + { + } + catch (TimeoutException) + { + throw new Exception($"assert failed: {scenario} did not complete"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } +} From 9b5a96325b980ba52f2030e598b9bcd988bcddb0 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:25:12 +0800 Subject: [PATCH 223/228] test(server): cover compatibility dispose publication race --- .../ServerDecodeExecutorSchedulerRaceTests.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs index 4204269c8..285e63fcf 100644 --- a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs @@ -73,6 +73,61 @@ await WaitUntilAsync( "multi-worker cancellation stress must reclaim scheduler metadata"); } + [Test] + public async Task DisposeShouldWaitForCompatibilityWriterThatAcquiredSlotBeforePublication() + { + var executor = new ServerDecodeExecutor(workerCount: 1, queueCapacity: 1); + var slots = ReadPrivateField(executor, "_compatibilitySlots"); + var schedulerGate = ReadPrivateField(executor, "_schedulerGate"); + slots.Wait(); + + Task enqueue; + Task dispose; + try + { + enqueue = executor.EnqueueAsync( + new ServerDecodeWorkItem(_ => ValueTask.CompletedTask), + CancellationToken.None).AsTask(); + Ensure(executor.QueueDepth == 1, + "compatibility writer must own pending depth while blocked on queue capacity"); + + lock (schedulerGate) + { + slots.Release(); + Ensure( + SpinWait.SpinUntil(() => slots.CurrentCount == 0, TimeSpan.FromSeconds(2)), + "compatibility writer did not acquire the released slot before publication"); + + dispose = executor.DisposeAsync().AsTask(); + Ensure(!dispose.IsCompleted, + "dispose must remain joined to the admitted compatibility writer"); + } + + await EnsureFailsAsync( + enqueue, + "compatibility writer crossing dispose before publication"); + await dispose.WaitAsync(TimeSpan.FromSeconds(2)); + + Ensure(executor.QueueDepth == 0, + "compatibility writer rollback must release pending depth before disposal completes"); + } + finally + { + if (Volatile.Read(ref dispose) is null) + await executor.DisposeAsync(); + } + } + + private static T ReadPrivateField(ServerDecodeExecutor executor, string name) + { + var field = typeof(ServerDecodeExecutor).GetField( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find executor field {name}"); + return (T)field.GetValue(executor)!; + } + private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -106,6 +161,23 @@ private static async Task EnsureCancelledAsync(Task task, string scenario) } } + private static async Task EnsureFailsAsync(Task task, string scenario) + where TException : Exception + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(2)); + throw new Exception($"assert failed: {scenario} should fail"); + } + catch (TException) + { + } + catch (TimeoutException) + { + throw new Exception($"assert failed: {scenario} did not complete"); + } + } + private static void Ensure(bool condition, string scenario) { if (!condition) From 0f33f7410577d7cdf1939e13188e32bcc1d8d286 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:25:36 +0800 Subject: [PATCH 224/228] fix(test): make dispose race regression deterministic --- .../Server/ServerDecodeExecutorSchedulerRaceTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs index 285e63fcf..e43dfc58b 100644 --- a/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs +++ b/test/SharpLink.UnitTests/Server/ServerDecodeExecutorSchedulerRaceTests.cs @@ -81,11 +81,10 @@ public async Task DisposeShouldWaitForCompatibilityWriterThatAcquiredSlotBeforeP var schedulerGate = ReadPrivateField(executor, "_schedulerGate"); slots.Wait(); - Task enqueue; - Task dispose; + Task? dispose = null; try { - enqueue = executor.EnqueueAsync( + var enqueue = executor.EnqueueAsync( new ServerDecodeWorkItem(_ => ValueTask.CompletedTask), CancellationToken.None).AsTask(); Ensure(executor.QueueDepth == 1, @@ -113,8 +112,10 @@ await EnsureFailsAsync( } finally { - if (Volatile.Read(ref dispose) is null) + if (dispose is null) await executor.DisposeAsync(); + else if (!dispose.IsCompleted) + await dispose; } } From 34c263a8d1cb3aa48231fb7f2ee1ced0b3f1dbf1 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:27:18 +0800 Subject: [PATCH 225/228] test(server): cover four-worker connection close --- ...ionPersistentDecodeFourWorkerCloseTests.cs | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs new file mode 100644 index 000000000..478ce9e67 --- /dev/null +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs @@ -0,0 +1,218 @@ +using System.Collections.Concurrent; + +namespace SharpLink.IntegrationTests; + +public class CompressionPersistentDecodeFourWorkerCloseTests +{ + private const int PayloadBytes = 2 * 1024 * 1024; + + [Test] + [NotInParallel] + public async Task ConnectionCloseShouldRemoveQueuedTurnWhileFourWorkersStayBusy() + { + PersistentDecodeReviewService.Reset(); + var coordinator = new Coordinator(); + await using var harness = await Harness.CreateAsync(coordinator); + await WaitUntilAsync(() => harness.WorkerCount == 4, "four decode workers started"); + + var serviceA = harness.ClientA.Get(); + var serviceB = harness.ClientB.Get(); + var payloadA = Enumerable.Repeat((byte)0x61, PayloadBytes).ToArray(); + var payloadB = Enumerable.Repeat((byte)0x62, PayloadBytes).ToArray(); + + var runningA = Enumerable.Range(0, 4) + .Select(_ => serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask()) + .ToArray(); + await coordinator.WaitForStartsAsync(4); + await WaitUntilAsync( + () => harness.ActiveDecodes == 4 && harness.QueueDepth == 0, + "four A providers occupied all workers"); + + var queuedA = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); + var queuedB = serviceB.MeasureAsync(payloadB, CancellationToken.None).AsTask(); + await WaitUntilAsync( + () => harness.QueueDepth == 2 && harness.QueueReservations == 2 && + harness.ScheduledConnections == 2 && harness.ActiveDecodes == 4, + "A and B queued behind occupied workers"); + + var stopA = harness.ClientA.StopAsync().AsTask(); + await WaitUntilAsync( + () => harness.QueueDepth == 1 && harness.QueueReservations == 1 && + harness.ScheduledConnections == 1 && harness.ActiveDecodes == 4, + "closed A queue removed before worker availability"); + Ensure(coordinator.StartOrder.Count == 4, + "queued close cleanup must not require a worker to return"); + + coordinator.ReleaseA(); + await coordinator.WaitForStartsAsync(5); + await queuedB.WaitAsync(TimeSpan.FromSeconds(10)); + await Task.WhenAll(runningA.Select(ObserveTerminationAsync)); + await ObserveTerminationAsync(queuedA); + await stopA.WaitAsync(TimeSpan.FromSeconds(5)); + + Ensure(coordinator.StartOrder[4] == "B", + $"B must receive the first post-close start; observed {string.Join(',', coordinator.StartOrder)}"); + await WaitUntilAsync( + () => harness.ActiveDecodes == 0 && harness.QueueDepth == 0 && + harness.QueueReservations == 0 && harness.ScheduledConnections == 0, + "four-worker close resources released"); + } + + private static async Task ObserveTerminationAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (OperationCanceledException) + { + } + catch (SharpLinkException exception) when ( + exception.Code is SharpLinkErrorCode.Cancelled or SharpLinkErrorCode.ConnectionClosed or + SharpLinkErrorCode.Unavailable) + { + } + catch (IOException) + { + } + } + + private static async Task WaitUntilAsync(Func condition, string scenario) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + while (!condition()) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: {scenario}"); + } + } + + private static void Ensure(bool condition, string scenario) + { + if (!condition) + throw new Exception($"assert failed: {scenario}"); + } + + private sealed class Harness : IAsyncDisposable + { + private readonly CancellationTokenSource _serverCts; + private readonly Task _serverTask; + private readonly ISharpLinkServer _server; + + private Harness(CancellationTokenSource serverCts, Task serverTask, ISharpLinkServer server, + ISharpLinkClient clientA, ISharpLinkClient clientB) + => (_serverCts, _serverTask, _server, ClientA, ClientB) = + (serverCts, serverTask, server, clientA, clientB); + + internal ISharpLinkClient ClientA { get; } + internal ISharpLinkClient ClientB { get; } + internal int ActiveDecodes => Read("ActiveDecodeCountForDiagnostics"); + internal int WorkerCount => Read("DecodeWorkerCountForDiagnostics"); + internal int QueueDepth => Read("DecodeQueueDepthForDiagnostics"); + internal int QueueReservations => Read("DecodeQueueReservationsForDiagnostics"); + internal int ScheduledConnections => Read("DecodeScheduledConnectionCountForDiagnostics"); + + internal static async Task CreateAsync(Coordinator coordinator) + { + var cts = new CancellationTokenSource(); + var builder = SharpLinkServerBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseRuntime(options => + { + options.FlowControl.MaxConcurrentCallsPerConnection = 16; + options.FlowControl.MaxConcurrentCallsPerServer = 32; + options.FlowControl.MaxConcurrentDecodesPerServer = 4; + options.FlowControl.MaxRetainedCompressedBytesPerServer = 64L * 1024 * 1024; + options.FlowControl.MaxDecodedBytesInFlightPerServer = 64L * 1024 * 1024; + options.Compression.Providers.Add(new Provider("review-four-a", "A", coordinator)); + options.Compression.Providers.Add(new Provider("review-four-b", "B", coordinator)); + }) + .UseTcp(0, IPAddress.Loopback.ToString()); + var port = ((IPEndPoint)builder.Transport!.LocalEndPoint!).Port; + var server = builder.Build(); + var serverTask = RunServerAsync(server, cts.Token); + var clientA = CreateClient(port, "review-four-a", "A"); + var clientB = CreateClient(port, "review-four-b", "B"); + await clientA.ConnectAsync(); + await clientB.ConnectAsync(); + return new Harness(cts, serverTask, server, clientA, clientB); + } + + public async ValueTask DisposeAsync() + { + await StopClientAsync(ClientA); + await StopClientAsync(ClientB); + await _serverCts.CancelAsync(); + await _server.StopAsync(TimeSpan.Zero); + await Task.WhenAny(_serverTask, Task.Delay(1000, CancellationToken.None)); + _serverCts.Dispose(); + } + + private T Read(string name) + => (T)(_server.GetType().GetProperty(name, + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?? throw new Exception($"cannot find {name}")).GetValue(_server)!; + + private static ISharpLinkClient CreateClient(int port, string profile, string tag) + => SharpClientBuilder.Create().UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + .UseTcp(IPAddress.Loopback.ToString(), port) + .UseRuntime(options => options.Compression.Providers.Add(new Provider(profile, tag, null))) + .Build(); + + private static async Task StopClientAsync(ISharpLinkClient client) + { + try { await client.StopAsync(); } + catch (Exception e) when (e is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) { } + } + } + + private sealed class Coordinator + { + private readonly ManualResetEventSlim _releaseA = new(); + private readonly ConcurrentQueue _order = new(); + private int _starts; + internal IReadOnlyList StartOrder => _order.ToArray(); + internal void ReleaseA() => _releaseA.Set(); + internal async Task WaitForStartsAsync(int expected) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (Volatile.Read(ref _starts) < expected) + await Task.Delay(10, timeout.Token); + } + internal CancellationToken Record(string tag, CancellationToken token) + { + _order.Enqueue(tag); + Interlocked.Increment(ref _starts); + if (tag == "A") + { + _releaseA.Wait(CancellationToken.None); + return CancellationToken.None; + } + return token; + } + } + + private sealed class Provider(string profile, string tag, Coordinator? coordinator) : ISharpLinkCompressionProvider + { + private readonly ISharpLinkCompressionProvider _inner = SharpLinkCompressionProviders.CreateBrotli(); + public string WireProfile => profile; + public SharpLinkCompressionResult Compress(ReadOnlySequence input, IBufferWriter output, + int maxOutputBytes, CancellationToken cancellationToken = default) + => _inner.Compress(input, output, maxOutputBytes, cancellationToken); + public SharpLinkCompressionResult Decompress(ReadOnlySequence input, IBufferWriter output, + int maxOutputBytes, CancellationToken cancellationToken = default) + => _inner.Decompress(input, output, maxOutputBytes, + coordinator?.Record(tag, cancellationToken) ?? cancellationToken); + } + + private static Task RunServerAsync(ISharpLinkServer server, CancellationToken token) + => Task.Run(async () => + { + try { await server.RunAsync(token); } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException or IOException or SocketException) { } + }, CancellationToken.None); +} From e10364056e2dd4e569b7bb30bf56ffac3b6767d6 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:34:12 +0800 Subject: [PATCH 226/228] test(server): make worker saturation portable and cover remote cancel --- ...ionPersistentDecodeFourWorkerCloseTests.cs | 201 ++++++++++++++---- 1 file changed, 163 insertions(+), 38 deletions(-) diff --git a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs index 478ce9e67..3b60d8da9 100644 --- a/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs +++ b/test/SharpLink.IntegrationTests/CompressionPersistentDecodeFourWorkerCloseTests.cs @@ -2,60 +2,137 @@ namespace SharpLink.IntegrationTests; -public class CompressionPersistentDecodeFourWorkerCloseTests +public class CompressionPersistentDecodeWorkerSaturationTests { private const int PayloadBytes = 2 * 1024 * 1024; [Test] [NotInParallel] - public async Task ConnectionCloseShouldRemoveQueuedTurnWhileFourWorkersStayBusy() + public async Task ConnectionCloseShouldRemoveQueuedTurnWhileAllWorkersStayBusy() { PersistentDecodeReviewService.Reset(); var coordinator = new Coordinator(); await using var harness = await Harness.CreateAsync(coordinator); - await WaitUntilAsync(() => harness.WorkerCount == 4, "four decode workers started"); + var workerCount = GetPortableWorkerCount(harness); var serviceA = harness.ClientA.Get(); var serviceB = harness.ClientB.Get(); var payloadA = Enumerable.Repeat((byte)0x61, PayloadBytes).ToArray(); var payloadB = Enumerable.Repeat((byte)0x62, PayloadBytes).ToArray(); - var runningA = Enumerable.Range(0, 4) + var runningA = Enumerable.Range(0, workerCount) .Select(_ => serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask()) .ToArray(); - await coordinator.WaitForStartsAsync(4); + await coordinator.WaitForStartsAsync(workerCount); await WaitUntilAsync( - () => harness.ActiveDecodes == 4 && harness.QueueDepth == 0, - "four A providers occupied all workers"); + () => harness.ActiveDecodes == workerCount && harness.QueueDepth == 0, + $"all {workerCount} A providers occupied the available workers"); var queuedA = serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask(); var queuedB = serviceB.MeasureAsync(payloadB, CancellationToken.None).AsTask(); await WaitUntilAsync( () => harness.QueueDepth == 2 && harness.QueueReservations == 2 && - harness.ScheduledConnections == 2 && harness.ActiveDecodes == 4, + harness.ScheduledConnections == 2 && harness.ActiveDecodes == workerCount, "A and B queued behind occupied workers"); var stopA = harness.ClientA.StopAsync().AsTask(); await WaitUntilAsync( () => harness.QueueDepth == 1 && harness.QueueReservations == 1 && - harness.ScheduledConnections == 1 && harness.ActiveDecodes == 4, + harness.ScheduledConnections == 1 && harness.ActiveDecodes == workerCount, "closed A queue removed before worker availability"); - Ensure(coordinator.StartOrder.Count == 4, + Ensure(coordinator.StartOrder.Count == workerCount, "queued close cleanup must not require a worker to return"); coordinator.ReleaseA(); - await coordinator.WaitForStartsAsync(5); + await coordinator.WaitForStartsAsync(workerCount + 1); await queuedB.WaitAsync(TimeSpan.FromSeconds(10)); await Task.WhenAll(runningA.Select(ObserveTerminationAsync)); await ObserveTerminationAsync(queuedA); await stopA.WaitAsync(TimeSpan.FromSeconds(5)); - Ensure(coordinator.StartOrder[4] == "B", + Ensure(coordinator.StartOrder[workerCount] == "B", $"B must receive the first post-close start; observed {string.Join(',', coordinator.StartOrder)}"); + await AssertSchedulerReleasedAsync(harness, "connection-close worker saturation"); + } + + [Test] + [NotInParallel] + public async Task RemoteCancelShouldRemoveQueuedTurnWithoutPerturbingPeerWhileAllWorkersStayBusy() + { + PersistentDecodeReviewService.Reset(); + var coordinator = new Coordinator(); + await using var harness = await Harness.CreateAsync(coordinator); + var workerCount = GetPortableWorkerCount(harness); + + var serviceA = harness.ClientA.Get(); + var serviceB = harness.ClientB.Get(); + var payloadA = Enumerable.Repeat((byte)0x71, PayloadBytes).ToArray(); + var payloadB = Enumerable.Repeat((byte)0x72, PayloadBytes).ToArray(); + + var runningA = Enumerable.Range(0, workerCount) + .Select(_ => serviceA.MeasureAsync(payloadA, CancellationToken.None).AsTask()) + .ToArray(); + await coordinator.WaitForStartsAsync(workerCount); + await WaitUntilAsync( + () => harness.ActiveDecodes == workerCount && harness.QueueDepth == 0, + $"all {workerCount} A providers occupied the available workers before remote Cancel"); + + using var queuedCancellation = new CancellationTokenSource(); + var queuedA = serviceA.MeasureAsync(payloadA, queuedCancellation.Token).AsTask(); + var queuedB = serviceB.MeasureAsync(payloadB, CancellationToken.None).AsTask(); + await WaitUntilAsync( + () => harness.QueueDepth == 2 && harness.QueueReservations == 2 && + harness.ScheduledConnections == 2 && harness.ActiveDecodes == workerCount, + "A and B queued behind occupied workers before remote Cancel"); + + queuedCancellation.Cancel(); + await WaitUntilAsync( + () => harness.QueueDepth == 1 && harness.QueueReservations == 1 && + harness.ScheduledConnections == 1 && harness.ActiveDecodes == workerCount, + "remote Cancel removed only A queued ownership before worker availability"); + Ensure(coordinator.StartOrder.Count == workerCount, + "remote Cancel cleanup must not consume a worker or start B early"); + + coordinator.ReleaseA(); + await coordinator.WaitForStartsAsync(workerCount + 1); + await queuedB.WaitAsync(TimeSpan.FromSeconds(10)); + await ObserveCancellationAsync(queuedA); + await Task.WhenAll(runningA.Select(ObserveTerminationAsync)); + + Ensure(coordinator.StartOrder[workerCount] == "B", + $"B must receive the first post-cancel start; observed {string.Join(',', coordinator.StartOrder)}"); + await AssertSchedulerReleasedAsync(harness, "remote Cancel worker saturation"); + } + + private static int GetPortableWorkerCount(Harness harness) + { + var workerCount = harness.WorkerCount; + Ensure(workerCount is >= 1 and <= 4, + $"decode worker count must stay within the production 1..4 clamp; observed {workerCount}"); + return workerCount; + } + + private static async Task AssertSchedulerReleasedAsync(Harness harness, string scenario) + { await WaitUntilAsync( () => harness.ActiveDecodes == 0 && harness.QueueDepth == 0 && harness.QueueReservations == 0 && harness.ScheduledConnections == 0, - "four-worker close resources released"); + $"{scenario} resources released"); + } + + private static async Task ObserveCancellationAsync(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(10)); + throw new Exception("assert failed: remotely cancelled queued call should not complete successfully"); + } + catch (OperationCanceledException) + { + } + catch (SharpLinkException exception) when (exception.Code == SharpLinkErrorCode.Cancelled) + { + } } private static async Task ObserveTerminationAsync(Task task) @@ -102,11 +179,17 @@ private sealed class Harness : IAsyncDisposable private readonly CancellationTokenSource _serverCts; private readonly Task _serverTask; private readonly ISharpLinkServer _server; + private readonly Coordinator _coordinator; - private Harness(CancellationTokenSource serverCts, Task serverTask, ISharpLinkServer server, - ISharpLinkClient clientA, ISharpLinkClient clientB) - => (_serverCts, _serverTask, _server, ClientA, ClientB) = - (serverCts, serverTask, server, clientA, clientB); + private Harness( + CancellationTokenSource serverCts, + Task serverTask, + ISharpLinkServer server, + ISharpLinkClient clientA, + ISharpLinkClient clientB, + Coordinator coordinator) + => (_serverCts, _serverTask, _server, ClientA, ClientB, _coordinator) = + (serverCts, serverTask, server, clientA, clientB, coordinator); internal ISharpLinkClient ClientA { get; } internal ISharpLinkClient ClientB { get; } @@ -128,22 +211,23 @@ internal static async Task CreateAsync(Coordinator coordinator) options.FlowControl.MaxConcurrentDecodesPerServer = 4; options.FlowControl.MaxRetainedCompressedBytesPerServer = 64L * 1024 * 1024; options.FlowControl.MaxDecodedBytesInFlightPerServer = 64L * 1024 * 1024; - options.Compression.Providers.Add(new Provider("review-four-a", "A", coordinator)); - options.Compression.Providers.Add(new Provider("review-four-b", "B", coordinator)); + options.Compression.Providers.Add(new Provider("review-saturation-a", "A", coordinator)); + options.Compression.Providers.Add(new Provider("review-saturation-b", "B", coordinator)); }) .UseTcp(0, IPAddress.Loopback.ToString()); var port = ((IPEndPoint)builder.Transport!.LocalEndPoint!).Port; var server = builder.Build(); var serverTask = RunServerAsync(server, cts.Token); - var clientA = CreateClient(port, "review-four-a", "A"); - var clientB = CreateClient(port, "review-four-b", "B"); + var clientA = CreateClient(port, "review-saturation-a", "A"); + var clientB = CreateClient(port, "review-saturation-b", "B"); await clientA.ConnectAsync(); await clientB.ConnectAsync(); - return new Harness(cts, serverTask, server, clientA, clientB); + return new Harness(cts, serverTask, server, clientA, clientB, coordinator); } public async ValueTask DisposeAsync() { + _coordinator.ReleaseA(); await StopClientAsync(ClientA); await StopClientAsync(ClientB); await _serverCts.CancelAsync(); @@ -153,20 +237,29 @@ public async ValueTask DisposeAsync() } private T Read(string name) - => (T)(_server.GetType().GetProperty(name, - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + => (T)(_server.GetType().GetProperty( + name, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic) ?? throw new Exception($"cannot find {name}")).GetValue(_server)!; private static ISharpLinkClient CreateClient(int port, string profile, string tag) - => SharpClientBuilder.Create().UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) + => SharpClientBuilder.Create() + .UseHeartbeat(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(5)) .UseTcp(IPAddress.Loopback.ToString(), port) .UseRuntime(options => options.Compression.Providers.Add(new Provider(profile, tag, null))) .Build(); private static async Task StopClientAsync(ISharpLinkClient client) { - try { await client.StopAsync(); } - catch (Exception e) when (e is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) { } + try + { + await client.StopAsync(); + } + catch (Exception exception) when ( + exception is OperationCanceledException or IOException or ObjectDisposedException or SharpLinkException) + { + } } } @@ -175,14 +268,25 @@ private sealed class Coordinator private readonly ManualResetEventSlim _releaseA = new(); private readonly ConcurrentQueue _order = new(); private int _starts; + internal IReadOnlyList StartOrder => _order.ToArray(); + internal void ReleaseA() => _releaseA.Set(); + internal async Task WaitForStartsAsync(int expected) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - while (Volatile.Read(ref _starts) < expected) - await Task.Delay(10, timeout.Token); + try + { + while (Volatile.Read(ref _starts) < expected) + await Task.Delay(10, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new Exception($"assert failed: provider starts did not reach {expected}"); + } } + internal CancellationToken Record(string tag, CancellationToken token) { _order.Enqueue(tag); @@ -192,27 +296,48 @@ internal CancellationToken Record(string tag, CancellationToken token) _releaseA.Wait(CancellationToken.None); return CancellationToken.None; } + return token; } } - private sealed class Provider(string profile, string tag, Coordinator? coordinator) : ISharpLinkCompressionProvider + private sealed class Provider(string profile, string tag, Coordinator? coordinator) + : ISharpLinkCompressionProvider { - private readonly ISharpLinkCompressionProvider _inner = SharpLinkCompressionProviders.CreateBrotli(); + private readonly ISharpLinkCompressionProvider _inner = + SharpLinkCompressionProviders.CreateBrotli(); + public string WireProfile => profile; - public SharpLinkCompressionResult Compress(ReadOnlySequence input, IBufferWriter output, - int maxOutputBytes, CancellationToken cancellationToken = default) + + public SharpLinkCompressionResult Compress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) => _inner.Compress(input, output, maxOutputBytes, cancellationToken); - public SharpLinkCompressionResult Decompress(ReadOnlySequence input, IBufferWriter output, - int maxOutputBytes, CancellationToken cancellationToken = default) - => _inner.Decompress(input, output, maxOutputBytes, + + public SharpLinkCompressionResult Decompress( + ReadOnlySequence input, + IBufferWriter output, + int maxOutputBytes, + CancellationToken cancellationToken = default) + => _inner.Decompress( + input, + output, + maxOutputBytes, coordinator?.Record(tag, cancellationToken) ?? cancellationToken); } private static Task RunServerAsync(ISharpLinkServer server, CancellationToken token) => Task.Run(async () => { - try { await server.RunAsync(token); } - catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException or IOException or SocketException) { } + try + { + await server.RunAsync(token); + } + catch (Exception exception) when ( + exception is OperationCanceledException or ObjectDisposedException or IOException or SocketException) + { + } }, CancellationToken.None); } From 87c97c7f9e018f9ef7cc7a1b7d0ff70633b4d771 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:49:45 +0800 Subject: [PATCH 227/228] test(server): align runtime admission context helpers --- .../DynamicAdmissionRuntimeControlTests.cs | 4 ++-- .../DynamicAdmissionRuntimeResourceRegressionTests.cs | 4 ++-- .../Server/AdmissionRuntimeControlTests.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs index 390b83fc5..33d13f639 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs @@ -229,7 +229,7 @@ public async Task ServerCallCapacityShouldRemainEnforcedAfterRuntimeAdmissionDis } private static SharpLinkAdmissionContext CreateAdmissionContext() - => new(1, 2, RpcMethodKind.Unary, "runtime-control-integration", null, null, null); + => new(1, 2, RpcMethodKind.Unary, "runtime-control-integration", null, null); private static async Task CaptureFailureAsync(Task task) { @@ -396,4 +396,4 @@ private static Task RunServerAsync( } }, CancellationToken.None); } -} +} \ No newline at end of file diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs index 8e3ce4913..d02e14c0b 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs @@ -195,7 +195,7 @@ await WaitUntilAsync( } private static SharpLinkAdmissionContext CreateAdmissionContext() - => new(11, 22, RpcMethodKind.OneWay, "runtime-enable-oneway", null, null, null); + => new(11, 22, RpcMethodKind.OneWay, "runtime-enable-oneway", null, null); private static byte[] CreateStreamItem(byte value) => Enumerable.Repeat(value, StreamItemBytes).ToArray(); @@ -407,4 +407,4 @@ private static Task RunServerAsync( } }, CancellationToken.None); } -} +} \ No newline at end of file diff --git a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs index 9280e514f..1925af884 100644 --- a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs +++ b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs @@ -382,7 +382,7 @@ private static void ConfigureQueue(SharpLinkAdmissionControlOptions options) } private static SharpLinkAdmissionContext CreateContext() - => new(101, 202, RpcMethodKind.Unary, "runtime-control-test", null, null, null); + => new(101, 202, RpcMethodKind.Unary, "runtime-control-test", null, null); private static async Task WaitUntilAsync(Func condition, string scenario) { @@ -475,4 +475,4 @@ public ValueTask StopAsync( public ValueTask DisposeAsync() => ValueTask.CompletedTask; } -} +} \ No newline at end of file From a03526df213352373dae45443bb808cab3842ba7 Mon Sep 17 00:00:00 2001 From: SunSi12138 <54728594+SunSi12138@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:53:32 +0800 Subject: [PATCH 228/228] style(test): restore final newlines in runtime admission tests --- .../DynamicAdmissionRuntimeControlTests.cs | 2 +- .../DynamicAdmissionRuntimeResourceRegressionTests.cs | 2 +- test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs index 33d13f639..039e01ac6 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeControlTests.cs @@ -396,4 +396,4 @@ private static Task RunServerAsync( } }, CancellationToken.None); } -} \ No newline at end of file +} diff --git a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs index d02e14c0b..0650f1a43 100644 --- a/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs +++ b/test/SharpLink.IntegrationTests/DynamicAdmissionRuntimeResourceRegressionTests.cs @@ -407,4 +407,4 @@ private static Task RunServerAsync( } }, CancellationToken.None); } -} \ No newline at end of file +} diff --git a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs index 1925af884..6fec681d1 100644 --- a/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs +++ b/test/SharpLink.UnitTests/Server/AdmissionRuntimeControlTests.cs @@ -475,4 +475,4 @@ public ValueTask StopAsync( public ValueTask DisposeAsync() => ValueTask.CompletedTask; } -} \ No newline at end of file +}