fix: isolate queued restart settings

This commit is contained in:
Anon 2026-07-26 01:16:07 +02:00
parent 1686d00f30
commit 9b90b535d8
5 changed files with 99 additions and 21 deletions

View file

@ -3,20 +3,27 @@ namespace MinecraftClient.Tests;
public sealed class RestartCoordinatorTests public sealed class RestartCoordinatorTests
{ {
[Fact] [Fact]
public async Task CoalescesSameAttemptAndQueuesNewerAttempt() public async Task ReplacesQueuedSameAttemptAndQueuesNewerAttempt()
{ {
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var replacementCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var secondCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var secondCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var executedAccounts = new List<string>();
using var coordinator = new RestartCoordinator( using var coordinator = new RestartCoordinator(
async (request, cancellationToken) => async (request, cancellationToken) =>
{ {
if (request.ConnectionAttempt == 10) if (request.ConnectionAttempt == 9)
{ {
firstStarted.SetResult(); firstStarted.SetResult();
await releaseFirst.Task.WaitAsync(cancellationToken); await releaseFirst.Task.WaitAsync(cancellationToken);
} }
else if (request.ConnectionAttempt == 10)
{
executedAccounts.Add(request.SettingsSnapshot?.Account.Login ?? string.Empty);
replacementCompleted.SetResult();
}
else if (request.ConnectionAttempt == 11) else if (request.ConnectionAttempt == 11)
{ {
secondCompleted.SetResult(); secondCompleted.SetResult();
@ -24,15 +31,19 @@ public sealed class RestartCoordinatorTests
}, },
exception => throw new Xunit.Sdk.XunitException(exception.ToString())); exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
Assert.True(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true))); Assert.True(coordinator.TrySchedule(new RestartRequest(9, TimeSpan.Zero, true)));
await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true))); Assert.True(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true, CreateSettingsSnapshot("first"))));
Assert.True(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true, CreateSettingsSnapshot("replacement"))));
Assert.True(coordinator.TrySchedule(new RestartRequest(11, TimeSpan.Zero, true))); Assert.True(coordinator.TrySchedule(new RestartRequest(11, TimeSpan.Zero, true)));
Assert.True(coordinator.HasScheduledRestart(11)); Assert.True(coordinator.HasScheduledRestart(11));
releaseFirst.SetResult(); releaseFirst.SetResult();
await replacementCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
await secondCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); await secondCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(["replacement"], executedAccounts);
} }
[Fact] [Fact]
@ -46,6 +57,25 @@ public sealed class RestartCoordinatorTests
Assert.False(coordinator.TrySchedule(new RestartRequest(19, TimeSpan.Zero, true))); Assert.False(coordinator.TrySchedule(new RestartRequest(19, TimeSpan.Zero, true)));
} }
[Fact]
public async Task RejectsCompletedAttempt()
{
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var coordinator = new RestartCoordinator(
(_, _) =>
{
completed.SetResult();
return Task.CompletedTask;
},
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
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] [Fact]
public void TerminalStopRejectsFurtherRestarts() public void TerminalStopRejectsFurtherRestarts()
{ {
@ -58,4 +88,12 @@ public sealed class RestartCoordinatorTests
Assert.False(coordinator.TrySchedule(new RestartRequest(1, TimeSpan.Zero, true))); Assert.False(coordinator.TrySchedule(new RestartRequest(1, TimeSpan.Zero, true)));
Assert.False(coordinator.HasScheduledRestart(1)); Assert.False(coordinator.HasScheduledRestart(1));
} }
private static RestartSettingsSnapshot CreateSettingsSnapshot(string account)
{
return new RestartSettingsSnapshot(
new Settings.MainConfigHelper.MainConfig.AccountInfoConfig(account, "-"),
"localhost",
25565);
}
} }

View file

@ -47,8 +47,9 @@ namespace MinecraftClient.Commands
if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(server), true)) if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(server), true))
{ {
Program.Restart(keepAccountAndServerSettings: true); return r.SetAndReturn(Program.TryRestart(keepAccountAndServerSettings: true)
return r.SetAndReturn(Status.Done); ? Status.Done
: Status.Fail);
} }
else else
{ {
@ -64,8 +65,9 @@ namespace MinecraftClient.Commands
if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(args[0]), true)) if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(args[0]), true))
{ {
Program.Restart(keepAccountAndServerSettings: true); return Program.TryRestart(keepAccountAndServerSettings: true)
return string.Empty; ? string.Empty
: Translations.general_fail;
} }
else else
{ {

View file

@ -47,8 +47,9 @@ namespace MinecraftClient.Commands
if (!Settings.Config.Main.Advanced.SetAccount(account)) if (!Settings.Config.Main.Advanced.SetAccount(account))
return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_connect_unknown, account)); return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_connect_unknown, account));
} }
Program.Restart(keepAccountAndServerSettings: true); return r.SetAndReturn(Program.TryRestart(keepAccountAndServerSettings: true)
return r.SetAndReturn(CmdResult.Status.Done); ? CmdResult.Status.Done
: CmdResult.Status.Fail);
} }
internal static string DoReconnect(string command) internal static string DoReconnect(string command)
@ -62,8 +63,9 @@ namespace MinecraftClient.Commands
return string.Format(Translations.cmd_connect_unknown, account); return string.Format(Translations.cmd_connect_unknown, account);
} }
} }
Program.Restart(keepAccountAndServerSettings: true); return Program.TryRestart(keepAccountAndServerSettings: true)
return String.Empty; ? String.Empty
: Translations.general_fail;
} }
} }
} }

View file

