Files
Rendezvous/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousHostCoordinator.cs
T
KyuubiYoru 94aba8a3bb
quality-gate / quality (push) Successful in 59s
feat(client): standardize connection outcomes (#13)
2026-07-16 10:18:41 +02:00

814 lines
28 KiB
C#

using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousHostState
{
Active = 1,
ManagerStopped = 2,
Disposed = 3,
}
public sealed class RendezvousHostAttemptCompletedEventArgs : EventArgs
{
[Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
public RendezvousHostAttemptCompletedEventArgs(
JoinAttemptId attemptId,
RendezvousConnectionState state,
NetPeer? peer)
: this(attemptId, state, RendezvousCompletionInvariant.FromLegacy(state, peer))
{
}
internal RendezvousHostAttemptCompletedEventArgs(
JoinAttemptId attemptId,
RendezvousConnectionState state,
RendezvousConnectionOutcome outcome)
{
if (attemptId.Value == Guid.Empty)
{
throw new ArgumentException("The completed attempt ID is invalid.", nameof(attemptId));
}
RendezvousCompletionInvariant.Validate(state, outcome);
AttemptId = attemptId;
State = state;
Outcome = outcome;
}
public JoinAttemptId AttemptId { get; }
public RendezvousConnectionState State { get; }
public RendezvousConnectionOutcome Outcome { get; }
public NetPeer? Peer => Outcome.Peer;
}
public sealed class RendezvousHostCoordinator : IDisposable
{
private readonly NetManager _manager;
private readonly RendezvousNetListener _networkEvents;
private readonly EventBasedNatPunchListener _punchEvents;
private readonly IPEndPoint _mediator;
private readonly PublishedSession _session;
private readonly IRendezvousJoinClient _joinClient;
private readonly RendezvousCoordinatorOptions _options;
private readonly IRendezvousCoordinatorClock _clock;
private readonly ConnectionTicketValidator _tickets;
private readonly Dictionary<JoinAttemptId, PendingHostAttempt> _attempts = [];
private readonly Dictionary<NetPeer, JoinAttemptId> _acceptedPeers = [];
private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = [];
private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = [];
private readonly Queue<JoinAttemptId> _attemptSchedule = [];
private readonly SortedDictionary<long, Queue<HostAttemptDeadline>> _deadlines = [];
private readonly List<JoinAttemptId> _cleanupScratch = [];
private HostJoinAttempt[]? _latestSnapshot;
private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue;
private DateTimeOffset _nextTerminalCleanupAt = DateTimeOffset.MinValue;
private int _refreshing;
private int _polling;
private bool _subscriptionsReleased;
private int _disposed;
public RendezvousHostCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
PublishedSession session,
IRendezvousJoinClient joinClient,
RendezvousCoordinatorOptions? options = null)
: this(
manager,
networkEvents,
mediator,
session,
joinClient,
options,
new SystemRendezvousCoordinatorClock(),
null)
{
}
internal RendezvousHostCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
PublishedSession session,
IRendezvousJoinClient joinClient,
RendezvousCoordinatorOptions? options,
IRendezvousCoordinatorClock clock,
ConnectionTicketValidator? tickets)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
_punchEvents = _networkEvents.PunchEvents;
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_session = session ?? throw new ArgumentNullException(nameof(session));
_joinClient = joinClient ?? throw new ArgumentNullException(nameof(joinClient));
_options = (options ?? new RendezvousCoordinatorOptions()).CopyAndValidate();
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_tickets = tickets ?? new ConnectionTicketValidator();
RendezvousManagerGuard.Validate(_manager, _networkEvents);
ValidateInputs();
_networkEvents.RendezvousConnectionRequest += OnConnectionRequest;
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
_networkEvents.RendezvousNetworkError += OnNetworkError;
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
}
public event EventHandler<RendezvousHostAttemptCompletedEventArgs>? AttemptCompleted;
public RendezvousHostState State { get; private set; } = RendezvousHostState.Active;
public int PendingAttemptCount => _attempts.Count;
internal int DeferredRequestCount => _deferredRequests.Count;
public async Task<RendezvousClientResult<int>> RefreshJoinAttemptsAsync(
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (Interlocked.Exchange(ref _refreshing, 1) != 0)
{
throw new InvalidOperationException("A host invitation refresh is already running.");
}
try
{
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
await _joinClient.BrowseAllForHostAsync(
_session,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
return RendezvousClientResult.Failure<int>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
HostJoinAttempt[] snapshot = result.Value.Select(CopyAttempt).ToArray();
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
Interlocked.Exchange(ref _latestSnapshot, snapshot);
if (Volatile.Read(ref _disposed) != 0)
{
Interlocked.Exchange(ref _latestSnapshot, null);
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
return RendezvousClientResult.Success(snapshot.Length);
}
finally
{
Volatile.Write(ref _refreshing, 0);
}
}
public void Poll()
{
ThrowIfDisposed();
if (State != RendezvousHostState.Active)
{
return;
}
if (Interlocked.Exchange(ref _polling, 1) != 0)
{
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
}
try
{
ApplySnapshots();
if (!_manager.IsRunning)
{
Stop(
RendezvousHostState.ManagerStopped,
RendezvousConnectionState.ManagerStopped,
ConnectionOutcomeKind.ManagerStopped);
return;
}
_manager.NatPunchModule.PollEvents();
_manager.PollEvents();
_manager.NatPunchModule.PollEvents();
if (State != RendezvousHostState.Active)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
TimeSpan elapsed = _clock.Elapsed;
if (!_manager.IsRunning)
{
Stop(
RendezvousHostState.ManagerStopped,
RendezvousConnectionState.ManagerStopped,
ConnectionOutcomeKind.ManagerStopped);
return;
}
RefreshPresence(now);
ProcessDueDeadlines(elapsed);
if (State != RendezvousHostState.Active)
{
return;
}
int checks = Math.Min(
_attemptSchedule.Count,
_options.MaximumAttemptChecksPerPoll);
for (int index = 0; index < checks; index++)
{
JoinAttemptId attemptId = _attemptSchedule.Dequeue();
if (!_attempts.TryGetValue(attemptId, out PendingHostAttempt? attempt))
{
continue;
}
if (attempt.State != RendezvousConnectionState.Punching)
{
continue;
}
if (attempt.Retry.IsDue(elapsed))
{
if (attempt.Retry.IsExhausted)
{
CompleteAttempt(
attemptId,
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.PunchTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.NatTraversal,
RendezvousConnectionPhase.NatTraversal);
continue;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
attempt.Invitation.MediationHandle,
attempt.Invitation.HostPunchCapability));
attempt.Retry.RecordRequest();
}
_attemptSchedule.Enqueue(attemptId);
}
if (now >= _nextTerminalCleanupAt)
{
_cleanupScratch.Clear();
foreach (KeyValuePair<JoinAttemptId, DateTimeOffset> terminal in _terminalAttempts)
{
if (terminal.Value <= now)
{
_cleanupScratch.Add(terminal.Key);
}
}
foreach (JoinAttemptId attemptId in _cleanupScratch)
{
_terminalAttempts.Remove(attemptId);
}
_nextTerminalCleanupAt = now + TimeSpan.FromSeconds(1);
}
}
finally
{
Volatile.Write(ref _polling, 0);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
Stop(
RendezvousHostState.Disposed,
RendezvousConnectionState.Disposed,
ConnectionOutcomeKind.Disposed);
Interlocked.Exchange(ref _latestSnapshot, null);
_attemptSchedule.Clear();
_deadlines.Clear();
_terminalAttempts.Clear();
_cleanupScratch.Clear();
_tickets.Dispose();
}
public override string ToString() =>
$"[RendezvousHostCoordinator {_session.ListingId}; credentials redacted]";
private void ApplySnapshots()
{
HostJoinAttempt[]? latest = Interlocked.Exchange(ref _latestSnapshot, null);
if (latest is null)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
TimeSpan elapsed = _clock.Elapsed;
foreach (HostJoinAttempt invitation in latest)
{
if (invitation.AttemptId.Value == Guid.Empty
|| invitation.MediationHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(invitation.HostPunchCapability)
|| !ContractValidation.IsConnectionTicketValid(
invitation.ConnectionTicketDigest))
{
continue;
}
if (invitation.IsCancelled)
{
if (_attempts.ContainsKey(invitation.AttemptId))
{
CompleteAttempt(
invitation.AttemptId,
RendezvousConnectionState.Cancelled,
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.RendezvousService,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Authorization);
}
_terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt;
continue;
}
if (invitation.ExpiresAt <= now
|| _attempts.ContainsKey(invitation.AttemptId)
|| _terminalAttempts.ContainsKey(invitation.AttemptId))
{
continue;
}
TimeSpan attemptDeadline = elapsed + (invitation.ExpiresAt - now);
TimeSpan punchDeadline = Min(
attemptDeadline,
elapsed + _options.PunchTimeout);
_attempts.Add(
invitation.AttemptId,
new PendingHostAttempt(
CopyAttempt(invitation),
new RendezvousPunchRetrySchedule(_options, _clock),
elapsed,
attemptDeadline,
punchDeadline));
EnqueueDeadline(
new HostAttemptDeadline(
invitation.AttemptId,
RendezvousConnectionState.Punching,
punchDeadline));
_attemptSchedule.Enqueue(invitation.AttemptId);
}
}
private void RefreshPresence(DateTimeOffset now)
{
if (now < _nextPresenceAt || now >= _session.ExpiresAt)
{
return;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
_session.HostPresenceHandle,
_session.HostPresenceCapability));
_nextPresenceAt = now + TimeSpan.FromSeconds(_session.HostPresenceRefreshAfterSeconds);
}
private void OnNatIntroductionSuccess(
IPEndPoint target,
NatAddressType addressType,
string encodedIntroduction)
{
_ = target;
_ = addressType;
if (!NatIntroductionTokenCodec.TryDecode(
encodedIntroduction,
out NatIntroductionToken? introduction)
|| introduction is null
|| !_attempts.TryGetValue(introduction.AttemptId, out PendingHostAttempt? attempt)
|| !NatIntroductionTokenCodec.MatchesDigest(
introduction.ConnectionTicket,
attempt.Invitation.ConnectionTicketDigest)
|| !_tickets.TryAuthorize(
introduction.AttemptId,
introduction.ConnectionTicket,
Min(
attempt.Invitation.ExpiresAt,
_clock.UtcNow + _options.ConnectionTicketLifetime)))
{
return;
}
attempt.State = RendezvousConnectionState.Connecting;
attempt.DirectDeadline = Min(
attempt.AttemptDeadline,
_clock.Elapsed + _options.DirectConnectTimeout);
EnqueueDeadline(new HostAttemptDeadline(
introduction.AttemptId,
RendezvousConnectionState.Connecting,
attempt.DirectDeadline.Value));
if (_deferredRequests.Remove(
introduction.AttemptId,
out DeferredConnectionRequest? deferred))
{
AcceptAuthorizedRequest(
introduction.AttemptId,
attempt,
deferred.Request,
deferred.ConnectionTicket);
}
}
private void OnConnectionRequest(ConnectionRequest request)
{
ReadOnlySpan<byte> data = request.Data.GetRemainingBytesSpan();
if (!DirectConnectionRequestCodec.IsRendezvousRequest(data))
{
return;
}
if (!DirectConnectionRequestCodec.TryDecode(data, out DirectConnectionRequest? connection)
|| connection is null
|| !_attempts.TryGetValue(connection.AttemptId, out PendingHostAttempt? attempt)
|| !NatIntroductionTokenCodec.MatchesDigest(
connection.ConnectionTicket,
attempt.Invitation.ConnectionTicketDigest))
{
request.RejectForce([]);
return;
}
if (attempt.State == RendezvousConnectionState.Punching)
{
_deferredRequests[connection.AttemptId] = new(
request,
connection.ConnectionTicket);
return;
}
if (attempt.State != RendezvousConnectionState.Connecting)
{
request.RejectForce([]);
return;
}
AcceptAuthorizedRequest(
connection.AttemptId,
attempt,
request,
connection.ConnectionTicket);
}
private void OnPeerConnected(NetPeer peer)
{
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{
CompleteAttempt(
attemptId,
RendezvousConnectionState.Connected,
ConnectionOutcomeKind.Connected,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.None,
RendezvousConnectionPhase.Complete,
peer);
}
}
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
_ = disconnectInfo;
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{
ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
? ConnectionOutcomeKind.DirectConnectTimedOut
: ConnectionOutcomeKind.TransportError;
CompleteAttempt(
attemptId,
kind == ConnectionOutcomeKind.DirectConnectTimedOut
? RendezvousConnectionState.TimedOut
: RendezvousConnectionState.Rejected,
kind,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
}
}
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
{
_ = socketError;
if (!endpoint.Equals(_mediator))
{
return;
}
foreach (JoinAttemptId attemptId in _attempts
.Where(static item => item.Value.State == RendezvousConnectionState.Punching)
.Select(static item => item.Key)
.ToArray())
{
CompleteAttempt(
attemptId,
RendezvousConnectionState.Rejected,
ConnectionOutcomeKind.MediatorUnavailable,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.Mediation,
RendezvousConnectionPhase.Mediation);
}
}
private void CompleteAttempt(
JoinAttemptId attemptId,
RendezvousConnectionState state,
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
NetPeer? peer = null)
{
if (TryCompleteAttempt(
attemptId,
state,
kind,
source,
category,
phase,
peer,
out RendezvousHostAttemptCompletedEventArgs? completion))
{
AttemptCompleted?.Invoke(this, completion!);
}
}
private bool TryCompleteAttempt(
JoinAttemptId attemptId,
RendezvousConnectionState state,
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
NetPeer? peer,
out RendezvousHostAttemptCompletedEventArgs? completion)
{
completion = null;
if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt))
{
return false;
}
if (attempt.AcceptedPeer is not null)
{
_acceptedPeers.Remove(attempt.AcceptedPeer);
if (kind != ConnectionOutcomeKind.Connected)
{
attempt.AcceptedPeer.Disconnect();
}
}
if (_deferredRequests.Remove(attemptId, out DeferredConnectionRequest? deferred))
{
deferred.Request.RejectForce([]);
}
_tickets.Revoke(attemptId);
_terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt;
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
kind,
source,
category,
phase,
_clock.Elapsed - attempt.StartedAt,
peer: peer);
completion = new(attemptId, state, outcome);
return true;
}
private void Stop(
RendezvousHostState hostState,
RendezvousConnectionState attemptState,
ConnectionOutcomeKind outcomeKind)
{
if (State != RendezvousHostState.Active)
{
return;
}
State = hostState;
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray())
{
RendezvousConnectionPhase phase = _attempts[attemptId].State
== RendezvousConnectionState.Connecting
? RendezvousConnectionPhase.DirectConnection
: RendezvousConnectionPhase.NatTraversal;
if (TryCompleteAttempt(
attemptId,
attemptState,
outcomeKind,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
phase,
null,
out RendezvousHostAttemptCompletedEventArgs? completion))
{
completions.Add(completion!);
}
}
ReleaseSubscriptions();
foreach (RendezvousHostAttemptCompletedEventArgs completion in completions)
{
AttemptCompleted?.Invoke(this, completion);
}
}
private void ReleaseSubscriptions()
{
if (_subscriptionsReleased)
{
return;
}
_networkEvents.RendezvousConnectionRequest -= OnConnectionRequest;
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_networkEvents.RendezvousNetworkError -= OnNetworkError;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
}
private void ValidateInputs()
{
if (_mediator.Port is < 1 or > 65_535
|| _session.HostPresenceHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(_session.HostPresenceCapability)
|| _session.HostPresenceRefreshAfterSeconds < 1
|| _session.ExpiresAt <= _clock.UtcNow)
{
throw new ArgumentException("The host traversal inputs are invalid.");
}
}
private static HostJoinAttempt CopyAttempt(HostJoinAttempt attempt) => new()
{
AttemptId = attempt.AttemptId,
MediationHandle = attempt.MediationHandle,
HostPunchCapability = attempt.HostPunchCapability,
ConnectionTicketDigest = attempt.ConnectionTicketDigest,
IsCancelled = attempt.IsCancelled,
ExpiresAt = attempt.ExpiresAt,
};
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
left <= right ? left : right;
private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) =>
left <= right ? left : right;
private void EnqueueDeadline(HostAttemptDeadline deadline)
{
if (!_deadlines.TryGetValue(deadline.Deadline.Ticks, out Queue<HostAttemptDeadline>? bucket))
{
bucket = new Queue<HostAttemptDeadline>();
_deadlines.Add(deadline.Deadline.Ticks, bucket);
}
bucket.Enqueue(deadline);
}
private void ProcessDueDeadlines(TimeSpan elapsed)
{
while (_deadlines.Count > 0)
{
KeyValuePair<long, Queue<HostAttemptDeadline>> first = _deadlines.First();
if (first.Key > elapsed.Ticks)
{
return;
}
HostAttemptDeadline deadline = first.Value.Dequeue();
if (first.Value.Count == 0)
{
_deadlines.Remove(first.Key);
}
if (!_attempts.TryGetValue(deadline.AttemptId, out PendingHostAttempt? attempt)
|| attempt.State != deadline.ExpectedState
|| (deadline.ExpectedState == RendezvousConnectionState.Punching
? attempt.PunchDeadline
: attempt.DirectDeadline) != deadline.Deadline)
{
continue;
}
bool expired = elapsed >= attempt.AttemptDeadline;
CompleteAttempt(
deadline.AttemptId,
RendezvousConnectionState.TimedOut,
expired
? ConnectionOutcomeKind.AttemptExpired
: deadline.ExpectedState == RendezvousConnectionState.Punching
? ConnectionOutcomeKind.PunchTimedOut
: ConnectionOutcomeKind.DirectConnectTimedOut,
expired
? RendezvousConnectionOutcomeSource.RendezvousService
: RendezvousConnectionOutcomeSource.LocalTraversal,
expired
? RendezvousConnectionFailureCategory.Authorization
: deadline.ExpectedState == RendezvousConnectionState.Punching
? RendezvousConnectionFailureCategory.NatTraversal
: RendezvousConnectionFailureCategory.DirectConnection,
expired
? RendezvousConnectionPhase.Authorization
: deadline.ExpectedState == RendezvousConnectionState.Punching
? RendezvousConnectionPhase.NatTraversal
: RendezvousConnectionPhase.DirectConnection);
if (State != RendezvousHostState.Active)
{
return;
}
}
}
private void AcceptAuthorizedRequest(
JoinAttemptId attemptId,
PendingHostAttempt attempt,
ConnectionRequest request,
string connectionTicket)
{
ConnectionTicketConsumptionResult consumption = _tickets.Consume(
attemptId,
connectionTicket);
if (consumption != ConnectionTicketConsumptionResult.Accepted)
{
request.RejectForce([]);
return;
}
NetPeer peer = request.Accept();
attempt.AcceptedPeer = peer;
_acceptedPeers[peer] = attemptId;
}
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
}
private sealed class PendingHostAttempt(
HostJoinAttempt invitation,
RendezvousPunchRetrySchedule retry,
TimeSpan startedAt,
TimeSpan attemptDeadline,
TimeSpan punchDeadline)
{
internal HostJoinAttempt Invitation { get; } = invitation;
internal RendezvousPunchRetrySchedule Retry { get; } = retry;
internal TimeSpan StartedAt { get; } = startedAt;
internal TimeSpan AttemptDeadline { get; } = attemptDeadline;
internal TimeSpan PunchDeadline { get; } = punchDeadline;
internal TimeSpan? DirectDeadline { get; set; }
internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching;
internal NetPeer? AcceptedPeer { get; set; }
}
private sealed class HostAttemptDeadline(
JoinAttemptId attemptId,
RendezvousConnectionState expectedState,
TimeSpan deadline)
{
internal JoinAttemptId AttemptId { get; } = attemptId;
internal RendezvousConnectionState ExpectedState { get; } = expectedState;
internal TimeSpan Deadline { get; } = deadline;
}
private sealed class DeferredConnectionRequest(
ConnectionRequest request,
string connectionTicket)
{
internal ConnectionRequest Request { get; } = request;
internal string ConnectionTicket { get; } = connectionTicket;
}
}