Compare commits

...

6 Commits

8 changed files with 113 additions and 43 deletions

View File

@ -16,32 +16,47 @@ sealed class MinecraftServerExecutableDownloader {
public event EventHandler? Completed;
private readonly CancellationTokenSource cancellationTokenSource = new ();
private int listeners = 0;
private readonly List<CancellationTokenRegistration> listenerCancellationRegistrations = new ();
private int listenerCount = 0;
public MinecraftServerExecutableDownloader(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, MinecraftServerExecutableDownloadListener listener) {
Register(listener);
Task = DownloadAndGetPath(fileDownloadInfo, minecraftVersion, filePath);
Task = DownloadAndGetPath(fileDownloadInfo, minecraftVersion, filePath, new DownloadProgressCallback(this), cancellationTokenSource.Token);
Task.ContinueWith(OnCompleted, TaskScheduler.Default);
}
public void Register(MinecraftServerExecutableDownloadListener listener) {
++listeners;
Logger.Debug("Registered download listener, current listener count: {Listeners}", listeners);
int newListenerCount;
DownloadProgress += listener.DownloadProgressEventHandler;
listener.CancellationToken.Register(Unregister, listener);
lock (this) {
newListenerCount = ++listenerCount;
DownloadProgress += listener.DownloadProgressEventHandler;
listenerCancellationRegistrations.Add(listener.CancellationToken.Register(Unregister, listener));
}
Logger.Debug("Registered download listener, current listener count: {Listeners}", newListenerCount);
}
private void Unregister(object? listenerObject) {
MinecraftServerExecutableDownloadListener listener = (MinecraftServerExecutableDownloadListener) listenerObject!;
DownloadProgress -= listener.DownloadProgressEventHandler;
int newListenerCount;
lock (this) {
MinecraftServerExecutableDownloadListener listener = (MinecraftServerExecutableDownloadListener) listenerObject!;
DownloadProgress -= listener.DownloadProgressEventHandler;
if (--listeners <= 0) {
newListenerCount = --listenerCount;
if (newListenerCount <= 0) {
cancellationTokenSource.Cancel();
}
}
if (newListenerCount <= 0) {
Logger.Debug("Unregistered last download listener, cancelling download.");
cancellationTokenSource.Cancel();
}
else {
Logger.Debug("Unregistered download listener, current listener count: {Listeners}", listeners);
Logger.Debug("Unregistered download listener, current listener count: {Listeners}", newListenerCount);
}
}
@ -51,9 +66,19 @@ sealed class MinecraftServerExecutableDownloader {
private void OnCompleted(Task task) {
Logger.Debug("Download task completed.");
Completed?.Invoke(this, EventArgs.Empty);
Completed = null;
DownloadProgress = null;
lock (this) {
Completed?.Invoke(this, EventArgs.Empty);
Completed = null;
DownloadProgress = null;
foreach (var registration in listenerCancellationRegistrations) {
registration.Dispose();
}
listenerCancellationRegistrations.Clear();
cancellationTokenSource.Dispose();
}
}
private sealed class DownloadProgressCallback {
@ -68,15 +93,14 @@ sealed class MinecraftServerExecutableDownloader {
}
}
private async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath) {
private static async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, DownloadProgressCallback progressCallback, CancellationToken cancellationToken) {
string tmpFilePath = filePath + ".tmp";
var cancellationToken = cancellationTokenSource.Token;
try {
Logger.Information("Downloading server version {Version} from: {Url} ({Size})", minecraftVersion, fileDownloadInfo.DownloadUrl, fileDownloadInfo.Size.ToHumanReadable(decimalPlaces: 1));
try {
using var http = new HttpClient();
await FetchServerExecutableFile(http, new DownloadProgressCallback(this), fileDownloadInfo, tmpFilePath, cancellationToken);
await FetchServerExecutableFile(http, progressCallback, fileDownloadInfo, tmpFilePath, cancellationToken);
} catch (Exception) {
TryDeleteExecutableAfterFailure(tmpFilePath);
throw;
@ -94,8 +118,6 @@ sealed class MinecraftServerExecutableDownloader {
} catch (Exception e) {
Logger.Error(e, "An unexpected error occurred.");
return null;
} finally {
cancellationTokenSource.Dispose();
}
}

View File

@ -67,6 +67,10 @@ sealed class BackupManager : IDisposable {
resultBuilder.Kind = BackupCreationResultKind.BackupCancelled;
logger.Warning("Backup creation was cancelled.");
return null;
} catch (TimeoutException) {
resultBuilder.Kind = BackupCreationResultKind.BackupTimedOut;
logger.Warning("Backup creation timed out.");
return null;
} catch (Exception e) {
resultBuilder.Kind = BackupCreationResultKind.UnknownError;
logger.Error(e, "Caught exception while creating an instance backup.");
@ -76,6 +80,9 @@ sealed class BackupManager : IDisposable {
await dispatcher.EnableAutomaticSaving();
} catch (OperationCanceledException) {
// Ignore.
} catch (TimeoutException) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Warning("Timed out waiting for automatic saving to be re-enabled.");
} catch (Exception e) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Error(e, "Caught exception while enabling automatic saving after creating an instance backup.");
@ -120,6 +127,7 @@ sealed class BackupManager : IDisposable {
BackupCreationResultKind.Success => "Backup created successfully.",
BackupCreationResultKind.InstanceNotRunning => "Instance is not running.",
BackupCreationResultKind.BackupCancelled => "Backup cancelled.",
BackupCreationResultKind.BackupTimedOut => "Backup timed out.",
BackupCreationResultKind.BackupAlreadyRunning => "A backup is already being created.",
BackupCreationResultKind.BackupFileAlreadyExists => "Backup with the same name already exists.",
BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.",

View File

@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Utils.Tasks;
@ -7,8 +8,26 @@ using Serilog;
namespace Phantom.Agent.Services.Backups;
sealed partial class BackupServerCommandDispatcher : IDisposable {
[GeneratedRegex(@"^\[(?:.*?)\] \[Server thread/INFO\]: (.*?)$", RegexOptions.NonBacktracking)]
[GeneratedRegex(@"^(?:(?:\[.*?\] \[Server thread/INFO\].*?:)|(?:[\d-]+? [\d:]+? \[INFO\])) (.*?)$", RegexOptions.NonBacktracking)]
private static partial Regex ServerThreadInfoRegex();
private static readonly ImmutableHashSet<string> AutomaticSavingDisabledMessages = ImmutableHashSet.Create(
"Automatic saving is now disabled",
"Turned off world auto-saving",
"CONSOLE: Disabling level saving.."
);
private static readonly ImmutableHashSet<string> SavedTheGameMessages = ImmutableHashSet.Create(
"Saved the game",
"Saved the world",
"CONSOLE: Save complete."
);
private static readonly ImmutableHashSet<string> AutomaticSavingEnabledMessages = ImmutableHashSet.Create(
"Automatic saving is now enabled",
"Turned on world auto-saving",
"CONSOLE: Enabling level saving.."
);
private readonly ILogger logger;
private readonly InstanceProcess process;
@ -32,18 +51,18 @@ sealed partial class BackupServerCommandDispatcher : IDisposable {
public async Task DisableAutomaticSaving() {
await process.SendCommand(MinecraftCommand.SaveOff, cancellationToken);
await automaticSavingDisabled.Task.WaitAsync(cancellationToken);
await automaticSavingDisabled.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken);
}
public async Task SaveAllChunks() {
// TODO Try if not flushing and waiting a few seconds before flushing reduces lag.
await process.SendCommand(MinecraftCommand.SaveAll(flush: true), cancellationToken);
await savedTheGame.Task.WaitAsync(cancellationToken);
await savedTheGame.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
}
public async Task EnableAutomaticSaving() {
await process.SendCommand(MinecraftCommand.SaveOn, cancellationToken);
await automaticSavingEnabled.Task.WaitAsync(cancellationToken);
await automaticSavingEnabled.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
}
private void OnOutput(object? sender, string? line) {
@ -59,19 +78,19 @@ sealed partial class BackupServerCommandDispatcher : IDisposable {
string info = match.Groups[1].Value;
if (!automaticSavingDisabled.Task.IsCompleted) {
if (info == "Automatic saving is now disabled") {
if (AutomaticSavingDisabledMessages.Contains(info)) {
logger.Debug("Detected that automatic saving is disabled.");
automaticSavingDisabled.SetResult();
}
}
else if (!savedTheGame.Task.IsCompleted) {
if (info == "Saved the game") {
if (SavedTheGameMessages.Contains(info)) {
logger.Debug("Detected that the game is saved.");
savedTheGame.SetResult();
}
}
else if (!automaticSavingEnabled.Task.IsCompleted) {
if (info == "Automatic saving is now enabled") {
if (AutomaticSavingEnabledMessages.Contains(info)) {
logger.Debug("Detected that automatic saving is enabled.");
automaticSavingEnabled.SetResult();
}

View File

@ -135,7 +135,12 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
}
else {
return await command.BackupManager.CreateBackup(context.ShortName, runningState.Process, shutdownCancellationToken);
SetAndReportStatus(InstanceStatus.BackingUp);
try {
return await command.BackupManager.CreateBackup(context.ShortName, runningState.Process, shutdownCancellationToken);
} finally {
SetAndReportStatus(InstanceStatus.Running);
}
}
}

View File

@ -1,15 +1,16 @@
namespace Phantom.Common.Data.Backups;
public enum BackupCreationResultKind : byte {
UnknownError,
Success,
InstanceNotRunning,
BackupCancelled,
BackupAlreadyRunning,
BackupFileAlreadyExists,
CouldNotCreateBackupFolder,
CouldNotCopyWorldToTemporaryFolder,
CouldNotCreateWorldArchive
UnknownError = 0,
Success = 1,
InstanceNotRunning = 2,
BackupTimedOut = 3,
BackupCancelled = 4,
BackupAlreadyRunning = 5,
BackupFileAlreadyExists = 6,
CouldNotCreateBackupFolder = 7,
CouldNotCopyWorldToTemporaryFolder = 8,
CouldNotCreateWorldArchive = 9
}
public static class BackupCreationResultSummaryExtensions {

View File

@ -9,9 +9,10 @@ namespace Phantom.Common.Data.Instance;
[MemoryPackUnion(3, typeof(InstanceIsDownloading))]
[MemoryPackUnion(4, typeof(InstanceIsLaunching))]
[MemoryPackUnion(5, typeof(InstanceIsRunning))]
[MemoryPackUnion(6, typeof(InstanceIsRestarting))]
[MemoryPackUnion(7, typeof(InstanceIsStopping))]
[MemoryPackUnion(8, typeof(InstanceIsFailed))]
[MemoryPackUnion(6, typeof(InstanceIsBackingUp))]
[MemoryPackUnion(7, typeof(InstanceIsRestarting))]
[MemoryPackUnion(8, typeof(InstanceIsStopping))]
[MemoryPackUnion(9, typeof(InstanceIsFailed))]
public partial interface IInstanceStatus {}
[MemoryPackable(GenerateType.VersionTolerant)]
@ -32,6 +33,9 @@ public sealed partial record InstanceIsLaunching : IInstanceStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceIsRunning : IInstanceStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceIsBackingUp : IInstanceStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceIsRestarting : IInstanceStatus;
@ -46,6 +50,7 @@ public static class InstanceStatus {
public static readonly IInstanceStatus NotRunning = new InstanceIsNotRunning();
public static readonly IInstanceStatus Launching = new InstanceIsLaunching();
public static readonly IInstanceStatus Running = new InstanceIsRunning();
public static readonly IInstanceStatus BackingUp = new InstanceIsBackingUp();
public static readonly IInstanceStatus Restarting = new InstanceIsRestarting();
public static readonly IInstanceStatus Stopping = new InstanceIsStopping();
@ -58,7 +63,7 @@ public static class InstanceStatus {
}
public static bool IsRunning(this IInstanceStatus status) {
return status is InstanceIsRunning;
return status is InstanceIsRunning or InstanceIsBackingUp;
}
public static bool IsStopping(this IInstanceStatus status) {
@ -70,10 +75,10 @@ public static class InstanceStatus {
}
public static bool CanStop(this IInstanceStatus status) {
return status is InstanceIsDownloading or InstanceIsLaunching or InstanceIsRunning;
return status.IsRunning() || status.IsLaunching();
}
public static bool CanSendCommand(this IInstanceStatus status) {
return status is InstanceIsRunning;
return status.IsRunning();
}
}

View File

@ -51,6 +51,7 @@
form.SubmitModel.StopSubmitting(result.Map(Messages.ToSentence, InstanceActionFailureExtensions.ToSentence));
}
StateHasChanged();
await commandInputElement.FocusAsync(preventScroll: true);
}

View File

@ -28,6 +28,11 @@
<span class="fw-semibold text-success">Running</span>
break;
case InstanceIsBackingUp:
<div class="spinner-border" role="status"></div>
<span class="fw-semibold">&nbsp;Backing Up</span>
break;
case InstanceIsRestarting:
<div class="spinner-border" role="status"></div>
<span class="fw-semibold">&nbsp;Restarting</span>
@ -41,6 +46,10 @@
case InstanceIsFailed failed:
<span class="fw-semibold text-danger">Failed <sup title="@failed.Reason.ToSentence()">[?]</sup></span>
break;
default:
<span class="fw-semibold">Unknown</span>
break;
}
</nobr>