@ -923,10 +923,15 @@ namespace MinecraftClient
if (delay < TimeSpan.Zero) if (delay < TimeSpan.Zero)
delay = TimeSpan.Zero; delay = TimeSpan.Zero;
RestartSettingsSnapshot? settingsSnapshot = keepAccountAndServerSettings
? new RestartSettingsSnapshot(InternalConfig.Account, InternalConfig.ServerIP, InternalConfig.ServerPort)
: null;
return restartCoordinator.TrySchedule(new RestartRequest( return restartCoordinator.TrySchedule(new RestartRequest(
Volatile.Read(ref connectionAttempt), Volatile.Read(ref connectionAttempt),
delay, delay,
keepAccountAndServerSettings)); keepAccountAndServerSettings,
settingsSnapshot));
} }
private static async Task ExecuteRestartAsync(RestartRequest request, CancellationToken cancellationToken) private static async Task ExecuteRestartAsync(RestartRequest request, CancellationToken cancellationToken)
@ -951,6 +956,12 @@ namespace MinecraftClient
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
ConsoleIO.WriteLine(Translations.mcc_restart); ConsoleIO.WriteLine(Translations.mcc_restart);
ReloadSettings(request.KeepAccountAndServerSettings); ReloadSettings(request.KeepAccountAndServerSettings);
if (request.SettingsSnapshot is RestartSettingsSnapshot settingsSnapshot)
{
InternalConfig.Account = settingsSnapshot.Account;
InternalConfig.ServerIP = settingsSnapshot.ServerIP;
InternalConfig.ServerPort = settingsSnapshot.ServerPort;
}
InitializeClient(); InitializeClient();
} }

View file

@ -9,7 +9,14 @@ namespace MinecraftClient
internal readonly record struct RestartRequest( internal readonly record struct RestartRequest(
long ConnectionAttempt, long ConnectionAttempt,
TimeSpan Delay, TimeSpan Delay,
bool KeepAccountAndServerSettings); bool KeepAccountAndServerSettings,
RestartSettingsSnapshot? SettingsSnapshot = null,
long RequestId = 0);
internal readonly record struct RestartSettingsSnapshot(
Settings.MainConfigHelper.MainConfig.AccountInfoConfig Account,
string ServerIP,
ushort ServerPort);
internal sealed class RestartCoordinator : IDisposable internal sealed class RestartCoordinator : IDisposable
{ {
@ -19,8 +26,9 @@ namespace MinecraftClient
private readonly Func<RestartRequest, CancellationToken, Task> restart; private readonly Func<RestartRequest, CancellationToken, Task> restart;
private readonly Action<Exception> reportFailure; private readonly Action<Exception> reportFailure;
private readonly Task worker; private readonly Task worker;
private readonly HashSet<long> pendingAttempts = []; private readonly Dictionary<long, long> pendingAttempts = [];
private long highestScheduledAttempt = -1; private long highestScheduledAttempt = -1;
private long nextRequestId;
private bool stopped; private bool stopped;
internal RestartCoordinator( internal RestartCoordinator(
@ -44,22 +52,28 @@ namespace MinecraftClient
internal bool HasScheduledRestart(long connectionAttempt) internal bool HasScheduledRestart(long connectionAttempt)
{ {
lock (stateLock) lock (stateLock)
return !stopped && pendingAttempts.Contains(connectionAttempt); return !stopped && pendingAttempts.ContainsKey(connectionAttempt);
} }
internal bool TrySchedule(RestartRequest request) internal bool TrySchedule(RestartRequest request)
{ {
lock (stateLock) lock (stateLock)
{ {
if (stopped || request.ConnectionAttempt <= highestScheduledAttempt) bool hasPendingRequest = pendingAttempts.TryGetValue(request.ConnectionAttempt, out long previousRequestId);
if (stopped || request.ConnectionAttempt < highestScheduledAttempt
|| (request.ConnectionAttempt == highestScheduledAttempt && !hasPendingRequest))
return false; return false;
highestScheduledAttempt = request.ConnectionAttempt; highestScheduledAttempt = Math.Max(highestScheduledAttempt, request.ConnectionAttempt);
pendingAttempts.Add(request.ConnectionAttempt); request = request with { RequestId = ++nextRequestId };
pendingAttempts[request.ConnectionAttempt] = request.RequestId;
if (requests.Writer.TryWrite(request)) if (requests.Writer.TryWrite(request))
return true; return true;
pendingAttempts.Remove(request.ConnectionAttempt); if (hasPendingRequest)
pendingAttempts[request.ConnectionAttempt] = previousRequestId;
else
pendingAttempts.Remove(request.ConnectionAttempt);
return false; return false;
} }
} }
@ -84,6 +98,13 @@ namespace MinecraftClient
{ {
await foreach (RestartRequest request in requests.Reader.ReadAllAsync(shutdown.Token).ConfigureAwait(false)) await foreach (RestartRequest request in requests.Reader.ReadAllAsync(shutdown.Token).ConfigureAwait(false))
{ {
lock (stateLock)
{
if (!pendingAttempts.TryGetValue(request.ConnectionAttempt, out long requestId)
|| requestId != request.RequestId)
continue;
}
try try
{ {
await restart(request, shutdown.Token).ConfigureAwait(false); await restart(request, shutdown.Token).ConfigureAwait(false);
@ -99,7 +120,11 @@ namespace MinecraftClient
finally finally
{ {
lock (stateLock) lock (stateLock)
pendingAttempts.Remove(request.ConnectionAttempt); {
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out long requestId)
&& requestId == request.RequestId)
pendingAttempts.Remove(request.ConnectionAttempt);
}
} }
} }
} }