diff --git a/DorkNet.Server.Tests/PendingJoinPresenceTests.cs b/DorkNet.Server.Tests/PendingJoinPresenceTests.cs new file mode 100644 index 0000000..0ec57ec --- /dev/null +++ b/DorkNet.Server.Tests/PendingJoinPresenceTests.cs @@ -0,0 +1,101 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DorkNet.Server.Tests; + +/// +/// While a join is pending, the heartbeat must tell the joining player which +/// instance they are joining — never null. +/// +/// Deferring the presence commit is about what everyone ELSE sees: presence +/// stays uncommitted until the join is confirmed so a failed join leaves no +/// ghost behind. It must not change what the joining player is told about +/// themselves, because the client XORs (cachedRoomInstance != null) against +/// (serverRoomInstance != null): holding the target while the server says null +/// reads as the room vanishing mid-join. The client then logs "presence +/// heartbeat response indicates local presence is out-of-sync", "Attempting to +/// go to Invalid RoomInstance", and cancels the very join the server was +/// waiting on — so it retries until a heartbeat happens to miss the window, and +/// entering a dorm takes several attempts while looking like a load failure. +/// +public sealed class PendingJoinPresenceTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public PendingJoinPresenceTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Heartbeat_during_a_pending_dorm_join_reports_the_instance_being_joined() + { + using var client = Client("match"); + var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", session.AccessToken); + + // matchmake/dorm is what arms the deferred-commit pending join. + using var dormForm = new FormUrlEncodedContent( + [new KeyValuePair("MaxPersistenceVersion", "45")]); + using var dorm = await client.PostAsync("/matchmake/dorm", dormForm); + var dormBody = await dorm.Content.ReadAsStringAsync(); + Assert.True(dorm.IsSuccessStatusCode, + $"POST /matchmake/dorm -> {(int)dorm.StatusCode}: {dormBody}"); + + var expectedInstance = JsonDocument.Parse(dormBody).RootElement; + var instanceId = FindInstanceId(expectedInstance); + Assert.True(instanceId is not null, $"no roomInstanceId in the dorm reply: {dormBody}"); + + // The join has NOT been reported yet, so the commit is still deferred — + // this is exactly the window the client heartbeats in. + using var beatForm = new FormUrlEncodedContent( + [new KeyValuePair("LoginLock", Guid.NewGuid().ToString())]); + using var beat = await client.PostAsync("/player/heartbeat", beatForm); + var beatBody = await beat.Content.ReadAsStringAsync(); + Assert.True(beat.IsSuccessStatusCode, + $"POST /player/heartbeat -> {(int)beat.StatusCode}: {beatBody}"); + + var presence = JsonDocument.Parse(beatBody).RootElement; + Assert.True(presence.TryGetProperty("roomInstance", out var reported) + || presence.TryGetProperty("RoomInstance", out reported), + $"no roomInstance key in the heartbeat: {beatBody}"); + + Assert.True(reported.ValueKind != JsonValueKind.Null, + $""" + The heartbeat returned a null roomInstance while the player's join was + still pending. The client reads that as the room vanishing mid-join and + cancels the load it is waiting on: + + {beatBody} + """); + + Assert.Equal(instanceId, FindInstanceId(reported)); + } + + /// Pull roomInstanceId out of either the matchmake reply (which + /// wraps it) or the heartbeat's roomInstance object, whatever the casing. + private static string? FindInstanceId(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) return null; + + foreach (var property in element.EnumerateObject()) + { + if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase)) + return property.Value.ToString(); + + if (property.Value.ValueKind == JsonValueKind.Object + && FindInstanceId(property.Value) is { } nested) + return nested; + } + return null; + } + + private HttpClient Client(string host) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}"); + return client; + } +} diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 136e610..217bcae 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -363,12 +363,34 @@ public class MatchPlayerController( var pending = joinTimeouts.GetPending(playerId); if (pending is { DeferPresenceCommit: true }) { + // Report the instance the player is joining — NOT null. + // + // Deferring the commit is about what everyone ELSE sees: presence + // stays uncommitted until the join is confirmed, so a failed join + // never leaves a ghost in the room. It was never meant to change + // what we tell the joining player about themselves. + // + // Returning null did exactly that, and the client treats it as the + // room vanishing mid-join: OnPresenceHeartbeatResponse XORs + // (cachedRoomInstance != null) against (serverRoomInstance != null), + // so a client holding the target while we say null fires + // "presence heartbeat response indicates local presence is + // out-of-sync", then "Attempting to go to Invalid RoomInstance" + // and cancels its own load ("OnPlayerPresenceUpdate is cancelling + // room load"). The join it aborted was the one we were waiting on, + // so it retried until a heartbeat happened to miss the window — + // entering a dorm took several attempts and looked like a load + // failure. The branch below documents this same XOR for the + // boot-race case; this one contradicted it. + // + // Echoing the pending target satisfies the XOR and matches what the + // client already has, while presence itself stays uncommitted. log.LogInformation( - "[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; returning null until join result", + "[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; echoing target until join result", playerId, pending.Value.TargetRoom.RoomId, pending.Value.TargetRoom.RoomInstanceId); - return null; + return pending.Value.TargetRoom; } if (room is null)