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
This commit is contained in:
Anon 2026-07-27 16:12:10 +02:00
parent 92212d2b95
commit 456a548cbc
11 changed files with 645 additions and 114 deletions

View file

@ -62,6 +62,37 @@ public sealed class AutoRelogRetryPolicyTests
Assert.Equal(0, retriesLeft);
}
[Fact]
public void CoalescedDuplicateRollsBackOnlyItsOwnReservation()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.True(policy.TryReserveAttempt(2, out int firstRetriesLeft));
Assert.True(policy.TryReserveAttempt(2, out int duplicateRetriesLeft));
policy.RollBackReservedAttempt();
Assert.Equal(1, firstRetriesLeft);
Assert.Equal(0, duplicateRetriesLeft);
Assert.Equal(1, policy.Attempts);
Assert.True(policy.TryReserveAttempt(2, out int secondFailureRetriesLeft));
Assert.Equal(0, secondFailureRetriesLeft);
Assert.False(policy.TryReserveAttempt(2, out _));
}
[Fact]
public void UnlimitedDuplicateRollbackKeepsUnlimitedBudget()
{
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
Assert.True(policy.TryReserveAttempt(-1, out _));
Assert.True(policy.TryReserveAttempt(-1, out _));
policy.RollBackReservedAttempt();
Assert.Equal(1, policy.Attempts);
Assert.True(policy.TryReserveAttempt(-1, out int retriesLeft));
Assert.Equal(-1, retriesLeft);
}
[Fact]
public void StableConnectionResetsRetryBudget()
{

View file

@ -0,0 +1,110 @@
using MinecraftClient.Scripting;
namespace MinecraftClient.Tests;
public sealed class McClientConnectionFailureTests
{
[Fact]
public void LoginRejectedClaimPreventsSyntheticConnectionLostFallback()
{
var lifecycle = new ConnectionAttemptLifecycle();
Assert.True(lifecycle.TryBeginDisconnect());
Assert.True(lifecycle.IsFailureClaimed);
Assert.False(lifecycle.TryBeginDisconnect());
lifecycle.CompleteDisconnect();
Assert.True(lifecycle.IsFailureClaimed);
Assert.False(lifecycle.TryBeginDisconnect());
}
[Fact]
public void UnclaimedGenericFailureCanBeClaimedExactlyOnce()
{
var lifecycle = new ConnectionAttemptLifecycle();
Assert.False(lifecycle.IsFailureClaimed);
Assert.True(lifecycle.TryBeginDisconnect());
Assert.False(lifecycle.TryBeginDisconnect());
}
[Fact]
public void HeldBotsAreRestoredBeforeFailureAndReceiveOriginalMessageOnce()
{
const string rejectionMessage = "You are not white-listed on this server!";
var bot = new RecordingBot();
List<ChatBot> heldBots = [bot];
var loadedBots = new List<ChatBot>();
ConnectionAttemptLifecycle.RestoreHeldBots(heldBots, loadedBots.Add);
foreach (ChatBot loadedBot in loadedBots)
loadedBot.OnDisconnect(ChatBot.DisconnectReason.LoginRejected, rejectionMessage);
Assert.Empty(heldBots);
Assert.Single(loadedBots);
Assert.Equal(1, bot.DisconnectCount);
Assert.Equal(ChatBot.DisconnectReason.LoginRejected, bot.LastReason);
Assert.Equal(rejectionMessage, bot.LastMessage);
}
[Fact]
public void OfflineRouteStaysOwnedAcrossReplacementAndSuccessfulHandoff()
{
var route = new AttemptOwnedRoute();
int activations = 0;
int deactivations = 0;
Assert.True(route.TryActivate(7, () => activations++));
Assert.False(route.TryActivate(7, () => activations++));
Assert.True(route.TryTransfer(7, 8));
Assert.False(route.TryDeactivate(7, () => deactivations++));
Assert.Equal(8, route.OwnerAttempt);
Assert.True(route.TryDeactivate(8, () => deactivations++));
Assert.Equal(1, activations);
Assert.Equal(1, deactivations);
Assert.Equal(-1, route.OwnerAttempt);
}
[Fact]
public void InitialConnectionAttemptCanOwnOfflineRoute()
{
var route = new AttemptOwnedRoute();
int activations = 0;
Assert.True(route.TryActivate(0, () => activations++));
Assert.Equal(1, activations);
Assert.Equal(0, route.OwnerAttempt);
}
[Fact]
public void StaleCleanupCannotClearNewerOfflineRoute()
{
var route = new AttemptOwnedRoute();
int deactivations = 0;
Assert.True(route.TryActivate(10, () => { }));
Assert.True(route.TryActivate(11, () => { }));
Assert.False(route.TryDeactivate(10, () => deactivations++));
Assert.Equal(11, route.OwnerAttempt);
Assert.Equal(0, deactivations);
}
private sealed class RecordingBot : ChatBot
{
internal int DisconnectCount { get; private set; }
internal DisconnectReason? LastReason { get; private set; }
internal string? LastMessage { get; private set; }
public override bool OnDisconnect(DisconnectReason reason, string message)
{
DisconnectCount++;
LastReason = reason;
LastMessage = message;
return false;
}
}
}

View file

@ -3,55 +3,163 @@ namespace MinecraftClient.Tests;
public sealed class RestartCoordinatorTests
{
[Fact]
public async Task ReplacesQueuedSameAttemptAndQueuesNewerAttempt()
public async Task AutomaticSameAttemptIsCoalescedWhileQueued()
{
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var replacementCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var secondCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var executedAccounts = new List<string>();
var blockerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseBlocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var queuedCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int queuedExecutions = 0;
using var coordinator = new RestartCoordinator(
RestartCoordinator coordinator = null!;
coordinator = new RestartCoordinator(
async (request, cancellationToken) =>
{
if (request.ConnectionAttempt == 9)
{
firstStarted.SetResult();
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)
{
secondCompleted.SetResult();
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 firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
await blockerStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
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.HasScheduledRestart(11));
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));
releaseFirst.SetResult();
await replacementCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
await secondCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
releaseBlocker.SetResult();
await queuedCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(["replacement"], executedAccounts);
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()
{
using var coordinator = new RestartCoordinator(
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)));
@ -61,13 +169,16 @@ public sealed class RestartCoordinatorTests
public async Task RejectsCompletedAttempt()
{
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var coordinator = new RestartCoordinator(
(_, _) =>
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));
@ -77,15 +188,23 @@ public sealed class RestartCoordinatorTests
}
[Fact]
public void TerminalStopRejectsFurtherRestarts()
public async Task TerminalStopCancelsInFlightWorkAndRejectsFurtherRestarts()
{
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var coordinator = new RestartCoordinator(
(_, _) => Task.CompletedTask,
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(1, TimeSpan.Zero, true)));
Assert.False(coordinator.TrySchedule(new RestartRequest(2, TimeSpan.Zero, true)));
Assert.False(coordinator.HasScheduledRestart(1));
}

View file

@ -78,6 +78,7 @@ namespace MinecraftClient.ChatBots
}
private static readonly AutoRelogRetryPolicy s_retryPolicy = new(TimeProvider.System);
private readonly long? sourceConnectionAttempt;
/// <summary>
/// This bot automatically re-join the server if kick message contains predefined string
@ -85,8 +86,13 @@ namespace MinecraftClient.ChatBots
/// <param name="DelayBeforeRelogMin">Minimum delay before re-joining the server (in seconds)</param>
/// <param name="DelayBeforeRelogMax">Maximum delay before re-joining the server (in seconds)</param>
/// <param name="retries">Number of retries if connection fails (-1 = infinite)</param>
public AutoRelog()
public AutoRelog() : this(null)
{
}
private AutoRelog(long? sourceConnectionAttempt)
{
this.sourceConnectionAttempt = sourceConnectionAttempt;
LogDebugToConsole(string.Format(Translations.bot_autoRelog_launch, Config.Retries));
}
@ -163,21 +169,27 @@ namespace MinecraftClient.ChatBots
: retriesLeft.ToString();
McClient.ReconnectionAttemptsLeft = retriesLeft;
if (Program.TryRestart(TimeSpan.FromSeconds(delay), true))
long connectionAttempt = sourceConnectionAttempt ?? Handler.ConnectionAttempt;
if (Program.TryRestart(connectionAttempt, TimeSpan.FromSeconds(delay), true))
{
LogToConsole(string.Format(Translations.bot_autoRelog_wait_with_retries, delay, retriesDisplay));
return true;
}
s_retryPolicy.RollBackReservedAttempt();
return Program.HasRestartPending;
return Program.HasRestartPending(connectionAttempt);
}
public static bool OnDisconnectStatic(DisconnectReason reason, string message)
{
return OnDisconnectStatic(reason, message, Program.CurrentConnectionAttempt);
}
internal static bool OnDisconnectStatic(DisconnectReason reason, string message, long sourceConnectionAttempt)
{
if (Config.Enabled)
{
AutoRelog bot = new();
AutoRelog bot = new(sourceConnectionAttempt);
bot.Initialize();
return bot.OnDisconnect(reason, message);
}

View file

@ -1,4 +1,5 @@
using Brigadier.NET;
using System;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using static MinecraftClient.CommandHandler.CmdResult;
@ -42,35 +43,55 @@ namespace MinecraftClient.Commands
private int DoConnect(CmdResult r, string server, string account)
{
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
if (!string.IsNullOrWhiteSpace(account) && !Settings.Config.Main.Advanced.SetAccount(account))
return r.SetAndReturn(Status.Fail, string.Format(Translations.cmd_connect_unknown, account));
if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(server), true))
{
return r.SetAndReturn(Program.TryRestart(keepAccountAndServerSettings: true)
? Status.Done
: Status.Fail);
if (Program.TryRestart(
Program.CurrentConnectionAttempt,
TimeSpan.Zero,
keepAccountAndServerSettings: true,
replaceUntilCommit: true))
{
return r.SetAndReturn(Status.Done);
}
Program.RestoreRestartSettings(previousSettings);
return r.SetAndReturn(Status.Fail);
}
else
{
Program.RestoreRestartSettings(previousSettings);
return r.SetAndReturn(Status.Fail, string.Format(Translations.cmd_connect_invalid_ip, server));
}
}
internal static string DoConnect(string command)
{
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
string[] args = GetArgs(command);
if (args.Length > 1 && !Settings.Config.Main.Advanced.SetAccount(args[1]))
return string.Format(Translations.cmd_connect_unknown, args[1]);
if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(args[0]), true))
{
return Program.TryRestart(keepAccountAndServerSettings: true)
? string.Empty
: Translations.general_fail;
if (Program.TryRestart(
Program.CurrentConnectionAttempt,
TimeSpan.Zero,
keepAccountAndServerSettings: true,
replaceUntilCommit: true))
{
return string.Empty;
}
Program.RestoreRestartSettings(previousSettings);
return Translations.general_fail;
}
else
{
Program.RestoreRestartSettings(previousSettings);
return string.Format(Translations.cmd_connect_invalid_ip, args[0]);
}
}

View file

@ -41,19 +41,30 @@ namespace MinecraftClient.Commands
private int DoReconnect(CmdResult r, string account)
{
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
if (!string.IsNullOrWhiteSpace(account))
{
account = account.Trim();
if (!Settings.Config.Main.Advanced.SetAccount(account))
return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_connect_unknown, account));
}
return r.SetAndReturn(Program.TryRestart(keepAccountAndServerSettings: true)
? CmdResult.Status.Done
: CmdResult.Status.Fail);
if (Program.TryRestart(
Program.CurrentConnectionAttempt,
TimeSpan.Zero,
keepAccountAndServerSettings: true,
replaceUntilCommit: true))
{
return r.SetAndReturn(CmdResult.Status.Done);
}
Program.RestoreRestartSettings(previousSettings);
return r.SetAndReturn(CmdResult.Status.Fail);
}
internal static string DoReconnect(string command)
{
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
string[] args = GetArgs(command);
if (args.Length > 0)
{
@ -63,9 +74,18 @@ namespace MinecraftClient.Commands
return string.Format(Translations.cmd_connect_unknown, account);
}
}
return Program.TryRestart(keepAccountAndServerSettings: true)
? String.Empty
: Translations.general_fail;
if (Program.TryRestart(
Program.CurrentConnectionAttempt,
TimeSpan.Zero,
keepAccountAndServerSettings: true,
replaceUntilCommit: true))
{
return string.Empty;
}
Program.RestoreRestartSettings(previousSettings);
return Translations.general_fail;
}
}
}

