Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

## [Unreleased]

### Changed

- Client calls now allocate the shared logical-call state only for the shapes that can observe one deadline claim from more than one participant: client/server/duplex streaming, OneWay with client streams, and any shape with a client interceptor. Plain unary and plain oneway calls re-check their frozen deadline directly from the resolved call control, which brings unary/oneway allocation back to the 1.1.1 per-call level. That state now holds only the mutable deadline-claim flag: the frozen deadline, time provider, telemetry detail, and captured retry generation live on the call control and survive control copies intact.

- A Request frame now carries the remaining `TimeBudget` sampled once when the frame is created, and the send pump copies frames verbatim. The pump no longer defers timed writes, re-samples `GetRemaining` per frame, compacts frames whose deadline elapsed while they waited in the local queue, or retains a process-local `EmissionDeadline` on the writer lease or the queued frame. The caller's own deadline stays authoritative end to end: a call that registers a pending entry still fails through its pending deadline, and cancellation still terminates a remote call that already started. Two client-side gaps that the removed compaction used to cover are closed with it: a plain OneWay now races its emission wait against its own sampled deadline, so it cannot block past it, and a Request whose deadline elapses while it is still being serialized is no longer published after the cancel that same deadline emitted. The same work closes the pair of guarantees around them: a plain OneWay whose budget elapses after its Request was published now sends the deadline cancel behind that Request, so the peer stops work whose caller has already given up, and a Request is reported as published only once the send queue accepted it, so a refused frame can no longer produce a cancel for a Request the peer never received. Frame preparation, including a user-provided compression provider, now runs before that publication decision instead of while the decision is being arbitrated, and a plain OneWay re-checks its budget after that preparation, so a Request that expired while the compression provider ran is never published. A shape that owns a pending entry - OneWay with client streams, client streaming and duplex streaming - also waits for the emission of its head Request on the pending call's own producer token, so the deadline, a caller cancellation or a closed connection ends the invocation from a stalled transport write instead of leaving it blocked in the send path. The wire budget is sampled before the request payload and metadata are serialized, so the value the server sees no longer deducts the rest of the serialization, the client send queue, or the network, and it is advisory: it bounds how long the server is willing to keep working, while the caller's deadline remains the one that decides the call, which matches the gRPC model where a deadline is fixed at invocation time. A timed Request also now waits for the configured batch window like any other frame when the caller sets an explicit `RpcSessionFlushOptions.MaxLatency`; previously timed frames bypassed that window, and they remain immediately emitted under the default profiles, where the batch window is zero. The pump still coalesces a batch into one output span at flush time, so dropping the deadline machinery does not cost the transport its segment coalescing.

### Fixed

- Fixed a hung OneWay call with client streams when the logical deadline elapsed while the producer was still running. The pending call was terminated by the deadline first, and the invoker then awaited the pooled lease operation a second time, which never completes; the invocation never returned, its producer stayed alive, and the client's logical invocation accounting never drained. The invoker now publishes the local send/producer failure to the pending request table and observes the lease operation exactly once on every path, so the terminal that actually won - local completion, send failure, deadline, caller cancellation, or a connection close - is what the caller sees, and the pooled operation is always returned.

## [2.0.0] - 2026-09-13

### Release boundaries
Expand Down
22 changes: 20 additions & 2 deletions src/SharpLink.Client/ClientConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,11 @@ void IPendingCallOwner.OnPendingCallCompleted(in PendingCallCompletion completio
PendingCallCompletionReason.UserCancellation or
PendingCallCompletionReason.DeadlineExceeded or
PendingCallCompletionReason.ConsumerAbandoned;
// Local cleanup follows shouldSendCancel - a dispatcher must be torn down even when the
// Request never reached the peer. Only the transport cancel is gated on publication: a
// cancel for a Request the peer never received is discarded by it, and emitting one would
// also let the cancel overtake a Request this connection is still serializing.
var shouldNotifyPeer = shouldSendCancel && completion.RequestPublished;

if (completion.Kind is PendingCallKind.ServerStreaming or PendingCallKind.DuplexStreaming)
{
Expand Down Expand Up @@ -503,6 +508,7 @@ PendingCallCompletionReason.DeadlineExceeded or
drain,
completion.RequestId,
GetCancelReason(completion.Reason),
shouldNotifyPeer,
localAbort),
"CancellationDispatchCleanup");
}
Expand All @@ -523,7 +529,7 @@ PendingCallCompletionReason.DeadlineExceeded or
}
}

if (shouldSendCancel)
if (shouldNotifyPeer)
TrySendCancel(completion.RequestId, GetCancelReason(completion.Reason));
}

