1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2024-11-26 01:42:53 +01:00

Compare commits

..

No commits in common. "b3104f9ac345c70cde3a41fcf9d442a705df42d0" and "62a683f8ef65fd0983835f07661bd73bcd201b69" have entirely different histories.

21 changed files with 106 additions and 406 deletions

View File

@ -1,12 +1,10 @@
using System.Diagnostics; using System.Diagnostics;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Minecraft.Instance; namespace Phantom.Agent.Minecraft.Instance;
public sealed class InstanceSession : IDisposable { public sealed class InstanceSession : IDisposable {
public InstanceProperties InstanceProperties { get; } public InstanceProperties InstanceProperties { get; }
public CancellableSemaphore BackupSemaphore { get; } = new (1, 1);
private readonly RingBuffer<string> outputBuffer = new (10000); private readonly RingBuffer<string> outputBuffer = new (10000);
private event EventHandler<string>? OutputEvent; private event EventHandler<string>? OutputEvent;
@ -66,7 +64,6 @@ public sealed class InstanceSession : IDisposable {
public void Dispose() { public void Dispose() {
process.Dispose(); process.Dispose();
BackupSemaphore.Dispose();
OutputEvent = null; OutputEvent = null;
SessionEnded = null; SessionEnded = null;
} }

View File

@ -1,6 +1,7 @@
using Phantom.Agent.Minecraft.Java; using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Minecraft.Launcher; namespace Phantom.Agent.Minecraft.Launcher;
public sealed record LaunchServices(MinecraftServerExecutables ServerExecutables, JavaRuntimeRepository JavaRuntimeRepository); public sealed record LaunchServices(TaskManager TaskManager, MinecraftServerExecutables ServerExecutables, JavaRuntimeRepository JavaRuntimeRepository);

View File

@ -1,93 +0,0 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Phantom.Common.Logging;
using Serilog;
namespace Phantom.Agent.Minecraft.Server;
public sealed class ServerStatusProtocol {
private readonly ILogger logger;
public ServerStatusProtocol(string loggerName) {
this.logger = PhantomLogger.Create<ServerStatusProtocol>(loggerName);
}
public async Task<int?> GetOnlinePlayerCount(int serverPort, CancellationToken cancellationToken) {
try {
return await GetOnlinePlayerCountOrThrow(serverPort, cancellationToken);
} catch (Exception e) {
logger.Error(e, "Caught exception while checking if players are online.");
return null;
}
}
private async Task<int?> GetOnlinePlayerCountOrThrow(int serverPort, CancellationToken cancellationToken) {
using var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
var tcpStream = tcpClient.GetStream();
// https://wiki.vg/Server_List_Ping
tcpStream.WriteByte(0xFE);
await tcpStream.FlushAsync(cancellationToken);
short? messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
return messageLength == null ? null : await ReadOnlinePlayerCount(tcpStream, messageLength.Value * 2, cancellationToken);
}
private async Task<short?> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
var headerBuffer = ArrayPool<byte>.Shared.Rent(3);
try {
await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken);
if (headerBuffer[0] != 0xFF) {
logger.Error("Unexpected first byte in response from server: {FirstByte}.", headerBuffer[0]);
return null;
}
short messageLength = BinaryPrimitives.ReadInt16BigEndian(headerBuffer.AsSpan(1));
if (messageLength <= 0) {
logger.Error("Unexpected message length in response from server: {MessageLength}.", messageLength);
return null;
}
return messageLength;
} finally {
ArrayPool<byte>.Shared.Return(headerBuffer);
}
}
private async Task<int?> ReadOnlinePlayerCount(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
try {
await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
// Valid response separator encoded in UTF-16BE is 0x00 0xA7 (§).
const byte SeparatorSecondByte = 0xA7;
static bool IsValidSeparator(ReadOnlySpan<byte> buffer, int index) {
return index > 0 && buffer[index - 1] == 0x00;
}
int separator2 = Array.LastIndexOf(messageBuffer, SeparatorSecondByte);
int separator1 = separator2 == -1 ? -1 : Array.LastIndexOf(messageBuffer, SeparatorSecondByte, separator2 - 1);
if (!IsValidSeparator(messageBuffer, separator1) || !IsValidSeparator(messageBuffer, separator2)) {
logger.Error("Could not find message separators in response from server.");
return null;
}
string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer[(separator1 + 1)..(separator2 - 1)]);
if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
logger.Error("Could not parse online player count in response from server: {OnlinePlayerCount}.", onlinePlayerCountStr);
return null;
}
logger.Verbose("Detected {OnlinePlayerCount} online player(s).", onlinePlayerCount);
return onlinePlayerCount;
} finally {
ArrayPool<byte>.Shared.Return(messageBuffer);
}
}
}

View File