View file

@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MinecraftClient.Scripting;
namespace MinecraftClient
{
internal sealed class ConnectionAttemptLifecycle
{
private int disconnectState;
internal bool IsFailureClaimed => Volatile.Read(ref disconnectState) != 0;
internal bool TryBeginDisconnect()
{
return Interlocked.CompareExchange(ref disconnectState, 1, 0) == 0;
}
internal void CompleteDisconnect()
{
Volatile.Write(ref disconnectState, 2);
}
internal static void RestoreHeldBots(ICollection<ChatBot> heldBots, Action<ChatBot> loadBot)
{
ArgumentNullException.ThrowIfNull(heldBots);
ArgumentNullException.ThrowIfNull(loadBot);
foreach (ChatBot bot in heldBots)
loadBot(bot);
heldBots.Clear();
}
}
internal sealed class AttemptOwnedRoute
{
private const long NoOwner = -1;
private readonly Lock stateLock = new();
private long ownerAttempt = NoOwner;
internal long OwnerAttempt
{
get
{
lock (stateLock)
return ownerAttempt;
}
}
internal bool TryActivate(long connectionAttempt, Action activate)
{
ArgumentNullException.ThrowIfNull(activate);
lock (stateLock)
{
if (ownerAttempt >= connectionAttempt)
return false;
ownerAttempt = connectionAttempt;
activate();
return true;
}
}
internal bool TryDeactivate(long connectionAttempt, Action deactivate)
{
ArgumentNullException.ThrowIfNull(deactivate);
lock (stateLock)
{
if (ownerAttempt != connectionAttempt)
return false;
ownerAttempt = NoOwner;
deactivate();
return true;
}
}
internal bool TryTransfer(long sourceConnectionAttempt, long targetConnectionAttempt)
{
lock (stateLock)
{
if (ownerAttempt != sourceConnectionAttempt)
return false;
ownerAttempt = targetConnectionAttempt;
return true;
}
}
internal bool TryDeactivate(Action deactivate)
{
ArgumentNullException.ThrowIfNull(deactivate);
lock (stateLock)
{
if (ownerAttempt == NoOwner)
return false;
ownerAttempt = NoOwner;
deactivate();
return true;
}
}
}
}