Expand All @@ -540,13 +546,15 @@ private async Task FinishCancellationAfterDispatchesAsync(
ValueTask drain,
long requestId,
ProtocolV2CancelReason reason,
bool notifyPeer,
IStreamLocalAbortDispatcher? localAbort)
{
try
{
await drain.ConfigureAwait(false);
localAbort?.RetireLocalAbortBuffer();
TrySendCancel(requestId, reason);
if (notifyPeer)
TrySendCancel(requestId, reason);
}
catch (Exception exception)
{
Expand Down Expand Up @@ -616,6 +624,16 @@ private static ProtocolV2CancelReason GetCancelReason(PendingCallCompletionReaso
_ => throw new ArgumentOutOfRangeException(nameof(reason), reason, null)
};

/// <summary>
/// Tells the peer to stop a call whose Request is already queued but whose caller gave up.
/// </summary>
/// <remarks>
/// Untracked dispatch shapes (plain OneWay) hold no pending entry, so nothing else observes the
/// terminal reason and therefore nothing else can publish the cancel.
/// </remarks>
internal void TrySendDeadlineCancel(long requestId)
=> TrySendCancel(requestId, ProtocolV2CancelReason.DeadlineExceeded);

private void TrySendCancel(long requestId, ProtocolV2CancelReason reason)
{
try
Expand Down
112 changes: 112 additions & 0 deletions src/SharpLink.Client/PendingRequestTable.Publication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
namespace SharpLink.Client;

/// <summary>
/// Request publication for <see cref="PendingRequestTable"/>.
/// </summary>
partial class PendingRequestTable
{
/// <summary>
/// Publishes one Request frame under the owning call's completion gate.
/// </summary>
/// <remarks>
/// The gate is the ordering authority between "this Request reached the session send queue" and
/// "this call produced a terminal reason that has to tell the peer". A terminal transition that
/// wins the race removes the slot first, so this method then refuses to publish and the caller
/// drops the frame instead of delivering a Request after its own cancel. A publication that
/// wins marks <see cref="PendingCall.RequestPublished"/> only for an accepted frame and before
/// the gate is released, so a later terminal transition still emits its cancel - behind the
/// Request, because both frames share the session's normal queue.
/// <para>
/// The same gate is the authority on whether this call's deadline already passed, exactly as it
/// is for an inbound response: the deadline scheduler's callback is the wake-up, not the fact.
/// A clock that crossed the boundary while the frame was still being prepared refuses the
/// publication here even when the timer has not run yet, so a Request can never reach the peer
/// carrying a budget that already elapsed on the client.
/// </para>
/// </remarks>
/// <param name="writer">
/// A frame that the caller already prepared outside this gate. It owns the packet on entry, and
/// every path except a refused publication either queues it or returns it.
/// </param>
/// <param name="id">The request identifier whose pending slot gates this publication.</param>
/// <param name="session">The session the prepared frame has to be admitted to.</param>
/// <param name="observeEmission">Whether the caller needs a waiter for the transport write.</param>
/// <param name="cancellationToken">Caller cancellation observed while waiting for emission.</param>
/// <param name="failureObserver">Observer notified when the queued frame fails in the transport.</param>
/// <param name="emission">
/// The emission waiter for an accepted frame, or <c>default</c> for every other outcome.
/// </param>
/// <param name="rejection">
/// Set when the session refused admission and already returned the frame. The caller owns the
/// frame on every other path.
/// </param>
/// <returns><c>true</c> when the frame reached the send queue.</returns>
public bool TryPublishRequest(
long id,
RpcSession session,
IRpcByteBufferWriter writer,
bool observeEmission,
CancellationToken cancellationToken,
IRequestEmissionFailureObserver? failureObserver,
out ValueTask emission,
out Exception? rejection)
{
emission = default;
rejection = null;
var slots = Volatile.Read(ref _slots);
if (slots is null)
return false;

var index = (int)(id & _indexMask);
var current = Volatile.Read(ref slots[index]);
if (current is null || current.Id != id)
return false;

PendingCall? expiredCall = null;
lock (current.CompletionGate)
{
if (!ReferenceEquals(Volatile.Read(ref slots[index]), current) || current.Id != id)
return false;

if (current.Deadline.IsExpired(_timeProvider))
{
// The deadline boundary and the admission have to be decided together. Relying on
// the scheduler callback here would let a late callback publish a Request whose
// budget already elapsed, and the peer would then dispatch work the caller has
// already given up on.
if (!ReferenceEquals(Interlocked.CompareExchange(ref slots[index], null, current), current))
return false;
current.WaitUntilRegistered();
expiredCall = current;
}
else
{
// Admission is the linearization point and it is synchronous: the frame is either in
// the queue now or it was already returned. Marking the call published on a faulted
// or deferred send is what produced a cancel for a Request the peer never received.
if (!session.TryEnqueuePreparedFrame(
writer,
observeEmission,
cancellationToken,
failureObserver,
out emission,
out rejection))
{
emission = default;
return false;
}

current.MarkRequestPublished();
return true;
}
}

var emptyPayload = ReadOnlySequence<byte>.Empty;
CompleteTakenCall(
expiredCall!,
PendingCallCompletionReason.DeadlineExceeded,
exception: null,
ref emptyPayload);
return false;
}
}
24 changes: 21 additions & 3 deletions src/SharpLink.Client/PendingRequestTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ internal readonly record struct PendingCallCompletion(
PendingCallKind Kind,
PendingCallCompletionReason Reason,
IStreamDispatcher? Dispatcher,
Exception? Exception);
Exception? Exception,
bool RequestPublished);

internal interface IPendingCallOwner
{
Expand Down Expand Up @@ -61,7 +62,7 @@ internal interface IPendingCallCompletionObserver
/// so response, cancellation, deadline and disconnect races converge on one terminal path. The
/// operation object is returned to its type-specific pool only after its caller observes GetResult.
/// </remarks>
internal sealed class PendingRequestTable : IDisposable, IRequestEmissionFailureObserver
internal sealed partial class PendingRequestTable : IDisposable, IRequestEmissionFailureObserver
{
private readonly int _indexMask;
private readonly int _capacity;
Expand Down Expand Up @@ -908,7 +909,8 @@ private void CompleteTakenCall(
call.Kind,
reason,
call.Dispatcher,
exception);
exception,
call.RequestPublished);
// Decode response payloads before reporting the terminal admission outcome so malformed
// endpoint responses are not published as successful attempts.
call.CompletionObserver?.OnPendingCallCompleted(in completion);
Expand Down Expand Up @@ -1122,6 +1124,7 @@ private sealed class PendingCall
private CancellationTokenSource? _producerCancellation;
private IPendingCallCompletionObserver? _completionObserver;
private int _registered;
private bool _requestPublished;

public object CompletionGate { get; } = new();

Expand All @@ -1135,6 +1138,20 @@ public CancellationToken ProducerCancellationToken
=> _producerCancellation?.Token ?? CancellationToken.None;
public IPendingCallCompletionObserver? CompletionObserver => _completionObserver;

/// <summary>
/// Whether the owning Request frame reached the session send queue while this call was
/// still live.
/// </summary>
/// <remarks>
/// Written once by the publisher while it holds <see cref="CompletionGate"/> and only read
/// after the call left the slot, so the gate orders the flag against every terminal
/// transition. A terminal transition that observes this as false must not emit a cancel:
/// the peer would discard a cancel for a request it never received.
/// </remarks>
public bool RequestPublished => _requestPublished;

public void MarkRequestPublished() => _requestPublished = true;

public static PendingCall Rent(
PendingRequestTable table,
long id,
Expand All @@ -1152,6 +1169,7 @@ public static PendingCall Rent(

call._table = table;
Volatile.Write(ref call._registered, 0);
call._requestPublished = false;
call.Id = id;
call.Kind = kind;
call.Operation = operation;
Expand Down
29 changes: 27 additions & 2 deletions src/SharpLink.Client/SharpLinkClient.CallControlTelemetry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ namespace SharpLink.Client;

internal sealed partial class SharpLinkClient
{
// Every entry point captures the interceptor generation exactly once and uses that same
// snapshot for both the shared-state allocation decision and the executed pipeline, so a
// runtime interceptor replacement cannot select an intercepted pipeline without shared state.
internal ClientInterceptorGeneration CaptureInterceptorGenerationForInvocation()
=> Volatile.Read(ref _clientInterceptorGeneration);

internal ResolvedCallControl ResolveCallControlForInvocation(
RpcMethodDescriptor method,
SharpLinkMetadata? metadata,
bool includeClientDefault)
bool includeClientDefault,
ClientInterceptorGeneration interceptors)
{
ArgumentNullException.ThrowIfNull(interceptors);
var lifetimeSource = ClientCallLifetimeSource.None;
try
{
Expand All @@ -15,7 +23,8 @@ internal ResolvedCallControl ResolveCallControlForInvocation(
includeClientDefault,
method.HasMethodTimeout,
method.MethodTimeout,
ref lifetimeSource);
ref lifetimeSource,
allocateSharedLogicalCall: MayObserveSharedLogicalCallAcrossParticipants(method, interceptors));
return CaptureRetryGenerationForInvocation(method, control);
}
catch (SharpLinkException exception)
Expand All @@ -30,4 +39,20 @@ internal ResolvedCallControl ResolveCallControlForInvocation(
throw;
}
}

// The logical-call object only publishes observable state when more than one participant can
// observe the deadline claim: a streaming dispatcher, a client-stream producer, or a client
// interceptor. Plain unary and plain oneway invocations are single-participant, so their
// deadline is re-checked directly from the frozen control instead of allocating an object per
// call. The interceptor generation must be the one captured by the calling entry point: an
// interceptor published after that capture belongs to the next logical call, and re-reading
// the live generation here could select an intercepted pipeline with no shared state.
private static bool MayObserveSharedLogicalCallAcrossParticipants(
RpcMethodDescriptor method,
ClientInterceptorGeneration interceptors)
=> method.Kind is RpcMethodKind.ClientStreaming
or RpcMethodKind.ServerStreaming
or RpcMethodKind.DuplexStreaming
|| method.HasClientStreams
|| interceptors.Count != 0;
}
Loading
Loading