mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
Fix: prevent duplicate AutoRelog retries (#3186)
Fix: prevent duplicate AutoRelog retries (#3186)
This commit is contained in:
commit
90fda17365
11 changed files with 788 additions and 118 deletions
|
|
@ -62,6 +62,37 @@ public sealed class AutoRelogRetryPolicyTests
|
|||
Assert.Equal(0, retriesLeft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CoalescedDuplicateRollsBackOnlyItsOwnReservation()
|
||||
{
|
||||
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
|
||||
|
||||
Assert.True(policy.TryReserveAttempt(2, out int firstRetriesLeft));
|
||||
Assert.True(policy.TryReserveAttempt(2, out int duplicateRetriesLeft));
|
||||
policy.RollBackReservedAttempt();
|
||||
|
||||
Assert.Equal(1, firstRetriesLeft);
|
||||
Assert.Equal(0, duplicateRetriesLeft);
|
||||
Assert.Equal(1, policy.Attempts);
|
||||
Assert.True(policy.TryReserveAttempt(2, out int secondFailureRetriesLeft));
|
||||
Assert.Equal(0, secondFailureRetriesLeft);
|
||||
Assert.False(policy.TryReserveAttempt(2, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlimitedDuplicateRollbackKeepsUnlimitedBudget()
|
||||
{
|
||||
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
|
||||
|
||||
Assert.True(policy.TryReserveAttempt(-1, out _));
|
||||
Assert.True(policy.TryReserveAttempt(-1, out _));
|
||||
policy.RollBackReservedAttempt();
|
||||
|
||||
Assert.Equal(1, policy.Attempts);
|
||||
Assert.True(policy.TryReserveAttempt(-1, out int retriesLeft));
|
||||
Assert.Equal(-1, retriesLeft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableConnectionResetsRetryBudget()
|
||||
{
|
||||
|
|
|
|||
122
MinecraftClient.Tests/McClientConnectionFailureTests.cs
Normal file
122
MinecraftClient.Tests/McClientConnectionFailureTests.cs
Normal file
|
|
@ -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<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, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
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<Exception>(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 sealed class RestartCoordinatorTests
|
|||
public async Task RejectsCompletedAttempt()
|
||||
{
|
||||
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var coordinator = new RestartCoordinator(
|
||||
(_, _) =>
|
||||
RestartCoordinator coordinator = null!;
|
||||
coordinator = new RestartCoordinator(
|
||||
(request, cancellationToken) =>
|
||||
{
|
||||
Assert.True(coordinator.TryBeginCommit(request, out _));
|
||||
completed.SetResult();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
using var cleanup = coordinator;
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
|
||||
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
|
@ -77,15 +272,23 @@ public sealed class RestartCoordinatorTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminalStopRejectsFurtherRestarts()
|
||||
public async Task TerminalStopCancelsInFlightWorkAndRejectsFurtherRestarts()
|
||||
{
|
||||
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var coordinator = new RestartCoordinator(
|
||||
(_, _) => Task.CompletedTask,
|
||||
async (_, cancellationToken) =>
|
||||
{
|
||||
callbackStarted.SetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(1, TimeSpan.Zero, true)));
|
||||
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
coordinator.Stop();
|
||||
|
||||
Assert.False(coordinator.TrySchedule(new RestartRequest(1, TimeSpan.Zero, true)));
|
||||
Assert.False(coordinator.TrySchedule(new RestartRequest(2, TimeSpan.Zero, true)));
|
||||
Assert.False(coordinator.HasScheduledRestart(1));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ namespace MinecraftClient.ChatBots
|
|||
}
|
||||
|
||||
private static readonly AutoRelogRetryPolicy s_retryPolicy = new(TimeProvider.System);
|
||||
private readonly long? sourceConnectionAttempt;
|
||||
|
||||
/// <summary>
|
||||
/// This bot automatically re-join the server if kick message contains predefined string
|
||||
|
|
@ -85,8 +86,13 @@ namespace MinecraftClient.ChatBots
|
|||
/// <param name="DelayBeforeRelogMin">Minimum delay before re-joining the server (in seconds)</param>
|
||||
/// <param name="DelayBeforeRelogMax">Maximum delay before re-joining the server (in seconds)</param>
|
||||
/// <param name="retries">Number of retries if connection fails (-1 = infinite)</param>
|
||||
public AutoRelog()
|
||||
public AutoRelog() : this(null)
|
||||
{
|
||||
}
|
||||
|
||||
private AutoRelog(long? sourceConnectionAttempt)
|
||||
{
|
||||
this.sourceConnectionAttempt = sourceConnectionAttempt;
|
||||
LogDebugToConsole(string.Format(Translations.bot_autoRelog_launch, Config.Retries));
|
||||
}
|
||||
|
||||
|
|
@ -163,21 +169,31 @@ namespace MinecraftClient.ChatBots
|
|||
: retriesLeft.ToString();
|
||||
|
||||
McClient.ReconnectionAttemptsLeft = retriesLeft;
|
||||
if (Program.TryRestart(TimeSpan.FromSeconds(delay), true))
|
||||
long connectionAttempt = sourceConnectionAttempt ?? Handler.ConnectionAttempt;
|
||||
if (Program.TryRestart(
|
||||
connectionAttempt,
|
||||
TimeSpan.FromSeconds(delay),
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Brigadier.NET;
|
||||
using System;
|
||||
using Brigadier.NET;
|
||||
using Brigadier.NET.Builder;
|
||||
using MinecraftClient.CommandHandler;
|
||||
using static MinecraftClient.CommandHandler.CmdResult;
|
||||
|
|
@ -42,35 +43,55 @@ namespace MinecraftClient.Commands
|
|||
|
||||
private int DoConnect(CmdResult r, string server, string account)
|
||||
{
|
||||
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||
if (!string.IsNullOrWhiteSpace(account) && !Settings.Config.Main.Advanced.SetAccount(account))
|
||||
return r.SetAndReturn(Status.Fail, string.Format(Translations.cmd_connect_unknown, account));
|
||||
|
||||
if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(server), true))
|
||||
{
|
||||
return r.SetAndReturn(Program.TryRestart(keepAccountAndServerSettings: true)
|
||||
? Status.Done
|
||||
: Status.Fail);
|
||||
if (Program.TryRestart(
|
||||
Program.CurrentConnectionAttempt,
|
||||
TimeSpan.Zero,
|
||||
keepAccountAndServerSettings: true,
|
||||
replaceUntilCommit: true))
|
||||
{
|
||||
return r.SetAndReturn(Status.Done);
|
||||
}
|
||||
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return r.SetAndReturn(Status.Fail);
|
||||
}
|
||||
else
|
||||
{
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return r.SetAndReturn(Status.Fail, string.Format(Translations.cmd_connect_invalid_ip, server));
|
||||
}
|
||||
}
|
||||
|
||||
internal static string DoConnect(string command)
|
||||
{
|
||||
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||
string[] args = GetArgs(command);
|
||||
if (args.Length > 1 && !Settings.Config.Main.Advanced.SetAccount(args[1]))
|
||||
return string.Format(Translations.cmd_connect_unknown, args[1]);
|
||||
|
||||
if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(args[0]), true))
|
||||
{
|
||||
return Program.TryRestart(keepAccountAndServerSettings: true)
|
||||
? string.Empty
|
||||
: Translations.general_fail;
|
||||
if (Program.TryRestart(
|
||||
Program.CurrentConnectionAttempt,
|
||||
TimeSpan.Zero,
|
||||
keepAccountAndServerSettings: true,
|
||||
replaceUntilCommit: true))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return Translations.general_fail;
|
||||
}
|
||||
else
|
||||
{
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return string.Format(Translations.cmd_connect_invalid_ip, args[0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,19 +41,30 @@ namespace MinecraftClient.Commands
|
|||
|
||||
private int DoReconnect(CmdResult r, string account)
|
||||
{
|
||||
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||
if (!string.IsNullOrWhiteSpace(account))
|
||||
{
|
||||
account = account.Trim();
|
||||
if (!Settings.Config.Main.Advanced.SetAccount(account))
|
||||
return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_connect_unknown, account));
|
||||
}
|
||||
return r.SetAndReturn(Program.TryRestart(keepAccountAndServerSettings: true)
|
||||
? CmdResult.Status.Done
|
||||
: CmdResult.Status.Fail);
|
||||
|
||||
if (Program.TryRestart(
|
||||
Program.CurrentConnectionAttempt,
|
||||
TimeSpan.Zero,
|
||||
keepAccountAndServerSettings: true,
|
||||
replaceUntilCommit: true))
|
||||
{
|
||||
return r.SetAndReturn(CmdResult.Status.Done);
|
||||
}
|
||||
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return r.SetAndReturn(CmdResult.Status.Fail);
|
||||
}
|
||||
|
||||
internal static string DoReconnect(string command)
|
||||
{
|
||||
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||
string[] args = GetArgs(command);
|
||||
if (args.Length > 0)
|
||||
{
|
||||
|
|
@ -63,9 +74,18 @@ namespace MinecraftClient.Commands
|
|||
return string.Format(Translations.cmd_connect_unknown, account);
|
||||
}
|
||||
}
|
||||
return Program.TryRestart(keepAccountAndServerSettings: true)
|
||||
? String.Empty
|
||||
: Translations.general_fail;
|
||||
|
||||
if (Program.TryRestart(
|
||||
Program.CurrentConnectionAttempt,
|
||||
TimeSpan.Zero,
|
||||
keepAccountAndServerSettings: true,
|
||||
replaceUntilCommit: true))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return Translations.general_fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -231,12 +231,14 @@ namespace MinecraftClient
|
|||
SessionToken _sessionToken;
|
||||
Tuple<Thread, CancellationTokenSource>? timeoutdetector = null;
|
||||
private int transferInProgress = 0;
|
||||
private int disconnectState;
|
||||
private readonly ConnectionAttemptLifecycle connectionLifecycle = new();
|
||||
private int disconnectOwnerThreadId;
|
||||
private readonly TaskCompletionSource<bool> disconnectCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public ILogger Log;
|
||||
public DialogManager Dialogs { get; }
|
||||
internal long ConnectionAttempt { get; }
|
||||
internal Task DisconnectCompletion => disconnectCompletion.Task;
|
||||
|
||||
private static IMinecraftComHandler? instance;
|
||||
public static IMinecraftComHandler? Instance => instance;
|
||||
|
|
@ -251,9 +253,22 @@ namespace MinecraftClient
|
|||
/// <param name="protocolversion">Minecraft protocol version to use</param>
|
||||
/// <param name="forgeInfo">ForgeInfo item stating that Forge is enabled</param>
|
||||
public McClient(SessionToken session, PlayerKeyPair? playerKeyPair, string server_ip, ushort port, int protocolversion, ForgeInfo? forgeInfo)
|
||||
: this(session, playerKeyPair, server_ip, port, protocolversion, forgeInfo, Program.CurrentConnectionAttempt)
|
||||
{
|
||||
}
|
||||
|
||||
internal McClient(
|
||||
SessionToken session,
|
||||
PlayerKeyPair? playerKeyPair,
|
||||
string server_ip,
|
||||
ushort port,
|
||||
int protocolversion,
|
||||
ForgeInfo? forgeInfo,
|
||||
long connectionAttempt)
|
||||
{
|
||||
CmdResult.currentHandler = this;
|
||||
instance = this;
|
||||
ConnectionAttempt = connectionAttempt;
|
||||
|
||||
terrainAndMovementsEnabled = Config.Main.Advanced.TerrainAndMovements;
|
||||
inventoryHandlingEnabled = Config.Main.Advanced.InventoryHandling;
|
||||
|
|
@ -311,7 +326,13 @@ namespace MinecraftClient
|
|||
LoadCommands();
|
||||
|
||||
if (botsOnHold.Count == 0)
|
||||
{
|
||||
RegisterBots();
|
||||
}
|
||||
else
|
||||
{
|
||||
ConnectionAttemptLifecycle.RestoreHeldBots(botsOnHold, bot => BotLoad(bot, false));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -331,10 +352,6 @@ namespace MinecraftClient
|
|||
{
|
||||
if (handler.Login(this.playerKeyPair, session))
|
||||
{
|
||||
foreach (ChatBot bot in botsOnHold)
|
||||
BotLoad(bot, false);
|
||||
botsOnHold.Clear();
|
||||
|
||||
Log.Info(string.Format(Translations.mcc_joined, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||
|
||||
StartConsoleSession();
|
||||
|
|
@ -368,6 +385,9 @@ namespace MinecraftClient
|
|||
timeoutdetector = null;
|
||||
}
|
||||
|
||||
if (connectionLifecycle.IsFailureClaimed)
|
||||
return;
|
||||
|
||||
if (!InternalConfig.InteractiveMode)
|
||||
{
|
||||
StopConsoleSession();
|
||||
|
|
@ -391,14 +411,8 @@ namespace MinecraftClient
|
|||
return;
|
||||
}
|
||||
|
||||
// AutoRelog is enabled - invoke its static handler to trigger reconnection.
|
||||
// Use the same "Connection has been lost" message that OnConnectionLost uses
|
||||
// for ConnectionLost, so it matches the default Kick_Messages.
|
||||
if (AutoRelog.OnDisconnectStatic(ChatBot.DisconnectReason.ConnectionLost, Translations.mcc_disconnect_lost))
|
||||
OnConnectionLost(ChatBot.DisconnectReason.ConnectionLost, Translations.mcc_disconnect_lost);
|
||||
return;
|
||||
|
||||
StopConsoleSession();
|
||||
Program.HandleFailure();
|
||||
}
|
||||
|
||||
public void Transfer(string newHost, int newPort)
|
||||
|
|
@ -530,6 +544,7 @@ namespace MinecraftClient
|
|||
|
||||
private void StartConsoleSession()
|
||||
{
|
||||
Program.EndOfflinePrompt(ConnectionAttempt);
|
||||
ConsoleInputRouter.RouteToClient(this);
|
||||
}
|
||||
|
||||
|
|
@ -875,7 +890,7 @@ namespace MinecraftClient
|
|||
}
|
||||
}
|
||||
|
||||
restartScheduled = !exitOnFailure && Program.HasRestartPending;
|
||||
restartScheduled = !exitOnFailure && Program.HasRestartPending(ConnectionAttempt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -888,7 +903,7 @@ namespace MinecraftClient
|
|||
|
||||
private bool TryBeginDisconnect()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref disconnectState, 1, 0) != 0)
|
||||
if (!connectionLifecycle.TryBeginDisconnect())
|
||||
return false;
|
||||
|
||||
Volatile.Write(ref disconnectOwnerThreadId, Environment.CurrentManagedThreadId);
|
||||
|
|
@ -937,7 +952,7 @@ namespace MinecraftClient
|
|||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref disconnectState, 2);
|
||||
connectionLifecycle.CompleteDisconnect();
|
||||
Volatile.Write(ref disconnectOwnerThreadId, 0);
|
||||
disconnectCompletion.TrySetResult(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ namespace MinecraftClient
|
|||
private static bool useMcVersionOnce = false;
|
||||
private static readonly RestartCoordinator restartCoordinator = new(ExecuteRestartAsync, ReportRestartFailure);
|
||||
private static long connectionAttempt;
|
||||
private static int offlinePromptActive;
|
||||
private static readonly AttemptOwnedRoute offlinePromptRoute = new();
|
||||
private static int exitOnFailurePending;
|
||||
private static string settingsIniPath = "MinecraftClient.ini";
|
||||
private static AuthenticationSelection? pendingAuthenticationSelection;
|
||||
|
|
@ -747,12 +747,14 @@ namespace MinecraftClient
|
|||
|
||||
private sealed record AuthenticationSelection(LoginType AccountType, LoginMethod Method, string AuthServerUrl);
|
||||
|
||||
internal static long CurrentConnectionAttempt => Volatile.Read(ref connectionAttempt);
|
||||
|
||||
/// <summary>
|
||||
/// Start a new Client
|
||||
/// </summary>
|
||||
private static void InitializeClient()
|
||||
{
|
||||
Interlocked.Increment(ref connectionAttempt);
|
||||
long attempt = Interlocked.Increment(ref connectionAttempt);
|
||||
|
||||
// Ensure that we use the provided Minecraft version if we can't connect automatically.
|
||||
//
|
||||
|
|
@ -984,7 +986,7 @@ namespace MinecraftClient
|
|||
try
|
||||
{
|
||||
//Start the main TCP client
|
||||
client = new McClient(session, playerKeyPair, InternalConfig.ServerIP, InternalConfig.ServerPort, protocolversion, forgeInfo);
|
||||
client = new McClient(session, playerKeyPair, InternalConfig.ServerIP, InternalConfig.ServerPort, protocolversion, forgeInfo, attempt);
|
||||
|
||||
//Update console title
|
||||
if (OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(Config.Main.Advanced.ConsoleTitle))
|
||||
|
|
@ -1062,7 +1064,22 @@ namespace MinecraftClient
|
|||
TryRestart(TimeSpan.FromSeconds(Math.Max(0, delaySeconds)), keepAccountAndServerSettings);
|
||||
}
|
||||
|
||||
internal static bool HasRestartPending => restartCoordinator.HasScheduledRestart(Volatile.Read(ref connectionAttempt));
|
||||
internal static bool HasRestartPending(long sourceConnectionAttempt)
|
||||
{
|
||||
return restartCoordinator.HasScheduledRestart(sourceConnectionAttempt);
|
||||
}
|
||||
|
||||
internal static RestartSettingsSnapshot CaptureRestartSettings()
|
||||
{
|
||||
return new RestartSettingsSnapshot(InternalConfig.Account, InternalConfig.ServerIP, InternalConfig.ServerPort);
|
||||
}
|
||||
|
||||
internal static void RestoreRestartSettings(RestartSettingsSnapshot settingsSnapshot)
|
||||
{
|
||||
InternalConfig.Account = settingsSnapshot.Account;
|
||||
InternalConfig.ServerIP = settingsSnapshot.ServerIP;
|
||||
InternalConfig.ServerPort = settingsSnapshot.ServerPort;
|
||||
}
|
||||
|
||||
internal static bool TryRestart(int delaySeconds = 0, bool keepAccountAndServerSettings = false)
|
||||
{
|
||||
|
|
@ -1070,26 +1087,47 @@ namespace MinecraftClient
|
|||
}
|
||||
|
||||
internal static bool TryRestart(TimeSpan delay, bool keepAccountAndServerSettings = false)
|
||||
{
|
||||
return TryRestart(CurrentConnectionAttempt, delay, keepAccountAndServerSettings);
|
||||
}
|
||||
|
||||
internal static bool TryRestart(
|
||||
long sourceConnectionAttempt,
|
||||
TimeSpan delay,
|
||||
bool keepAccountAndServerSettings = false,
|
||||
bool replaceUntilCommit = false,
|
||||
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),
|
||||
bool scheduled = restartCoordinator.TrySchedule(
|
||||
new RestartRequest(
|
||||
sourceConnectionAttempt,
|
||||
delay,
|
||||
keepAccountAndServerSettings,
|
||||
settingsSnapshot));
|
||||
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 @@ namespace MinecraftClient
|
|||
client = null;
|
||||
}
|
||||
|
||||
EndOfflinePrompt();
|
||||
ConsoleIO.Reset();
|
||||
|
||||
if (request.Delay > TimeSpan.Zero)
|
||||
|
|
@ -1108,14 +1145,22 @@ namespace MinecraftClient
|
|||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request.ConnectionAttempt != CurrentConnectionAttempt
|
||||
|| !restartCoordinator.TryBeginCommit(request, out RestartRequest latestRequest)
|
||||
|| latestRequest.ConnectionAttempt != CurrentConnectionAttempt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConsoleIO.WriteLine(Translations.mcc_restart);
|
||||
ReloadSettings(request.KeepAccountAndServerSettings);
|
||||
if (request.SettingsSnapshot is RestartSettingsSnapshot settingsSnapshot)
|
||||
ReloadSettings(latestRequest.KeepAccountAndServerSettings);
|
||||
if (latestRequest.SettingsSnapshot is RestartSettingsSnapshot settingsSnapshot)
|
||||
{
|
||||
InternalConfig.Account = settingsSnapshot.Account;
|
||||
InternalConfig.ServerIP = settingsSnapshot.ServerIP;
|
||||
InternalConfig.ServerPort = settingsSnapshot.ServerPort;
|
||||
}
|
||||
TransferOfflinePrompt(latestRequest.ConnectionAttempt, latestRequest.ConnectionAttempt + 1);
|
||||
InitializeClient();
|
||||
}
|
||||
|
||||
|
|
@ -1197,7 +1242,7 @@ namespace MinecraftClient
|
|||
if (!string.IsNullOrEmpty(errorMessage) && disconnectReason.HasValue)
|
||||
{
|
||||
autoRelogHandled = true;
|
||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage))
|
||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage, CurrentConnectionAttempt))
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1217,19 +1262,22 @@ namespace MinecraftClient
|
|||
|
||||
if (!autoRelogHandled && disconnectReason.HasValue)
|
||||
{
|
||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!))
|
||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!, CurrentConnectionAttempt))
|
||||
return;
|
||||
}
|
||||
|
||||
BeginOfflinePrompt();
|
||||
BeginOfflinePrompt(CurrentConnectionAttempt);
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeginOfflinePrompt()
|
||||
private static bool BeginOfflinePrompt(long connectionAttempt)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref offlinePromptActive, 1, 0) != 0)
|
||||
return;
|
||||
long currentConnectionAttempt = CurrentConnectionAttempt;
|
||||
if (connectionAttempt != currentConnectionAttempt)
|
||||
return false;
|
||||
|
||||
if (offlinePromptRoute.TryActivate(connectionAttempt, currentConnectionAttempt, () =>
|
||||
{
|
||||
ConsoleInputRouter.RouteOffline(HandleOfflineCommand);
|
||||
ConsoleIO.WriteLine(string.Empty);
|
||||
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_disconnected, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||
|
|
@ -1237,15 +1285,35 @@ namespace MinecraftClient
|
|||
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||
else
|
||||
ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true);
|
||||
}))
|
||||
{
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
internal static void EndOfflinePrompt(long connectionAttempt)
|
||||
{
|
||||
offlinePromptRoute.TryDeactivate(connectionAttempt, () =>
|
||||
{
|
||||
ConsoleInputRouter.ClearOfflineRoute(HandleOfflineCommand);
|
||||
ConsoleIO.Reset();
|
||||
});
|
||||
}
|
||||
|
||||
private static void TransferOfflinePrompt(long sourceConnectionAttempt, long targetConnectionAttempt)
|
||||
{
|
||||
offlinePromptRoute.TryTransfer(sourceConnectionAttempt, targetConnectionAttempt);
|
||||
}
|
||||
|
||||
private static void HandleOfflineCommand(string input)
|
||||
|
|
@ -1269,20 +1337,14 @@ namespace MinecraftClient
|
|||
{
|
||||
message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command));
|
||||
if (message.Length == 0)
|
||||
{
|
||||
EndOfflinePrompt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (command.StartsWith("connect", StringComparison.Ordinal))
|
||||
{
|
||||
message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command));
|
||||
if (message.Length == 0)
|
||||
{
|
||||
EndOfflinePrompt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (command.StartsWith("exit", StringComparison.Ordinal)
|
||||
|| command.StartsWith("quit", StringComparison.Ordinal))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ namespace MinecraftClient
|
|||
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 @@ namespace MinecraftClient
|
|||
string ServerIP,
|
||||
ushort ServerPort);
|
||||
|
||||
internal enum RestartRequestState
|
||||
{
|
||||
Replaceable,
|
||||
Committing,
|
||||
}
|
||||
|
||||
internal readonly record struct PendingRestart(
|
||||
long RequestId,
|
||||
RestartRequest Request,
|
||||
RestartRequestState State);
|
||||
|
||||
internal sealed class RestartCoordinator : IDisposable
|
||||
{
|
||||
private readonly Lock stateLock = new();
|
||||
|
|
@ -26,7 +39,7 @@ namespace MinecraftClient
|
|||
private readonly Func<RestartRequest, CancellationToken, Task> restart;
|
||||
private readonly Action<Exception> reportFailure;
|
||||
private readonly Task worker;
|
||||
private readonly Dictionary<long, long> pendingAttempts = [];
|
||||
private readonly Dictionary<long, PendingRestart> pendingAttempts = [];
|
||||
private long highestScheduledAttempt = -1;
|
||||
private long nextRequestId;
|
||||
private bool stopped;
|
||||
|
|
@ -55,27 +68,80 @@ namespace MinecraftClient
|
|||
return !stopped && pendingAttempts.ContainsKey(connectionAttempt);
|
||||
}
|
||||
|
||||
internal bool TrySchedule(RestartRequest request)
|
||||
internal bool TrySchedule(RestartRequest request, Func<bool>? 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))
|
||||
return true;
|
||||
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest))
|
||||
{
|
||||
if (pendingRequest.State != RestartRequestState.Replaceable || !request.ReplaceUntilCommit)
|
||||
return false;
|
||||
|
||||
if (hasPendingRequest)
|
||||
pendingAttempts[request.ConnectionAttempt] = previousRequestId;
|
||||
else
|
||||
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;
|
||||
|
||||
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()
|
||||
|
|
@ -100,13 +166,16 @@ namespace MinecraftClient
|
|||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (!pendingAttempts.TryGetValue(request.ConnectionAttempt, out long requestId)
|
||||
|| requestId != request.RequestId)
|
||||
if (!pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest)
|
||||
|| pendingRequest.RequestId != request.RequestId)
|
||||
continue;
|
||||
}
|
||||
|
||||
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 @@ namespace MinecraftClient
|
|||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out long requestId)
|
||||
&& requestId == request.RequestId)
|
||||
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest)
|
||||
&& pendingRequest.RequestId == request.RequestId)
|
||||
pendingAttempts.Remove(request.ConnectionAttempt);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1322,6 +1322,8 @@ redirectFrom:
|
|||
|
||||
A lost TCP connection always triggers Auto Relog when the bot is enabled. `Kick_Messages` only filters server kick and login rejection messages. Logging out with an MCC command never triggers Auto Relog.
|
||||
|
||||
One logical disconnect or login rejection consumes one retry. `Ignore_Kick_Message` controls filtering, but it does not create additional restart decisions for the same failure.
|
||||
|
||||
- **Settings:**
|
||||
|
||||
**Section:** **`ChatBot.AutoRelog`**
|
||||
|
|
@ -1349,6 +1351,8 @@ redirectFrom:
|
|||
|
||||
If `min` and `max` are equal, every attempt uses that delay. Otherwise, MCC picks a random value in the range. Values are seconds and may include a fractional part, such as `0.5` or `37.0`.
|
||||
|
||||
For multi-process deployments, use a nonzero range such as `{ min = 3.0, max = 10.0 }` so clients do not reconnect in lockstep during maintenance. Equal values remain supported when a fixed interval is required.
|
||||
|
||||
- **Format:** `{ min = <seconds (double)>, max = <seconds (double)> }`
|
||||
|
||||
- **Type:** `inline table`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue