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