View file

@ -231,12 +231,13 @@ namespace MinecraftClient
SessionToken _sessionToken;
Tuple<Thread, CancellationTokenSource>? timeoutdetector = null;
private int transferInProgress = 0;
private int disconnectState;
private readonly ConnectionAttemptLifecycle connectionLifecycle = new();
private int disconnectOwnerThreadId;
private readonly TaskCompletionSource<bool> disconnectCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public ILogger Log;
public DialogManager Dialogs { get; }
internal long ConnectionAttempt { get; }
private static IMinecraftComHandler? instance;
public static IMinecraftComHandler? Instance => instance;
@ -251,9 +252,22 @@ namespace MinecraftClient
/// <param name="protocolversion">Minecraft protocol version to use</param>
/// <param name="forgeInfo">ForgeInfo item stating that Forge is enabled</param>
public McClient(SessionToken session, PlayerKeyPair? playerKeyPair, string server_ip, ushort port, int protocolversion, ForgeInfo? forgeInfo)
: this(session, playerKeyPair, server_ip, port, protocolversion, forgeInfo, Program.CurrentConnectionAttempt)
{
}
internal McClient(
SessionToken session,
PlayerKeyPair? playerKeyPair,
string server_ip,
ushort port,
int protocolversion,
ForgeInfo? forgeInfo,
long connectionAttempt)
{
CmdResult.currentHandler = this;
instance = this;
ConnectionAttempt = connectionAttempt;
terrainAndMovementsEnabled = Config.Main.Advanced.TerrainAndMovements;
inventoryHandlingEnabled = Config.Main.Advanced.InventoryHandling;
@ -311,7 +325,13 @@ namespace MinecraftClient
LoadCommands();
if (botsOnHold.Count == 0)
{
RegisterBots();
}
else
{
ConnectionAttemptLifecycle.RestoreHeldBots(botsOnHold, bot => BotLoad(bot, false));
}
try
{
@ -331,10 +351,6 @@ namespace MinecraftClient
{
if (handler.Login(this.playerKeyPair, session))
{
foreach (ChatBot bot in botsOnHold)
BotLoad(bot, false);
botsOnHold.Clear();
Log.Info(string.Format(Translations.mcc_joined, Config.Main.Advanced.InternalCmdChar.ToLogString()));
StartConsoleSession();
@ -368,6 +384,9 @@ namespace MinecraftClient
timeoutdetector = null;
}
if (connectionLifecycle.IsFailureClaimed)
return;
if (!InternalConfig.InteractiveMode)
{
StopConsoleSession();
@ -391,14 +410,8 @@ namespace MinecraftClient
return;
}
// AutoRelog is enabled - invoke its static handler to trigger reconnection.
// Use the same "Connection has been lost" message that OnConnectionLost uses
// for ConnectionLost, so it matches the default Kick_Messages.
if (AutoRelog.OnDisconnectStatic(ChatBot.DisconnectReason.ConnectionLost, Translations.mcc_disconnect_lost))
OnConnectionLost(ChatBot.DisconnectReason.ConnectionLost, Translations.mcc_disconnect_lost);
return;
StopConsoleSession();
Program.HandleFailure();
}
public void Transfer(string newHost, int newPort)
@ -530,6 +543,7 @@ namespace MinecraftClient
private void StartConsoleSession()
{
Program.EndOfflinePrompt(ConnectionAttempt);
ConsoleInputRouter.RouteToClient(this);
}
@ -875,7 +889,7 @@ namespace MinecraftClient
}
}
restartScheduled = !exitOnFailure && Program.HasRestartPending;
restartScheduled = !exitOnFailure && Program.HasRestartPending(ConnectionAttempt);
}
finally
{
@ -888,7 +902,7 @@ namespace MinecraftClient
private bool TryBeginDisconnect()
{
if (Interlocked.CompareExchange(ref disconnectState, 1, 0) != 0)
if (!connectionLifecycle.TryBeginDisconnect())
return false;
Volatile.Write(ref disconnectOwnerThreadId, Environment.CurrentManagedThreadId);
@ -937,7 +951,7 @@ namespace MinecraftClient
}
finally
{
Volatile.Write(ref disconnectState, 2);
connectionLifecycle.CompleteDisconnect();
Volatile.Write(ref disconnectOwnerThreadId, 0);
disconnectCompletion.TrySetResult(true);
}

View file

@ -55,7 +55,7 @@ namespace MinecraftClient
private static bool useMcVersionOnce = false;
private static readonly RestartCoordinator restartCoordinator = new(ExecuteRestartAsync, ReportRestartFailure);
private static long connectionAttempt;
private static int offlinePromptActive;
private static readonly AttemptOwnedRoute offlinePromptRoute = new();
private static int exitOnFailurePending;
private static string settingsIniPath = "MinecraftClient.ini";
private static AuthenticationSelection? pendingAuthenticationSelection;
@ -747,12 +747,14 @@ namespace MinecraftClient
private sealed record AuthenticationSelection(LoginType AccountType, LoginMethod Method, string AuthServerUrl);
internal static long CurrentConnectionAttempt => Volatile.Read(ref connectionAttempt);
/// <summary>
/// Start a new Client
/// </summary>
private static void InitializeClient()
{
Interlocked.Increment(ref connectionAttempt);
long attempt = Interlocked.Increment(ref connectionAttempt);
// Ensure that we use the provided Minecraft version if we can't connect automatically.
//
@ -984,7 +986,7 @@ namespace MinecraftClient
try
{
//Start the main TCP client
client = new McClient(session, playerKeyPair, InternalConfig.ServerIP, InternalConfig.ServerPort, protocolversion, forgeInfo);
client = new McClient(session, playerKeyPair, InternalConfig.ServerIP, InternalConfig.ServerPort, protocolversion, forgeInfo, attempt);
//Update console title
if (OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(Config.Main.Advanced.ConsoleTitle))
@ -1062,7 +1064,22 @@ namespace MinecraftClient
TryRestart(TimeSpan.FromSeconds(Math.Max(0, delaySeconds)), keepAccountAndServerSettings);
}
internal static bool HasRestartPending => restartCoordinator.HasScheduledRestart(Volatile.Read(ref connectionAttempt));
internal static bool HasRestartPending(long sourceConnectionAttempt)
{
return restartCoordinator.HasScheduledRestart(sourceConnectionAttempt);
}
internal static RestartSettingsSnapshot CaptureRestartSettings()
{
return new RestartSettingsSnapshot(InternalConfig.Account, InternalConfig.ServerIP, InternalConfig.ServerPort);
}
internal static void RestoreRestartSettings(RestartSettingsSnapshot settingsSnapshot)
{
InternalConfig.Account = settingsSnapshot.Account;
InternalConfig.ServerIP = settingsSnapshot.ServerIP;
InternalConfig.ServerPort = settingsSnapshot.ServerPort;
}
internal static bool TryRestart(int delaySeconds = 0, bool keepAccountAndServerSettings = false)
{
@ -1070,6 +1087,15 @@ namespace MinecraftClient
}
internal static bool TryRestart(TimeSpan delay, bool keepAccountAndServerSettings = false)
{
return TryRestart(CurrentConnectionAttempt, delay, keepAccountAndServerSettings);
}
internal static bool TryRestart(
long sourceConnectionAttempt,
TimeSpan delay,
bool keepAccountAndServerSettings = false,
bool replaceUntilCommit = false)
{
if (Volatile.Read(ref exitOnFailurePending) != 0)
return false;
@ -1078,18 +1104,25 @@ namespace MinecraftClient
delay = TimeSpan.Zero;
RestartSettingsSnapshot? settingsSnapshot = keepAccountAndServerSettings
? new RestartSettingsSnapshot(InternalConfig.Account, InternalConfig.ServerIP, InternalConfig.ServerPort)
? CaptureRestartSettings()
: null;
return restartCoordinator.TrySchedule(new RestartRequest(
Volatile.Read(ref connectionAttempt),
bool scheduled = restartCoordinator.TrySchedule(new RestartRequest(
sourceConnectionAttempt,
delay,
keepAccountAndServerSettings,
settingsSnapshot));
settingsSnapshot,
replaceUntilCommit));
if (scheduled)
BeginOfflinePrompt(sourceConnectionAttempt);
return scheduled;
}
private static async Task ExecuteRestartAsync(RestartRequest request, CancellationToken cancellationToken)
{
if (request.ConnectionAttempt != CurrentConnectionAttempt)
return;
McClient? disconnectedClient = client;
if (disconnectedClient is not null)
{
@ -1098,7 +1131,6 @@ namespace MinecraftClient
client = null;
}
EndOfflinePrompt();
ConsoleIO.Reset();
if (request.Delay > TimeSpan.Zero)
@ -1108,14 +1140,22 @@ namespace MinecraftClient
}
cancellationToken.ThrowIfCancellationRequested();
if (request.ConnectionAttempt != CurrentConnectionAttempt
|| !restartCoordinator.TryBeginCommit(request, out RestartRequest latestRequest)
|| latestRequest.ConnectionAttempt != CurrentConnectionAttempt)
{
return;
}
ConsoleIO.WriteLine(Translations.mcc_restart);
ReloadSettings(request.KeepAccountAndServerSettings);
if (request.SettingsSnapshot is RestartSettingsSnapshot settingsSnapshot)
ReloadSettings(latestRequest.KeepAccountAndServerSettings);
if (latestRequest.SettingsSnapshot is RestartSettingsSnapshot settingsSnapshot)
{
InternalConfig.Account = settingsSnapshot.Account;
InternalConfig.ServerIP = settingsSnapshot.ServerIP;
InternalConfig.ServerPort = settingsSnapshot.ServerPort;
}
TransferOfflinePrompt(latestRequest.ConnectionAttempt, latestRequest.ConnectionAttempt + 1);
InitializeClient();
}
@ -1197,7 +1237,7 @@ namespace MinecraftClient
if (!string.IsNullOrEmpty(errorMessage) && disconnectReason.HasValue)
{
autoRelogHandled = true;
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage))
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage, CurrentConnectionAttempt))
return;
}
@ -1217,19 +1257,18 @@ namespace MinecraftClient
if (!autoRelogHandled && disconnectReason.HasValue)
{
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!))
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!, CurrentConnectionAttempt))
return;
}
BeginOfflinePrompt();
BeginOfflinePrompt(CurrentConnectionAttempt);
}
}
private static void BeginOfflinePrompt()
private static void BeginOfflinePrompt(long connectionAttempt)
{
offlinePromptRoute.TryActivate(connectionAttempt, () =>
{
if (Interlocked.CompareExchange(ref offlinePromptActive, 1, 0) != 0)
return;
ConsoleInputRouter.RouteOffline(HandleOfflineCommand);
ConsoleIO.WriteLine(string.Empty);
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_disconnected, Config.Main.Advanced.InternalCmdChar.ToLogString()));
@ -1237,15 +1276,30 @@ namespace MinecraftClient
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString()));
else
ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true);
});
}
private static void EndOfflinePrompt()
{
if (Interlocked.Exchange(ref offlinePromptActive, 0) == 0)
return;
offlinePromptRoute.TryDeactivate(() =>
{
ConsoleInputRouter.ClearOfflineRoute(HandleOfflineCommand);
ConsoleIO.Reset();
});
}
internal static void EndOfflinePrompt(long connectionAttempt)
{
offlinePromptRoute.TryDeactivate(connectionAttempt, () =>
{
ConsoleInputRouter.ClearOfflineRoute(HandleOfflineCommand);
ConsoleIO.Reset();
});
}
private static void TransferOfflinePrompt(long sourceConnectionAttempt, long targetConnectionAttempt)
{
offlinePromptRoute.TryTransfer(sourceConnectionAttempt, targetConnectionAttempt);
}
private static void HandleOfflineCommand(string input)
@ -1269,20 +1323,14 @@ namespace MinecraftClient
{
message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command));
if (message.Length == 0)
{
EndOfflinePrompt();
return;
}
}
else if (command.StartsWith("connect", StringComparison.Ordinal))
{
message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command));
if (message.Length == 0)
{
EndOfflinePrompt();
return;
}
}
else if (command.StartsWith("exit", StringComparison.Ordinal)
|| command.StartsWith("quit", StringComparison.Ordinal))
{

View file

@ -11,6 +11,7 @@ namespace MinecraftClient
TimeSpan Delay,
bool KeepAccountAndServerSettings,
RestartSettingsSnapshot? SettingsSnapshot = null,
bool ReplaceUntilCommit = false,
long RequestId = 0);
internal readonly record struct RestartSettingsSnapshot(
@ -18,6 +19,17 @@ namespace MinecraftClient
string ServerIP,
ushort ServerPort);
internal enum RestartRequestState
{
Replaceable,
Committing,
}
internal readonly record struct PendingRestart(
long RequestId,
RestartRequest Request,
RestartRequestState State);
internal sealed class RestartCoordinator : IDisposable
{
private readonly Lock stateLock = new();
@ -26,7 +38,7 @@ namespace MinecraftClient
private readonly Func<RestartRequest, CancellationToken, Task> restart;
private readonly Action<Exception> reportFailure;
private readonly Task worker;
private readonly Dictionary<long, long> pendingAttempts = [];
private readonly Dictionary<long, PendingRestart> pendingAttempts = [];
private long highestScheduledAttempt = -1;
private long nextRequestId;
private bool stopped;
@ -59,25 +71,58 @@ namespace MinecraftClient
{
lock (stateLock)
{
bool hasPendingRequest = pendingAttempts.TryGetValue(request.ConnectionAttempt, out long previousRequestId);
if (stopped || request.ConnectionAttempt < highestScheduledAttempt
|| (request.ConnectionAttempt == highestScheduledAttempt && !hasPendingRequest))
if (stopped)
return false;
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest))
{
if (pendingRequest.State != RestartRequestState.Replaceable || !request.ReplaceUntilCommit)
return false;
request = request with { RequestId = pendingRequest.RequestId };
pendingAttempts[request.ConnectionAttempt] = pendingRequest with { Request = request };
return true;
}
if (request.ConnectionAttempt <= highestScheduledAttempt)
return false;
highestScheduledAttempt = Math.Max(highestScheduledAttempt, request.ConnectionAttempt);
request = request with { RequestId = ++nextRequestId };
pendingAttempts[request.ConnectionAttempt] = request.RequestId;
pendingAttempts[request.ConnectionAttempt] = new PendingRestart(
request.RequestId,
request,
RestartRequestState.Replaceable);
if (requests.Writer.TryWrite(request))
return true;
if (hasPendingRequest)
pendingAttempts[request.ConnectionAttempt] = previousRequestId;
else
pendingAttempts.Remove(request.ConnectionAttempt);
return false;
}
}
internal bool TryBeginCommit(RestartRequest scheduledRequest, out RestartRequest latestRequest)
{
lock (stateLock)
{
if (stopped
|| !pendingAttempts.TryGetValue(scheduledRequest.ConnectionAttempt, out PendingRestart pendingRequest)
|| pendingRequest.RequestId != scheduledRequest.RequestId
|| pendingRequest.State != RestartRequestState.Replaceable)
{
latestRequest = default;
return false;
}
latestRequest = pendingRequest.Request;
pendingAttempts[scheduledRequest.ConnectionAttempt] = pendingRequest with
{
State = RestartRequestState.Committing,
};
return true;
}
}
internal void Stop()
{
lock (stateLock)
@ -100,8 +145,8 @@ namespace MinecraftClient
{
lock (stateLock)
{
if (!pendingAttempts.TryGetValue(request.ConnectionAttempt, out long requestId)
|| requestId != request.RequestId)
if (!pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest)
|| pendingRequest.RequestId != request.RequestId)
continue;
}
@ -121,8 +166,8 @@ namespace MinecraftClient
{
lock (stateLock)
{
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out long requestId)
&& requestId == request.RequestId)
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest)
&& pendingRequest.RequestId == request.RequestId)
pendingAttempts.Remove(request.ConnectionAttempt);
}
}

View file

@ -1322,6 +1322,8 @@ redirectFrom:
A lost TCP connection always triggers Auto Relog when the bot is enabled. `Kick_Messages` only filters server kick and login rejection messages. Logging out with an MCC command never triggers Auto Relog.
One logical disconnect or login rejection consumes one retry. `Ignore_Kick_Message` controls filtering, but it does not create additional restart decisions for the same failure.
- **Settings:**
**Section:** **`ChatBot.AutoRelog`**
@ -1349,6 +1351,8 @@ redirectFrom:
If `min` and `max` are equal, every attempt uses that delay. Otherwise, MCC picks a random value in the range. Values are seconds and may include a fractional part, such as `0.5` or `37.0`.
For multi-process deployments, use a nonzero range such as `{ min = 3.0, max = 10.0 }` so clients do not reconnect in lockstep during maintenance. Equal values remain supported when a fixed interval is required.
- **Format:** `{ min = <seconds (double)>, max = <seconds (double)> }`
- **Type:** `inline table`