feat(browser): stream bounded live session updates (#26)
This commit is contained in:
@@ -167,6 +167,88 @@ public sealed class RendezvousClientBehaviorTests
|
||||
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamRejectsMalformedAndOversizedEventEnvelopes()
|
||||
{
|
||||
string[] bodies =
|
||||
[
|
||||
"event: session_upsert\nid: valid-cursor\ndata: {}\n\n",
|
||||
"data: " + new string('x', ContractLimits.SessionStreamEventMaxBytes + 1) + "\n\n",
|
||||
];
|
||||
foreach (string body in bodies)
|
||||
{
|
||||
StringContent content = new(body, Encoding.UTF8, "text/event-stream");
|
||||
ScriptedHandler handler = new(Response(HttpStatusCode.OK, content));
|
||||
using HttpClient httpClient = new(handler)
|
||||
{
|
||||
BaseAddress = new("http://rendezvous.test/"),
|
||||
};
|
||||
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||
.StreamAsync(new BrowseSessionsRequest
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
}, "valid-stream-cursor")
|
||||
.GetAsyncEnumerator();
|
||||
|
||||
Assert.True(await events.MoveNextAsync());
|
||||
Assert.False(events.Current.IsSuccess);
|
||||
Assert.Equal(RendezvousErrorCode.InternalError, events.Current.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamRequiresANonEmptySnapshotCursor()
|
||||
{
|
||||
using HttpClient httpClient = new(new ScriptedHandler())
|
||||
{
|
||||
BaseAddress = new("http://rendezvous.test/"),
|
||||
};
|
||||
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
{
|
||||
await foreach (RendezvousClientResult<SessionStreamEvent> _ in browser.StreamAsync(
|
||||
new BrowseSessionsRequest
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
},
|
||||
string.Empty))
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamOpeningIsBoundedByTheConfiguredRequestTimeout()
|
||||
{
|
||||
using HttpClient httpClient = new(new SilentHandler())
|
||||
{
|
||||
BaseAddress = new("http://rendezvous.test/"),
|
||||
};
|
||||
RendezvousSessionBrowserClient browser = new(
|
||||
httpClient,
|
||||
new RendezvousClientOptions
|
||||
{
|
||||
MaximumSafeRetries = 0,
|
||||
RequestTimeout = TimeSpan.FromMilliseconds(20),
|
||||
});
|
||||
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||
.StreamAsync(new BrowseSessionsRequest
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
}, "valid-stream-cursor")
|
||||
.GetAsyncEnumerator();
|
||||
|
||||
Assert.True(await events.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, events.Current.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeaseMaintainerReportsLeaseLoss()
|
||||
{
|
||||
|
||||
@@ -142,6 +142,77 @@ public sealed class RendezvousClientIntegrationTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowserStreamResetsInvalidCursorReplaysReconnectAndReleasesConnections()
|
||||
{
|
||||
await using ClientTestHost host = await ClientTestHost.StartAsync();
|
||||
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||
RendezvousSessionBrowserClient browser = new(host.HttpClient);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistration(200),
|
||||
host.PublisherCredential));
|
||||
BindPresence(host, session, 41_200);
|
||||
BrowseSessionsRequest request = BrowseRequest();
|
||||
BrowseSessionsResponse snapshot = AssertSuccess(await browser.BrowseAsync(request));
|
||||
Assert.False(string.IsNullOrWhiteSpace(snapshot.StreamCursor));
|
||||
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> invalid = browser
|
||||
.StreamAsync(request, CorruptCursor(snapshot.StreamCursor), timeout.Token)
|
||||
.GetAsyncEnumerator(timeout.Token))
|
||||
{
|
||||
Assert.True(await invalid.MoveNextAsync());
|
||||
Assert.Equal(SessionStreamEventKind.Reset, AssertSuccess(invalid.Current).Kind);
|
||||
Assert.False(await invalid.MoveNextAsync());
|
||||
}
|
||||
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
|
||||
.GetAsyncEnumerator(timeout.Token);
|
||||
Task<bool> upsertPending = events.MoveNextAsync().AsTask();
|
||||
Assert.True((await publisher.UpdateAsync(
|
||||
session,
|
||||
new UpdateSessionRequest
|
||||
{
|
||||
BuildVersion = "2.0.0",
|
||||
DisplayName = "Live update",
|
||||
Capacity = new() { CurrentPlayers = 3, MaximumPlayers = 8 },
|
||||
Metadata = new() { ["mode"] = "online-coop" },
|
||||
},
|
||||
host.PublisherCredential,
|
||||
timeout.Token)).IsSuccess);
|
||||
Assert.True(await upsertPending);
|
||||
SessionStreamEvent upsert = AssertSuccess(events.Current);
|
||||
Assert.Equal(SessionStreamEventKind.SessionUpsert, upsert.Kind);
|
||||
Assert.Equal("Live update", upsert.Session!.DisplayName);
|
||||
|
||||
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> replay = browser
|
||||
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
|
||||
.GetAsyncEnumerator(timeout.Token))
|
||||
{
|
||||
Assert.True(await replay.MoveNextAsync());
|
||||
SessionStreamEvent replayed = AssertSuccess(replay.Current);
|
||||
Assert.Equal(SessionStreamEventKind.SessionUpsert, replayed.Kind);
|
||||
Assert.Equal(upsert.Cursor, replayed.Cursor);
|
||||
Assert.Equal("Live update", replayed.Session!.DisplayName);
|
||||
}
|
||||
|
||||
Task<bool> removePending = events.MoveNextAsync().AsTask();
|
||||
Assert.True((await publisher.DeregisterAsync(
|
||||
session,
|
||||
host.PublisherCredential,
|
||||
timeout.Token)).IsSuccess);
|
||||
Assert.True(await removePending);
|
||||
SessionStreamEvent remove = AssertSuccess(events.Current);
|
||||
Assert.Equal(SessionStreamEventKind.SessionRemove, remove.Kind);
|
||||
Assert.Equal(session.ListingId, remove.ListingId);
|
||||
}
|
||||
|
||||
private static string CorruptCursor(string cursor)
|
||||
{
|
||||
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
|
||||
return cursor[..^1] + replacement;
|
||||
}
|
||||
|
||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||
{
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
@@ -210,7 +281,8 @@ public sealed class RendezvousClientIntegrationTests
|
||||
{
|
||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
|
||||
EphemeralCapabilityIssuer capabilities = new();
|
||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||
ProvisioningTestData.CreateOptions(),
|
||||
@@ -239,7 +311,10 @@ public sealed class RendezvousClientIntegrationTests
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||
builder.Services.AddSingleton(changes);
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<SessionStreamService>();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
|
||||
Reference in New Issue
Block a user