@ -23,7 +23,7 @@ public sealed class AgentServices {
this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, AgentServices>()); this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, AgentServices>());
this.BackupManager = new BackupManager(agentFolders); this.BackupManager = new BackupManager(agentFolders);
this.JavaRuntimeRepository = new JavaRuntimeRepository(); this.JavaRuntimeRepository = new JavaRuntimeRepository();
this.InstanceSessionManager = new InstanceSessionManager(agentInfo, agentFolders, JavaRuntimeRepository, TaskManager, BackupManager); this.InstanceSessionManager = new InstanceSessionManager(agentInfo, agentFolders, JavaRuntimeRepository, TaskManager);
} }
public async Task Initialize() { public async Task Initialize() {

View File

@ -10,15 +10,11 @@ using Serilog;
namespace Phantom.Agent.Services.Backups; namespace Phantom.Agent.Services.Backups;
sealed class BackupArchiver { sealed class BackupArchiver {
private readonly string destinationBasePath;
private readonly string temporaryBasePath;
private readonly ILogger logger; private readonly ILogger logger;
private readonly InstanceProperties instanceProperties; private readonly InstanceProperties instanceProperties;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
public BackupArchiver(string destinationBasePath, string temporaryBasePath, string loggerName, InstanceProperties instanceProperties, CancellationToken cancellationToken) { public BackupArchiver(string loggerName, InstanceProperties instanceProperties, CancellationToken cancellationToken) {
this.destinationBasePath = destinationBasePath;
this.temporaryBasePath = temporaryBasePath;
this.logger = PhantomLogger.Create<BackupArchiver>(loggerName); this.logger = PhantomLogger.Create<BackupArchiver>(loggerName);
this.instanceProperties = instanceProperties; this.instanceProperties = instanceProperties;
this.cancellationToken = cancellationToken; this.cancellationToken = cancellationToken;
@ -44,14 +40,12 @@ sealed class BackupArchiver {
return false; return false;
} }
public async Task ArchiveWorld(BackupCreationResult.Builder resultBuilder) { public async Task ArchiveWorld(string destinationPath, BackupCreationResult.Builder resultBuilder) {
string guid = instanceProperties.InstanceGuid.ToString(); string backupFolderPath = Path.Combine(destinationPath, instanceProperties.InstanceGuid.ToString());
string currentDateTime = DateTime.Now.ToString("yyyyMMdd-HHmmss"); string backupFilePath = Path.Combine(backupFolderPath, DateTime.Now.ToString("yyyyMMdd-HHmmss") + ".tar");
string backupFolderPath = Path.Combine(destinationBasePath, guid);
string backupFilePath = Path.Combine(backupFolderPath, currentDateTime + ".tar");
if (File.Exists(backupFilePath)) { if (File.Exists(backupFilePath)) {
resultBuilder.Kind = BackupCreationResultKind.BackupFileAlreadyExists; resultBuilder.Kind = BackupCreationResultKind.BackupAlreadyExists;
logger.Warning("Skipping backup, file already exists: {File}", backupFilePath); logger.Warning("Skipping backup, file already exists: {File}", backupFilePath);
return; return;
} }
@ -64,8 +58,7 @@ sealed class BackupArchiver {
return; return;
} }
string temporaryFolderPath = Path.Combine(temporaryBasePath, guid + "_" + currentDateTime); if (!await CopyWorldAndCreateTarArchive(backupFolderPath, backupFilePath, resultBuilder)) {
if (!await CopyWorldAndCreateTarArchive(temporaryFolderPath, backupFilePath, resultBuilder)) {
return; return;
} }
@ -75,7 +68,9 @@ sealed class BackupArchiver {
} }
} }
private async Task<bool> CopyWorldAndCreateTarArchive(string temporaryFolderPath, string backupFilePath, BackupCreationResult.Builder resultBuilder) { private async Task<bool> CopyWorldAndCreateTarArchive(string backupFolderPath, string backupFilePath, BackupCreationResult.Builder resultBuilder) {
string temporaryFolderPath = Path.Combine(backupFolderPath, "temp");
try { try {
if (!await CopyWorldToTemporaryFolder(temporaryFolderPath)) { if (!await CopyWorldToTemporaryFolder(temporaryFolderPath)) {
resultBuilder.Kind = BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder; resultBuilder.Kind = BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder;
@ -114,21 +109,10 @@ sealed class BackupArchiver {
return true; return true;
} catch (Exception e) { } catch (Exception e) {
logger.Error(e, "Could not create archive."); logger.Error(e, "Could not create archive.");
DeleteBrokenArchiveFile(backupFilePath);
return false; return false;
} }
} }
private void DeleteBrokenArchiveFile(string filePath) {
if (File.Exists(filePath)) {
try {
File.Delete(filePath);
} catch (Exception e) {
logger.Error(e, "Could not delete broken archive: {File}", filePath);
}
}
}
private async Task CopyDirectory(DirectoryInfo sourceFolder, string destinationFolderPath, ImmutableList<string> relativePath) { private async Task CopyDirectory(DirectoryInfo sourceFolder, string destinationFolderPath, ImmutableList<string> relativePath) {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();

View File

@ -8,44 +8,26 @@ using Serilog;
namespace Phantom.Agent.Services.Backups; namespace Phantom.Agent.Services.Backups;
sealed partial class BackupManager { sealed partial class BackupManager {
private readonly string destinationBasePath; private readonly string basePath;
private readonly string temporaryBasePath;
public BackupManager(AgentFolders agentFolders) { public BackupManager(AgentFolders agentFolders) {
this.destinationBasePath = agentFolders.BackupsFolderPath; this.basePath = agentFolders.BackupsFolderPath;
this.temporaryBasePath = Path.Combine(agentFolders.TemporaryFolderPath, "backups");
} }
public async Task<BackupCreationResult> CreateBackup(string loggerName, InstanceSession session, CancellationToken cancellationToken) { public async Task<BackupCreationResult> CreateBackup(string loggerName, InstanceSession session, CancellationToken cancellationToken) {
try { return await new BackupCreator(basePath, loggerName, session, cancellationToken).CreateBackup();
if (!await session.BackupSemaphore.Wait(TimeSpan.FromSeconds(1), cancellationToken)) {
return new BackupCreationResult(BackupCreationResultKind.BackupAlreadyRunning);
}
} catch (ObjectDisposedException) {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
} catch (OperationCanceledException) {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
}
try {
return await new BackupCreator(destinationBasePath, temporaryBasePath, loggerName, session, cancellationToken).CreateBackup();
} finally {
session.BackupSemaphore.Release();
}
} }
private sealed class BackupCreator { private sealed class BackupCreator {
private readonly string destinationBasePath; private readonly string basePath;
private readonly string temporaryBasePath;
private readonly string loggerName; private readonly string loggerName;
private readonly ILogger logger; private readonly ILogger logger;
private readonly InstanceSession session; private readonly InstanceSession session;
private readonly BackupCommandListener listener; private readonly BackupCommandListener listener;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
public BackupCreator(string destinationBasePath, string temporaryBasePath, string loggerName, InstanceSession session, CancellationToken cancellationToken) { public BackupCreator(string basePath, string loggerName, InstanceSession session, CancellationToken cancellationToken) {
this.destinationBasePath = destinationBasePath; this.basePath = basePath;
this.temporaryBasePath = temporaryBasePath;
this.loggerName = loggerName; this.loggerName = loggerName;
this.logger = PhantomLogger.Create<BackupManager>(loggerName); this.logger = PhantomLogger.Create<BackupManager>(loggerName);
this.session = session; this.session = session;
@ -54,38 +36,21 @@ sealed partial class BackupManager {
} }
public async Task<BackupCreationResult> CreateBackup() { public async Task<BackupCreationResult> CreateBackup() {
logger.Information("Backup started.");
session.AddOutputListener(listener.OnOutput, 0); session.AddOutputListener(listener.OnOutput, 0);
try { try {
var resultBuilder = new BackupCreationResult.Builder(); var resultBuilder = new BackupCreationResult.Builder();
await RunBackupProcess(resultBuilder);
await RunBackupProcedure(resultBuilder); return resultBuilder.Build();
var result = resultBuilder.Build();
if (result.Kind == BackupCreationResultKind.Success) {
var warningCount = result.Warnings.Count();
if (warningCount == 0) {
logger.Information("Backup finished successfully.");
}
else {
logger.Warning("Backup finished with {Warnings} warning(s).", warningCount);
}
}
else {
logger.Warning("Backup failed: {Reason}", result.Kind.ToSentence());
}
return result;
} finally { } finally {
session.RemoveOutputListener(listener.OnOutput); session.RemoveOutputListener(listener.OnOutput);
} }
} }
private async Task RunBackupProcedure(BackupCreationResult.Builder resultBuilder) { private async Task RunBackupProcess(BackupCreationResult.Builder resultBuilder) {
try { try {
await DisableAutomaticSaving(); await DisableAutomaticSaving();
await SaveAllChunks(); await SaveAllChunks();
await new BackupArchiver(destinationBasePath, temporaryBasePath, loggerName, session.InstanceProperties, cancellationToken).ArchiveWorld(resultBuilder); await new BackupArchiver(loggerName, session.InstanceProperties, cancellationToken).ArchiveWorld(basePath, resultBuilder);
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
resultBuilder.Kind = BackupCreationResultKind.BackupCancelled; resultBuilder.Kind = BackupCreationResultKind.BackupCancelled;
logger.Warning("Backup creation was cancelled."); logger.Warning("Backup creation was cancelled.");

View File

@ -1,75 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data.Backups;
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Services.Backups;
sealed class BackupScheduler : CancellableBackgroundTask {
// TODO make configurable
private static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(2);
private static readonly TimeSpan BackupInterval = TimeSpan.FromMinutes(30);
private static readonly TimeSpan BackupFailureRetryDelay = TimeSpan.FromMinutes(5);
private static readonly TimeSpan OnlinePlayersCheckInterval = TimeSpan.FromMinutes(1);
private readonly string loggerName;
private readonly BackupManager backupManager;
private readonly InstanceSession session;
private readonly int serverPort;
private readonly ServerStatusProtocol serverStatusProtocol;
public BackupScheduler(TaskManager taskManager, BackupManager backupManager, InstanceSession session, int serverPort, string loggerName) : base(PhantomLogger.Create<BackupScheduler>(loggerName), taskManager, "Backup scheduler for " + loggerName) {
this.loggerName = loggerName;
this.backupManager = backupManager;
this.session = session;
this.serverPort = serverPort;
this.serverStatusProtocol = new ServerStatusProtocol(loggerName);
}
protected override async Task RunTask() {
await Task.Delay(InitialDelay, CancellationToken);
Logger.Information("Starting a new backup after server launched.");
while (!CancellationToken.IsCancellationRequested) {
var result = await CreateBackup();
if (result.Kind.ShouldRetry()) {
Logger.Warning("Scheduled backup failed, retrying in {Minutes} minutes.", BackupFailureRetryDelay.TotalMinutes);
await Task.Delay(BackupFailureRetryDelay, CancellationToken);
}
else {
Logger.Warning("Scheduling next backup in {Minutes} minutes.", BackupInterval.TotalMinutes);
await Task.Delay(BackupInterval, CancellationToken);
await WaitForOnlinePlayers();
}
}
}
private async Task<BackupCreationResult> CreateBackup() {
return await backupManager.CreateBackup(loggerName, session, CancellationToken.None);
}
private async Task WaitForOnlinePlayers() {
bool needsToLogOfflinePlayersMessage = true;
while (!CancellationToken.IsCancellationRequested) {
var onlinePlayerCount = await serverStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken);
if (onlinePlayerCount == null) {
Logger.Warning("Could not detect whether any players are online, starting a new backup.");
break;
}
if (onlinePlayerCount > 0) {
Logger.Information("Players are online, starting a new backup.");
break;
}
if (needsToLogOfflinePlayersMessage) {
needsToLogOfflinePlayersMessage = false;
Logger.Information("No players are online, waiting for someone to join before starting a new backup.");
}
await Task.Delay(OnlinePlayersCheckInterval, CancellationToken);
}
}
}

View File

@ -18,19 +18,21 @@ sealed class Instance : IDisposable {
return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref loggerSequenceId); return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref loggerSequenceId);
} }
public static async Task<Instance> Create(InstanceConfiguration configuration, InstanceServices services, BaseLauncher launcher) { public static async Task<Instance> Create(InstanceConfiguration configuration, BaseLauncher launcher, LaunchServices launchServices, PortManager portManager) {
var instance = new Instance(configuration, services, launcher); var instance = new Instance(configuration, launcher, launchServices, portManager);
await instance.ReportLastStatus(); await instance.ReportLastStatus();
return instance; return instance;
} }
public InstanceConfiguration Configuration { get; private set; } public InstanceConfiguration Configuration { get; private set; }
private InstanceServices Services { get; }
private BaseLauncher Launcher { get; set; } private BaseLauncher Launcher { get; set; }
private readonly string shortName; private readonly string shortName;
private readonly ILogger logger; private readonly ILogger logger;
private readonly LaunchServices launchServices;
private readonly PortManager portManager;
private IInstanceStatus currentStatus; private IInstanceStatus currentStatus;
private IInstanceState currentState; private IInstanceState currentState;
private readonly SemaphoreSlim stateTransitioningActionSemaphore = new (1, 1); private readonly SemaphoreSlim stateTransitioningActionSemaphore = new (1, 1);
@ -39,14 +41,15 @@ sealed class Instance : IDisposable {
public event EventHandler? IsRunningChanged; public event EventHandler? IsRunningChanged;
private Instance(InstanceConfiguration configuration, InstanceServices services, BaseLauncher launcher) { private Instance(InstanceConfiguration configuration, BaseLauncher launcher, LaunchServices launchServices, PortManager portManager) {
this.shortName = GetLoggerName(configuration.InstanceGuid); this.shortName = GetLoggerName(configuration.InstanceGuid);
this.logger = PhantomLogger.Create<Instance>(shortName); this.logger = PhantomLogger.Create<Instance>(shortName);
this.Configuration = configuration; this.Configuration = configuration;
this.Services = services;
this.Launcher = launcher; this.Launcher = launcher;
this.launchServices = launchServices;
this.portManager = portManager;
this.currentState = new InstanceNotRunningState(); this.currentState = new InstanceNotRunningState();
this.currentStatus = InstanceStatus.NotRunning; this.currentStatus = InstanceStatus.NotRunning;
} }
@ -136,18 +139,20 @@ sealed class Instance : IDisposable {
private int statusUpdateCounter; private int statusUpdateCounter;
public InstanceContextImpl(Instance instance, CancellationToken shutdownCancellationToken) : base(instance.Configuration, instance.Services, instance.Launcher) { public InstanceContextImpl(Instance instance, CancellationToken shutdownCancellationToken) : base(instance.Configuration, instance.Launcher) {
this.instance = instance; this.instance = instance;
this.shutdownCancellationToken = shutdownCancellationToken; this.shutdownCancellationToken = shutdownCancellationToken;
} }
public override LaunchServices LaunchServices => instance.launchServices;
public override PortManager PortManager => instance.portManager;
public override ILogger Logger => instance.logger; public override ILogger Logger => instance.logger;
public override string ShortName => instance.shortName; public override string ShortName => instance.shortName;
public override void ReportStatus(IInstanceStatus newStatus) { public override void ReportStatus(IInstanceStatus newStatus) {
int myStatusUpdateCounter = Interlocked.Increment(ref statusUpdateCounter); int myStatusUpdateCounter = Interlocked.Increment(ref statusUpdateCounter);
instance.Services.TaskManager.Run("Report status of instance " + instance.shortName + " as " + newStatus.GetType().Name, async () => { instance.launchServices.TaskManager.Run("Report status of instance " + instance.shortName + " as " + newStatus.GetType().Name, async () => {
if (myStatusUpdateCounter == statusUpdateCounter) { if (myStatusUpdateCounter == statusUpdateCounter) {
instance.currentStatus = newStatus; instance.currentStatus = newStatus;
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, newStatus)); await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, newStatus));

View File

@ -7,16 +7,16 @@ namespace Phantom.Agent.Services.Instances;
abstract class InstanceContext { abstract class InstanceContext {
public InstanceConfiguration Configuration { get; } public InstanceConfiguration Configuration { get; }
public InstanceServices Services { get; }
public BaseLauncher Launcher { get; } public BaseLauncher Launcher { get; }
public abstract LaunchServices LaunchServices { get; }
public abstract PortManager PortManager { get; }
public abstract ILogger Logger { get; } public abstract ILogger Logger { get; }
public abstract string ShortName { get; } public abstract string ShortName { get; }
protected InstanceContext(InstanceConfiguration configuration, InstanceServices services, BaseLauncher launcher) { protected InstanceContext(InstanceConfiguration configuration, BaseLauncher launcher) {
Configuration = configuration; Configuration = configuration;
Launcher = launcher; Launcher = launcher;
Services = services;
} }
public abstract void ReportStatus(IInstanceStatus newStatus); public abstract void ReportStatus(IInstanceStatus newStatus);

View File

@ -4,33 +4,50 @@ using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer; using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
sealed class InstanceLogSender : CancellableBackgroundTask { sealed class InstanceLogSender {
private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200); private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200);
private readonly Guid instanceGuid; private readonly Guid instanceGuid;
private readonly ILogger logger;
private readonly CancellationTokenSource cancellationTokenSource;
private readonly CancellationToken cancellationToken;
private readonly SemaphoreSlim semaphore = new (1, 1); private readonly SemaphoreSlim semaphore = new (1, 1);
private readonly RingBuffer<string> buffer = new (1000); private readonly RingBuffer<string> buffer = new (1000);
public InstanceLogSender(TaskManager taskManager, Guid instanceGuid, string loggerName) : base(PhantomLogger.Create<InstanceLogSender>(loggerName), taskManager, "Instance log sender for " + loggerName) { public InstanceLogSender(TaskManager taskManager, Guid instanceGuid, string name) {
this.instanceGuid = instanceGuid; this.instanceGuid = instanceGuid;
this.logger = PhantomLogger.Create<InstanceLogSender>(name);
this.cancellationTokenSource = new CancellationTokenSource();
this.cancellationToken = cancellationTokenSource.Token;
taskManager.Run("Instance log sender for " + name, Run);
} }
protected override async Task RunTask() {
try {
while (!CancellationToken.IsCancellationRequested) {
await SendOutputToServer(await DequeueOrThrow());
await Task.Delay(SendDelay, CancellationToken);
}
} catch (OperationCanceledException) {
// Ignore.
}
// Flush remaining lines. private async Task Run() {
await SendOutputToServer(DequeueWithoutSemaphore()); logger.Verbose("Task started.");
try {
try {
while (!cancellationToken.IsCancellationRequested) {
await SendOutputToServer(await DequeueOrThrow());
await Task.Delay(SendDelay, cancellationToken);
}
} catch (OperationCanceledException) {
// Ignore.
}
// Flush remaining lines.
await SendOutputToServer(DequeueWithoutSemaphore());
} catch (Exception e) {
logger.Error(e, "Caught exception in task.");
} finally {
cancellationTokenSource.Dispose();
logger.Verbose("Task stopped.");
}
} }
private async Task SendOutputToServer(ImmutableArray<string> lines) { private async Task SendOutputToServer(ImmutableArray<string> lines) {
@ -46,7 +63,7 @@ sealed class InstanceLogSender : CancellableBackgroundTask {
} }
private async Task<ImmutableArray<string>> DequeueOrThrow() { private async Task<ImmutableArray<string>> DequeueOrThrow() {
await semaphore.WaitAsync(CancellationToken); await semaphore.WaitAsync(cancellationToken);
try { try {
return DequeueWithoutSemaphore(); return DequeueWithoutSemaphore();
@ -57,7 +74,7 @@ sealed class InstanceLogSender : CancellableBackgroundTask {
public void Enqueue(string line) { public void Enqueue(string line) {
try { try {
semaphore.Wait(CancellationToken); semaphore.Wait(cancellationToken);
} catch (Exception) { } catch (Exception) {
return; return;
} }
@ -68,4 +85,12 @@ sealed class InstanceLogSender : CancellableBackgroundTask {
semaphore.Release(); semaphore.Release();
} }
} }
public void Cancel() {
try {
cancellationTokenSource.Cancel();
} catch (ObjectDisposedException) {
// Ignore.
}
}
} }

View File

@ -1,7 +0,0 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Services.Instances;
sealed record InstanceServices(TaskManager TaskManager, PortManager PortManager, BackupManager BackupManager, LaunchServices LaunchServices);

View File

@ -7,7 +7,6 @@ using Phantom.Agent.Minecraft.Launcher.Types;
using Phantom.Agent.Minecraft.Properties; using Phantom.Agent.Minecraft.Properties;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Rpc; using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Backups;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
@ -28,22 +27,21 @@ sealed class InstanceSessionManager : IDisposable {
private readonly string basePath; private readonly string basePath;
private readonly MinecraftServerExecutables minecraftServerExecutables; private readonly MinecraftServerExecutables minecraftServerExecutables;
private readonly InstanceServices instanceServices; private readonly LaunchServices launchServices;
private readonly PortManager portManager;
private readonly Dictionary<Guid, Instance> instances = new (); private readonly Dictionary<Guid, Instance> instances = new ();
private readonly CancellationTokenSource shutdownCancellationTokenSource = new (); private readonly CancellationTokenSource shutdownCancellationTokenSource = new ();
private readonly CancellationToken shutdownCancellationToken; private readonly CancellationToken shutdownCancellationToken;
private readonly SemaphoreSlim semaphore = new (1, 1); private readonly SemaphoreSlim semaphore = new (1, 1);
public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager, BackupManager backupManager) { public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager) {
this.agentInfo = agentInfo; this.agentInfo = agentInfo;
this.basePath = agentFolders.InstancesFolderPath; this.basePath = agentFolders.InstancesFolderPath;
this.minecraftServerExecutables = new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath); this.minecraftServerExecutables = new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath);
this.launchServices = new LaunchServices(taskManager, minecraftServerExecutables, javaRuntimeRepository);
this.portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
this.shutdownCancellationToken = shutdownCancellationTokenSource.Token; this.shutdownCancellationToken = shutdownCancellationTokenSource.Token;
var launchServices = new LaunchServices(minecraftServerExecutables, javaRuntimeRepository);
var portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
this.instanceServices = new InstanceServices(taskManager, portManager, backupManager, launchServices);
} }
private async Task<InstanceActionResult<T>> AcquireSemaphoreAndRun<T>(Func<Task<InstanceActionResult<T>>> func) { private async Task<InstanceActionResult<T>> AcquireSemaphoreAndRun<T>(Func<Task<InstanceActionResult<T>>> func) {
@ -100,7 +98,7 @@ sealed class InstanceSessionManager : IDisposable {
Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid); Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
} }
else { else {
instances[instanceGuid] = instance = await Instance.Create(configuration, instanceServices, launcher); instances[instanceGuid] = instance = await Instance.Create(configuration, launcher, launchServices, portManager);
instance.IsRunningChanged += OnInstanceIsRunningChanged; instance.IsRunningChanged += OnInstanceIsRunningChanged;
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid); Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
} }
@ -118,7 +116,7 @@ sealed class InstanceSessionManager : IDisposable {
} }
private void OnInstanceIsRunningChanged(object? sender, EventArgs e) { private void OnInstanceIsRunningChanged(object? sender, EventArgs e) {
instanceServices.TaskManager.Run("Handle instance running state changed event", RefreshAgentStatus); launchServices.TaskManager.Run("Handle instance running state changed event", RefreshAgentStatus);
} }
public async Task RefreshAgentStatus() { public async Task RefreshAgentStatus() {

View File

@ -19,7 +19,7 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
public void Initialize() { public void Initialize() {
context.Logger.Information("Session starting..."); context.Logger.Information("Session starting...");
var launchTask = context.Services.TaskManager.Run("Launch procedure for instance " + context.ShortName, DoLaunch); var launchTask = context.LaunchServices.TaskManager.Run("Launch procedure for instance " + context.ShortName, DoLaunch);
launchTask.ContinueWith(OnLaunchSuccess, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default); launchTask.ContinueWith(OnLaunchSuccess, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
launchTask.ContinueWith(OnLaunchFailure, CancellationToken.None, TaskContinuationOptions.NotOnRanToCompletion, TaskScheduler.Default); launchTask.ContinueWith(OnLaunchFailure, CancellationToken.None, TaskContinuationOptions.NotOnRanToCompletion, TaskScheduler.Default);
} }
@ -37,7 +37,7 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
} }
} }
var launchResult = await context.Launcher.Launch(context.Logger, context.Services.LaunchServices, OnDownloadProgress, cancellationToken); var launchResult = await context.Launcher.Launch(context.Logger, context.LaunchServices, OnDownloadProgress, cancellationToken);
if (launchResult is LaunchResult.InvalidJavaRuntime) { if (launchResult is LaunchResult.InvalidJavaRuntime) {
throw new LaunchFailureException(InstanceLaunchFailReason.JavaRuntimeNotFound, "Session failed to launch, invalid Java runtime."); throw new LaunchFailureException(InstanceLaunchFailReason.JavaRuntimeNotFound, "Session failed to launch, invalid Java runtime.");
} }
@ -65,7 +65,7 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
private void OnLaunchSuccess(Task<InstanceSession> task) { private void OnLaunchSuccess(Task<InstanceSession> task) {
context.TransitionState(() => { context.TransitionState(() => {
if (cancellationTokenSource.IsCancellationRequested) { if (cancellationTokenSource.IsCancellationRequested) {
context.Services.PortManager.Release(context.Configuration); context.PortManager.Release(context.Configuration);
return (new InstanceNotRunningState(), InstanceStatus.NotRunning); return (new InstanceNotRunningState(), InstanceStatus.NotRunning);
} }
else { else {
@ -83,7 +83,7 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
context.ReportStatus(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)); context.ReportStatus(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError));
} }
context.Services.PortManager.Release(context.Configuration); context.PortManager.Release(context.Configuration);
context.TransitionState(new InstanceNotRunningState()); context.TransitionState(new InstanceNotRunningState());
} }

View File

@ -8,7 +8,7 @@ sealed class InstanceNotRunningState : IInstanceState {
public void Initialize() {} public void Initialize() {}
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) { public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
InstanceLaunchFailReason? failReason = context.Services.PortManager.Reserve(context.Configuration) switch { InstanceLaunchFailReason? failReason = context.PortManager.Reserve(context.Configuration) switch {
PortManager.Result.ServerPortNotAllowed => InstanceLaunchFailReason.ServerPortNotAllowed, PortManager.Result.ServerPortNotAllowed => InstanceLaunchFailReason.ServerPortNotAllowed,
PortManager.Result.ServerPortAlreadyInUse => InstanceLaunchFailReason.ServerPortAlreadyInUse, PortManager.Result.ServerPortAlreadyInUse => InstanceLaunchFailReason.ServerPortAlreadyInUse,
PortManager.Result.RconPortNotAllowed => InstanceLaunchFailReason.RconPortNotAllowed, PortManager.Result.RconPortNotAllowed => InstanceLaunchFailReason.RconPortNotAllowed,

View File

@ -1,6 +1,5 @@
using Phantom.Agent.Minecraft.Command; using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Services.Backups;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
@ -11,7 +10,6 @@ sealed class InstanceRunningState : IInstanceState {
private readonly InstanceContext context; private readonly InstanceContext context;
private readonly InstanceSession session; private readonly InstanceSession session;
private readonly InstanceLogSender logSender; private readonly InstanceLogSender logSender;
private readonly BackupScheduler backupScheduler;
private readonly SessionObjects sessionObjects; private readonly SessionObjects sessionObjects;
private readonly CancellationTokenSource delayedStopCancellationTokenSource = new (); private readonly CancellationTokenSource delayedStopCancellationTokenSource = new ();
@ -21,8 +19,7 @@ sealed class InstanceRunningState : IInstanceState {
public InstanceRunningState(InstanceContext context, InstanceSession session) { public InstanceRunningState(InstanceContext context, InstanceSession session) {
this.context = context; this.context = context;
this.session = session; this.session = session;
this.logSender = new InstanceLogSender(context.Services.TaskManager, context.Configuration.InstanceGuid, context.ShortName); this.logSender = new InstanceLogSender(context.LaunchServices.TaskManager, context.Configuration.InstanceGuid, context.ShortName);
this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, session, context.Configuration.ServerPort, context.ShortName);
this.sessionObjects = new SessionObjects(this); this.sessionObjects = new SessionObjects(this);
} }
@ -33,7 +30,7 @@ sealed class InstanceRunningState : IInstanceState {
if (session.HasEnded) { if (session.HasEnded) {
if (sessionObjects.Dispose()) { if (sessionObjects.Dispose()) {
context.Logger.Warning("Session ended immediately after it was started."); context.Logger.Warning("Session ended immediately after it was started.");
context.Services.TaskManager.Run("Transition state of instance " + context.ShortName + " to not running", () => context.TransitionState(new InstanceNotRunningState(), InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError))); context.LaunchServices.TaskManager.Run("Transition state of instance " + context.ShortName + " to not running", () => context.TransitionState(new InstanceNotRunningState(), InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)));
} }
} }
else { else {
@ -42,9 +39,9 @@ sealed class InstanceRunningState : IInstanceState {
} }
} }
private void SessionOutput(object? sender, string line) { private void SessionOutput(object? sender, string e) {
context.Logger.Verbose("[Server] {Line}", line); context.Logger.Verbose("[Server] {Line}", e);
logSender.Enqueue(line); logSender.Enqueue(e);
} }
private void SessionEnded(object? sender, EventArgs e) { private void SessionEnded(object? sender, EventArgs e) {
@ -78,13 +75,12 @@ sealed class InstanceRunningState : IInstanceState {
} }
isStopping = true; isStopping = true;
context.Services.TaskManager.Run("Delayed stop timer for instance " + context.ShortName, () => StopLater(stopStrategy.Seconds)); context.LaunchServices.TaskManager.Run("Delayed stop timer for instance " + context.ShortName, () => StopLater(stopStrategy.Seconds));
return (this, StopInstanceResult.StopInitiated); return (this, StopInstanceResult.StopInitiated);
} }
private IInstanceState PrepareStoppedState() { private IInstanceState PrepareStoppedState() {
session.SessionEnded -= SessionEnded; session.SessionEnded -= SessionEnded;
backupScheduler.Stop();
return new InstanceStoppingState(context, session, sessionObjects); return new InstanceStoppingState(context, session, sessionObjects);
} }
@ -163,9 +159,9 @@ sealed class InstanceRunningState : IInstanceState {
state.CancelDelayedStop(); state.CancelDelayedStop();
} }
state.logSender.Stop(); state.logSender.Cancel();
state.session.Dispose(); state.session.Dispose();
state.context.Services.PortManager.Release(state.context.Configuration); state.context.PortManager.Release(state.context.Configuration);
return true; return true;
} }
} }

View File

@ -21,17 +21,11 @@ sealed class InstanceStoppingState : IInstanceState, IDisposable {
public void Initialize() { public void Initialize() {
context.Logger.Information("Session stopping."); context.Logger.Information("Session stopping.");
context.ReportStatus(InstanceStatus.Stopping); context.ReportStatus(InstanceStatus.Stopping);
context.Services.TaskManager.Run("Stop procedure for instance " + context.ShortName, DoStop); context.LaunchServices.TaskManager.Run("Stop procedure for instance " + context.ShortName, DoStop);
} }
private async Task DoStop() { private async Task DoStop() {
try { try {
// Do not release the semaphore after this point.
if (!await session.BackupSemaphore.CancelAndWait(TimeSpan.FromSeconds(1))) {
context.Logger.Information("Waiting for backup to finish...");
await session.BackupSemaphore.CancelAndWait(Timeout.InfiniteTimeSpan);
}
context.Logger.Information("Sending stop command..."); context.Logger.Information("Sending stop command...");
await DoSendStopCommand(); await DoSendStopCommand();

View File

@ -3,35 +3,23 @@
public enum BackupCreationResultKind : byte { public enum BackupCreationResultKind : byte {
UnknownError, UnknownError,
Success, Success,
InstanceNotRunning,
BackupCancelled, BackupCancelled,
BackupAlreadyRunning, BackupAlreadyExists,
BackupFileAlreadyExists,
CouldNotCreateBackupFolder, CouldNotCreateBackupFolder,
CouldNotCopyWorldToTemporaryFolder, CouldNotCopyWorldToTemporaryFolder,
CouldNotCreateWorldArchive CouldNotCreateWorldArchive
} }
public static class BackupCreationResultSummaryExtensions { public static class BackupCreationResultSummaryExtensions {
public static bool ShouldRetry(this BackupCreationResultKind kind) {
return kind != BackupCreationResultKind.Success &&
kind != BackupCreationResultKind.InstanceNotRunning &&
kind != BackupCreationResultKind.BackupCancelled &&
kind != BackupCreationResultKind.BackupAlreadyRunning &&
kind != BackupCreationResultKind.BackupFileAlreadyExists;
}
public static string ToSentence(this BackupCreationResultKind kind) { public static string ToSentence(this BackupCreationResultKind kind) {
return kind switch { return kind switch {
BackupCreationResultKind.Success => "Backup created successfully.", BackupCreationResultKind.Success => "Backup created successfully.",
BackupCreationResultKind.InstanceNotRunning => "Instance is not running.",
BackupCreationResultKind.BackupCancelled => "Backup cancelled.", BackupCreationResultKind.BackupCancelled => "Backup cancelled.",
BackupCreationResultKind.BackupAlreadyRunning => "A backup is already being created.", BackupCreationResultKind.BackupAlreadyExists => "Backup with the same name already exists.",
BackupCreationResultKind.BackupFileAlreadyExists => "Backup with the same name already exists.",
BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.", BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.",
BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder => "Could not copy world to temporary folder.", BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder => "Could not copy world to temporary folder.",
BackupCreationResultKind.CouldNotCreateWorldArchive => "Could not create world archive.", BackupCreationResultKind.CouldNotCreateWorldArchive => "Could not create world archive.",
_ => "Unknown error." _ => "Unknown error."
}; };
} }
} }

View File

@ -1,6 +1,4 @@
using System.Numerics; namespace Phantom.Common.Data.Backups;
namespace Phantom.Common.Data.Backups;
[Flags] [Flags]
public enum BackupCreationWarnings : byte { public enum BackupCreationWarnings : byte {
@ -9,9 +7,3 @@ public enum BackupCreationWarnings : byte {
CouldNotCompressWorldArchive = 1 << 1, CouldNotCompressWorldArchive = 1 << 1,
CouldNotRestoreAutomaticSaving = 1 << 2 CouldNotRestoreAutomaticSaving = 1 << 2
} }
public static class BackupCreationWarningsExtensions {
public static int Count(this BackupCreationWarnings warnings) {
return BitOperations.PopCount((byte) warnings);
}
}

View File

@ -59,8 +59,7 @@ RUN --mount=target=/var/lib/apt/lists,type=cache,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \ rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update && \ apt-get update && \
apt-get install -y \ apt-get install -y \
openjdk-18-jre-headless \ openjdk-18-jre-headless
zstd
RUN mkdir /data && chmod 777 /data RUN mkdir /data && chmod 777 /data
WORKDIR /data WORKDIR /data

View File

@ -1,41 +0,0 @@
using Serilog;
namespace Phantom.Utils.Runtime;
public abstract class CancellableBackgroundTask {
private readonly CancellationTokenSource cancellationTokenSource = new ();
protected ILogger Logger { get; }
protected CancellationToken CancellationToken { get; }
protected CancellableBackgroundTask(ILogger logger, TaskManager taskManager, string taskName) {
this.Logger = logger;
this.CancellationToken = cancellationTokenSource.Token;
taskManager.Run(taskName, Run);
}
private async Task Run() {
Logger.Verbose("Task started.");
try {
await RunTask();
} catch (OperationCanceledException) {
// Ignore.
} catch (Exception e) {
Logger.Fatal(e, "Caught exception in task.");
} finally {
cancellationTokenSource.Dispose();
Logger.Verbose("Task stopped.");
}
}
protected abstract Task RunTask();
public void Stop() {
try {
cancellationTokenSource.Cancel();
} catch (ObjectDisposedException) {
// Ignore.
}
}
}

View File

@ -1,28 +0,0 @@
namespace Phantom.Utils.Runtime;
public sealed class CancellableSemaphore : IDisposable {
private readonly SemaphoreSlim semaphore;
private readonly CancellationTokenSource cancellationTokenSource = new ();
public CancellableSemaphore(int initialCount, int maxCount) {
this.semaphore = new SemaphoreSlim(initialCount, maxCount);
}
public async Task<bool> Wait(TimeSpan timeout, CancellationToken cancellationToken) {
return await semaphore.WaitAsync(timeout, cancellationTokenSource.Token).WaitAsync(cancellationToken);
}
public async Task<bool> CancelAndWait(TimeSpan timeout) {
cancellationTokenSource.Cancel();
return await semaphore.WaitAsync(timeout);
}
public void Release() {
semaphore.Release();
}
public void Dispose() {
semaphore.Dispose();
cancellationTokenSource.Dispose();
}
}