Fix Auto Relog reconnect lifecycle

This commit is contained in:
Anon 2026-07-19 14:42:10 +02:00
parent c19fdd6634
commit 64c1dc55e0
20 changed files with 1243 additions and 415 deletions

View file

@ -0,0 +1,145 @@
using MinecraftClient.ChatBots;
using MinecraftClient.Scripting;
namespace MinecraftClient.Tests;
public sealed class AutoRelogRetryPolicyTests
{
[Fact]
public void DefaultConfigurationUsesUnlimitedRetries()
{
Assert.Equal(-1, new AutoRelog.Configs().Retries);
}
[Fact]
public void UnlimitedRetriesNeverExhaust()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
for (int attempt = 1; attempt <= 100; attempt++)
{
Assert.True(policy.TryReserveAttempt(-1, out int retriesLeft));
Assert.Equal(-1, retriesLeft);
Assert.Equal(attempt, policy.Attempts);
}
}
[Fact]
public void ZeroRetriesDisablesReconnect()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.False(policy.TryReserveAttempt(0, out int retriesLeft));
Assert.Equal(0, retriesLeft);
Assert.Equal(0, policy.Attempts);
}
[Fact]
public void FiniteRetryLimitIsExact()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.True(policy.TryReserveAttempt(3, out int firstRetriesLeft));
Assert.True(policy.TryReserveAttempt(3, out int secondRetriesLeft));
Assert.True(policy.TryReserveAttempt(3, out int thirdRetriesLeft));
Assert.False(policy.TryReserveAttempt(3, out int exhaustedRetriesLeft));
Assert.Equal(2, firstRetriesLeft);
Assert.Equal(1, secondRetriesLeft);
Assert.Equal(0, thirdRetriesLeft);
Assert.Equal(0, exhaustedRetriesLeft);
}
[Fact]
public void RejectedRestartDoesNotConsumeRetry()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.True(policy.TryReserveAttempt(1, out _));
policy.RollBackReservedAttempt();
Assert.True(policy.TryReserveAttempt(1, out int retriesLeft));
Assert.Equal(0, retriesLeft);
}
[Fact]
public void StableConnectionResetsRetryBudget()
{
var timeProvider = new ManualTimeProvider();
var policy = new AutoRelogRetryPolicy(timeProvider);
Assert.True(policy.TryReserveAttempt(1, out _));
policy.MarkJoined();
timeProvider.Advance(AutoRelogRetryPolicy.StableConnectionThreshold - TimeSpan.FromMilliseconds(1));
Assert.False(policy.ResetAfterStableConnection());
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
Assert.True(policy.ResetAfterStableConnection());
Assert.Equal(0, policy.Attempts);
Assert.True(policy.TryReserveAttempt(1, out _));
}
[Fact]
public void TransportLossAlwaysReconnectsWhenEnabled()
{
bool reconnect = AutoRelogRetryPolicy.ShouldReconnect(
ChatBot.DisconnectReason.ConnectionLost,
"A transport-specific error without a configured phrase",
ignoreKickMessage: false,
["Server is restarting"],
out string? matchedMessage);
Assert.True(reconnect);
Assert.Null(matchedMessage);
}
[Theory]
[InlineData(ChatBot.DisconnectReason.InGameKick)]
[InlineData(ChatBot.DisconnectReason.LoginRejected)]
public void ServerMessageMatchingIsCaseInsensitive(ChatBot.DisconnectReason reason)
{
bool reconnect = AutoRelogRetryPolicy.ShouldReconnect(
reason,
"THE SERVER IS RESTARTING NOW",
ignoreKickMessage: false,
["server is restarting"],
out string? matchedMessage);
Assert.True(reconnect);
Assert.Equal("server is restarting", matchedMessage);
}
[Fact]
public void UserLogoutNeverReconnects()
{
Assert.False(AutoRelogRetryPolicy.ShouldReconnect(
ChatBot.DisconnectReason.UserLogout,
"Server is restarting",
ignoreKickMessage: true,
["Server is restarting"],
out _));
}
[Fact]
public void IgnoreKickMessageAllowsNonmatchingServerKick()
{
Assert.True(AutoRelogRetryPolicy.ShouldReconnect(
ChatBot.DisconnectReason.InGameKick,
"Administrative removal",
ignoreKickMessage: true,
[],
out _));
}
private sealed class ManualTimeProvider : TimeProvider
{
private DateTimeOffset utcNow = DateTimeOffset.UnixEpoch;
public override DateTimeOffset GetUtcNow() => utcNow;
internal void Advance(TimeSpan duration)
{
utcNow += duration;
}
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MinecraftClient\MinecraftClient.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,61 @@
namespace MinecraftClient.Tests;
public sealed class RestartCoordinatorTests
{
[Fact]
public async Task CoalescesSameAttemptAndQueuesNewerAttempt()
{
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var secondCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
if (request.ConnectionAttempt == 10)
{
firstStarted.SetResult();
await releaseFirst.Task.WaitAsync(cancellationToken);
}
else if (request.ConnectionAttempt == 11)
{
secondCompleted.SetResult();
}
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
Assert.True(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true)));
await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true)));
Assert.True(coordinator.TrySchedule(new RestartRequest(11, TimeSpan.Zero, true)));
Assert.True(coordinator.HasScheduledRestart(11));
releaseFirst.SetResult();
await secondCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public void RejectsStaleAttempt()
{
using var coordinator = new RestartCoordinator(
(_, _) => Task.CompletedTask,
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
Assert.False(coordinator.TrySchedule(new RestartRequest(19, TimeSpan.Zero, true)));
}
[Fact]
public void TerminalStopRejectsFurtherRestarts()
{
using var coordinator = new RestartCoordinator(
(_, _) => Task.CompletedTask,
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
coordinator.Stop();
Assert.False(coordinator.TrySchedule(new RestartRequest(1, TimeSpan.Zero, true)));
Assert.False(coordinator.HasScheduledRestart(1));
}
}

View file

@ -0,0 +1,34 @@
using System.Net;
using System.Net.Sockets;
using MinecraftClient.Protocol.Handlers;
namespace MinecraftClient.Tests;
public sealed class SocketWrapperTests
{
[Fact]
public async Task GracefulPeerCloseEndsReadInsteadOfSpinning()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
using var client = new TcpClient();
Task<TcpClient> acceptTask = listener.AcceptTcpClientAsync();
await client.ConnectAsync((IPEndPoint)listener.LocalEndpoint);
using TcpClient peer = await acceptTask;
var wrapper = new SocketWrapper(client);
peer.Client.Shutdown(SocketShutdown.Both);
peer.Close();
Assert.True(SpinWait.SpinUntil(wrapper.HasDataAvailable, TimeSpan.FromSeconds(5)));
await Assert.ThrowsAsync<EndOfStreamException>(
() => Task.Run(() => wrapper.ReadDataRAW(1)).WaitAsync(TimeSpan.FromSeconds(5)));
}
finally
{
listener.Stop();
}
}
}

View file

@ -0,0 +1 @@
global using Xunit;