Skip to content

follow-up(PR #6): fix admission and compression correctness gaps #7

Description

@sunsi-agent

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions