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)