Context
Post-merge review follow-up for PR #6: SharpLink 0.7.4: negotiated compression and bounded admission control.
The Release build and all current unit, integration, and generator tests pass, but targeted review/reproduction found four correctness gaps not covered by the current suite.
Findings
[P1] Composite admission retries consume rate permits more than once
AdmissionRequest.TryAcquire and TryAcquireUsing dispose earlier successful leases when a later limiter fails. Concurrency leases are refundable, but TokenBucket, FixedWindow, and SlidingWindow permits are not. A rejected downstream scope therefore burns upstream rate budget, and the same queued request can reacquire and consume that upstream budget again on every internal retry.
Source:
|
var lease = slots[index].Limiter.AttemptAcquire(1); |
|
if (!lease.IsAcquired) |
|
{ |
|
lease.Dispose(); |
|
for (var acquired = index - 1; acquired >= 0; acquired--) |
|
leases[acquired].Dispose(); |
|
admissionLease = null; |
|
failedSlot = slots[index]; |
|
return false; |
|
} |
|
leases[index] = lease; |
|
} |
|
|
|
var ownedPartition = Interlocked.Exchange(ref _partition, null); |
|
admissionLease = new AdmissionLease(owner, leases, ownedPartition); |
|
failedSlot = default; |
|
return true; |
|
} |
|
|
|
internal bool TryAcquireUsing( |
|
SharpLinkAdmissionController owner, |
|
RateLimiter suppliedLimiter, |
|
RateLimitLease suppliedLease, |
|
out AdmissionLease? admissionLease, |
|
out AdmissionLimiterSlot failedSlot) |
|
{ |
|
var leases = new RateLimitLease[slotCount]; |
|
for (var index = 0; index < slotCount; index++) |
|
{ |
|
var lease = ReferenceEquals(slots[index].Limiter, suppliedLimiter) |
|
? suppliedLease |
|
: slots[index].Limiter.AttemptAcquire(1); |
|
if (!lease.IsAcquired) |
|
{ |
|
lease.Dispose(); |
|
for (var acquired = index - 1; acquired >= 0; acquired--) |
|
leases[acquired].Dispose(); |
|
if (!leases.Contains(suppliedLease)) |
|
suppliedLease.Dispose(); |
[P2] Built-in decompression accepts trailing garbage with a recomputed CRC
The trailing-data check uses how many bytes the decompressor read from the underlying stream. Gzip, Deflate, and Brotli may prefetch all input, so this is not the exact compressed-format consumption boundary. A minimal reproduction inserted one byte before the SCP1 trailer, recomputed the public CRC32, and all three built-in providers accepted it.
Source:
|
if (input.Length <= IntegrityTrailerBytes) |
|
throw new InvalidDataException("Compressed payload integrity trailer is truncated."); |
|
Span<byte> trailer = stackalloc byte[IntegrityTrailerBytes]; |
|
input.Slice(input.Length - IntegrityTrailerBytes).CopyTo(trailer); |
|
if (BinaryPrimitives.ReadUInt32LittleEndian(trailer) != IntegrityMagic) |
|
throw new InvalidDataException("Compressed payload integrity trailer is missing."); |
|
var compressedPayload = input.Slice(0, input.Length - IntegrityTrailerBytes); |
|
var expectedChecksum = BinaryPrimitives.ReadUInt32LittleEndian(trailer[sizeof(uint)..]); |
|
if (Crc32Accumulator.Compute(compressedPayload) != expectedChecksum) |
|
throw new InvalidDataException("Compressed payload integrity checksum does not match."); |
|
|
|
using var inputStream = new ReadOnlySequenceStream(compressedPayload); |
|
using var decompressor = CreateDecompressionStream(inputStream); |
|
var written = 0; |
|
while (written < maxOutputBytes) |
|
{ |
|
cancellationToken.ThrowIfCancellationRequested(); |
|
var span = output.GetSpan(Math.Min(8192, maxOutputBytes - written)); |
|
var read = decompressor.Read(span[..Math.Min(span.Length, maxOutputBytes - written)]); |
|
if (read == 0) |
|
break; |
|
output.Advance(read); |
|
written += read; |
|
} |
|
|
|
if (written == maxOutputBytes) |
|
{ |
|
Span<byte> probe = stackalloc byte[1]; |
|
if (decompressor.Read(probe) != 0) |
|
throw new InvalidDataException("Decompressed payload exceeds its declared original length."); |
|
} |
|
|
|
if (inputStream.ConsumedBytes != compressedPayload.Length) |
|
throw new InvalidDataException("Compressed payload contains trailing data."); |
|
return ValueTask.FromResult(new SharpLinkCompressionResult(checked((int)input.Length), written)); |
[P2] The asynchronous compression-provider contract is synchronously blocked
ISharpLinkCompressionProvider returns ValueTask, but incomplete operations are completed with GetAwaiter().GetResult(). A custom provider that captures a UI or single-thread synchronization context can deadlock; other asynchronous providers block the request/session processing thread.
Source:
|
private static SharpLinkCompressionResult CompleteProviderOperation( |
|
ValueTask<SharpLinkCompressionResult> operation) |
|
=> operation.IsCompletedSuccessfully |
|
? operation.Result |
|
: operation.AsTask().GetAwaiter().GetResult(); |
[P2] Pre-admission completion races lose accepted receive credit
StreamManager charges receive credit before dispatch. If a pre-admission stream completes while its frame is being retained, the attached is null branches discard that frame without invoking _bytesConsumed. The compressed path has the same gap. Repeated races can permanently reduce the connection receive window and stall later streams.
Source:
|
lock (_gate) |
|
{ |
|
if (_dispatcher is null && !_completed) |
|
{ |
|
_items.Enqueue(new BufferedItem(owner, retainedBytes, encodedByteCount)); |
|
return ValueTask.CompletedTask; |
|
} |
|
attached = _dispatcher; |
|
} |
|
|
|
buffers.Return(owner); |
|
releaseBytes(retainedBytes); |
|
return attached is null |
|
? ValueTask.CompletedTask |
|
: DispatchAttached(attached, payload, encodedByteCount); |
Acceptance criteria
- A logical request consumes each non-refundable rate permit at most once, including queue retries and downstream limiter failures.
- Built-in providers reject valid-stream-plus-trailing-data inputs independently of decompressor read-ahead.
- The provider API has no sync-over-async path: either await operations end-to-end or make the contract explicitly synchronous.
- Every frame charged to flow control returns credit when it is discarded by a pre-admission completion race, for compressed and uncompressed paths.
- Add regression tests for all four cases.
Context
Post-merge review follow-up for PR #6: SharpLink 0.7.4: negotiated compression and bounded admission control.
ec04c90bdb52bec071d07c9691f15c1c37a0e686e02dd8742a360572264ec2d5a89b62fd9e07c7a0The Release build and all current unit, integration, and generator tests pass, but targeted review/reproduction found four correctness gaps not covered by the current suite.
Findings
[P1] Composite admission retries consume rate permits more than once
AdmissionRequest.TryAcquireandTryAcquireUsingdispose earlier successful leases when a later limiter fails. Concurrency leases are refundable, but TokenBucket, FixedWindow, and SlidingWindow permits are not. A rejected downstream scope therefore burns upstream rate budget, and the same queued request can reacquire and consume that upstream budget again on every internal retry.Source:
SharpLink/src/SharpLink.Server/Admission/SharpLinkAdmissionController.cs
Lines 512 to 550 in ec04c90
[P2] Built-in decompression accepts trailing garbage with a recomputed CRC
The trailing-data check uses how many bytes the decompressor read from the underlying stream. Gzip, Deflate, and Brotli may prefetch all input, so this is not the exact compressed-format consumption boundary. A minimal reproduction inserted one byte before the
SCP1trailer, recomputed the public CRC32, and all three built-in providers accepted it.Source:
SharpLink/src/SharpLink.Runtime/Compression/SharpLinkCompression.cs
Lines 221 to 255 in ec04c90
[P2] The asynchronous compression-provider contract is synchronously blocked
ISharpLinkCompressionProviderreturnsValueTask, but incomplete operations are completed withGetAwaiter().GetResult(). A custom provider that captures a UI or single-thread synchronization context can deadlock; other asynchronous providers block the request/session processing thread.Source:
SharpLink/src/SharpLink.Runtime/RpcSession.Compression.cs
Lines 258 to 262 in ec04c90
[P2] Pre-admission completion races lose accepted receive credit
StreamManagercharges receive credit before dispatch. If a pre-admission stream completes while its frame is being retained, theattached is nullbranches discard that frame without invoking_bytesConsumed. The compressed path has the same gap. Repeated races can permanently reduce the connection receive window and stall later streams.Source:
SharpLink/src/SharpLink.Runtime/PreAdmissionStreamDispatcher.cs
Lines 70 to 84 in ec04c90
Acceptance criteria