fix(multiplayer): harden reconnect lifecycle fencing

This commit is contained in:
Josh Creek
2026-09-03 13:24:52 +01:00
parent cee0163eac
commit 8c28374eb4
5 changed files with 62 additions and 8 deletions
+15
View File
@@ -91,6 +91,9 @@ func (r *RankedConnections) validate(auth JoinAuthorisation, now time.Time) erro
// slot with the next server-owned generation. A newer generation fences every
// older connection, even if the backend is temporarily unavailable.
func (r *RankedConnections) Admit(auth JoinAuthorisation, now time.Time) (uint64, error) {
if now.IsZero() {
return 0, ErrJoinAuthorisation
}
if err := r.validate(auth, now); err != nil {
return 0, err
}
@@ -107,7 +110,13 @@ func (r *RankedConnections) Admit(auth JoinAuthorisation, now time.Time) (uint64
if player.Abandoned {
return 0, ErrReconnectExpired
}
if !player.ConnectedAt.IsZero() && player.LostAt.IsZero() {
return 0, ErrConnectionFenced
}
if !player.LostAt.IsZero() {
if now.Before(player.LostAt) {
return 0, ErrJoinAuthorisation
}
if now.Sub(player.LostAt) > RankedReconnectGrace {
return 0, ErrReconnectExpired
}
@@ -120,6 +129,9 @@ func (r *RankedConnections) Admit(auth JoinAuthorisation, now time.Time) (uint64
}
func (r *RankedConnections) Disconnect(playerID string, generation uint64, now time.Time) error {
if now.IsZero() {
return ErrJoinAuthorisation
}
player, ok := r.players[playerID]
if !ok {
return ErrJoinAuthorisation
@@ -130,6 +142,9 @@ func (r *RankedConnections) Disconnect(playerID string, generation uint64, now t
if player.Abandoned {
return ErrReconnectExpired
}
if player.ConnectedAt.IsZero() || !player.LostAt.IsZero() || now.Before(player.ConnectedAt) {
return ErrConnectionFenced
}
player.LostAt = now
r.players[playerID] = player
return nil
+41 -5
View File
@@ -26,19 +26,19 @@ func TestRankedReconnectReclaimsWithinGraceAndFencesOldGeneration(t *testing.T)
if gen, err := r.Admit(auth, now); err != nil || gen != 1 {
t.Fatalf("initial admit = %d, %v", gen, err)
}
if err := r.Disconnect("a", 1, now); err != nil {
if err := r.Disconnect("a", 1, now.Add(time.Second)); err != nil {
t.Fatal(err)
}
if gen, err := r.Admit(auth, now.Add(RankedReconnectGrace)); err != nil || gen != 2 {
if gen, err := r.Admit(auth, now.Add(time.Second+RankedReconnectGrace)); err != nil || gen != 2 {
t.Fatalf("boundary reclaim = %d, %v", gen, err)
}
if err := r.Disconnect("a", 1, now.Add(31*time.Second)); !errors.Is(err, ErrConnectionFenced) {
if err := r.Disconnect("a", 1, now.Add(62*time.Second)); !errors.Is(err, ErrConnectionFenced) {
t.Fatalf("old connection was not fenced: %v", err)
}
if err := r.Disconnect("a", 2, now.Add(31*time.Second)); err != nil {
if err := r.Disconnect("a", 2, now.Add(62*time.Second)); err != nil {
t.Fatal(err)
}
if gen, err := r.Admit(auth, now.Add(32*time.Second)); err != nil || gen != 3 {
if gen, err := r.Admit(auth, now.Add(63*time.Second)); err != nil || gen != 3 {
t.Fatalf("repeated reclaim with existing authorisation = %d, %v", gen, err)
}
}
@@ -59,6 +59,9 @@ func TestRankedReconnectRejectsWrongBindingAndExpiredGrace(t *testing.T) {
if _, err := r.Admit(wrongIdentity, now); !errors.Is(err, ErrJoinAuthorisation) {
t.Fatalf("wrong SteamID accepted: %v", err)
}
if _, err := r.Admit(testRoster(now)[0], now); err != nil {
t.Fatal(err)
}
if err := r.Disconnect("a", 1, now); err != nil {
t.Fatal(err)
}
@@ -67,6 +70,36 @@ func TestRankedReconnectRejectsWrongBindingAndExpiredGrace(t *testing.T) {
}
}
func TestRankedReconnectRejectsDuplicateAndTimeReversedLifecycle(t *testing.T) {
now := time.Unix(1000, 0)
r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now))
if err != nil {
t.Fatal(err)
}
auth := testRoster(now)[0]
if err := r.Disconnect("a", 1, now); !errors.Is(err, ErrConnectionFenced) {
t.Fatalf("disconnect before admission error = %v", err)
}
if _, err := r.Admit(auth, time.Time{}); !errors.Is(err, ErrJoinAuthorisation) {
t.Fatalf("zero-time admission error = %v", err)
}
if _, err := r.Admit(auth, now); err != nil {
t.Fatal(err)
}
if _, err := r.Admit(auth, now.Add(time.Second)); !errors.Is(err, ErrConnectionFenced) {
t.Fatalf("duplicate active admission error = %v", err)
}
if err := r.Disconnect("a", 1, now.Add(2*time.Second)); err != nil {
t.Fatal(err)
}
if err := r.Disconnect("a", 1, now.Add(30*time.Second)); !errors.Is(err, ErrConnectionFenced) {
t.Fatalf("duplicate disconnect error = %v", err)
}
if _, err := r.Admit(auth, now.Add(time.Second)); !errors.Is(err, ErrJoinAuthorisation) {
t.Fatalf("time-reversed reclaim error = %v", err)
}
}
func TestRankedRosterRejectsDuplicateSlots(t *testing.T) {
now := time.Unix(1000, 0)
roster := testRoster(now)
@@ -91,6 +124,9 @@ func TestRankedAbandonCooldownUsesRollingSevenDayLadder(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := r.Admit(testRoster(now)[0], now); err != nil {
t.Fatal(err)
}
if err := r.Disconnect("a", 1, now); err != nil {
t.Fatal(err)
}