feat: add publisher and browser client SDK (#9)
quality-gate / quality (push) Successful in 1m6s

Closes #9
This commit is contained in:
KyuubiYoru
2026-07-16 06:27:44 +02:00
parent a9a2b3db35
commit 06c3973ce7
13 changed files with 1534 additions and 1 deletions
@@ -5,10 +5,12 @@
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
<IsPackable>true</IsPackable>
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" />
<None Update="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>
@@ -0,0 +1,61 @@
# FinalFactory.Rendezvous.Client
Godot-independent .NET publisher and session-browser SDK for Rendezvous v1.
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
```csharp
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using HttpClient http = new()
{
BaseAddress = new Uri("https://rendezvous.example/"),
};
string publisherCredential = Environment.GetEnvironmentVariable(
"RENDEZVOUS_PUBLISHER_CREDENTIAL")
?? throw new InvalidOperationException("Publisher credential is not configured.");
CancellationToken cancellationToken = default;
RendezvousPublisherClient publisher = new(http);
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
new RegisterSessionRequest
{
IdempotencyKey = Guid.NewGuid().ToString("N"),
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = "My server",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
},
publisherCredential,
cancellationToken);
if (!registered.IsSuccess || registered.Value is null)
{
throw new InvalidOperationException(
$"Registration failed: {registered.Error} ({registered.Message})");
}
```
Load `publisherCredential` from the game's deployment secret boundary; never
embed it in a client build or source control. A successful registration returns a
`PublishedSession` containing the lease and host-presence capabilities.
Lease renewal is explicit and caller-controlled:
```csharp
PublishedSession session = registered.Value;
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
session,
publisherCredential);
LeaseMaintenanceResult stopped = await maintainer.RunAsync(cancellationToken);
```
Creating the maintainer does not start background work. Await its run and dispose
it when hosting stops. Use `IRendezvousPublisherClient` and
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
the requests and responses it creates but never disposes the supplied `HttpClient`.
See the repository's ADR 0007 for retry, paging, ownership, and failure semantics.
@@ -0,0 +1,141 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousClientResult<T>
{
internal RendezvousClientResult(
RendezvousErrorCode error,
T? value,
string message,
int? retryAfterSeconds)
{
Error = error;
Value = value;
Message = message;
RetryAfterSeconds = retryAfterSeconds;
}
public bool IsSuccess => Error == RendezvousErrorCode.None;
public RendezvousErrorCode Error { get; }
public T? Value { get; }
public string Message { get; }
public int? RetryAfterSeconds { get; }
}
public static class RendezvousClientResult
{
public static RendezvousClientResult<T> Success<T>(T value) =>
value is null
? throw new ArgumentNullException(nameof(value))
: new(RendezvousErrorCode.None, value, string.Empty, null);
public static RendezvousClientResult<T> Failure<T>(
RendezvousErrorCode error,
string message,
int? retryAfterSeconds = null) =>
error == RendezvousErrorCode.None
? throw new ArgumentException("A failure requires a non-success error.", nameof(error))
: new(error, default, message ?? string.Empty, retryAfterSeconds);
}
public sealed class PublishedSession
{
internal PublishedSession(RegisterSessionResponse response)
{
ListingId = response.ListingId;
LeaseId = response.LeaseId;
LeaseToken = response.LeaseToken;
HostPresenceHandle = response.HostPresenceHandle;
HostPresenceCapability = response.HostPresenceCapability;
ExpiresAt = response.ExpiresAt;
LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
}
public SessionListingId ListingId { get; }
public LeaseId LeaseId { get; }
public string LeaseToken { get; }
public MediationHandle HostPresenceHandle { get; }
public string HostPresenceCapability { get; }
public DateTimeOffset ExpiresAt { get; internal set; }
public int LeaseRenewAfterSeconds { get; internal set; }
public int HostPresenceRefreshAfterSeconds { get; }
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
}
public interface IRendezvousPublisherClient
{
Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
RegisterSessionRequest request,
string publisherCredential,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
PublishedSession session,
string publisherCredential,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<bool>> UpdateAsync(
PublishedSession session,
UpdateSessionRequest request,
string publisherCredential,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<bool>> DeregisterAsync(
PublishedSession session,
string publisherCredential,
CancellationToken cancellationToken = default);
}
public interface IRendezvousSessionBrowserClient
{
Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
BrowseSessionsRequest request,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
BrowseSessionsRequest request,
int maximumPages = 100,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
SessionListingId listingId,
GameId gameId,
EnvironmentId environmentId,
uint protocolVersion,
CancellationToken cancellationToken = default);
}
public interface IRendezvousDelay
{
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
}
public sealed class RendezvousClientOptions
{
public int MaximumSafeRetries { get; set; } = 2;
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
public double JitterRatio { get; set; } = 0.2;
internal void Validate()
{
if (MaximumSafeRetries is < 0 or > 5
|| InitialRetryDelay < TimeSpan.Zero
|| MaximumRetryDelay < InitialRetryDelay
|| MaximumRetryDelay > TimeSpan.FromSeconds(30)
|| JitterRatio is < 0 or > 1)
{
throw new ArgumentOutOfRangeException(nameof(RendezvousClientOptions));
}
}
}
internal sealed class SystemRendezvousDelay : IRendezvousDelay
{
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
Task.Delay(delay, cancellationToken);
}
@@ -0,0 +1,242 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
internal sealed class RendezvousHttpTransport
{
private readonly HttpClient _httpClient;
private readonly RendezvousClientOptions _options;
private readonly IRendezvousDelay _delay;
internal RendezvousHttpTransport(
HttpClient httpClient,
RendezvousClientOptions? options,
IRendezvousDelay? delay)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
RendezvousClientOptions suppliedOptions = options ?? new RendezvousClientOptions();
suppliedOptions.Validate();
_options = new RendezvousClientOptions
{
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
InitialRetryDelay = suppliedOptions.InitialRetryDelay,
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
JitterRatio = suppliedOptions.JitterRatio,
};
_delay = delay ?? new SystemRendezvousDelay();
}
internal async Task<RendezvousClientResult<T>> SendSafeAsync<T>(
Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken)
{
for (int attempt = 0; ; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
using HttpRequestMessage request = requestFactory();
using HttpResponseMessage response = await _httpClient
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
if (typeof(T) == typeof(bool) && response.StatusCode == HttpStatusCode.NoContent)
{
return RendezvousClientResult.Success((T)(object)true);
}
byte[] payload;
try
{
payload = await ReadBoundedAsync(response.Content, cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidDataException)
{
return RendezvousClientResult.Failure<T>(
RendezvousErrorCode.InternalError,
"The service returned an oversized success response.");
}
T? value;
try
{
value = JsonSerializer.Deserialize<T>(payload, ContractJson.Options);
}
catch (JsonException)
{
value = default;
}
return value is null
? RendezvousClientResult.Failure<T>(
RendezvousErrorCode.InternalError,
"The service returned an invalid success response.")
: RendezvousClientResult.Success(value);
}
ApiError error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false);
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
{
await _delay.DelayAsync(
GetRetryDelay(attempt, retryAfter),
cancellationToken).ConfigureAwait(false);
continue;
}
return RendezvousClientResult.Failure<T>(error.Code, error.Message, retryAfter);
}
catch (Exception exception) when (
IsTransientTransportFailure(exception, cancellationToken)
&& attempt < _options.MaximumSafeRetries)
{
await _delay.DelayAsync(GetRetryDelay(attempt, null), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
{
return RendezvousClientResult.Failure<T>(
RendezvousErrorCode.ServiceUnavailable,
"The Rendezvous service did not return a valid response.");
}
}
}
internal static HttpRequestMessage JsonRequest<T>(
HttpMethod method,
string uri,
T body,
string? publisherCredential = null)
{
HttpRequestMessage request = new(method, uri)
{
Content = new StringContent(
JsonSerializer.Serialize(body, ContractJson.Options),
Encoding.UTF8,
"application/json"),
};
if (publisherCredential is not null)
{
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer",
RequireCredential(publisherCredential));
}
return request;
}
internal static string RequireCredential(string credential) =>
!string.IsNullOrWhiteSpace(credential)
? credential
: throw new ArgumentException("A publisher credential is required.", nameof(credential));
private static async Task<ApiError> ReadErrorAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
try
{
byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken)
.ConfigureAwait(false);
ApiError? error = JsonSerializer.Deserialize<ApiError>(payload, ContractJson.Options);
return error is not null && error.Code != RendezvousErrorCode.None
? error
: FallbackError(response.StatusCode);
}
catch (Exception exception) when (exception is JsonException or InvalidDataException)
{
return FallbackError(response.StatusCode);
}
}
private static async Task<byte[]> ReadBoundedAsync(
HttpContent content,
CancellationToken cancellationToken)
{
using Stream source = await content.ReadAsStreamAsync().ConfigureAwait(false);
using MemoryStream destination = new();
byte[] buffer = new byte[8192];
while (true)
{
int read = await source.ReadAsync(buffer.AsMemory(), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return destination.ToArray();
}
if (destination.Length + read > ContractLimits.BrowserResponseMaxBytes)
{
throw new InvalidDataException("The service response exceeded the SDK limit.");
}
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
.ConfigureAwait(false);
}
}
private TimeSpan GetRetryDelay(int attempt, int? retryAfterSeconds)
{
TimeSpan basis = retryAfterSeconds.HasValue
? TimeSpan.FromSeconds(Math.Max(0, retryAfterSeconds.Value))
: TimeSpan.FromMilliseconds(
_options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt));
double bounded = Math.Min(basis.TotalMilliseconds, _options.MaximumRetryDelay.TotalMilliseconds);
if (_options.JitterRatio == 0 || bounded == 0)
{
return TimeSpan.FromMilliseconds(bounded);
}
byte[] random = new byte[1];
RandomNumberGenerator.Fill(random);
double unit = random[0] / 255d;
double multiplier = 1 - _options.JitterRatio + (2 * _options.JitterRatio * unit);
return TimeSpan.FromMilliseconds(Math.Min(
bounded * multiplier,
_options.MaximumRetryDelay.TotalMilliseconds));
}
private static bool IsTransient(RendezvousErrorCode code) => code is
RendezvousErrorCode.RateLimited
or RendezvousErrorCode.CapacityExceeded
or RendezvousErrorCode.ServiceUnavailable;
private static bool IsTransientTransportFailure(
Exception exception,
CancellationToken callerCancellation) =>
exception is HttpRequestException
or IOException
|| exception is OperationCanceledException && !callerCancellation.IsCancellationRequested;
private static int? GetRetryAfterSeconds(RetryConditionHeaderValue? retryAfter) =>
retryAfter?.Delta is TimeSpan delta
? Math.Max(0, (int)Math.Ceiling(delta.TotalSeconds))
: null;
private static ApiError FallbackError(HttpStatusCode statusCode) => new()
{
Code = statusCode switch
{
HttpStatusCode.BadRequest => RendezvousErrorCode.InvalidRequest,
HttpStatusCode.Unauthorized => RendezvousErrorCode.AuthenticationRequired,
HttpStatusCode.Forbidden => RendezvousErrorCode.Forbidden,
HttpStatusCode.NotFound => RendezvousErrorCode.NotFound,
HttpStatusCode.Conflict => RendezvousErrorCode.Conflict,
HttpStatusCode.Gone => RendezvousErrorCode.Expired,
HttpStatusCode.TooManyRequests => RendezvousErrorCode.RateLimited,
HttpStatusCode.RequestTimeout => RendezvousErrorCode.ServiceUnavailable,
HttpStatusCode.BadGateway => RendezvousErrorCode.ServiceUnavailable,
HttpStatusCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
HttpStatusCode.GatewayTimeout => RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError,
},
Message = "The service returned an error without a valid Rendezvous envelope.",
};
}
@@ -0,0 +1,151 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
{
private readonly RendezvousHttpTransport _transport;
private readonly IRendezvousDelay _delay;
public RendezvousPublisherClient(
HttpClient httpClient,
RendezvousClientOptions? options = null,
IRendezvousDelay? delay = null)
{
_delay = delay ?? new SystemRendezvousDelay();
_transport = new(httpClient, options, _delay);
}
public async Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
RegisterSessionRequest request,
string publisherCredential,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
RegisterSessionRequest body = CopyRegistration(request);
RendezvousClientResult<RegisterSessionResponse> result = await _transport.SendSafeAsync<RegisterSessionResponse>(
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/sessions", body, publisherCredential),
cancellationToken).ConfigureAwait(false);
return result.IsSuccess && result.Value is not null
? RendezvousClientResult.Success(new PublishedSession(result.Value))
: RendezvousClientResult.Failure<PublishedSession>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
public async Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
PublishedSession session,
string publisherCredential,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
RendezvousClientResult<RenewLeaseResponse> result = await _transport.SendSafeAsync<RenewLeaseResponse>(
() => RendezvousHttpTransport.JsonRequest(
HttpMethod.Post,
$"v1/sessions/{session.ListingId}/renew",
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
publisherCredential),
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess && result.Value is not null)
{
session.ExpiresAt = result.Value.ExpiresAt;
session.LeaseRenewAfterSeconds = result.Value.RenewAfterSeconds;
}
return result;
}
public Task<RendezvousClientResult<bool>> UpdateAsync(
PublishedSession session,
UpdateSessionRequest request,
string publisherCredential,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
UpdateSessionRequest body = new()
{
ContractVersion = request.ContractVersion,
LeaseToken = session.LeaseToken,
BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName,
Capacity = CopyCapacity(request.Capacity),
Metadata = CopyMetadata(request.Metadata),
};
return _transport.SendSafeAsync<bool>(
() => RendezvousHttpTransport.JsonRequest(
HttpMethod.Put,
$"v1/sessions/{session.ListingId}",
body,
publisherCredential),
cancellationToken);
}
public Task<RendezvousClientResult<bool>> DeregisterAsync(
PublishedSession session,
string publisherCredential,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
return _transport.SendSafeAsync<bool>(
() => RendezvousHttpTransport.JsonRequest(
HttpMethod.Delete,
$"v1/sessions/{session.ListingId}",
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
publisherCredential),
cancellationToken);
}
public SessionLeaseMaintainer CreateLeaseMaintainer(
PublishedSession session,
string publisherCredential) => new(
this,
session ?? throw new ArgumentNullException(nameof(session)),
RendezvousHttpTransport.RequireCredential(publisherCredential),
_delay);
private static RegisterSessionRequest CopyRegistration(RegisterSessionRequest request) => new()
{
ContractVersion = request.ContractVersion,
IdempotencyKey = request.IdempotencyKey,
GameId = request.GameId,
EnvironmentId = request.EnvironmentId,
RegionId = request.RegionId,
ProtocolVersion = request.ProtocolVersion,
BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName,
Visibility = request.Visibility,
Capacity = CopyCapacity(request.Capacity),
Metadata = CopyMetadata(request.Metadata),
};
private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new()
{
CurrentPlayers = capacity.CurrentPlayers,
MaximumPlayers = capacity.MaximumPlayers,
};
private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) =>
new(metadata, StringComparer.Ordinal);
}
@@ -0,0 +1,110 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserClient
{
private readonly RendezvousHttpTransport _transport;
public RendezvousSessionBrowserClient(
HttpClient httpClient,
RendezvousClientOptions? options = null,
IRendezvousDelay? delay = null)
{
_transport = new(httpClient, options, delay);
}
public Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
BrowseSessionsRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
string query = $"v1/sessions?contractVersion={request.ContractVersion}"
+ $"&gameId={Escape(request.GameId.Value)}"
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
+ $"&protocolVersion={request.ProtocolVersion}"
+ $"&pageSize={request.PageSize}"
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
+ (request.RegionId.HasValue ? $"&regionId={Escape(request.RegionId.Value.Value)}" : string.Empty)
+ (request.Cursor is not null ? $"&cursor={Escape(request.Cursor)}" : string.Empty);
return _transport.SendSafeAsync<BrowseSessionsResponse>(
() => new HttpRequestMessage(HttpMethod.Get, query),
cancellationToken);
}
public async Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
BrowseSessionsRequest request,
int maximumPages = 100,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (maximumPages is < 1 or > 1000)
{
throw new ArgumentOutOfRangeException(nameof(maximumPages));
}
List<SessionListing> items = [];
string? cursor = request.Cursor;
for (int page = 0; page < maximumPages; page++)
{
BrowseSessionsRequest pageRequest = new()
{
ContractVersion = request.ContractVersion,
GameId = request.GameId,
EnvironmentId = request.EnvironmentId,
ProtocolVersion = request.ProtocolVersion,
RegionId = request.RegionId,
PageSize = request.PageSize,
ExcludeFull = request.ExcludeFull,
Cursor = cursor,
};
RendezvousClientResult<BrowseSessionsResponse> result = await BrowseAsync(
pageRequest,
cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
items.AddRange(result.Value.Items);
cursor = result.Value.NextCursor;
if (string.IsNullOrEmpty(cursor))
{
return RendezvousClientResult.Success<IReadOnlyList<SessionListing>>(items.AsReadOnly());
}
}
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
RendezvousErrorCode.CapacityExceeded,
$"Browsing exceeded the configured {maximumPages}-page limit.");
}
public Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
SessionListingId listingId,
GameId gameId,
EnvironmentId environmentId,
uint protocolVersion,
CancellationToken cancellationToken = default)
{
string query = $"v1/sessions/{listingId}?contractVersion={ContractLimits.ContractVersion}"
+ $"&gameId={Escape(gameId.Value)}"
+ $"&environmentId={Escape(environmentId.Value)}"
+ $"&protocolVersion={protocolVersion}";
return _transport.SendSafeAsync<GetSessionResponse>(
() => new HttpRequestMessage(HttpMethod.Get, query),
cancellationToken);
}
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
}
@@ -0,0 +1,150 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public enum LeaseMaintenanceStopReason
{
Cancelled = 1,
Disposed = 2,
LeaseLost = 3,
Failed = 4,
}
public sealed class LeaseMaintenanceResult
{
internal LeaseMaintenanceResult(LeaseMaintenanceStopReason reason, RendezvousErrorCode error)
{
Reason = reason;
Error = error;
}
public LeaseMaintenanceStopReason Reason { get; }
public RendezvousErrorCode Error { get; }
}
public sealed class SessionLeaseMaintainer : IAsyncDisposable
{
private readonly object _gate = new();
private readonly IRendezvousPublisherClient _publisher;
private readonly PublishedSession _session;
private readonly string _publisherCredential;
private readonly IRendezvousDelay _delay;
private readonly CancellationTokenSource _disposeCancellation = new();
private Task<LeaseMaintenanceResult>? _activeRun;
private Task? _disposeTask;
private bool _disposed;
internal SessionLeaseMaintainer(
IRendezvousPublisherClient publisher,
PublishedSession session,
string publisherCredential,
IRendezvousDelay? delay = null)
{
_publisher = publisher;
_session = session;
_publisherCredential = publisherCredential;
_delay = delay ?? new SystemRendezvousDelay();
}
public event EventHandler? LeaseLost;
public Task<LeaseMaintenanceResult> RunAsync(CancellationToken cancellationToken = default)
{
lock (_gate)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SessionLeaseMaintainer));
}
if (_activeRun is not null)
{
throw new InvalidOperationException("Lease maintenance is already running.");
}
_activeRun = RunCoreAsync(cancellationToken);
return _activeRun;
}
}
public ValueTask DisposeAsync()
{
lock (_gate)
{
if (_disposeTask is not null)
{
return new(_disposeTask);
}
_disposed = true;
_disposeCancellation.Cancel();
_disposeTask = FinishDisposeAsync(_activeRun);
return new(_disposeTask);
}
}
private async Task FinishDisposeAsync(Task<LeaseMaintenanceResult>? active)
{
try
{
if (active is not null)
{
await active.ConfigureAwait(false);
}
}
finally
{
_disposeCancellation.Dispose();
}
}
private async Task<LeaseMaintenanceResult> RunCoreAsync(CancellationToken cancellationToken)
{
await Task.Yield();
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
_disposeCancellation.Token);
try
{
while (true)
{
await _delay.DelayAsync(
TimeSpan.FromSeconds(Math.Max(1, _session.LeaseRenewAfterSeconds)),
linked.Token).ConfigureAwait(false);
RendezvousClientResult<RenewLeaseResponse> renewed = await _publisher.RenewAsync(
_session,
_publisherCredential,
linked.Token).ConfigureAwait(false);
if (renewed.IsSuccess)
{
continue;
}
if (renewed.Error is RendezvousErrorCode.NotFound
or RendezvousErrorCode.Expired
or RendezvousErrorCode.Forbidden
or RendezvousErrorCode.AuthenticationRequired)
{
LeaseLost?.Invoke(this, EventArgs.Empty);
return new(LeaseMaintenanceStopReason.LeaseLost, renewed.Error);
}
return new(LeaseMaintenanceStopReason.Failed, renewed.Error);
}
}
catch (OperationCanceledException) when (linked.IsCancellationRequested)
{
return new(
_disposeCancellation.IsCancellationRequested
? LeaseMaintenanceStopReason.Disposed
: LeaseMaintenanceStopReason.Cancelled,
RendezvousErrorCode.None);
}
finally
{
lock (_gate)
{
_activeRun = null;
}
}
}
}