Fix restart publication races (#3186)

Prepare offline routing before restart requests become observable, reject stale route owners, and wait for source-client cleanup before executing automatic retries.

Add deterministic publication-order, cleanup-gate, and stale-route regression tests.
This commit is contained in:
Anon 2026-07-27 17:12:03 +02:00
parent 456a548cbc
commit 5655e3fc89
7 changed files with 164 additions and 25 deletions

View file

@ -170,7 +170,11 @@ namespace MinecraftClient.ChatBots
McClient.ReconnectionAttemptsLeft = retriesLeft;
long connectionAttempt = sourceConnectionAttempt ?? Handler.ConnectionAttempt;
if (Program.TryRestart(connectionAttempt, TimeSpan.FromSeconds(delay), true))
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;

View file

@ -47,13 +47,13 @@ namespace MinecraftClient
}
}
internal bool TryActivate(long connectionAttempt, Action activate)
internal bool TryActivate(long connectionAttempt, long currentConnectionAttempt, Action activate)
{
ArgumentNullException.ThrowIfNull(activate);
lock (stateLock)
{
if (ownerAttempt >= connectionAttempt)
if (connectionAttempt < currentConnectionAttempt || ownerAttempt >= connectionAttempt)
return false;
ownerAttempt = connectionAttempt;

View file

@ -238,6 +238,7 @@ namespace MinecraftClient
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;

View file

@ -1095,11 +1095,15 @@ namespace MinecraftClient
long sourceConnectionAttempt,
TimeSpan delay,
bool keepAccountAndServerSettings = false,
bool replaceUntilCommit = 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;
@ -1107,14 +1111,15 @@ namespace MinecraftClient
? CaptureRestartSettings()
: null;
bool scheduled = restartCoordinator.TrySchedule(new RestartRequest(
sourceConnectionAttempt,
delay,
keepAccountAndServerSettings,
settingsSnapshot,
replaceUntilCommit));
if (scheduled)
BeginOfflinePrompt(sourceConnectionAttempt);
bool scheduled = restartCoordinator.TrySchedule(
new RestartRequest(
sourceConnectionAttempt,
delay,
keepAccountAndServerSettings,
settingsSnapshot,
replaceUntilCommit,
sourceCleanupCompletion),
() => BeginOfflinePrompt(sourceConnectionAttempt));
return scheduled;
}
@ -1265,9 +1270,13 @@ namespace MinecraftClient
}
}
private static void BeginOfflinePrompt(long connectionAttempt)
private static bool BeginOfflinePrompt(long connectionAttempt)
{
offlinePromptRoute.TryActivate(connectionAttempt, () =>
long currentConnectionAttempt = CurrentConnectionAttempt;
if (connectionAttempt != currentConnectionAttempt)
return false;
if (offlinePromptRoute.TryActivate(connectionAttempt, currentConnectionAttempt, () =>
{
ConsoleInputRouter.RouteOffline(HandleOfflineCommand);
ConsoleIO.WriteLine(string.Empty);
@ -1276,7 +1285,12 @@ 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()

View file

@ -12,6 +12,7 @@ namespace MinecraftClient
bool KeepAccountAndServerSettings,
RestartSettingsSnapshot? SettingsSnapshot = null,
bool ReplaceUntilCommit = false,
Task? SourceCleanupCompletion = null,
long RequestId = 0);
internal readonly record struct RestartSettingsSnapshot(
@ -67,7 +68,7 @@ namespace MinecraftClient
return !stopped && pendingAttempts.ContainsKey(connectionAttempt);
}
internal bool TrySchedule(RestartRequest request)
internal bool TrySchedule(RestartRequest request, Func<bool>? beforePublish = null)
{
lock (stateLock)
{
@ -79,7 +80,11 @@ namespace MinecraftClient
if (pendingRequest.State != RestartRequestState.Replaceable || !request.ReplaceUntilCommit)
return false;
request = request with { RequestId = pendingRequest.RequestId };
request = request with
{
RequestId = pendingRequest.RequestId,
SourceCleanupCompletion = pendingRequest.Request.SourceCleanupCompletion,
};
pendingAttempts[request.ConnectionAttempt] = pendingRequest with { Request = request };
return true;
}
@ -87,14 +92,30 @@ namespace MinecraftClient
if (request.ConnectionAttempt <= highestScheduledAttempt)
return false;
highestScheduledAttempt = Math.Max(highestScheduledAttempt, request.ConnectionAttempt);
request = request with { RequestId = ++nextRequestId };
pendingAttempts[request.ConnectionAttempt] = new PendingRestart(
request.RequestId,
request,
RestartRequestState.Replaceable);
if (requests.Writer.TryWrite(request))
return true;
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;
@ -152,6 +173,9 @@ namespace MinecraftClient
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)