Minecraft-Console-Client/MinecraftClient.Tests/RestartCoordinatorTests.cs
Anon 456a548cbc Fix: make AutoRelog retries single-owner (#3186)
Login rejections could schedule and execute multiple restarts for one failure while leaving no usable console route during reconnect delays.

Changes:
- Bind failure and restart work to immutable connection attempts
- Coalesce automatic retries while allowing explicit settings replacement until commit
- Preserve held bots and offline command routing across failed logins
- Add deterministic retry, ownership, and routing regression tests

Fixes #3186
2026-07-27 17:26:52 +02:00

218 lines
8.9 KiB
C#

namespace MinecraftClient.Tests;
public sealed class RestartCoordinatorTests
{
[Fact]
public async Task AutomaticSameAttemptIsCoalescedWhileQueued()
{
var blockerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseBlocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var queuedCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int queuedExecutions = 0;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
if (request.ConnectionAttempt == 9)
{
blockerStarted.SetResult();
await releaseBlocker.Task.WaitAsync(cancellationToken);
return;
}
Interlocked.Increment(ref queuedExecutions);
Assert.True(coordinator.TryBeginCommit(request, out _));
queuedCompleted.SetResult();
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(9, TimeSpan.Zero, true)));
await blockerStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true)));
Assert.False(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true)));
Assert.True(coordinator.HasScheduledRestart(10));
releaseBlocker.SetResult();
await queuedCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, queuedExecutions);
}
[Fact]
public async Task AutomaticSameAttemptIsCoalescedDuringCallback()
{
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int executions = 0;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
Interlocked.Increment(ref executions);
callbackStarted.SetResult();
await releaseCallback.Task.WaitAsync(cancellationToken);
Assert.True(coordinator.TryBeginCommit(request, out _));
callbackCompleted.SetResult();
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(42, TimeSpan.Zero, true)));
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(coordinator.TrySchedule(new RestartRequest(42, TimeSpan.Zero, true)));
releaseCallback.SetResult();
await callbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, executions);
}
[Fact]
public async Task ExplicitReplacementDuringDelayUsesLatestSnapshotWithoutAnotherExecution()
{
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var allowCommit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
RestartRequest committedRequest = default;
int executions = 0;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
Interlocked.Increment(ref executions);
callbackStarted.SetResult();
await allowCommit.Task.WaitAsync(cancellationToken);
Assert.True(coordinator.TryBeginCommit(request, out committedRequest));
callbackCompleted.SetResult();
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(
50,
TimeSpan.FromSeconds(10),
true,
CreateSettingsSnapshot("first"))));
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(coordinator.TrySchedule(new RestartRequest(
50,
TimeSpan.Zero,
true,
CreateSettingsSnapshot("replacement"),
ReplaceUntilCommit: true)));
allowCommit.SetResult();
await callbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, executions);
Assert.Equal("replacement", committedRequest.SettingsSnapshot?.Account.Login);
}
[Fact]
public async Task RejectsSameAttemptReplacementAfterCommit()
{
var commitStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseCommit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
RestartRequest committedRequest = default;
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
Assert.True(coordinator.TryBeginCommit(request, out committedRequest));
commitStarted.SetResult();
await releaseCommit.Task.WaitAsync(cancellationToken);
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(
60,
TimeSpan.Zero,
true,
CreateSettingsSnapshot("committed"))));
await commitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(coordinator.TrySchedule(new RestartRequest(
60,
TimeSpan.Zero,
true,
CreateSettingsSnapshot("rejected"),
ReplaceUntilCommit: true)));
Assert.Equal("committed", committedRequest.SettingsSnapshot?.Account.Login);
releaseCommit.SetResult();
}
[Fact]
public void RejectsStaleAttempt()
{
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
(_, _) => Task.CompletedTask,
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
Assert.False(coordinator.TrySchedule(new RestartRequest(19, TimeSpan.Zero, true)));
}
[Fact]
public async Task RejectsCompletedAttempt()
{
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
(request, cancellationToken) =>
{
Assert.True(coordinator.TryBeginCommit(request, out _));
completed.SetResult();
return Task.CompletedTask;
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
using var cleanup = coordinator;
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(SpinWait.SpinUntil(() => !coordinator.HasScheduledRestart(20), TimeSpan.FromSeconds(5)));
Assert.False(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
}
[Fact]
public async Task TerminalStopCancelsInFlightWorkAndRejectsFurtherRestarts()
{
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var coordinator = new RestartCoordinator(
async (_, cancellationToken) =>
{
callbackStarted.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
Assert.True(coordinator.TrySchedule(new RestartRequest(1, TimeSpan.Zero, true)));
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
coordinator.Stop();
Assert.False(coordinator.TrySchedule(new RestartRequest(2, TimeSpan.Zero, true)));
Assert.False(coordinator.HasScheduledRestart(1));
}
private static RestartSettingsSnapshot CreateSettingsSnapshot(string account)
{
return new RestartSettingsSnapshot(
new Settings.MainConfigHelper.MainConfig.AccountInfoConfig(account, "-"),
"localhost",
25565);
}
}