mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
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:
parent
92212d2b95
commit
456a548cbc
11 changed files with 645 additions and 114 deletions
|
|
@ -62,6 +62,37 @@ public sealed class AutoRelogRetryPolicyTests
|
||||||
Assert.Equal(0, retriesLeft);
|
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]
|
[Fact]
|
||||||
public void StableConnectionResetsRetryBudget()
|
public void StableConnectionResetsRetryBudget()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
110
MinecraftClient.Tests/McClientConnectionFailureTests.cs
Normal file
110
MinecraftClient.Tests/McClientConnectionFailureTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,55 +3,163 @@ namespace MinecraftClient.Tests;
|
||||||
public sealed class RestartCoordinatorTests
|
public sealed class RestartCoordinatorTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReplacesQueuedSameAttemptAndQueuesNewerAttempt()
|
public async Task AutomaticSameAttemptIsCoalescedWhileQueued()
|
||||||
{
|
{
|
||||||
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var blockerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var releaseBlocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
var replacementCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var queuedCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
var secondCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
int queuedExecutions = 0;
|
||||||
var executedAccounts = new List<string>();
|
|
||||||
|
|
||||||
using var coordinator = new RestartCoordinator(
|
RestartCoordinator coordinator = null!;
|
||||||
|
coordinator = new RestartCoordinator(
|
||||||
async (request, cancellationToken) =>
|
async (request, cancellationToken) =>
|
||||||
{
|
{
|
||||||
if (request.ConnectionAttempt == 9)
|
if (request.ConnectionAttempt == 9)
|
||||||
{
|
{
|
||||||
firstStarted.SetResult();
|
blockerStarted.SetResult();
|
||||||
await releaseFirst.Task.WaitAsync(cancellationToken);
|
await releaseBlocker.Task.WaitAsync(cancellationToken);
|
||||||
}
|
return;
|
||||||
else if (request.ConnectionAttempt == 10)
|
|
||||||
{
|
|
||||||
executedAccounts.Add(request.SettingsSnapshot?.Account.Login ?? string.Empty);
|
|
||||||
replacementCompleted.SetResult();
|
|
||||||
}
|
|
||||||
else if (request.ConnectionAttempt == 11)
|
|
||||||
{
|
|
||||||
secondCompleted.SetResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Interlocked.Increment(ref queuedExecutions);
|
||||||
|
Assert.True(coordinator.TryBeginCommit(request, out _));
|
||||||
|
queuedCompleted.SetResult();
|
||||||
},
|
},
|
||||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||||
|
using var cleanup = coordinator;
|
||||||
|
|
||||||
Assert.True(coordinator.TrySchedule(new RestartRequest(9, TimeSpan.Zero, true)));
|
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)));
|
||||||
Assert.True(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true, CreateSettingsSnapshot("replacement"))));
|
Assert.False(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true)));
|
||||||
Assert.True(coordinator.TrySchedule(new RestartRequest(11, TimeSpan.Zero, true)));
|
Assert.True(coordinator.HasScheduledRestart(10));
|
||||||
Assert.True(coordinator.HasScheduledRestart(11));
|
|
||||||
|
|
||||||
releaseFirst.SetResult();
|
releaseBlocker.SetResult();
|
||||||
await replacementCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
await queuedCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
await secondCompleted.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]
|
[Fact]
|
||||||
public void RejectsStaleAttempt()
|
public void RejectsStaleAttempt()
|
||||||
{
|
{
|
||||||
using var coordinator = new RestartCoordinator(
|
RestartCoordinator coordinator = null!;
|
||||||
|
coordinator = new RestartCoordinator(
|
||||||
(_, _) => Task.CompletedTask,
|
(_, _) => Task.CompletedTask,
|
||||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||||
|
using var cleanup = coordinator;
|
||||||
|
|
||||||
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
|
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
|
||||||
Assert.False(coordinator.TrySchedule(new RestartRequest(19, 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()
|
public async Task RejectsCompletedAttempt()
|
||||||
{
|
{
|
||||||
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
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();
|
completed.SetResult();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
},
|
},
|
||||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||||
|
using var cleanup = coordinator;
|
||||||
|
|
||||||
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
|
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
|
||||||
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
@ -77,15 +188,23 @@ public sealed class RestartCoordinatorTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TerminalStopRejectsFurtherRestarts()
|
public async Task TerminalStopCancelsInFlightWorkAndRejectsFurtherRestarts()
|
||||||
{
|
{
|
||||||
|
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
using var coordinator = new RestartCoordinator(
|
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()));
|
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();
|
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));
|
Assert.False(coordinator.HasScheduledRestart(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ namespace MinecraftClient.ChatBots
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly AutoRelogRetryPolicy s_retryPolicy = new(TimeProvider.System);
|
private static readonly AutoRelogRetryPolicy s_retryPolicy = new(TimeProvider.System);
|
||||||
|
private readonly long? sourceConnectionAttempt;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This bot automatically re-join the server if kick message contains predefined string
|
/// 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="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="DelayBeforeRelogMax">Maximum delay before re-joining the server (in seconds)</param>
|
||||||
/// <param name="retries">Number of retries if connection fails (-1 = infinite)</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));
|
LogDebugToConsole(string.Format(Translations.bot_autoRelog_launch, Config.Retries));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,21 +169,27 @@ namespace MinecraftClient.ChatBots
|
||||||
: retriesLeft.ToString();
|
: retriesLeft.ToString();
|
||||||
|
|
||||||
McClient.ReconnectionAttemptsLeft = retriesLeft;
|
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));
|
LogToConsole(string.Format(Translations.bot_autoRelog_wait_with_retries, delay, retriesDisplay));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
s_retryPolicy.RollBackReservedAttempt();
|
s_retryPolicy.RollBackReservedAttempt();
|
||||||
return Program.HasRestartPending;
|
return Program.HasRestartPending(connectionAttempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool OnDisconnectStatic(DisconnectReason reason, string message)
|
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)
|
if (Config.Enabled)
|
||||||
{
|
{
|
||||||
AutoRelog bot = new();
|
AutoRelog bot = new(sourceConnectionAttempt);
|
||||||
bot.Initialize();
|
bot.Initialize();
|
||||||
return bot.OnDisconnect(reason, message);
|
return bot.OnDisconnect(reason, message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Brigadier.NET;
|
using System;
|
||||||
|
using Brigadier.NET;
|
||||||
using Brigadier.NET.Builder;
|
using Brigadier.NET.Builder;
|
||||||
using MinecraftClient.CommandHandler;
|
using MinecraftClient.CommandHandler;
|
||||||
using static MinecraftClient.CommandHandler.CmdResult;
|
using static MinecraftClient.CommandHandler.CmdResult;
|
||||||
|
|
@ -42,35 +43,55 @@ namespace MinecraftClient.Commands
|
||||||
|
|
||||||
private int DoConnect(CmdResult r, string server, string account)
|
private int DoConnect(CmdResult r, string server, string account)
|
||||||
{
|
{
|
||||||
|
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||||
if (!string.IsNullOrWhiteSpace(account) && !Settings.Config.Main.Advanced.SetAccount(account))
|
if (!string.IsNullOrWhiteSpace(account) && !Settings.Config.Main.Advanced.SetAccount(account))
|
||||||
return r.SetAndReturn(Status.Fail, string.Format(Translations.cmd_connect_unknown, 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))
|
if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(server), true))
|
||||||
{
|
{
|
||||||
return r.SetAndReturn(Program.TryRestart(keepAccountAndServerSettings: true)
|
if (Program.TryRestart(
|
||||||
? Status.Done
|
Program.CurrentConnectionAttempt,
|
||||||
: Status.Fail);
|
TimeSpan.Zero,
|
||||||
|
keepAccountAndServerSettings: true,
|
||||||
|
replaceUntilCommit: true))
|
||||||
|
{
|
||||||
|
return r.SetAndReturn(Status.Done);
|
||||||
|
}
|
||||||
|
|
||||||
|
Program.RestoreRestartSettings(previousSettings);
|
||||||
|
return r.SetAndReturn(Status.Fail);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
Program.RestoreRestartSettings(previousSettings);
|
||||||
return r.SetAndReturn(Status.Fail, string.Format(Translations.cmd_connect_invalid_ip, server));
|
return r.SetAndReturn(Status.Fail, string.Format(Translations.cmd_connect_invalid_ip, server));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string DoConnect(string command)
|
internal static string DoConnect(string command)
|
||||||
{
|
{
|
||||||
|
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||||
string[] args = GetArgs(command);
|
string[] args = GetArgs(command);
|
||||||
if (args.Length > 1 && !Settings.Config.Main.Advanced.SetAccount(args[1]))
|
if (args.Length > 1 && !Settings.Config.Main.Advanced.SetAccount(args[1]))
|
||||||
return string.Format(Translations.cmd_connect_unknown, args[1]);
|
return string.Format(Translations.cmd_connect_unknown, args[1]);
|
||||||
|
|
||||||
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))
|
||||||
{
|
{
|
||||||
return Program.TryRestart(keepAccountAndServerSettings: true)
|
if (Program.TryRestart(
|
||||||
? string.Empty
|
Program.CurrentConnectionAttempt,
|
||||||
: Translations.general_fail;
|
TimeSpan.Zero,
|
||||||
|
keepAccountAndServerSettings: true,
|
||||||
|
replaceUntilCommit: true))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Program.RestoreRestartSettings(previousSettings);
|
||||||
|
return Translations.general_fail;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
Program.RestoreRestartSettings(previousSettings);
|
||||||
return string.Format(Translations.cmd_connect_invalid_ip, args[0]);
|
return string.Format(Translations.cmd_connect_invalid_ip, args[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,19 +41,30 @@ namespace MinecraftClient.Commands
|
||||||
|
|
||||||
private int DoReconnect(CmdResult r, string account)
|
private int DoReconnect(CmdResult r, string account)
|
||||||
{
|
{
|
||||||
|
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||||
if (!string.IsNullOrWhiteSpace(account))
|
if (!string.IsNullOrWhiteSpace(account))
|
||||||
{
|
{
|
||||||
account = account.Trim();
|
account = account.Trim();
|
||||||
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));
|
||||||
}
|
}
|
||||||
return r.SetAndReturn(Program.TryRestart(keepAccountAndServerSettings: true)
|
|
||||||
? CmdResult.Status.Done
|
if (Program.TryRestart(
|
||||||
: CmdResult.Status.Fail);
|
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)
|
internal static string DoReconnect(string command)
|
||||||
{
|
{
|
||||||
|
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||||
string[] args = GetArgs(command);
|
string[] args = GetArgs(command);
|
||||||
if (args.Length > 0)
|
if (args.Length > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -63,9 +74,18 @@ namespace MinecraftClient.Commands
|
||||||
return string.Format(Translations.cmd_connect_unknown, account);
|
return string.Format(Translations.cmd_connect_unknown, account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Program.TryRestart(keepAccountAndServerSettings: true)
|
|
||||||
? String.Empty
|
if (Program.TryRestart(
|
||||||
: Translations.general_fail;
|
Program.CurrentConnectionAttempt,
|
||||||
|
TimeSpan.Zero,
|
||||||
|
keepAccountAndServerSettings: true,
|
||||||
|
replaceUntilCommit: true))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Program.RestoreRestartSettings(previousSettings);
|
||||||
|
return Translations.general_fail;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
MinecraftClient/ConnectionAttemptLifecycle.cs
Normal file
107
MinecraftClient/ConnectionAttemptLifecycle.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -231,12 +231,13 @@ namespace MinecraftClient
|
||||||
SessionToken _sessionToken;
|
SessionToken _sessionToken;
|
||||||
Tuple<Thread, CancellationTokenSource>? timeoutdetector = null;
|
Tuple<Thread, CancellationTokenSource>? timeoutdetector = null;
|
||||||
private int transferInProgress = 0;
|
private int transferInProgress = 0;
|
||||||
private int disconnectState;
|
private readonly ConnectionAttemptLifecycle connectionLifecycle = new();
|
||||||
private int disconnectOwnerThreadId;
|
private int disconnectOwnerThreadId;
|
||||||
private readonly TaskCompletionSource<bool> disconnectCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private readonly TaskCompletionSource<bool> disconnectCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
public ILogger Log;
|
public ILogger Log;
|
||||||
public DialogManager Dialogs { get; }
|
public DialogManager Dialogs { get; }
|
||||||
|
internal long ConnectionAttempt { get; }
|
||||||
|
|
||||||
private static IMinecraftComHandler? instance;
|
private static IMinecraftComHandler? instance;
|
||||||
public static IMinecraftComHandler? Instance => instance;
|
public static IMinecraftComHandler? Instance => instance;
|
||||||
|
|
@ -251,9 +252,22 @@ namespace MinecraftClient
|
||||||
/// <param name="protocolversion">Minecraft protocol version to use</param>
|
/// <param name="protocolversion">Minecraft protocol version to use</param>
|
||||||
/// <param name="forgeInfo">ForgeInfo item stating that Forge is enabled</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)
|
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;
|
CmdResult.currentHandler = this;
|
||||||
instance = this;
|
instance = this;
|
||||||
|
ConnectionAttempt = connectionAttempt;
|
||||||
|
|
||||||
terrainAndMovementsEnabled = Config.Main.Advanced.TerrainAndMovements;
|
terrainAndMovementsEnabled = Config.Main.Advanced.TerrainAndMovements;
|
||||||
inventoryHandlingEnabled = Config.Main.Advanced.InventoryHandling;
|
inventoryHandlingEnabled = Config.Main.Advanced.InventoryHandling;
|
||||||
|
|
@ -311,7 +325,13 @@ namespace MinecraftClient
|
||||||
LoadCommands();
|
LoadCommands();
|
||||||
|
|
||||||
if (botsOnHold.Count == 0)
|
if (botsOnHold.Count == 0)
|
||||||
|
{
|
||||||
RegisterBots();
|
RegisterBots();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ConnectionAttemptLifecycle.RestoreHeldBots(botsOnHold, bot => BotLoad(bot, false));
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -331,10 +351,6 @@ namespace MinecraftClient
|
||||||
{
|
{
|
||||||
if (handler.Login(this.playerKeyPair, session))
|
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()));
|
Log.Info(string.Format(Translations.mcc_joined, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||||
|
|
||||||
StartConsoleSession();
|
StartConsoleSession();
|
||||||
|
|
@ -368,6 +384,9 @@ namespace MinecraftClient
|
||||||
timeoutdetector = null;
|
timeoutdetector = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (connectionLifecycle.IsFailureClaimed)
|
||||||
|
return;
|
||||||
|
|
||||||
if (!InternalConfig.InteractiveMode)
|
if (!InternalConfig.InteractiveMode)
|
||||||
{
|
{
|
||||||
StopConsoleSession();
|
StopConsoleSession();
|
||||||
|
|
@ -391,14 +410,8 @@ namespace MinecraftClient
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoRelog is enabled - invoke its static handler to trigger reconnection.
|
OnConnectionLost(ChatBot.DisconnectReason.ConnectionLost, Translations.mcc_disconnect_lost);
|
||||||
// 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;
|
return;
|
||||||
|
|
||||||
StopConsoleSession();
|
|
||||||
Program.HandleFailure();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Transfer(string newHost, int newPort)
|
public void Transfer(string newHost, int newPort)
|
||||||
|
|
@ -530,6 +543,7 @@ namespace MinecraftClient
|
||||||
|
|
||||||
private void StartConsoleSession()
|
private void StartConsoleSession()
|
||||||
{
|
{
|
||||||
|
Program.EndOfflinePrompt(ConnectionAttempt);
|
||||||
ConsoleInputRouter.RouteToClient(this);
|
ConsoleInputRouter.RouteToClient(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -875,7 +889,7 @@ namespace MinecraftClient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
restartScheduled = !exitOnFailure && Program.HasRestartPending;
|
restartScheduled = !exitOnFailure && Program.HasRestartPending(ConnectionAttempt);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -888,7 +902,7 @@ namespace MinecraftClient
|
||||||
|
|
||||||
private bool TryBeginDisconnect()
|
private bool TryBeginDisconnect()
|
||||||
{
|
{
|
||||||
if (Interlocked.CompareExchange(ref disconnectState, 1, 0) != 0)
|
if (!connectionLifecycle.TryBeginDisconnect())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
Volatile.Write(ref disconnectOwnerThreadId, Environment.CurrentManagedThreadId);
|
Volatile.Write(ref disconnectOwnerThreadId, Environment.CurrentManagedThreadId);
|
||||||
|
|
@ -937,7 +951,7 @@ namespace MinecraftClient
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Volatile.Write(ref disconnectState, 2);
|
connectionLifecycle.CompleteDisconnect();
|
||||||
Volatile.Write(ref disconnectOwnerThreadId, 0);
|
Volatile.Write(ref disconnectOwnerThreadId, 0);
|
||||||
disconnectCompletion.TrySetResult(true);
|
disconnectCompletion.TrySetResult(true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ namespace MinecraftClient
|
||||||
private static bool useMcVersionOnce = false;
|
private static bool useMcVersionOnce = false;
|
||||||
private static readonly RestartCoordinator restartCoordinator = new(ExecuteRestartAsync, ReportRestartFailure);
|
private static readonly RestartCoordinator restartCoordinator = new(ExecuteRestartAsync, ReportRestartFailure);
|
||||||
private static long connectionAttempt;
|
private static long connectionAttempt;
|
||||||
private static int offlinePromptActive;
|
private static readonly AttemptOwnedRoute offlinePromptRoute = new();
|
||||||
private static int exitOnFailurePending;
|
private static int exitOnFailurePending;
|
||||||
private static string settingsIniPath = "MinecraftClient.ini";
|
private static string settingsIniPath = "MinecraftClient.ini";
|
||||||
private static AuthenticationSelection? pendingAuthenticationSelection;
|
private static AuthenticationSelection? pendingAuthenticationSelection;
|
||||||
|
|
@ -747,12 +747,14 @@ namespace MinecraftClient
|
||||||
|
|
||||||
private sealed record AuthenticationSelection(LoginType AccountType, LoginMethod Method, string AuthServerUrl);
|
private sealed record AuthenticationSelection(LoginType AccountType, LoginMethod Method, string AuthServerUrl);
|
||||||
|
|
||||||
|
internal static long CurrentConnectionAttempt => Volatile.Read(ref connectionAttempt);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Start a new Client
|
/// Start a new Client
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void InitializeClient()
|
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.
|
// Ensure that we use the provided Minecraft version if we can't connect automatically.
|
||||||
//
|
//
|
||||||
|
|
@ -984,7 +986,7 @@ namespace MinecraftClient
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
//Start the main TCP client
|
//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
|
//Update console title
|
||||||
if (OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(Config.Main.Advanced.ConsoleTitle))
|
if (OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(Config.Main.Advanced.ConsoleTitle))
|
||||||
|
|
@ -1062,7 +1064,22 @@ namespace MinecraftClient
|
||||||
TryRestart(TimeSpan.FromSeconds(Math.Max(0, delaySeconds)), keepAccountAndServerSettings);
|
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)
|
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)
|
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)
|
if (Volatile.Read(ref exitOnFailurePending) != 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -1078,18 +1104,25 @@ namespace MinecraftClient
|
||||||
delay = TimeSpan.Zero;
|
delay = TimeSpan.Zero;
|
||||||
|
|
||||||
RestartSettingsSnapshot? settingsSnapshot = keepAccountAndServerSettings
|
RestartSettingsSnapshot? settingsSnapshot = keepAccountAndServerSettings
|
||||||
? new RestartSettingsSnapshot(InternalConfig.Account, InternalConfig.ServerIP, InternalConfig.ServerPort)
|
? CaptureRestartSettings()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return restartCoordinator.TrySchedule(new RestartRequest(
|
bool scheduled = restartCoordinator.TrySchedule(new RestartRequest(
|
||||||
Volatile.Read(ref connectionAttempt),
|
sourceConnectionAttempt,
|
||||||
delay,
|
delay,
|
||||||
keepAccountAndServerSettings,
|
keepAccountAndServerSettings,
|
||||||
settingsSnapshot));
|
settingsSnapshot,
|
||||||
|
replaceUntilCommit));
|
||||||
|
if (scheduled)
|
||||||
|
BeginOfflinePrompt(sourceConnectionAttempt);
|
||||||
|
return scheduled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ExecuteRestartAsync(RestartRequest request, CancellationToken cancellationToken)
|
private static async Task ExecuteRestartAsync(RestartRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (request.ConnectionAttempt != CurrentConnectionAttempt)
|
||||||
|
return;
|
||||||
|
|
||||||
McClient? disconnectedClient = client;
|
McClient? disconnectedClient = client;
|
||||||
if (disconnectedClient is not null)
|
if (disconnectedClient is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -1098,7 +1131,6 @@ namespace MinecraftClient
|
||||||
client = null;
|
client = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
EndOfflinePrompt();
|
|
||||||
ConsoleIO.Reset();
|
ConsoleIO.Reset();
|
||||||
|
|
||||||
if (request.Delay > TimeSpan.Zero)
|
if (request.Delay > TimeSpan.Zero)
|
||||||
|
|
@ -1108,14 +1140,22 @@ namespace MinecraftClient
|
||||||
}
|
}
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (request.ConnectionAttempt != CurrentConnectionAttempt
|
||||||
|
|| !restartCoordinator.TryBeginCommit(request, out RestartRequest latestRequest)
|
||||||
|
|| latestRequest.ConnectionAttempt != CurrentConnectionAttempt)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ConsoleIO.WriteLine(Translations.mcc_restart);
|
ConsoleIO.WriteLine(Translations.mcc_restart);
|
||||||
ReloadSettings(request.KeepAccountAndServerSettings);
|
ReloadSettings(latestRequest.KeepAccountAndServerSettings);
|
||||||
if (request.SettingsSnapshot is RestartSettingsSnapshot settingsSnapshot)
|
if (latestRequest.SettingsSnapshot is RestartSettingsSnapshot settingsSnapshot)
|
||||||
{
|
{
|
||||||
InternalConfig.Account = settingsSnapshot.Account;
|
InternalConfig.Account = settingsSnapshot.Account;
|
||||||
InternalConfig.ServerIP = settingsSnapshot.ServerIP;
|
InternalConfig.ServerIP = settingsSnapshot.ServerIP;
|
||||||
InternalConfig.ServerPort = settingsSnapshot.ServerPort;
|
InternalConfig.ServerPort = settingsSnapshot.ServerPort;
|
||||||
}
|
}
|
||||||
|
TransferOfflinePrompt(latestRequest.ConnectionAttempt, latestRequest.ConnectionAttempt + 1);
|
||||||
InitializeClient();
|
InitializeClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1197,7 +1237,7 @@ namespace MinecraftClient
|
||||||
if (!string.IsNullOrEmpty(errorMessage) && disconnectReason.HasValue)
|
if (!string.IsNullOrEmpty(errorMessage) && disconnectReason.HasValue)
|
||||||
{
|
{
|
||||||
autoRelogHandled = true;
|
autoRelogHandled = true;
|
||||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage))
|
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage, CurrentConnectionAttempt))
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1217,19 +1257,18 @@ namespace MinecraftClient
|
||||||
|
|
||||||
if (!autoRelogHandled && disconnectReason.HasValue)
|
if (!autoRelogHandled && disconnectReason.HasValue)
|
||||||
{
|
{
|
||||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!))
|
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!, CurrentConnectionAttempt))
|
||||||
return;
|
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);
|
ConsoleInputRouter.RouteOffline(HandleOfflineCommand);
|
||||||
ConsoleIO.WriteLine(string.Empty);
|
ConsoleIO.WriteLine(string.Empty);
|
||||||
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_disconnected, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
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()));
|
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||||
else
|
else
|
||||||
ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true);
|
ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EndOfflinePrompt()
|
private static void EndOfflinePrompt()
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref offlinePromptActive, 0) == 0)
|
offlinePromptRoute.TryDeactivate(() =>
|
||||||
return;
|
{
|
||||||
|
|
||||||
ConsoleInputRouter.ClearOfflineRoute(HandleOfflineCommand);
|
ConsoleInputRouter.ClearOfflineRoute(HandleOfflineCommand);
|
||||||
ConsoleIO.Reset();
|
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)
|
private static void HandleOfflineCommand(string input)
|
||||||
|
|
@ -1269,20 +1323,14 @@ namespace MinecraftClient
|
||||||
{
|
{
|
||||||
message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command));
|
message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command));
|
||||||
if (message.Length == 0)
|
if (message.Length == 0)
|
||||||
{
|
|
||||||
EndOfflinePrompt();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if (command.StartsWith("connect", StringComparison.Ordinal))
|
else if (command.StartsWith("connect", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command));
|
message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command));
|
||||||
if (message.Length == 0)
|
if (message.Length == 0)
|
||||||
{
|
|
||||||
EndOfflinePrompt();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if (command.StartsWith("exit", StringComparison.Ordinal)
|
else if (command.StartsWith("exit", StringComparison.Ordinal)
|
||||||
|| command.StartsWith("quit", StringComparison.Ordinal))
|
|| command.StartsWith("quit", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ namespace MinecraftClient
|
||||||
TimeSpan Delay,
|
TimeSpan Delay,
|
||||||
bool KeepAccountAndServerSettings,
|
bool KeepAccountAndServerSettings,
|
||||||
RestartSettingsSnapshot? SettingsSnapshot = null,
|
RestartSettingsSnapshot? SettingsSnapshot = null,
|
||||||
|
bool ReplaceUntilCommit = false,
|
||||||
long RequestId = 0);
|
long RequestId = 0);
|
||||||
|
|
||||||
internal readonly record struct RestartSettingsSnapshot(
|
internal readonly record struct RestartSettingsSnapshot(
|
||||||
|
|
@ -18,6 +19,17 @@ namespace MinecraftClient
|
||||||
string ServerIP,
|
string ServerIP,
|
||||||
ushort ServerPort);
|
ushort ServerPort);
|
||||||
|
|
||||||
|
internal enum RestartRequestState
|
||||||
|
{
|
||||||
|
Replaceable,
|
||||||
|
Committing,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct PendingRestart(
|
||||||
|
long RequestId,
|
||||||
|
RestartRequest Request,
|
||||||
|
RestartRequestState State);
|
||||||
|
|
||||||
internal sealed class RestartCoordinator : IDisposable
|
internal sealed class RestartCoordinator : IDisposable
|
||||||
{
|
{
|
||||||
private readonly Lock stateLock = new();
|
private readonly Lock stateLock = new();
|
||||||
|
|
@ -26,7 +38,7 @@ 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 Dictionary<long, long> pendingAttempts = [];
|
private readonly Dictionary<long, PendingRestart> pendingAttempts = [];
|
||||||
private long highestScheduledAttempt = -1;
|
private long highestScheduledAttempt = -1;
|
||||||
private long nextRequestId;
|
private long nextRequestId;
|
||||||
private bool stopped;
|
private bool stopped;
|
||||||
|
|
@ -59,25 +71,58 @@ namespace MinecraftClient
|
||||||
{
|
{
|
||||||
lock (stateLock)
|
lock (stateLock)
|
||||||
{
|
{
|
||||||
bool hasPendingRequest = pendingAttempts.TryGetValue(request.ConnectionAttempt, out long previousRequestId);
|
if (stopped)
|
||||||
if (stopped || request.ConnectionAttempt < highestScheduledAttempt
|
return false;
|
||||||
|| (request.ConnectionAttempt == highestScheduledAttempt && !hasPendingRequest))
|
|
||||||
|
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;
|
return false;
|
||||||
|
|
||||||
highestScheduledAttempt = Math.Max(highestScheduledAttempt, request.ConnectionAttempt);
|
highestScheduledAttempt = Math.Max(highestScheduledAttempt, request.ConnectionAttempt);
|
||||||
request = request with { RequestId = ++nextRequestId };
|
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))
|
if (requests.Writer.TryWrite(request))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
if (hasPendingRequest)
|
|
||||||
pendingAttempts[request.ConnectionAttempt] = previousRequestId;
|
|
||||||
else
|
|
||||||
pendingAttempts.Remove(request.ConnectionAttempt);
|
pendingAttempts.Remove(request.ConnectionAttempt);
|
||||||
return false;
|
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()
|
internal void Stop()
|
||||||
{
|
{
|
||||||
lock (stateLock)
|
lock (stateLock)
|
||||||
|
|
@ -100,8 +145,8 @@ namespace MinecraftClient
|
||||||
{
|
{
|
||||||
lock (stateLock)
|
lock (stateLock)
|
||||||
{
|
{
|
||||||
if (!pendingAttempts.TryGetValue(request.ConnectionAttempt, out long requestId)
|
if (!pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest)
|
||||||
|| requestId != request.RequestId)
|
|| pendingRequest.RequestId != request.RequestId)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,8 +166,8 @@ namespace MinecraftClient
|
||||||
{
|
{
|
||||||
lock (stateLock)
|
lock (stateLock)
|
||||||
{
|
{
|
||||||
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out long requestId)
|
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest)
|
||||||
&& requestId == request.RequestId)
|
&& pendingRequest.RequestId == request.RequestId)
|
||||||
pendingAttempts.Remove(request.ConnectionAttempt);
|
pendingAttempts.Remove(request.ConnectionAttempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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:**
|
- **Settings:**
|
||||||
|
|
||||||
**Section:** **`ChatBot.AutoRelog`**
|
**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`.
|
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)> }`
|
- **Format:** `{ min = <seconds (double)>, max = <seconds (double)> }`
|
||||||
|
|
||||||
- **Type:** `inline table`
|
- **Type:** `inline table`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue