diff --git a/MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs b/MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs index 18416d091f..8751664667 100644 --- a/MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs +++ b/MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs @@ -62,6 +62,37 @@ public void RejectedRestartDoesNotConsumeRetry() 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() { diff --git a/MinecraftClient.Tests/McClientConnectionFailureTests.cs b/MinecraftClient.Tests/McClientConnectionFailureTests.cs new file mode 100644 index 0000000000..3789e53148 --- /dev/null +++ b/MinecraftClient.Tests/McClientConnectionFailureTests.cs @@ -0,0 +1,122 @@ +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 heldBots = [bot]; + var loadedBots = new List(); + + 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, 7, () => activations++)); + Assert.False(route.TryActivate(7, 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, 0, () => activations++)); + + Assert.Equal(1, activations); + Assert.Equal(0, route.OwnerAttempt); + } + + [Fact] + public void OlderAttemptCannotTakeAnEmptyOfflineRoute() + { + var route = new AttemptOwnedRoute(); + int activations = 0; + + Assert.False(route.TryActivate(4, 5, () => activations++)); + + Assert.Equal(0, activations); + Assert.Equal(-1, route.OwnerAttempt); + } + + [Fact] + public void StaleCleanupCannotClearNewerOfflineRoute() + { + var route = new AttemptOwnedRoute(); + int deactivations = 0; + + Assert.True(route.TryActivate(10, 10, () => { })); + Assert.True(route.TryActivate(11, 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; + } + } +} diff --git a/MinecraftClient.Tests/RestartCoordinatorTests.cs b/MinecraftClient.Tests/RestartCoordinatorTests.cs index 80b41a3c0a..f9c9edef7e 100644 --- a/MinecraftClient.Tests/RestartCoordinatorTests.cs +++ b/MinecraftClient.Tests/RestartCoordinatorTests.cs @@ -3,55 +3,247 @@ namespace MinecraftClient.Tests; public sealed class RestartCoordinatorTests { [Fact] - public async Task ReplacesQueuedSameAttemptAndQueuesNewerAttempt() + public async Task PreparationCompletesBeforeRequestCanExecute() { - 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(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + bool prepared = false; + + RestartCoordinator coordinator = null!; + coordinator = new RestartCoordinator( + (request, cancellationToken) => + { + Assert.True(Volatile.Read(ref prepared)); + 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(1, TimeSpan.Zero, true), + () => + { + Volatile.Write(ref prepared, true); + return true; + })); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task RejectedPreparationDoesNotPublishOrAdvanceAttempt() + { + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int executions = 0; + + RestartCoordinator coordinator = null!; + coordinator = new RestartCoordinator( + (request, cancellationToken) => + { + Interlocked.Increment(ref executions); + Assert.True(coordinator.TryBeginCommit(request, out _)); + completed.SetResult(); + return Task.CompletedTask; + }, + exception => throw new Xunit.Sdk.XunitException(exception.ToString())); + using var cleanup = coordinator; + + Assert.False(coordinator.TrySchedule( + new RestartRequest(2, TimeSpan.Zero, true), + () => false)); + Assert.False(coordinator.HasScheduledRestart(2)); + + Assert.True(coordinator.TrySchedule(new RestartRequest(2, TimeSpan.Zero, true))); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(1, executions); + } + + [Fact] + public async Task FaultedSourceCleanupPreventsRestartExecution() + { + var failureReported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int executions = 0; using var coordinator = new RestartCoordinator( + (_, _) => + { + Interlocked.Increment(ref executions); + return Task.CompletedTask; + }, + exception => failureReported.SetResult(exception)); + + var cleanupFailure = new InvalidOperationException("cleanup failed"); + Assert.True(coordinator.TrySchedule(new RestartRequest( + 3, + TimeSpan.Zero, + true, + SourceCleanupCompletion: Task.FromException(cleanupFailure)))); + + Exception reportedException = await failureReported.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Same(cleanupFailure, reportedException); + Assert.Equal(0, executions); + } + + [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) { - 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 +253,16 @@ public void RejectsStaleAttempt() 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 +272,23 @@ public async Task RejectsCompletedAttempt() } [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)); } diff --git a/MinecraftClient/ChatBots/AutoRelog.cs b/MinecraftClient/ChatBots/AutoRelog.cs index af3b3e7134..3a07f7f434 100644 --- a/MinecraftClient/ChatBots/AutoRelog.cs +++ b/MinecraftClient/ChatBots/AutoRelog.cs @@ -78,6 +78,7 @@ public Range(int min, int max) } private static readonly AutoRelogRetryPolicy s_retryPolicy = new(TimeProvider.System); + private readonly long? sourceConnectionAttempt; /// /// This bot automatically re-join the server if kick message contains predefined string @@ -85,8 +86,13 @@ public Range(int min, int max) /// Minimum delay before re-joining the server (in seconds) /// Maximum delay before re-joining the server (in seconds) /// Number of retries if connection fails (-1 = infinite) - 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,31 @@ private bool LaunchDelayedReconnection(string? msg) : retriesLeft.ToString(); McClient.ReconnectionAttemptsLeft = retriesLeft; - if (Program.TryRestart(TimeSpan.FromSeconds(delay), true)) + long connectionAttempt = sourceConnectionAttempt ?? Handler.ConnectionAttempt; + if (Program.TryRestart( + connectionAttempt, + TimeSpan.FromSeconds(delay), + keepAccountAndServerSettings: true, + sourceCleanupCompletion: sourceConnectionAttempt.HasValue ? null : Handler.DisconnectCompletion)) { 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); } diff --git a/MinecraftClient/Commands/Connect.cs b/MinecraftClient/Commands/Connect.cs index 174fca224c..51206462b7 100644 --- a/MinecraftClient/Commands/Connect.cs +++ b/MinecraftClient/Commands/Connect.cs @@ -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 @@ private int GetUsage(CmdResult r, string? cmd) 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]); } } diff --git a/MinecraftClient/Commands/Reco.cs b/MinecraftClient/Commands/Reco.cs index 006217ad11..05fbe6f617 100644 --- a/MinecraftClient/Commands/Reco.cs +++ b/MinecraftClient/Commands/Reco.cs @@ -41,19 +41,30 @@ private int GetUsage(CmdResult r, string? cmd) 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 @@ internal static string DoReconnect(string command) 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; } } } diff --git a/MinecraftClient/ConnectionAttemptLifecycle.cs b/MinecraftClient/ConnectionAttemptLifecycle.cs new file mode 100644 index 0000000000..360ae01305 --- /dev/null +++ b/MinecraftClient/ConnectionAttemptLifecycle.cs @@ -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 heldBots, Action 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, long currentConnectionAttempt, Action activate) + { + ArgumentNullException.ThrowIfNull(activate); + + lock (stateLock) + { + if (connectionAttempt < currentConnectionAttempt || 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; + } + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index c20b03dd42..11d860cd8f 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -231,12 +231,14 @@ public Dictionary GetTeams() SessionToken _sessionToken; Tuple? timeoutdetector = null; private int transferInProgress = 0; - private int disconnectState; + private readonly ConnectionAttemptLifecycle connectionLifecycle = new(); private int disconnectOwnerThreadId; private readonly TaskCompletionSource disconnectCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); public ILogger Log; public DialogManager Dialogs { get; } + internal long ConnectionAttempt { get; } + internal Task DisconnectCompletion => disconnectCompletion.Task; private static IMinecraftComHandler? instance; public static IMinecraftComHandler? Instance => instance; @@ -251,9 +253,22 @@ public Dictionary GetTeams() /// Minecraft protocol version to use /// ForgeInfo item stating that Forge is enabled 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 +326,13 @@ public McClient(SessionToken session, PlayerKeyPair? playerKeyPair, string serve LoadCommands(); if (botsOnHold.Count == 0) + { RegisterBots(); + } + else + { + ConnectionAttemptLifecycle.RestoreHeldBots(botsOnHold, bot => BotLoad(bot, false)); + } try { @@ -331,10 +352,6 @@ public McClient(SessionToken session, PlayerKeyPair? playerKeyPair, string serve { 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 +385,9 @@ public McClient(SessionToken session, PlayerKeyPair? playerKeyPair, string serve timeoutdetector = null; } + if (connectionLifecycle.IsFailureClaimed) + return; + if (!InternalConfig.InteractiveMode) { StopConsoleSession(); @@ -391,14 +411,8 @@ public McClient(SessionToken session, PlayerKeyPair? playerKeyPair, string serve 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)) - return; - - StopConsoleSession(); - Program.HandleFailure(); + OnConnectionLost(ChatBot.DisconnectReason.ConnectionLost, Translations.mcc_disconnect_lost); + return; } public void Transfer(string newHost, int newPort) @@ -530,6 +544,7 @@ private static void ResolveTransferAddress(ref string host, ref int port) private void StartConsoleSession() { + Program.EndOfflinePrompt(ConnectionAttempt); ConsoleInputRouter.RouteToClient(this); } @@ -875,7 +890,7 @@ public void OnConnectionLost(ChatBot.DisconnectReason reason, string message) } } - restartScheduled = !exitOnFailure && Program.HasRestartPending; + restartScheduled = !exitOnFailure && Program.HasRestartPending(ConnectionAttempt); } finally { @@ -888,7 +903,7 @@ public void OnConnectionLost(ChatBot.DisconnectReason reason, string message) private bool TryBeginDisconnect() { - if (Interlocked.CompareExchange(ref disconnectState, 1, 0) != 0) + if (!connectionLifecycle.TryBeginDisconnect()) return false; Volatile.Write(ref disconnectOwnerThreadId, Environment.CurrentManagedThreadId); @@ -937,7 +952,7 @@ private void CompleteDisconnect(bool sendDisconnectPacket) } finally { - Volatile.Write(ref disconnectState, 2); + connectionLifecycle.CompleteDisconnect(); Volatile.Write(ref disconnectOwnerThreadId, 0); disconnectCompletion.TrySetResult(true); } diff --git a/MinecraftClient/Program.cs b/MinecraftClient/Program.cs index e5bf657243..dcf5daaf2c 100644 --- a/MinecraftClient/Program.cs +++ b/MinecraftClient/Program.cs @@ -55,7 +55,7 @@ static class Program 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 @@ private static void DiscardAuthenticationSelection() private sealed record AuthenticationSelection(LoginType AccountType, LoginMethod Method, string AuthServerUrl); + internal static long CurrentConnectionAttempt => Volatile.Read(ref connectionAttempt); + /// /// Start a new Client /// 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 @@ private static void InitializeClient() 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 @@ public static void Restart(int delaySeconds = 0, bool keepAccountAndServerSettin 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,26 +1087,47 @@ internal static bool TryRestart(int delaySeconds = 0, bool keepAccountAndServerS } 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, + Task? sourceCleanupCompletion = null) { if (Volatile.Read(ref exitOnFailurePending) != 0) return false; + if (sourceConnectionAttempt != CurrentConnectionAttempt) + return false; + if (delay < TimeSpan.Zero) 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), - delay, - keepAccountAndServerSettings, - settingsSnapshot)); + bool scheduled = restartCoordinator.TrySchedule( + new RestartRequest( + sourceConnectionAttempt, + delay, + keepAccountAndServerSettings, + settingsSnapshot, + replaceUntilCommit, + sourceCleanupCompletion), + () => 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 +1136,6 @@ private static async Task ExecuteRestartAsync(RestartRequest request, Cancellati client = null; } - EndOfflinePrompt(); ConsoleIO.Reset(); if (request.Delay > TimeSpan.Zero) @@ -1108,14 +1145,22 @@ private static async Task ExecuteRestartAsync(RestartRequest request, Cancellati } 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 +1242,7 @@ public static void HandleFailure(string? errorMessage = null, bool versionError 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,35 +1262,58 @@ public static void HandleFailure(string? errorMessage = null, bool versionError 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 bool BeginOfflinePrompt(long connectionAttempt) { - if (Interlocked.CompareExchange(ref offlinePromptActive, 1, 0) != 0) - return; + long currentConnectionAttempt = CurrentConnectionAttempt; + if (connectionAttempt != currentConnectionAttempt) + return false; - ConsoleInputRouter.RouteOffline(HandleOfflineCommand); - ConsoleIO.WriteLine(string.Empty); - ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_disconnected, Config.Main.Advanced.InternalCmdChar.ToLogString())); - if (ConsoleIO.Backend is Tui.TuiConsoleBackend) - ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString())); - else - ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true); + if (offlinePromptRoute.TryActivate(connectionAttempt, currentConnectionAttempt, () => + { + ConsoleInputRouter.RouteOffline(HandleOfflineCommand); + ConsoleIO.WriteLine(string.Empty); + ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_disconnected, Config.Main.Advanced.InternalCmdChar.ToLogString())); + if (ConsoleIO.Backend is Tui.TuiConsoleBackend) + ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString())); + else + ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true); + })) + { + return true; + } + + return offlinePromptRoute.OwnerAttempt == connectionAttempt; } private static void EndOfflinePrompt() { - if (Interlocked.Exchange(ref offlinePromptActive, 0) == 0) - return; + offlinePromptRoute.TryDeactivate(() => + { + ConsoleInputRouter.ClearOfflineRoute(HandleOfflineCommand); + ConsoleIO.Reset(); + }); + } - 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,19 +1337,13 @@ private static void HandleOfflineCommand(string input) { 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)) diff --git a/MinecraftClient/RestartCoordinator.cs b/MinecraftClient/RestartCoordinator.cs index 193d454b99..00a759943f 100644 --- a/MinecraftClient/RestartCoordinator.cs +++ b/MinecraftClient/RestartCoordinator.cs @@ -11,6 +11,8 @@ internal readonly record struct RestartRequest( TimeSpan Delay, bool KeepAccountAndServerSettings, RestartSettingsSnapshot? SettingsSnapshot = null, + bool ReplaceUntilCommit = false, + Task? SourceCleanupCompletion = null, long RequestId = 0); internal readonly record struct RestartSettingsSnapshot( @@ -18,6 +20,17 @@ internal readonly record struct RestartSettingsSnapshot( 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 +39,7 @@ internal sealed class RestartCoordinator : IDisposable private readonly Func restart; private readonly Action reportFailure; private readonly Task worker; - private readonly Dictionary pendingAttempts = []; + private readonly Dictionary pendingAttempts = []; private long highestScheduledAttempt = -1; private long nextRequestId; private bool stopped; @@ -55,29 +68,82 @@ internal bool HasScheduledRestart(long connectionAttempt) return !stopped && pendingAttempts.ContainsKey(connectionAttempt); } - internal bool TrySchedule(RestartRequest request) + internal bool TrySchedule(RestartRequest request, Func? beforePublish = null) { lock (stateLock) { - bool hasPendingRequest = pendingAttempts.TryGetValue(request.ConnectionAttempt, out long previousRequestId); - if (stopped || request.ConnectionAttempt < highestScheduledAttempt - || (request.ConnectionAttempt == highestScheduledAttempt && !hasPendingRequest)) + if (stopped) return false; - highestScheduledAttempt = Math.Max(highestScheduledAttempt, request.ConnectionAttempt); - request = request with { RequestId = ++nextRequestId }; - pendingAttempts[request.ConnectionAttempt] = request.RequestId; - if (requests.Writer.TryWrite(request)) + if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest)) + { + if (pendingRequest.State != RestartRequestState.Replaceable || !request.ReplaceUntilCommit) + return false; + + request = request with + { + RequestId = pendingRequest.RequestId, + SourceCleanupCompletion = pendingRequest.Request.SourceCleanupCompletion, + }; + pendingAttempts[request.ConnectionAttempt] = pendingRequest with { Request = request }; return true; + } + + if (request.ConnectionAttempt <= highestScheduledAttempt) + return false; - if (hasPendingRequest) - pendingAttempts[request.ConnectionAttempt] = previousRequestId; - else + request = request with { RequestId = ++nextRequestId }; + pendingAttempts[request.ConnectionAttempt] = new PendingRestart( + request.RequestId, + request, + RestartRequestState.Replaceable); + try + { + if (beforePublish is not null && !beforePublish()) + { + pendingAttempts.Remove(request.ConnectionAttempt); + return false; + } + + if (requests.Writer.TryWrite(request)) + { + highestScheduledAttempt = Math.Max(highestScheduledAttempt, request.ConnectionAttempt); + return true; + } + } + catch + { pendingAttempts.Remove(request.ConnectionAttempt); + throw; + } + + 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,13 +166,16 @@ private async Task ProcessRequestsAsync() { 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; } try { + if (request.SourceCleanupCompletion is Task sourceCleanupCompletion) + await sourceCleanupCompletion.WaitAsync(shutdown.Token).ConfigureAwait(false); + await restart(request, shutdown.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (shutdown.IsCancellationRequested) @@ -121,8 +190,8 @@ private async Task ProcessRequestsAsync() { 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); } } diff --git a/docs/guide/chat-bots.md b/docs/guide/chat-bots.md index d93eb2108f..2fce441862 100644 --- a/docs/guide/chat-bots.md +++ b/docs/guide/chat-bots.md @@ -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 = , max = }` - **Type:** `inline table`