feat(browser): stream bounded live session updates (#26)
This commit is contained in:
@@ -144,6 +144,11 @@ public interface IRendezvousSessionBrowserClient
|
||||
EnvironmentId environmentId,
|
||||
uint protocolVersion,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
|
||||
BrowseSessionsRequest request,
|
||||
string streamCursor,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousJoinClient
|
||||
|
||||
@@ -114,6 +114,49 @@ internal sealed class RendezvousHttpTransport
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<RendezvousClientResult<HttpResponseMessage>> OpenStreamAsync(
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using CancellationTokenSource requestTimeout =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
requestTimeout.CancelAfter(_options.RequestTimeout);
|
||||
CancellationToken requestCancellation = requestTimeout.Token;
|
||||
using HttpRequestMessage request = requestFactory();
|
||||
HttpResponseMessage? response = null;
|
||||
try
|
||||
{
|
||||
response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
requestCancellation).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
HttpResponseMessage ownedResponse = response;
|
||||
response = null;
|
||||
return RendezvousClientResult.Success(ownedResponse);
|
||||
}
|
||||
|
||||
ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
|
||||
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||
return RendezvousClientResult.Failure<HttpResponseMessage>(
|
||||
error.Code,
|
||||
error.Message,
|
||||
retryAfter);
|
||||
}
|
||||
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||
{
|
||||
return RendezvousClientResult.Failure<HttpResponseMessage>(
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
"The Rendezvous event stream could not be opened.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static HttpRequestMessage JsonRequest<T>(
|
||||
HttpMethod method,
|
||||
string uri,
|
||||
|
||||
@@ -84,6 +84,9 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
LeaseToken = session.LeaseToken,
|
||||
RegionId = request.RegionId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
Visibility = request.Visibility,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Capacity = CopyCapacity(request.Capacity),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
@@ -106,5 +110,264 @@ public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserCl
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
|
||||
BrowseSessionsRequest request,
|
||||
string streamCursor,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(streamCursor)
|
||||
|| !ContractValidation.IsCursorValid(streamCursor))
|
||||
{
|
||||
throw new ArgumentException("A valid snapshot stream cursor is required.", nameof(streamCursor));
|
||||
}
|
||||
|
||||
string query = $"v1/sessions/stream?contractVersion={request.ContractVersion}"
|
||||
+ $"&gameId={Escape(request.GameId.Value)}"
|
||||
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
|
||||
+ $"&protocolVersion={request.ProtocolVersion}"
|
||||
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
|
||||
+ (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty);
|
||||
RendezvousClientResult<HttpResponseMessage> opened = await _transport.OpenStreamAsync(
|
||||
() =>
|
||||
{
|
||||
HttpRequestMessage message = new(HttpMethod.Get, query);
|
||||
message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
message.Headers.TryAddWithoutValidation("Last-Event-ID", streamCursor);
|
||||
return message;
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!opened.IsSuccess || opened.Value is null)
|
||||
{
|
||||
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||
opened.Error,
|
||||
opened.Message,
|
||||
opened.RetryAfterSeconds);
|
||||
yield break;
|
||||
}
|
||||
|
||||
using HttpResponseMessage response = opened.Value;
|
||||
if (!string.Equals(
|
||||
response.Content.Headers.ContentType?.MediaType,
|
||||
"text/event-stream",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an invalid event-stream content type.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
using Stream source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
using SseLineReader reader = new(source);
|
||||
while (true)
|
||||
{
|
||||
SseReadResult? read = null;
|
||||
RendezvousClientResult<SessionStreamEvent>? readFailure = null;
|
||||
bool cancelled = false;
|
||||
try
|
||||
{
|
||||
read = await ReadEventAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
cancelled = true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or JsonException or InvalidDataException)
|
||||
{
|
||||
readFailure = RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an invalid or oversized event stream.");
|
||||
}
|
||||
if (cancelled)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
if (readFailure is not null)
|
||||
{
|
||||
yield return readFailure;
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (read!.EndOfStream)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
yield return read.Result!;
|
||||
if (!read.Result!.IsSuccess)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<SseReadResult> ReadEventAsync(
|
||||
SseLineReader reader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? eventName = null;
|
||||
string? id = null;
|
||||
string? data = null;
|
||||
int bytes = 0;
|
||||
while (true)
|
||||
{
|
||||
string? line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
return eventName is null && id is null && data is null
|
||||
? SseReadResult.End
|
||||
: throw new InvalidDataException("The final SSE event was incomplete.");
|
||||
}
|
||||
bytes += Encoding.UTF8.GetByteCount(line) + 1;
|
||||
if (bytes > ContractLimits.SessionStreamEventMaxBytes)
|
||||
{
|
||||
throw new InvalidDataException("The SSE event exceeded the contract limit.");
|
||||
}
|
||||
if (line.Length == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (line.StartsWith("event: ", StringComparison.Ordinal))
|
||||
{
|
||||
eventName = line[7..];
|
||||
}
|
||||
else if (line.StartsWith("id: ", StringComparison.Ordinal))
|
||||
{
|
||||
id = line[4..];
|
||||
}
|
||||
else if (line.StartsWith("data: ", StringComparison.Ordinal))
|
||||
{
|
||||
data = line[6..];
|
||||
}
|
||||
}
|
||||
|
||||
SessionStreamEvent? item = data is null
|
||||
? null
|
||||
: JsonSerializer.Deserialize<SessionStreamEvent>(data, ContractJson.Options);
|
||||
if (item is null
|
||||
|| !string.Equals(item.Cursor, id, StringComparison.Ordinal)
|
||||
|| !string.Equals(eventName, EventName(item.Kind), StringComparison.Ordinal)
|
||||
|| !IsValidShape(item))
|
||||
{
|
||||
return new(false, RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an invalid event envelope."));
|
||||
}
|
||||
return new(false, RendezvousClientResult.Success(item));
|
||||
}
|
||||
|
||||
private static string EventName(SessionStreamEventKind kind) => kind switch
|
||||
{
|
||||
SessionStreamEventKind.SessionUpsert => "session_upsert",
|
||||
SessionStreamEventKind.SessionRemove => "session_remove",
|
||||
SessionStreamEventKind.Reset => "reset",
|
||||
SessionStreamEventKind.Keepalive => "keepalive",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static bool IsValidShape(SessionStreamEvent item) =>
|
||||
item.ContractVersion == ContractLimits.ContractVersion
|
||||
&& !string.IsNullOrWhiteSpace(item.Cursor)
|
||||
&& ContractValidation.IsCursorValid(item.Cursor)
|
||||
&& (item.Kind == SessionStreamEventKind.SessionUpsert
|
||||
&& item.Session is not null
|
||||
&& IsValidListing(item.Session)
|
||||
&& item.ListingId is null
|
||||
|| item.Kind == SessionStreamEventKind.SessionRemove
|
||||
&& item.Session is null
|
||||
&& item.ListingId.HasValue
|
||||
&& item.ListingId.Value.Value != Guid.Empty
|
||||
|| item.Kind is SessionStreamEventKind.Reset or SessionStreamEventKind.Keepalive
|
||||
&& item.Session is null
|
||||
&& item.ListingId is null);
|
||||
|
||||
private static bool IsValidListing(SessionListing listing) =>
|
||||
listing.ContractVersion == ContractLimits.ContractVersion
|
||||
&& listing.ListingId.Value != Guid.Empty
|
||||
&& !string.IsNullOrWhiteSpace(listing.GameId.Value)
|
||||
&& !string.IsNullOrWhiteSpace(listing.EnvironmentId.Value)
|
||||
&& !string.IsNullOrWhiteSpace(listing.RegionId.Value)
|
||||
&& listing.ProtocolVersion != 0
|
||||
&& ContractValidation.IsBuildVersionValid(listing.BuildVersion)
|
||||
&& ContractValidation.IsDisplayNameValid(listing.DisplayName)
|
||||
&& listing.Visibility == ListingVisibility.Public
|
||||
&& Enum.IsDefined(typeof(PublisherTrustMode), listing.PublisherTrustMode)
|
||||
&& ContractValidation.IsCapacityValid(listing.Capacity)
|
||||
&& ContractValidation.IsMetadataValid(listing.Metadata)
|
||||
&& (listing.DedicatedFallback is null
|
||||
|| ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback));
|
||||
|
||||
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||
|
||||
private sealed class SseReadResult
|
||||
{
|
||||
public SseReadResult(
|
||||
bool endOfStream,
|
||||
RendezvousClientResult<SessionStreamEvent>? result)
|
||||
{
|
||||
EndOfStream = endOfStream;
|
||||
Result = result;
|
||||
}
|
||||
|
||||
public bool EndOfStream { get; }
|
||||
public RendezvousClientResult<SessionStreamEvent>? Result { get; }
|
||||
public static SseReadResult End { get; } = new(true, null);
|
||||
}
|
||||
|
||||
private sealed class SseLineReader(Stream source) : IDisposable
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8 = new(false, true);
|
||||
private readonly byte[] _buffer = new byte[4096];
|
||||
private readonly MemoryStream _line = new();
|
||||
private int _offset;
|
||||
private int _count;
|
||||
|
||||
public async Task<string?> ReadLineAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_offset >= _count)
|
||||
{
|
||||
_count = await source.ReadAsync(
|
||||
_buffer.AsMemory(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_offset = 0;
|
||||
if (_count == 0)
|
||||
{
|
||||
if (_line.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return TakeLine();
|
||||
}
|
||||
}
|
||||
|
||||
byte value = _buffer[_offset++];
|
||||
if (value == (byte)'\n')
|
||||
{
|
||||
return TakeLine();
|
||||
}
|
||||
if (_line.Length >= ContractLimits.SessionStreamEventMaxBytes)
|
||||
{
|
||||
throw new InvalidDataException("An SSE line exceeded the contract limit.");
|
||||
}
|
||||
_line.WriteByte(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _line.Dispose();
|
||||
|
||||
private string TakeLine()
|
||||
{
|
||||
byte[] bytes = _line.ToArray();
|
||||
_line.SetLength(0);
|
||||
int length = bytes.Length > 0 && bytes[^1] == (byte)'\r'
|
||||
? bytes.Length - 1
|
||||
: bytes.Length;
|
||||
return Utf8.GetString(bytes, 0, length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user