1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-09-13 18:32:10 +02:00

1 Commits

Author SHA1 Message Date
101ca865fe WIP 2023-03-03 22:50:52 +01:00
61 changed files with 768 additions and 851 deletions

View File

@@ -5,8 +5,9 @@ namespace Phantom.Agent.Minecraft.Instance;
public sealed class InstanceProcess : IDisposable { public sealed class InstanceProcess : IDisposable {
public InstanceProperties InstanceProperties { get; } public InstanceProperties InstanceProperties { get; }
public CancellableSemaphore BackupSemaphore { get; } = new (1, 1);
private readonly RingBuffer<string> outputBuffer = new (100); private readonly RingBuffer<string> outputBuffer = new (10000);
private event EventHandler<string>? OutputEvent; private event EventHandler<string>? OutputEvent;
public event EventHandler? Ended; public event EventHandler? Ended;
@@ -60,6 +61,7 @@ public sealed class InstanceProcess : IDisposable {
public void Dispose() { public void Dispose() {
process.Dispose(); process.Dispose();
BackupSemaphore.Dispose();
OutputEvent = null; OutputEvent = null;
Ended = null; Ended = null;
} }

View File

@@ -1,92 +0,0 @@
using System.Text;
using Kajabity.Tools.Java;
namespace Phantom.Agent.Minecraft.Java;
sealed class JavaPropertiesFileEditor {
private static readonly Encoding Encoding = Encoding.GetEncoding("ISO-8859-1");
private readonly Dictionary<string, string> overriddenProperties = new ();
public void Set(string key, string value) {
overriddenProperties[key] = value;
}
public async Task EditOrCreate(string filePath) {
if (File.Exists(filePath)) {
string tmpFilePath = filePath + ".tmp";
File.Copy(filePath, tmpFilePath, overwrite: true);
await EditFromCopyOrCreate(filePath, tmpFilePath);
File.Move(tmpFilePath, filePath, overwrite: true);
}
else {
await EditFromCopyOrCreate(null, filePath);
}
}
private async Task EditFromCopyOrCreate(string? sourceFilePath, string targetFilePath) {
var properties = new JavaProperties();
if (sourceFilePath != null) {
// TODO replace with custom async parser
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
properties.Load(sourceStream, Encoding);
}
foreach (var (key, value) in overriddenProperties) {
properties[key] = value;
}
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var targetWriter = new StreamWriter(targetStream, Encoding);
await targetWriter.WriteLineAsync("# Properties");
foreach (var (key, value) in properties) {
await WriteProperty(targetWriter, key, value);
}
}
private static async Task WriteProperty(StreamWriter writer, string key, string value) {
await WritePropertyComponent(writer, key, escapeSpaces: true);
await writer.WriteAsync('=');
await WritePropertyComponent(writer, value, escapeSpaces: false);
await writer.WriteLineAsync();
}
private static async Task WritePropertyComponent(TextWriter writer, string component, bool escapeSpaces) {
for (int index = 0; index < component.Length; index++) {
var c = component[index];
switch (c) {
case '\\':
case '#':
case '!':
case '=':
case ':':
case ' ' when escapeSpaces || index == 0:
await writer.WriteAsync('\\');
await writer.WriteAsync(c);
break;
case var _ when c > 31 && c < 127:
await writer.WriteAsync(c);
break;
case '\t':
await writer.WriteAsync("\\t");
break;
case '\n':
await writer.WriteAsync("\\n");
break;
case '\r':
await writer.WriteAsync("\\r");
break;
case '\f':
await writer.WriteAsync("\\f");
break;
default:
await writer.WriteAsync("\\u");
await writer.WriteAsync(((int) c).ToString("X4"));
break;
}
}
}
}

View File

@@ -79,9 +79,10 @@ public sealed class JavaRuntimeDiscovery {
WorkingDirectory = Path.GetDirectoryName(javaExecutablePath), WorkingDirectory = Path.GetDirectoryName(javaExecutablePath),
Arguments = "-XshowSettings:properties -version", Arguments = "-XshowSettings:properties -version",
RedirectStandardInput = false, RedirectStandardInput = false,
RedirectStandardOutput = false, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false UseShellExecute = false,
CreateNoWindow = false
}; };
var process = new Process { StartInfo = startInfo }; var process = new Process { StartInfo = startInfo };

View File

@@ -1,8 +1,9 @@
using System.Text; using System.Text;
using Kajabity.Tools.Java;
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java; using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data.Java; using Phantom.Common.Minecraft;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
using Serilog; using Serilog;
@@ -107,8 +108,21 @@ public abstract class BaseLauncher : IServerLauncher {
} }
private static async Task UpdateServerProperties(InstanceProperties instanceProperties) { private static async Task UpdateServerProperties(InstanceProperties instanceProperties) {
var serverPropertiesEditor = new JavaPropertiesFileEditor(); var serverPropertiesFilePath = Path.Combine(instanceProperties.InstanceFolder, "server.properties");
instanceProperties.ServerProperties.SetTo(serverPropertiesEditor); var serverPropertiesData = new JavaProperties();
await serverPropertiesEditor.EditOrCreate(Path.Combine(instanceProperties.InstanceFolder, "server.properties"));
await using var fileStream = new FileStream(serverPropertiesFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
try {
serverPropertiesData.Load(fileStream);
} catch (ParseException e) {
throw new Exception("Could not parse server.properties file: " + serverPropertiesFilePath, e);
}
instanceProperties.ServerProperties.SetTo(serverPropertiesData);
fileStream.Seek(0L, SeekOrigin.Begin);
fileStream.SetLength(0L);
serverPropertiesData.Store(fileStream, true);
} }
} }

View File

@@ -0,0 +1,12 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
namespace Phantom.Agent.Minecraft.Launcher.Types;
public class ForgeLauncher : BaseLauncher {
public ForgeLauncher(InstanceProperties instanceProperties) : base(instanceProperties) {}
private protected override void CustomizeJvmArguments(JvmArgumentBuilder arguments) {
arguments.AddProperty("terminal.ansi", "true"); // TODO
}
}

View File

@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Minecraft\Phantom.Common.Minecraft.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Cryptography\Phantom.Utils.Cryptography.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Cryptography\Phantom.Utils.Cryptography.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />

View File

@@ -1,4 +1,4 @@
using Phantom.Agent.Minecraft.Java; using Kajabity.Tools.Java;
namespace Phantom.Agent.Minecraft.Properties; namespace Phantom.Agent.Minecraft.Properties;
@@ -12,7 +12,7 @@ abstract class MinecraftServerProperty<T> {
protected abstract T Read(string value); protected abstract T Read(string value);
protected abstract string Write(T value); protected abstract string Write(T value);
public void Set(JavaPropertiesFileEditor properties, T value) { public void Set(JavaProperties properties, T value) {
properties.Set(key, Write(value)); properties.SetProperty(key, Write(value));
} }
} }

View File

@@ -1,4 +1,4 @@
using Phantom.Agent.Minecraft.Java; using Kajabity.Tools.Java;
namespace Phantom.Agent.Minecraft.Properties; namespace Phantom.Agent.Minecraft.Properties;
@@ -7,7 +7,7 @@ public sealed record ServerProperties(
ushort RconPort, ushort RconPort,
bool EnableRcon = true bool EnableRcon = true
) { ) {
internal void SetTo(JavaPropertiesFileEditor properties) { internal void SetTo(JavaProperties properties) {
MinecraftServerProperties.ServerPort.Set(properties, ServerPort); MinecraftServerProperties.ServerPort.Set(properties, ServerPort);
MinecraftServerProperties.RconPort.Set(properties, RconPort); MinecraftServerProperties.RconPort.Set(properties, RconPort);
MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon); MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon);

View File

@@ -10,7 +10,7 @@ public sealed partial class MinecraftServerExecutables {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutables>(); private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutables>();
[GeneratedRegex(@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled)] [GeneratedRegex(@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled)]
private static partial Regex SanitizePathRegex(); private static partial Regex VersionFolderSanitizeRegex();
private readonly string basePath; private readonly string basePath;
private readonly Dictionary<string, MinecraftServerExecutableDownloader> runningDownloadersByVersion = new (); private readonly Dictionary<string, MinecraftServerExecutableDownloader> runningDownloadersByVersion = new ();
@@ -20,7 +20,7 @@ public sealed partial class MinecraftServerExecutables {
} }
internal async Task<string?> DownloadAndGetPath(FileDownloadInfo? fileDownloadInfo, string minecraftVersion, EventHandler<DownloadProgressEventArgs> progressEventHandler, CancellationToken cancellationToken) { internal async Task<string?> DownloadAndGetPath(FileDownloadInfo? fileDownloadInfo, string minecraftVersion, EventHandler<DownloadProgressEventArgs> progressEventHandler, CancellationToken cancellationToken) {
string serverExecutableFolderPath = Path.Combine(basePath, SanitizePathRegex().IsMatch(minecraftVersion) ? SanitizePathRegex().Replace(minecraftVersion, "_") : minecraftVersion); string serverExecutableFolderPath = Path.Combine(basePath, VersionFolderSanitizeRegex().Replace(minecraftVersion, "_"));
string serverExecutableFilePath = Path.Combine(serverExecutableFolderPath, "server.jar"); string serverExecutableFilePath = Path.Combine(serverExecutableFolderPath, "server.jar");
if (File.Exists(serverExecutableFilePath)) { if (File.Exists(serverExecutableFilePath)) {

View File

@@ -78,7 +78,7 @@ public sealed class ServerStatusProtocol {
return null; return null;
} }
string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer.AsSpan((separator1 + 1)..(separator2 - 1))); string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer[(separator1 + 1)..(separator2 - 1)]);
if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) { if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
logger.Error("Could not parse online player count in response from server: {OnlinePlayerCount}.", onlinePlayerCountStr); logger.Error("Could not parse online player count in response from server: {OnlinePlayerCount}.", onlinePlayerCountStr);
return null; return null;

View File

@@ -13,7 +13,7 @@ using Serilog.Events;
namespace Phantom.Agent.Rpc; namespace Phantom.Agent.Rpc;
public sealed class RpcLauncher : RpcRuntime<ClientSocket> { public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
public static Task Launch(RpcConfiguration config, AgentAuthToken authToken, AgentInfo agentInfo, Func<RpcServerConnection, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) { public static async Task Launch(RpcConfiguration config, AgentAuthToken authToken, AgentInfo agentInfo, Func<RpcServerConnection, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) {
var socket = new ClientSocket(); var socket = new ClientSocket();
var options = socket.Options; var options = socket.Options;
@@ -21,7 +21,7 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
options.CurveCertificate = new NetMQCertificate(); options.CurveCertificate = new NetMQCertificate();
options.HelloMessage = MessageRegistries.ToServer.Write(new RegisterAgentMessage(authToken, agentInfo)).ToArray(); options.HelloMessage = MessageRegistries.ToServer.Write(new RegisterAgentMessage(authToken, agentInfo)).ToArray();
return new RpcLauncher(config, socket, agentInfo.Guid, listenerFactory, disconnectSemaphore, receiveCancellationToken).Launch(); await new RpcLauncher(config, socket, agentInfo.Guid, listenerFactory, disconnectSemaphore, receiveCancellationToken).Launch();
} }
private readonly RpcConfiguration config; private readonly RpcConfiguration config;

View File

@@ -1,3 +0,0 @@
namespace Phantom.Agent.Services;
public readonly record struct AgentServiceConfiguration(int MaxConcurrentCompressionTasks);

View File

@@ -18,10 +18,10 @@ public sealed class AgentServices {
internal JavaRuntimeRepository JavaRuntimeRepository { get; } internal JavaRuntimeRepository JavaRuntimeRepository { get; }
internal InstanceSessionManager InstanceSessionManager { get; } internal InstanceSessionManager InstanceSessionManager { get; }
public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders, AgentServiceConfiguration serviceConfiguration) { public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders) {
this.AgentFolders = agentFolders; this.AgentFolders = agentFolders;
this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, AgentServices>()); this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, AgentServices>());
this.BackupManager = new BackupManager(agentFolders, serviceConfiguration.MaxConcurrentCompressionTasks); 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, BackupManager);
} }
@@ -35,10 +35,10 @@ public sealed class AgentServices {
public async Task Shutdown() { public async Task Shutdown() {
Logger.Information("Stopping services..."); Logger.Information("Stopping services...");
await InstanceSessionManager.DisposeAsync(); await InstanceSessionManager.StopAll();
await TaskManager.Stop(); InstanceSessionManager.Dispose();
BackupManager.Dispose(); await TaskManager.Stop();
Logger.Information("Services stopped."); Logger.Information("Services stopped.");
} }

View File

@@ -5,34 +5,44 @@ using Serilog;
namespace Phantom.Agent.Services.Backups; namespace Phantom.Agent.Services.Backups;
sealed class BackupManager : IDisposable { sealed class BackupManager {
private readonly string destinationBasePath; private readonly string destinationBasePath;
private readonly string temporaryBasePath; private readonly string temporaryBasePath;
private readonly SemaphoreSlim compressionSemaphore;
public BackupManager(AgentFolders agentFolders, int maxConcurrentCompressionTasks) { public BackupManager(AgentFolders agentFolders) {
this.destinationBasePath = agentFolders.BackupsFolderPath; this.destinationBasePath = agentFolders.BackupsFolderPath;
this.temporaryBasePath = Path.Combine(agentFolders.TemporaryFolderPath, "backups"); this.temporaryBasePath = Path.Combine(agentFolders.TemporaryFolderPath, "backups");
this.compressionSemaphore = new SemaphoreSlim(maxConcurrentCompressionTasks, maxConcurrentCompressionTasks);
} }
public Task<BackupCreationResult> CreateBackup(string loggerName, InstanceProcess process, CancellationToken cancellationToken) { public async Task<BackupCreationResult> CreateBackup(string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
return new BackupCreator(this, loggerName, process, cancellationToken).CreateBackup(); try {
} if (!await process.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);
}
public void Dispose() { try {
compressionSemaphore.Dispose(); return await new BackupCreator(destinationBasePath, temporaryBasePath, loggerName, process, cancellationToken).CreateBackup();
} finally {
process.BackupSemaphore.Release();
}
} }
private sealed class BackupCreator { private sealed class BackupCreator {
private readonly BackupManager manager; private readonly string destinationBasePath;
private readonly string temporaryBasePath;
private readonly string loggerName; private readonly string loggerName;
private readonly ILogger logger; private readonly ILogger logger;
private readonly InstanceProcess process; private readonly InstanceProcess process;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
public BackupCreator(BackupManager manager, string loggerName, InstanceProcess process, CancellationToken cancellationToken) { public BackupCreator(string destinationBasePath, string temporaryBasePath, string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
this.manager = manager; this.destinationBasePath = destinationBasePath;
this.temporaryBasePath = temporaryBasePath;
this.loggerName = loggerName; this.loggerName = loggerName;
this.logger = PhantomLogger.Create<BackupManager>(loggerName); this.logger = PhantomLogger.Create<BackupManager>(loggerName);
this.process = process; this.process = process;
@@ -62,7 +72,7 @@ sealed class BackupManager : IDisposable {
try { try {
await dispatcher.DisableAutomaticSaving(); await dispatcher.DisableAutomaticSaving();
await dispatcher.SaveAllChunks(); await dispatcher.SaveAllChunks();
return await new BackupArchiver(manager.destinationBasePath, manager.temporaryBasePath, loggerName, process.InstanceProperties, cancellationToken).ArchiveWorld(resultBuilder); return await new BackupArchiver(destinationBasePath, temporaryBasePath, loggerName, process.InstanceProperties, cancellationToken).ArchiveWorld(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.");
@@ -75,28 +85,18 @@ sealed class BackupManager : IDisposable {
try { try {
await dispatcher.EnableAutomaticSaving(); await dispatcher.EnableAutomaticSaving();
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
// Ignore. // ignore
} catch (Exception e) { } catch (Exception e) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving; resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Error(e, "Caught exception while enabling automatic saving after creating an instance backup."); logger.Error(e, "Caught exception while enabling automatic saving after creating an instance backup.");
} }
} }
} }
private async Task CompressWorldArchive(string filePath, BackupCreationResult.Builder resultBuilder) {
if (!await manager.compressionSemaphore.WaitAsync(TimeSpan.FromSeconds(1), cancellationToken)) {
logger.Information("Too many compression tasks running, waiting for one of them to complete...");
await manager.compressionSemaphore.WaitAsync(cancellationToken);
}
logger.Information("Compressing backup..."); private async Task CompressWorldArchive(string filePath, BackupCreationResult.Builder resultBuilder) {
try { var compressedFilePath = await BackupCompressor.Compress(filePath, cancellationToken);
var compressedFilePath = await BackupCompressor.Compress(filePath, cancellationToken); if (compressedFilePath == null) {
if (compressedFilePath == null) { resultBuilder.Warnings |= BackupCreationWarnings.CouldNotCompressWorldArchive;
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotCompressWorldArchive;
}
} finally {
manager.compressionSemaphore.Release();
} }
} }

View File

@@ -1,7 +1,5 @@
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Instances;
using Phantom.Agent.Services.Instances.Procedures;
using Phantom.Common.Data.Backups; using Phantom.Common.Data.Backups;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
@@ -14,23 +12,21 @@ sealed class BackupScheduler : CancellableBackgroundTask {
private static readonly TimeSpan BackupInterval = TimeSpan.FromMinutes(30); private static readonly TimeSpan BackupInterval = TimeSpan.FromMinutes(30);
private static readonly TimeSpan BackupFailureRetryDelay = TimeSpan.FromMinutes(5); private static readonly TimeSpan BackupFailureRetryDelay = TimeSpan.FromMinutes(5);
private readonly string loggerName;
private readonly BackupManager backupManager; private readonly BackupManager backupManager;
private readonly InstanceProcess process; private readonly InstanceProcess process;
private readonly IInstanceContext context;
private readonly SemaphoreSlim backupSemaphore = new (1, 1);
private readonly int serverPort; private readonly int serverPort;
private readonly ServerStatusProtocol serverStatusProtocol; private readonly ServerStatusProtocol serverStatusProtocol;
private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new (); private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
public event EventHandler<BackupCreationResult>? BackupCompleted; public event EventHandler<BackupCreationResult>? BackupCompleted;
public BackupScheduler(TaskManager taskManager, BackupManager backupManager, InstanceProcess process, IInstanceContext context, int serverPort) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName), taskManager, "Backup scheduler for " + context.ShortName) { public BackupScheduler(TaskManager taskManager, BackupManager backupManager, InstanceProcess process, int serverPort, string loggerName) : base(PhantomLogger.Create<BackupScheduler>(loggerName), taskManager, "Backup scheduler for " + loggerName) {
this.loggerName = loggerName;
this.backupManager = backupManager; this.backupManager = backupManager;
this.process = process; this.process = process;
this.context = context;
this.serverPort = serverPort; this.serverPort = serverPort;
this.serverStatusProtocol = new ServerStatusProtocol(context.ShortName); this.serverStatusProtocol = new ServerStatusProtocol(loggerName);
Start();
} }
protected override async Task RunTask() { protected override async Task RunTask() {
@@ -54,17 +50,7 @@ sealed class BackupScheduler : CancellableBackgroundTask {
} }
private async Task<BackupCreationResult> CreateBackup() { private async Task<BackupCreationResult> CreateBackup() {
if (!await backupSemaphore.WaitAsync(TimeSpan.FromSeconds(1))) { return await backupManager.CreateBackup(loggerName, process, CancellationToken.None);
return new BackupCreationResult(BackupCreationResultKind.BackupAlreadyRunning);
}
try {
var procedure = new BackupInstanceProcedure(backupManager);
context.EnqueueProcedure(procedure);
return await procedure.Result;
} finally {
backupSemaphore.Release();
}
} }
private async Task WaitForOnlinePlayers() { private async Task WaitForOnlinePlayers() {
@@ -107,9 +93,4 @@ sealed class BackupScheduler : CancellableBackgroundTask {
Logger.Debug("Detected server output, signalling to check for online players again."); Logger.Debug("Detected server output, signalling to check for online players again.");
} }
} }
protected override void Dispose() {
backupSemaphore.Dispose();
serverOutputWhileWaitingForOnlinePlayers.Dispose();
}
} }

View File

@@ -1,25 +0,0 @@
using Phantom.Agent.Services.Instances.Procedures;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Serilog;
namespace Phantom.Agent.Services.Instances;
interface IInstanceContext {
string ShortName { get; }
ILogger Logger { get; }
InstanceServices Services { get; }
IInstanceState CurrentState { get; }
void SetStatus(IInstanceStatus newStatus);
void ReportEvent(IInstanceEvent instanceEvent);
void EnqueueProcedure(IInstanceProcedure procedure, bool immediate = false);
}
static class InstanceContextExtensions {
public static void SetLaunchFailedStatusAndReportEvent(this IInstanceContext context, InstanceLaunchFailReason reason) {
context.SetStatus(InstanceStatus.Failed(reason));
context.ReportEvent(new InstanceLaunchFailedEvent(reason));
}
}

View File

@@ -1,6 +1,5 @@
using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Rpc; using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Instances.Procedures;
using Phantom.Agent.Services.Instances.States; using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
@@ -11,12 +10,18 @@ using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
sealed class Instance : IAsyncDisposable { sealed class Instance : IDisposable {
private static uint loggerSequenceId = 0;
private static string GetLoggerName(Guid guid) {
var prefix = guid.ToString();
return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref loggerSequenceId);
}
private InstanceServices Services { get; } private InstanceServices Services { get; }
public InstanceConfiguration Configuration { get; private set; } public InstanceConfiguration Configuration { get; private set; }
private IServerLauncher Launcher { get; set; } private IServerLauncher Launcher { get; set; }
private readonly SemaphoreSlim configurationSemaphore = new (1, 1);
private readonly string shortName; private readonly string shortName;
private readonly ILogger logger; private readonly ILogger logger;
@@ -25,14 +30,14 @@ sealed class Instance : IAsyncDisposable {
private int statusUpdateCounter; private int statusUpdateCounter;
private IInstanceState currentState; private IInstanceState currentState;
private readonly SemaphoreSlim stateTransitioningActionSemaphore = new (1, 1);
public bool IsRunning => currentState is not InstanceNotRunningState; public bool IsRunning => currentState is not InstanceNotRunningState;
public event EventHandler? IsRunningChanged; public event EventHandler? IsRunningChanged;
private readonly InstanceProcedureManager procedureManager;
public Instance(string shortName, InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) { public Instance(InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
this.shortName = shortName; this.shortName = GetLoggerName(configuration.InstanceGuid);
this.logger = PhantomLogger.Create<Instance>(shortName); this.logger = PhantomLogger.Create<Instance>(shortName);
this.Services = services; this.Services = services;
@@ -41,8 +46,6 @@ sealed class Instance : IAsyncDisposable {
this.currentState = new InstanceNotRunningState(); this.currentState = new InstanceNotRunningState();
this.currentStatus = InstanceStatus.NotRunning; this.currentStatus = InstanceStatus.NotRunning;
this.procedureManager = new InstanceProcedureManager(this, new Context(this), services.TaskManager);
} }
private void TryUpdateStatus(string taskName, Func<Task> getUpdateTask) { private void TryUpdateStatus(string taskName, Func<Task> getUpdateTask) {
@@ -63,10 +66,8 @@ sealed class Instance : IAsyncDisposable {
private void ReportAndSetStatus(IInstanceStatus status) { private void ReportAndSetStatus(IInstanceStatus status) {
TryUpdateStatus("Report status of instance " + shortName + " as " + status.GetType().Name, async () => { TryUpdateStatus("Report status of instance " + shortName + " as " + status.GetType().Name, async () => {
if (status != currentStatus) { currentStatus = status;
currentStatus = status; await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, status));
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, status));
}
}); });
} }
@@ -75,7 +76,7 @@ sealed class Instance : IAsyncDisposable {
Services.TaskManager.Run("Report event for instance " + shortName, async () => await ServerMessaging.Send(message)); Services.TaskManager.Run("Report event for instance " + shortName, async () => await ServerMessaging.Send(message));
} }
internal void TransitionState(IInstanceState newState) { private void TransitionState(IInstanceState newState) {
if (currentState == newState) { if (currentState == newState) {
return; return;
} }
@@ -95,94 +96,114 @@ sealed class Instance : IAsyncDisposable {
} }
} }
private T TransitionStateAndReturn<T>((IInstanceState State, T Result) newStateAndResult) {
TransitionState(newStateAndResult.State);
return newStateAndResult.Result;
}
public async Task Reconfigure(InstanceConfiguration configuration, IServerLauncher launcher, CancellationToken cancellationToken) { public async Task Reconfigure(InstanceConfiguration configuration, IServerLauncher launcher, CancellationToken cancellationToken) {
await configurationSemaphore.WaitAsync(cancellationToken); await stateTransitioningActionSemaphore.WaitAsync(cancellationToken);
try { try {
Configuration = configuration; Configuration = configuration;
Launcher = launcher; Launcher = launcher;
} finally { } finally {
configurationSemaphore.Release(); stateTransitioningActionSemaphore.Release();
} }
} }
public async Task<LaunchInstanceResult> Launch(CancellationToken cancellationToken) { public async Task<LaunchInstanceResult> Launch(CancellationToken shutdownCancellationToken) {
if (IsRunning) { await stateTransitioningActionSemaphore.WaitAsync(shutdownCancellationToken);
return LaunchInstanceResult.InstanceAlreadyRunning;
}
if (await procedureManager.GetCurrentProcedure(cancellationToken) is LaunchInstanceProcedure) {
return LaunchInstanceResult.InstanceAlreadyLaunching;
}
LaunchInstanceProcedure procedure;
await configurationSemaphore.WaitAsync(cancellationToken);
try { try {
procedure = new LaunchInstanceProcedure(Configuration, Launcher); return TransitionStateAndReturn(currentState.Launch(new InstanceContextImpl(this, shutdownCancellationToken)));
} catch (Exception e) {
logger.Error(e, "Caught exception while launching instance.");
return LaunchInstanceResult.UnknownError;
} finally { } finally {
configurationSemaphore.Release(); stateTransitioningActionSemaphore.Release();
} }
ReportAndSetStatus(InstanceStatus.Launching);
await procedureManager.Enqueue(procedure);
return LaunchInstanceResult.LaunchInitiated;
} }
public async Task<StopInstanceResult> Stop(MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) { public async Task<StopInstanceResult> Stop(MinecraftStopStrategy stopStrategy) {
if (!IsRunning) { await stateTransitioningActionSemaphore.WaitAsync();
return StopInstanceResult.InstanceAlreadyStopped; try {
return TransitionStateAndReturn(currentState.Stop(stopStrategy));
} catch (Exception e) {
logger.Error(e, "Caught exception while stopping instance.");
return StopInstanceResult.UnknownError;
} finally {
stateTransitioningActionSemaphore.Release();
} }
}
if (await procedureManager.GetCurrentProcedure(cancellationToken) is StopInstanceProcedure) {
return StopInstanceResult.InstanceAlreadyStopping; public async Task StopAndWait(TimeSpan waitTime) {
await Stop(MinecraftStopStrategy.Instant);
using var waitTokenSource = new CancellationTokenSource(waitTime);
var waitToken = waitTokenSource.Token;
while (currentState is not InstanceNotRunningState) {
await Task.Delay(TimeSpan.FromMilliseconds(250), waitToken);
} }
ReportAndSetStatus(InstanceStatus.Stopping);
await procedureManager.Enqueue(new StopInstanceProcedure(stopStrategy));
return StopInstanceResult.StopInitiated;
} }
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) { public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return await currentState.SendCommand(command, cancellationToken); return await currentState.SendCommand(command, cancellationToken);
} }
public async ValueTask DisposeAsync() { private sealed class InstanceContextImpl : InstanceContext {
await procedureManager.DisposeAsync();
while (currentState is not InstanceNotRunningState) {
await Task.Delay(TimeSpan.FromMilliseconds(250), CancellationToken.None);
}
if (currentState is IDisposable disposable) {
disposable.Dispose();
}
configurationSemaphore.Dispose();
}
private sealed class Context : IInstanceContext {
public string ShortName => instance.shortName;
public ILogger Logger => instance.logger;
public InstanceServices Services => instance.Services;
public IInstanceState CurrentState => instance.currentState;
private readonly Instance instance; private readonly Instance instance;
private readonly CancellationToken shutdownCancellationToken;
public Context(Instance instance) { public InstanceContextImpl(Instance instance, CancellationToken shutdownCancellationToken) : base(instance.Services, instance.Configuration, instance.Launcher) {
this.instance = instance; this.instance = instance;
this.shutdownCancellationToken = shutdownCancellationToken;
} }
public void SetStatus(IInstanceStatus newStatus) { public override ILogger Logger => instance.logger;
public override string ShortName => instance.shortName;
public override void SetStatus(IInstanceStatus newStatus) {
instance.ReportAndSetStatus(newStatus); instance.ReportAndSetStatus(newStatus);
} }
public void ReportEvent(IInstanceEvent instanceEvent) { public override void ReportEvent(IInstanceEvent instanceEvent) {
instance.ReportEvent(instanceEvent); instance.ReportEvent(instanceEvent);
} }
public void EnqueueProcedure(IInstanceProcedure procedure, bool immediate) { public override void TransitionState(Func<(IInstanceState, IInstanceStatus?)> newStateAndStatus) {
Services.TaskManager.Run("Enqueue procedure for instance " + instance.shortName, () => instance.procedureManager.Enqueue(procedure, immediate)); instance.stateTransitioningActionSemaphore.Wait(CancellationToken.None);
try {
var (state, status) = newStateAndStatus();
if (!instance.IsRunning) {
// Only InstanceSessionManager is allowed to transition an instance out of a non-running state.
instance.logger.Debug("Cancelled state transition to {State} because instance is not running.", state.GetType().Name);
return;
}
if (state is not InstanceNotRunningState && shutdownCancellationToken.IsCancellationRequested) {
instance.logger.Debug("Cancelled state transition to {State} due to Agent shutdown.", state.GetType().Name);
return;
}
if (status != null) {
SetStatus(status);
}
instance.TransitionState(state);
} catch (Exception e) {
instance.logger.Error(e, "Caught exception during state transition.");
} finally {
instance.stateTransitioningActionSemaphore.Release();
}
}
}
public void Dispose() {
stateTransitioningActionSemaphore.Dispose();
if (currentState is IDisposable disposable) {
disposable.Dispose();
} }
} }
} }

View File

@@ -0,0 +1,35 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Serilog;
namespace Phantom.Agent.Services.Instances;
abstract class InstanceContext {
public InstanceServices Services { get; }
public InstanceConfiguration Configuration { get; }
public IServerLauncher Launcher { get; }
public abstract ILogger Logger { get; }
public abstract string ShortName { get; }
protected InstanceContext(InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
Services = services;
Configuration = configuration;
Launcher = launcher;
}
public abstract void SetStatus(IInstanceStatus newStatus);
public void SetLaunchFailedStatusAndReportEvent(InstanceLaunchFailReason reason) {
SetStatus(InstanceStatus.Failed(reason));
ReportEvent(new InstanceLaunchFailedEvent(reason));
}
public abstract void ReportEvent(IInstanceEvent instanceEvent);
public abstract void TransitionState(Func<(IInstanceState, IInstanceStatus?)> newStateAndStatus);
public void TransitionState(IInstanceState newState, IInstanceStatus? newStatus = null) {
TransitionState(() => (newState, newStatus));
}
}

View File

@@ -1,62 +1,36 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Threading.Channels;
using Phantom.Agent.Rpc; using Phantom.Agent.Rpc;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer; using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Collections;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
sealed class InstanceLogSender : CancellableBackgroundTask { sealed class InstanceLogSender : CancellableBackgroundTask {
private static readonly BoundedChannelOptions BufferOptions = new (capacity: 64) {
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.DropNewest
};
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 Channel<string> outputChannel;
private readonly SemaphoreSlim semaphore = new (1, 1);
private int droppedLinesSinceLastSend; 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 loggerName) : base(PhantomLogger.Create<InstanceLogSender>(loggerName), taskManager, "Instance log sender for " + loggerName) {
this.instanceGuid = instanceGuid; this.instanceGuid = instanceGuid;
this.outputChannel = Channel.CreateBounded<string>(BufferOptions, OnLineDropped);
Start();
} }
protected override async Task RunTask() { protected override async Task RunTask() {
var lineReader = outputChannel.Reader;
var lineBuilder = ImmutableArray.CreateBuilder<string>();
try { try {
while (await lineReader.WaitToReadAsync(CancellationToken)) { while (!CancellationToken.IsCancellationRequested) {
await SendOutputToServer(await DequeueOrThrow());
await Task.Delay(SendDelay, CancellationToken); await Task.Delay(SendDelay, CancellationToken);
await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder));
} }
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
// Ignore. // Ignore.
} }
// Flush remaining lines. // Flush remaining lines.
await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder)); await SendOutputToServer(DequeueWithoutSemaphore());
}
private ImmutableArray<string> ReadLinesFromChannel(ChannelReader<string> reader, ImmutableArray<string>.Builder builder) {
builder.Clear();
while (reader.TryRead(out string? line)) {
builder.Add(line);
}
int droppedLines = Interlocked.Exchange(ref droppedLinesSinceLastSend, 0);
if (droppedLines > 0) {
builder.Add($"Dropped {droppedLines} {(droppedLines == 1 ? "line" : "lines")} due to buffer overflow.");
}
return builder.ToImmutable();
} }
private async Task SendOutputToServer(ImmutableArray<string> lines) { private async Task SendOutputToServer(ImmutableArray<string> lines) {
@@ -65,18 +39,33 @@ sealed class InstanceLogSender : CancellableBackgroundTask {
} }
} }
private void OnLineDropped(string line) { private ImmutableArray<string> DequeueWithoutSemaphore() {
Logger.Warning("Buffer is full, dropped line: {Line}", line); ImmutableArray<string> lines = buffer.Count > 0 ? buffer.EnumerateLast(uint.MaxValue).ToImmutableArray() : ImmutableArray<string>.Empty;
Interlocked.Increment(ref droppedLinesSinceLastSend); buffer.Clear();
return lines;
}
private async Task<ImmutableArray<string>> DequeueOrThrow() {
await semaphore.WaitAsync(CancellationToken);
try {
return DequeueWithoutSemaphore();
} finally {
semaphore.Release();
}
} }
public void Enqueue(string line) { public void Enqueue(string line) {
outputChannel.Writer.TryWrite(line); try {
} semaphore.Wait(CancellationToken);
} catch (Exception) {
return;
}
protected override void Dispose() { try {
if (!outputChannel.Writer.TryComplete()) { buffer.Add(line);
Logger.Error("Could not mark channel as completed."); } finally {
semaphore.Release();
} }
} }
} }

View File

@@ -1,85 +0,0 @@
using Phantom.Agent.Services.Instances.Procedures;
using Phantom.Common.Data.Minecraft;
using Phantom.Utils.Collections;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceProcedureManager : IAsyncDisposable {
private readonly record struct CurrentProcedure(IInstanceProcedure Procedure, CancellationTokenSource CancellationTokenSource);
private readonly ThreadSafeStructRef<CurrentProcedure> currentProcedure = new ();
private readonly ThreadSafeLinkedList<IInstanceProcedure> procedureQueue = new ();
private readonly AutoResetEvent procedureQueueReady = new (false);
private readonly ManualResetEventSlim procedureQueueFinished = new (false);
private readonly Instance instance;
private readonly IInstanceContext context;
private readonly CancellationTokenSource shutdownCancellationTokenSource = new ();
public InstanceProcedureManager(Instance instance, IInstanceContext context, TaskManager taskManager) {
this.instance = instance;
this.context = context;
taskManager.Run("Procedure manager for instance " + context.ShortName, Run);
}
public async Task Enqueue(IInstanceProcedure procedure, bool immediate = false) {
await procedureQueue.Add(procedure, toFront: immediate, shutdownCancellationTokenSource.Token);
procedureQueueReady.Set();
}
public async Task<IInstanceProcedure?> GetCurrentProcedure(CancellationToken cancellationToken) {
return (await currentProcedure.Get(cancellationToken))?.Procedure;
}
private async Task Run() {
try {
var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
while (true) {
await procedureQueueReady.WaitOneAsync(shutdownCancellationToken);
while (await procedureQueue.TryTakeFromFront(shutdownCancellationToken) is {} nextProcedure) {
using var procedureCancellationTokenSource = new CancellationTokenSource();
await currentProcedure.Set(new CurrentProcedure(nextProcedure, procedureCancellationTokenSource), shutdownCancellationToken);
await RunProcedure(nextProcedure, procedureCancellationTokenSource.Token);
await currentProcedure.Set(null, shutdownCancellationToken);
}
}
} catch (OperationCanceledException) {
// Ignore.
}
await RunProcedure(new StopInstanceProcedure(MinecraftStopStrategy.Instant), CancellationToken.None);
procedureQueueFinished.Set();
}
private async Task RunProcedure(IInstanceProcedure procedure, CancellationToken cancellationToken) {
var procedureName = procedure.GetType().Name;
context.Logger.Debug("Started procedure: {Procedure}", procedureName);
try {
var newState = await procedure.Run(context, cancellationToken);
context.Logger.Debug("Finished procedure: {Procedure}", procedureName);
if (newState != null) {
instance.TransitionState(newState);
}
} catch (OperationCanceledException) {
context.Logger.Debug("Cancelled procedure: {Procedure}", procedureName);
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while running procedure: {Procedure}", procedureName);
}
}
public async ValueTask DisposeAsync() {
shutdownCancellationTokenSource.Cancel();
(await currentProcedure.Get(CancellationToken.None))?.CancellationTokenSource.Cancel();
await procedureQueueFinished.WaitHandle.WaitOneAsync();
currentProcedure.Dispose();
procedureQueue.Dispose();
procedureQueueReady.Dispose();
procedureQueueFinished.Dispose();
shutdownCancellationTokenSource.Dispose();
}
}

View File

@@ -21,9 +21,9 @@ using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
sealed class InstanceSessionManager : IAsyncDisposable { sealed class InstanceSessionManager : IDisposable {
private static readonly ILogger Logger = PhantomLogger.Create<InstanceSessionManager>(); private static readonly ILogger Logger = PhantomLogger.Create<InstanceSessionManager>();
private readonly AgentInfo agentInfo; private readonly AgentInfo agentInfo;
private readonly string basePath; private readonly string basePath;
@@ -34,17 +34,15 @@ sealed class InstanceSessionManager : IAsyncDisposable {
private readonly CancellationToken shutdownCancellationToken; private readonly CancellationToken shutdownCancellationToken;
private readonly SemaphoreSlim semaphore = new (1, 1); private readonly SemaphoreSlim semaphore = new (1, 1);
private uint instanceLoggerSequenceId = 0;
public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager, BackupManager backupManager) { public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager, BackupManager backupManager) {
this.agentInfo = agentInfo; this.agentInfo = agentInfo;
this.basePath = agentFolders.InstancesFolderPath; this.basePath = agentFolders.InstancesFolderPath;
this.shutdownCancellationToken = shutdownCancellationTokenSource.Token; this.shutdownCancellationToken = shutdownCancellationTokenSource.Token;
var minecraftServerExecutables = new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath); var minecraftServerExecutables = new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath);
var launchServices = new LaunchServices(minecraftServerExecutables, javaRuntimeRepository); var launchServices = new LaunchServices(minecraftServerExecutables, javaRuntimeRepository);
var portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts); var portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
this.instanceServices = new InstanceServices(taskManager, portManager, backupManager, launchServices); this.instanceServices = new InstanceServices(taskManager, portManager, backupManager, launchServices);
} }
@@ -78,7 +76,7 @@ sealed class InstanceSessionManager : IAsyncDisposable {
var instanceGuid = configuration.InstanceGuid; var instanceGuid = configuration.InstanceGuid;
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString()); var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
Directories.Create(instanceFolder, Chmod.URWX_GRX); Directories.Create(instanceFolder, Chmod.URWX_GRX);
var heapMegabytes = configuration.MemoryAllocation.InMegabytes; var heapMegabytes = configuration.MemoryAllocation.InMegabytes;
var jvmProperties = new JvmProperties( var jvmProperties = new JvmProperties(
InitialHeapMegabytes: heapMegabytes / 2, InitialHeapMegabytes: heapMegabytes / 2,
@@ -105,15 +103,15 @@ sealed class InstanceSessionManager : IAsyncDisposable {
if (instances.TryGetValue(instanceGuid, out var instance)) { if (instances.TryGetValue(instanceGuid, out var instance)) {
await instance.Reconfigure(configuration, launcher, shutdownCancellationToken); await instance.Reconfigure(configuration, launcher, shutdownCancellationToken);
Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid); Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
if (alwaysReportStatus) { if (alwaysReportStatus) {
instance.ReportLastStatus(); instance.ReportLastStatus();
} }
} }
else { else {
instances[instanceGuid] = instance = new Instance(GetInstanceLoggerName(instanceGuid), instanceServices, configuration, launcher); instances[instanceGuid] = instance = new Instance(instanceServices, configuration, launcher);
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid); Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
instance.ReportLastStatus(); instance.ReportLastStatus();
instance.IsRunningChanged += OnInstanceIsRunningChanged; instance.IsRunningChanged += OnInstanceIsRunningChanged;
} }
@@ -126,11 +124,6 @@ sealed class InstanceSessionManager : IAsyncDisposable {
}); });
} }
private string GetInstanceLoggerName(Guid guid) {
var prefix = guid.ToString();
return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref instanceLoggerSequenceId);
}
private ImmutableArray<Instance> GetRunningInstancesInternal() { private ImmutableArray<Instance> GetRunningInstancesInternal() {
return instances.Values.Where(static instance => instance.IsRunning).ToImmutableArray(); return instances.Values.Where(static instance => instance.IsRunning).ToImmutableArray();
} }
@@ -174,23 +167,38 @@ sealed class InstanceSessionManager : IAsyncDisposable {
} }
public Task<InstanceActionResult<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) { public Task<InstanceActionResult<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy, shutdownCancellationToken)); return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy));
} }
public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) { public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, async instance => await instance.SendCommand(command, shutdownCancellationToken) ? SendCommandToInstanceResult.Success : SendCommandToInstanceResult.UnknownError); return AcquireSemaphoreAndRunWithInstance(instanceGuid, async instance => await instance.SendCommand(command, shutdownCancellationToken) ? SendCommandToInstanceResult.Success : SendCommandToInstanceResult.UnknownError);
} }
public async ValueTask DisposeAsync() { public async Task StopAll() {
Logger.Information("Stopping all instances...");
shutdownCancellationTokenSource.Cancel(); shutdownCancellationTokenSource.Cancel();
Logger.Information("Stopping all instances...");
await semaphore.WaitAsync(CancellationToken.None); await semaphore.WaitAsync(CancellationToken.None);
await Task.WhenAll(instances.Values.Select(static instance => instance.DisposeAsync().AsTask())); try {
instances.Clear(); await Task.WhenAll(instances.Values.Select(static instance => instance.StopAndWait(TimeSpan.FromSeconds(30))));
DisposeAllInstances();
} finally {
semaphore.Release();
}
}
public void Dispose() {
DisposeAllInstances();
shutdownCancellationTokenSource.Dispose(); shutdownCancellationTokenSource.Dispose();
semaphore.Dispose(); semaphore.Dispose();
} }
private void DisposeAllInstances() {
foreach (var (_, instance) in instances) {
instance.Dispose();
}
instances.Clear();
}
} }

View File

@@ -1,29 +0,0 @@
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Backups;
namespace Phantom.Agent.Services.Instances.Procedures;
sealed record BackupInstanceProcedure(BackupManager BackupManager) : IInstanceProcedure {
private readonly TaskCompletionSource<BackupCreationResult> resultCompletionSource = new ();
public Task<BackupCreationResult> Result => resultCompletionSource.Task;
public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) {
if (context.CurrentState is not InstanceRunningState runningState || runningState.Process.HasEnded) {
resultCompletionSource.SetResult(new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning));
return null;
}
try {
var result = await BackupManager.CreateBackup(context.ShortName, runningState.Process, cancellationToken);
resultCompletionSource.SetResult(result);
} catch (OperationCanceledException) {
resultCompletionSource.SetCanceled(cancellationToken);
} catch (Exception e) {
resultCompletionSource.SetException(e);
}
return null;
}
}

View File

@@ -1,7 +0,0 @@
using Phantom.Agent.Services.Instances.States;
namespace Phantom.Agent.Services.Instances.Procedures;
interface IInstanceProcedure {
Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken);
}

View File

@@ -1,100 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.Procedures;
sealed record LaunchInstanceProcedure(InstanceConfiguration Configuration, IServerLauncher Launcher, bool IsRestarting = false) : IInstanceProcedure {
public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) {
if (!IsRestarting && context.CurrentState is InstanceRunningState) {
return null;
}
context.SetStatus(IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching);
InstanceLaunchFailReason? failReason = context.Services.PortManager.Reserve(Configuration) switch {
PortManager.Result.ServerPortNotAllowed => InstanceLaunchFailReason.ServerPortNotAllowed,
PortManager.Result.ServerPortAlreadyInUse => InstanceLaunchFailReason.ServerPortAlreadyInUse,
PortManager.Result.RconPortNotAllowed => InstanceLaunchFailReason.RconPortNotAllowed,
PortManager.Result.RconPortAlreadyInUse => InstanceLaunchFailReason.RconPortAlreadyInUse,
_ => null
};
if (failReason is {} reason) {
context.SetLaunchFailedStatusAndReportEvent(reason);
return new InstanceNotRunningState();
}
context.Logger.Information("Session starting...");
try {
InstanceProcess process = await DoLaunch(context, cancellationToken);
return new InstanceRunningState(Configuration, Launcher, process, context);
} catch (OperationCanceledException) {
context.SetStatus(InstanceStatus.NotRunning);
} catch (LaunchFailureException e) {
context.Logger.Error(e.LogMessage);
context.SetLaunchFailedStatusAndReportEvent(e.Reason);
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while launching instance.");
context.SetLaunchFailedStatusAndReportEvent(InstanceLaunchFailReason.UnknownError);
}
context.Services.PortManager.Release(Configuration);
return new InstanceNotRunningState();
}
private async Task<InstanceProcess> DoLaunch(IInstanceContext context, CancellationToken cancellationToken) {
cancellationToken.ThrowIfCancellationRequested();
byte lastDownloadProgress = byte.MaxValue;
void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) {
byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, 100);
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
context.SetStatus(InstanceStatus.Downloading(progress));
}
}
var launchResult = await Launcher.Launch(context.Logger, context.Services.LaunchServices, OnDownloadProgress, cancellationToken);
if (launchResult is LaunchResult.InvalidJavaRuntime) {
throw new LaunchFailureException(InstanceLaunchFailReason.JavaRuntimeNotFound, "Session failed to launch, invalid Java runtime.");
}
else if (launchResult is LaunchResult.InvalidJvmArguments) {
throw new LaunchFailureException(InstanceLaunchFailReason.InvalidJvmArguments, "Session failed to launch, invalid JVM arguments.");
}
else if (launchResult is LaunchResult.CouldNotDownloadMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotDownloadMinecraftServer, "Session failed to launch, could not download Minecraft server.");
}
else if (launchResult is LaunchResult.CouldNotPrepareMinecraftServerLauncher) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher, "Session failed to launch, could not prepare Minecraft server launcher.");
}
else if (launchResult is LaunchResult.CouldNotConfigureMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotConfigureMinecraftServer, "Session failed to launch, could not configure Minecraft server.");
}
else if (launchResult is LaunchResult.CouldNotStartMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotStartMinecraftServer, "Session failed to launch, could not start Minecraft server.");
}
if (launchResult is not LaunchResult.Success launchSuccess) {
throw new LaunchFailureException(InstanceLaunchFailReason.UnknownError, "Session failed to launch.");
}
context.SetStatus(InstanceStatus.Running);
context.ReportEvent(InstanceEvent.LaunchSucceded);
return launchSuccess.Process;
}
private sealed class LaunchFailureException : Exception {
public InstanceLaunchFailReason Reason { get; }
public string LogMessage { get; }
public LaunchFailureException(InstanceLaunchFailReason reason, string logMessage) {
this.Reason = reason;
this.LogMessage = logMessage;
}
}
}

View File

@@ -1,17 +0,0 @@
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.Procedures;
sealed record SetInstanceToNotRunningStateProcedure(IInstanceStatus Status) : IInstanceProcedure {
public Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) {
if (context.CurrentState is InstanceRunningState { Process.HasEnded: true }) {
context.SetStatus(Status);
context.ReportEvent(InstanceEvent.Stopped);
return Task.FromResult<IInstanceState?>(new InstanceNotRunningState());
}
else {
return Task.FromResult<IInstanceState?>(null);
}
}
}

View File

@@ -1,103 +0,0 @@
using System.Diagnostics;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
namespace Phantom.Agent.Services.Instances.Procedures;
sealed record StopInstanceProcedure(MinecraftStopStrategy StopStrategy) : IInstanceProcedure {
private static readonly ushort[] Stops = { 60, 30, 10, 5, 4, 3, 2, 1, 0 };
public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) {
if (context.CurrentState is not InstanceRunningState runningState) {
return null;
}
var process = runningState.Process;
runningState.IsStopping = true;
context.SetStatus(InstanceStatus.Stopping);
var seconds = StopStrategy.Seconds;
if (seconds > 0) {
try {
await CountDownWithAnnouncements(context, process, seconds, cancellationToken);
} catch (OperationCanceledException) {
runningState.IsStopping = false;
return null;
}
}
try {
// Too late to cancel the stop procedure now.
if (!process.HasEnded) {
context.Logger.Information("Session stopping now.");
await DoStop(context, process);
}
} finally {
context.Logger.Information("Session stopped.");
context.SetStatus(InstanceStatus.NotRunning);
context.ReportEvent(InstanceEvent.Stopped);
}
return new InstanceNotRunningState();
}
private async Task CountDownWithAnnouncements(IInstanceContext context, InstanceProcess process, ushort seconds, CancellationToken cancellationToken) {
context.Logger.Information("Session stopping in {Seconds} seconds.", seconds);
foreach (var stop in Stops) {
// TODO change to event-based cancellation
if (process.HasEnded) {
return;
}
if (seconds > stop) {
await process.SendCommand(GetCountDownAnnouncementCommand(seconds), cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(seconds - stop), cancellationToken);
seconds = stop;
}
}
}
private static string GetCountDownAnnouncementCommand(ushort seconds) {
return MinecraftCommand.Say("Server shutting down in " + seconds + (seconds == 1 ? " second." : " seconds."));
}
private async Task DoStop(IInstanceContext context, InstanceProcess process) {
context.Logger.Information("Sending stop command...");
await TrySendStopCommand(context, process);
context.Logger.Information("Waiting for session to end...");
await WaitForSessionToEnd(context, process);
}
private async Task TrySendStopCommand(IInstanceContext context, InstanceProcess process) {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try {
await process.SendCommand(MinecraftCommand.Stop, timeout.Token);
} catch (OperationCanceledException) {
// Ignore.
} catch (ObjectDisposedException e) when (e.ObjectName == typeof(Process).FullName && process.HasEnded) {
// Ignore.
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending stop command.");
}
}
private async Task WaitForSessionToEnd(IInstanceContext context, InstanceProcess process) {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(55));
try {
await process.WaitForExit(timeout.Token);
} catch (OperationCanceledException) {
try {
context.Logger.Warning("Waiting timed out, killing session...");
process.Kill();
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while killing session.");
}
}
}
}

View File

@@ -0,0 +1,28 @@
using Phantom.Agent.Minecraft.Instance;
namespace Phantom.Agent.Services.Instances.Sessions;
sealed class InstanceSession : IDisposable {
private readonly InstanceProcess process;
private readonly InstanceContext context;
private readonly InstanceLogSender logSender;
public InstanceSession(InstanceProcess process, InstanceContext context) {
this.process = process;
this.context = context;
this.logSender = new InstanceLogSender(context.Services.TaskManager, context.Configuration.InstanceGuid, context.ShortName);
this.process.AddOutputListener(SessionOutput);
}
private void SessionOutput(object? sender, string line) {
context.Logger.Debug("[Server] {Line}", line);
logSender.Enqueue(line);
}
public void Dispose() {
logSender.Stop();
process.Dispose();
context.Services.PortManager.Release(context.Configuration);
}
}

View File

@@ -1,6 +1,11 @@
namespace Phantom.Agent.Services.Instances.States; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
interface IInstanceState { interface IInstanceState {
void Initialize(); void Initialize();
(IInstanceState, LaunchInstanceResult) Launch(InstanceContext context);
(IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy);
Task<bool> SendCommand(string command, CancellationToken cancellationToken); Task<bool> SendCommand(string command, CancellationToken cancellationToken);
} }

View File

@@ -0,0 +1,127 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Instances.Sessions;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceLaunchingState : IInstanceState, IDisposable {
private readonly InstanceContext context;
private readonly CancellationTokenSource cancellationTokenSource = new ();
private byte lastDownloadProgress = byte.MaxValue;
public InstanceLaunchingState(InstanceContext context) {
this.context = context;
}
public void Initialize() {
context.Logger.Information("Session starting...");
var launchTask = context.Services.TaskManager.Run("Launch procedure for instance " + context.ShortName, DoLaunch);
launchTask.ContinueWith(OnLaunchSuccess, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
launchTask.ContinueWith(OnLaunchFailure, CancellationToken.None, TaskContinuationOptions.NotOnRanToCompletion, TaskScheduler.Default);
}
private async Task<InstanceProcess> DoLaunch() {
var cancellationToken = cancellationTokenSource.Token;
cancellationToken.ThrowIfCancellationRequested();
void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) {
byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, 100);
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
context.SetStatus(InstanceStatus.Downloading(progress));
}
}
var launchResult = await context.Launcher.Launch(context.Logger, context.Services.LaunchServices, OnDownloadProgress, cancellationToken);
if (launchResult is LaunchResult.InvalidJavaRuntime) {
throw new LaunchFailureException(InstanceLaunchFailReason.JavaRuntimeNotFound, "Session failed to launch, invalid Java runtime.");
}
else if (launchResult is LaunchResult.InvalidJvmArguments) {
throw new LaunchFailureException(InstanceLaunchFailReason.InvalidJvmArguments, "Session failed to launch, invalid JVM arguments.");
}
else if (launchResult is LaunchResult.CouldNotDownloadMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotDownloadMinecraftServer, "Session failed to launch, could not download Minecraft server.");
}
else if (launchResult is LaunchResult.CouldNotPrepareMinecraftServerLauncher) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher, "Session failed to launch, could not prepare Minecraft server launcher.");
}
else if (launchResult is LaunchResult.CouldNotConfigureMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotConfigureMinecraftServer, "Session failed to launch, could not configure Minecraft server.");
}
else if (launchResult is LaunchResult.CouldNotStartMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotStartMinecraftServer, "Session failed to launch, could not start Minecraft server.");
}
if (launchResult is not LaunchResult.Success launchSuccess) {
throw new LaunchFailureException(InstanceLaunchFailReason.UnknownError, "Session failed to launch.");
}
context.SetStatus(InstanceStatus.Launching);
return launchSuccess.Process;
}
private void OnLaunchSuccess(Task<InstanceProcess> task) {
context.TransitionState(() => {
context.ReportEvent(InstanceEvent.LaunchSucceded);
var process = task.Result;
var session = new InstanceSession(process, context);
if (cancellationTokenSource.IsCancellationRequested) {
return (new InstanceStoppingState(context, process, session), InstanceStatus.Stopping);
}
else {
return (new InstanceRunningState(context, process, session), null);
}
});
}
private void OnLaunchFailure(Task task) {
if (task.IsFaulted) {
if (task.Exception is { InnerException: LaunchFailureException e }) {
context.Logger.Error(e.LogMessage);
context.SetLaunchFailedStatusAndReportEvent(e.Reason);
}
else {
context.Logger.Error(task.Exception, "Caught exception while launching instance.");
context.SetLaunchFailedStatusAndReportEvent(InstanceLaunchFailReason.UnknownError);
}
}
context.Services.PortManager.Release(context.Configuration);
context.TransitionState(new InstanceNotRunningState());
}
private sealed class LaunchFailureException : Exception {
public InstanceLaunchFailReason Reason { get; }
public string LogMessage { get; }
public LaunchFailureException(InstanceLaunchFailReason reason, string logMessage) {
this.Reason = reason;
this.LogMessage = logMessage;
}
}
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
return (this, LaunchInstanceResult.InstanceAlreadyLaunching);
}
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
cancellationTokenSource.Cancel();
return (this, StopInstanceResult.StopInitiated);
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return Task.FromResult(false);
}
public void Dispose() {
cancellationTokenSource.Dispose();
}
}

View File

@@ -1,8 +1,34 @@
namespace Phantom.Agent.Services.Instances.States; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceNotRunningState : IInstanceState { sealed class InstanceNotRunningState : IInstanceState {
public void Initialize() {} public void Initialize() {}
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
InstanceLaunchFailReason? failReason = context.Services.PortManager.Reserve(context.Configuration) switch {
PortManager.Result.ServerPortNotAllowed => InstanceLaunchFailReason.ServerPortNotAllowed,
PortManager.Result.ServerPortAlreadyInUse => InstanceLaunchFailReason.ServerPortAlreadyInUse,
PortManager.Result.RconPortNotAllowed => InstanceLaunchFailReason.RconPortNotAllowed,
PortManager.Result.RconPortAlreadyInUse => InstanceLaunchFailReason.RconPortAlreadyInUse,
_ => null
};
if (failReason is {} reason) {
context.SetLaunchFailedStatusAndReportEvent(reason);
return (this, LaunchInstanceResult.LaunchInitiated);
}
context.SetStatus(InstanceStatus.Launching);
return (new InstanceLaunchingState(context), LaunchInstanceResult.LaunchInitiated);
}
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
return (this, StopInstanceResult.InstanceAlreadyStopped);
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) { public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return Task.FromResult(false); return Task.FromResult(false);
} }

View File

@@ -1,81 +1,133 @@
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Services.Backups; using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances.Procedures; using Phantom.Agent.Services.Instances.Sessions;
using Phantom.Common.Data.Backups; using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States; namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceRunningState : IInstanceState, IDisposable { sealed class InstanceRunningState : IInstanceState {
public InstanceProcess Process { get; } private readonly InstanceContext context;
private readonly InstanceProcess process;
internal bool IsStopping { get; set; }
private readonly InstanceConfiguration configuration;
private readonly IServerLauncher launcher;
private readonly IInstanceContext context;
private readonly InstanceLogSender logSender;
private readonly BackupScheduler backupScheduler; private readonly BackupScheduler backupScheduler;
private readonly RunningSessionDisposer runningSessionDisposer;
private readonly CancellationTokenSource delayedStopCancellationTokenSource = new ();
private bool stateOwnsDelayedStopCancellationTokenSource = true;
private bool isStopping;
private bool isDisposed; public InstanceRunningState(InstanceContext context, InstanceProcess process, InstanceSession session) {
public InstanceRunningState(InstanceConfiguration configuration, IServerLauncher launcher, InstanceProcess process, IInstanceContext context) {
this.configuration = configuration;
this.launcher = launcher;
this.context = context; this.context = context;
this.Process = process; this.process = process;
this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, process, context.Configuration.ServerPort, context.ShortName);
this.logSender = new InstanceLogSender(context.Services.TaskManager, configuration.InstanceGuid, context.ShortName);
this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, process, context, configuration.ServerPort);
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted; this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
this.runningSessionDisposer = new RunningSessionDisposer(this, session);
} }
public void Initialize() { public void Initialize() {
Process.Ended += ProcessEnded; process.Ended += ProcessEnded;
if (Process.HasEnded) { if (process.HasEnded) {
if (TryDispose()) { if (runningSessionDisposer.Dispose()) {
context.Logger.Warning("Session ended immediately after it was started."); context.Logger.Warning("Session ended immediately after it was started.");
context.EnqueueProcedure(new SetInstanceToNotRunningStateProcedure(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)), immediate: true); context.ReportEvent(InstanceEvent.Stopped);
context.Services.TaskManager.Run("Transition state of instance " + context.ShortName + " to not running", () => context.TransitionState(new InstanceNotRunningState(), InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)));
} }
} }
else { else {
context.SetStatus(InstanceStatus.Running);
context.Logger.Information("Session started."); context.Logger.Information("Session started.");
Process.AddOutputListener(SessionOutput);
} }
} }
private void SessionOutput(object? sender, string line) {
context.Logger.Debug("[Server] {Line}", line);
logSender.Enqueue(line);
}
private void ProcessEnded(object? sender, EventArgs e) { private void ProcessEnded(object? sender, EventArgs e) {
if (!TryDispose()) { if (!runningSessionDisposer.Dispose()) {
return; return;
} }
if (IsStopping) { if (isStopping) {
context.EnqueueProcedure(new SetInstanceToNotRunningStateProcedure(InstanceStatus.NotRunning), immediate: true); context.Logger.Information("Session ended.");
context.ReportEvent(InstanceEvent.Stopped);
context.TransitionState(new InstanceNotRunningState(), InstanceStatus.NotRunning);
} }
else { else {
context.Logger.Information("Session ended unexpectedly, restarting..."); context.Logger.Information("Session ended unexpectedly, restarting...");
context.ReportEvent(InstanceEvent.Crashed); context.ReportEvent(InstanceEvent.Crashed);
context.EnqueueProcedure(new LaunchInstanceProcedure(configuration, launcher, IsRestarting: true)); context.TransitionState(new InstanceLaunchingState(context), InstanceStatus.Restarting);
} }
} }
private void OnScheduledBackupCompleted(object? sender, BackupCreationResult e) { public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings)); return (this, LaunchInstanceResult.InstanceAlreadyRunning);
}
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
if (stopStrategy == MinecraftStopStrategy.Instant) {
CancelDelayedStop();
return (PrepareStoppedState(), StopInstanceResult.StopInitiated);
}
if (isStopping) {
// TODO change delay or something
return (this, StopInstanceResult.InstanceAlreadyStopping);
}
isStopping = true;
context.Services.TaskManager.Run("Delayed stop timer for instance " + context.ShortName, () => StopLater(stopStrategy.Seconds));
return (this, StopInstanceResult.StopInitiated);
}
private IInstanceState PrepareStoppedState() {
process.Ended -= ProcessEnded;
backupScheduler.Stop();
return new InstanceStoppingState(context, process, runningSessionDisposer);
}
private void CancelDelayedStop() {
try {
delayedStopCancellationTokenSource.Cancel();
} catch (ObjectDisposedException) {
// ignore
}
}
private async Task StopLater(int seconds) {
var cancellationToken = delayedStopCancellationTokenSource.Token;
try {
stateOwnsDelayedStopCancellationTokenSource = false;
int[] stops = { 60, 30, 10, 5, 4, 3, 2, 1, 0 };
foreach (var stop in stops) {
if (seconds > stop) {
await SendCommand(MinecraftCommand.Say("Server shutting down in " + seconds + (seconds == 1 ? " second." : " seconds.")), cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(seconds - stop), cancellationToken);
seconds = stop;
}
}
} catch (OperationCanceledException) {
context.Logger.Debug("Cancelled delayed stop.");
return;
} catch (ObjectDisposedException) {
return;
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception during delayed stop.");
return;
} finally {
delayedStopCancellationTokenSource.Dispose();
}
context.TransitionState(PrepareStoppedState());
} }
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) { public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
try { try {
context.Logger.Information("Sending command: {Command}", command); context.Logger.Information("Sending command: {Command}", command);
await Process.SendCommand(command, cancellationToken); await process.SendCommand(command, cancellationToken);
return true; return true;
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
return false; return false;
@@ -85,25 +137,42 @@ sealed class InstanceRunningState : IInstanceState, IDisposable {
} }
} }
private bool TryDispose() { private void OnScheduledBackupCompleted(object? sender, BackupCreationResult e) {
lock (this) { context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings));
if (isDisposed) { }
return false;
}
isDisposed = true; private sealed class RunningSessionDisposer : IDisposable {
private readonly InstanceRunningState state;
private readonly InstanceSession session;
private bool isDisposed;
public RunningSessionDisposer(InstanceRunningState state, InstanceSession session) {
this.state = state;
this.session = session;
} }
logSender.Stop(); public bool Dispose() {
backupScheduler.Stop(); lock (this) {
if (isDisposed) {
Process.Dispose(); return false;
context.Services.PortManager.Release(configuration); }
return true;
}
public void Dispose() { isDisposed = true;
TryDispose(); }
if (state.stateOwnsDelayedStopCancellationTokenSource) {
state.delayedStopCancellationTokenSource.Dispose();
}
else {
state.CancelDelayedStop();
}
session.Dispose();
return true;
}
void IDisposable.Dispose() {
Dispose();
}
} }
} }

View File

@@ -0,0 +1,89 @@
using System.Diagnostics;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceStoppingState : IInstanceState, IDisposable {
private readonly InstanceContext context;
private readonly InstanceProcess process;
private readonly IDisposable sessionDisposer;
public InstanceStoppingState(InstanceContext context, InstanceProcess process, IDisposable sessionDisposer) {
this.context = context;
this.process = process;
this.sessionDisposer = sessionDisposer;
}
public void Initialize() {
context.Logger.Information("Session stopping.");
context.SetStatus(InstanceStatus.Stopping);
context.Services.TaskManager.Run("Stop procedure for instance " + context.ShortName, DoStop);
}
private async Task DoStop() {
try {
// Do not release the semaphore after this point.
if (!await process.BackupSemaphore.CancelAndWait(TimeSpan.FromSeconds(1))) {
context.Logger.Information("Waiting for backup to finish...");
await process.BackupSemaphore.CancelAndWait(Timeout.InfiniteTimeSpan);
}
context.Logger.Information("Sending stop command...");
await DoSendStopCommand();
context.Logger.Information("Waiting for session to end...");
await DoWaitForSessionToEnd();
} finally {
context.Logger.Information("Session stopped.");
context.ReportEvent(InstanceEvent.Stopped);
context.TransitionState(new InstanceNotRunningState(), InstanceStatus.NotRunning);
}
}
private async Task DoSendStopCommand() {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try {
await process.SendCommand(MinecraftCommand.Stop, timeout.Token);
} catch (OperationCanceledException) {
// ignore
} catch (ObjectDisposedException e) when (e.ObjectName == typeof(Process).FullName && process.HasEnded) {
// ignore
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending stop command.");
}
}
private async Task DoWaitForSessionToEnd() {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(55));
try {
await process.WaitForExit(timeout.Token);
} catch (OperationCanceledException) {
try {
context.Logger.Warning("Waiting timed out, killing session...");
process.Kill();
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while killing session.");
}
}
}
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
return (this, LaunchInstanceResult.InstanceIsStopping);
}
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
return (this, StopInstanceResult.InstanceAlreadyStopping); // TODO maybe provide a way to kill?
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return Task.FromResult(false);
}
public void Dispose() {
sessionDisposer.Dispose();
}
}

View File

@@ -17,15 +17,13 @@ PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, static () =>
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Stopping Phantom Panel agent...");
}); });
ThreadPool.SetMinThreads(workerThreads: 2, completionPortThreads: 1);
try { try {
var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly()); var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion); PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
var (serverHost, serverPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrExit(); var (serverHost, serverPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts) = Variables.LoadOrExit();
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath); var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
if (agentKey == null) { if (agentKey == null) {
@@ -44,7 +42,7 @@ try {
var (serverCertificate, agentToken) = agentKey.Value; var (serverCertificate, agentToken) = agentKey.Value;
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts); var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks)); var agentServices = new AgentServices(agentInfo, folders);
MessageListener MessageListenerFactory(RpcServerConnection connection) { MessageListener MessageListenerFactory(RpcServerConnection connection) {
return new MessageListener(connection, agentServices, shutdownCancellationTokenSource); return new MessageListener(connection, agentServices, shutdownCancellationTokenSource);

View File

@@ -15,8 +15,7 @@ sealed record Variables(
ushort MaxInstances, ushort MaxInstances,
RamAllocationUnits MaxMemory, RamAllocationUnits MaxMemory,
AllowedPorts AllowedServerPorts, AllowedPorts AllowedServerPorts,
AllowedPorts AllowedRconPorts, AllowedPorts AllowedRconPorts
ushort MaxConcurrentBackupCompressionTasks
) { ) {
private static Variables LoadOrThrow() { private static Variables LoadOrThrow() {
var (agentKeyToken, agentKeyFilePath) = EnvironmentVariables.GetEitherString("AGENT_KEY", "AGENT_KEY_FILE").Require; var (agentKeyToken, agentKeyFilePath) = EnvironmentVariables.GetEitherString("AGENT_KEY", "AGENT_KEY_FILE").Require;
@@ -32,8 +31,7 @@ sealed record Variables(
(ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require, (ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require,
EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require, EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require, EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).Require, EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).Require
(ushort) EnvironmentVariables.GetInteger("MAX_CONCURRENT_BACKUP_COMPRESSION_TASKS", min: 1, max: 10000).WithDefault(1)
); );
} }

View File

@@ -53,6 +53,6 @@ public sealed partial class AllowedPorts {
} }
public static AllowedPorts FromString(string definitions) { public static AllowedPorts FromString(string definitions) {
return FromString(definitions.AsSpan()); return FromString((ReadOnlySpan<char>) definitions);
} }
} }

View File

@@ -98,6 +98,6 @@ public readonly partial record struct RamAllocationUnits(
/// </summary> /// </summary>
/// <exception cref="ArgumentOutOfRangeException">If the <paramref name="definition"/> is in the incorrect format, or the value cannot be converted via <see cref="FromMegabytes"/>.</exception> /// <exception cref="ArgumentOutOfRangeException">If the <paramref name="definition"/> is in the incorrect format, or the value cannot be converted via <see cref="FromMegabytes"/>.</exception>
public static RamAllocationUnits FromString(string definition) { public static RamAllocationUnits FromString(string definition) {
return FromString(definition.AsSpan()); return FromString((ReadOnlySpan<char>) definition);
} }
} }

View File

@@ -1,11 +1,13 @@
namespace Phantom.Common.Data.Replies; namespace Phantom.Common.Data.Replies;
public enum LaunchInstanceResult : byte { public enum LaunchInstanceResult : byte {
LaunchInitiated = 1, UnknownError,
InstanceAlreadyLaunching = 2, LaunchInitiated,
InstanceAlreadyRunning = 3, InstanceAlreadyLaunching,
InstanceLimitExceeded = 4, InstanceAlreadyRunning,
MemoryLimitExceeded = 5 InstanceIsStopping,
InstanceLimitExceeded,
MemoryLimitExceeded
} }
public static class LaunchInstanceResultExtensions { public static class LaunchInstanceResultExtensions {
@@ -14,6 +16,7 @@ public static class LaunchInstanceResultExtensions {
LaunchInstanceResult.LaunchInitiated => "Launch initiated.", LaunchInstanceResult.LaunchInitiated => "Launch initiated.",
LaunchInstanceResult.InstanceAlreadyLaunching => "Instance is already launching.", LaunchInstanceResult.InstanceAlreadyLaunching => "Instance is already launching.",
LaunchInstanceResult.InstanceAlreadyRunning => "Instance is already running.", LaunchInstanceResult.InstanceAlreadyRunning => "Instance is already running.",
LaunchInstanceResult.InstanceIsStopping => "Instance is stopping.",
LaunchInstanceResult.InstanceLimitExceeded => "Agent does not have any more available instances.", LaunchInstanceResult.InstanceLimitExceeded => "Agent does not have any more available instances.",
LaunchInstanceResult.MemoryLimitExceeded => "Agent does not have enough available memory.", LaunchInstanceResult.MemoryLimitExceeded => "Agent does not have enough available memory.",
_ => "Unknown error." _ => "Unknown error."

View File

@@ -1,9 +1,10 @@
namespace Phantom.Common.Data.Replies; namespace Phantom.Common.Data.Replies;
public enum StopInstanceResult : byte { public enum StopInstanceResult : byte {
StopInitiated = 1, UnknownError,
InstanceAlreadyStopping = 2, StopInitiated,
InstanceAlreadyStopped = 3 InstanceAlreadyStopping,
InstanceAlreadyStopped
} }
public static class StopInstanceResultExtensions { public static class StopInstanceResultExtensions {

View File

@@ -5,7 +5,7 @@ using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToServer; namespace Phantom.Common.Messages.ToServer;
[MemoryPackable] [MemoryPackable]
public sealed partial record ReportAgentStatusMessage( public partial record ReportAgentStatusMessage(
[property: MemoryPackOrder(0)] int RunningInstanceCount, [property: MemoryPackOrder(0)] int RunningInstanceCount,
[property: MemoryPackOrder(1)] RamAllocationUnits RunningInstanceMemory [property: MemoryPackOrder(1)] RamAllocationUnits RunningInstanceMemory
) : IMessageToServer { ) : IMessageToServer {

View File

@@ -1,6 +1,6 @@
using System.Collections.Immutable; using System.Collections.Immutable;
namespace Phantom.Common.Data.Java; namespace Phantom.Common.Minecraft;
public static class JvmArgumentsHelper { public static class JvmArgumentsHelper {
public static ImmutableArray<string> Split(string arguments) { public static ImmutableArray<string> Split(string arguments) {

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -1,11 +1,11 @@
<Project> <Project>
<ItemGroup> <ItemGroup>
<PackageReference Update="Microsoft.AspNetCore.Components.Authorization" Version="7.0.3" /> <PackageReference Update="Microsoft.AspNetCore.Components.Authorization" Version="7.0.1" />
<PackageReference Update="Microsoft.AspNetCore.Components.Web" Version="7.0.3" /> <PackageReference Update="Microsoft.AspNetCore.Components.Web" Version="7.0.1" />
<PackageReference Update="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.3" /> <PackageReference Update="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.1" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3" /> <PackageReference Update="Microsoft.EntityFrameworkCore.Tools" Version="7.0.1" />
<PackageReference Update="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.3" /> <PackageReference Update="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -13,7 +13,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="MemoryPack" Version="1.9.13" /> <PackageReference Update="MemoryPack" Version="1.9.7" />
<PackageReference Update="NetMQ" Version="4.0.1.10" /> <PackageReference Update="NetMQ" Version="4.0.1.10" />
</ItemGroup> </ItemGroup>
@@ -26,10 +26,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Update="coverlet.collector" Version="3.2.0" /> <PackageReference Update="coverlet.collector" Version="3.2.0" />
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="17.5.0" /> <PackageReference Update="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Update="NUnit" Version="3.13.3" /> <PackageReference Update="NUnit" Version="3.13.3" />
<PackageReference Update="NUnit.Analyzers" Version="3.6.0" /> <PackageReference Update="NUnit.Analyzers" Version="3.5.0" />
<PackageReference Update="NUnit3TestAdapter" Version="4.4.2" /> <PackageReference Update="NUnit3TestAdapter" Version="4.3.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data.Tests",
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Logging", "Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj", "{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Logging", "Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj", "{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Minecraft", "Common\Phantom.Common.Minecraft\Phantom.Common.Minecraft.csproj", "{48278E42-17BB-442B-9877-ACCE0C02C268}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages", "Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj", "{95B55357-F8F0-48C2-A1C2-5EA997651783}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages", "Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj", "{95B55357-F8F0-48C2-A1C2-5EA997651783}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server", "Server\Phantom.Server\Phantom.Server.csproj", "{A0F1C595-96B6-4DBF-8C16-6B99223F8F35}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server", "Server\Phantom.Server\Phantom.Server.csproj", "{A0F1C595-96B6-4DBF-8C16-6B99223F8F35}"
@@ -98,6 +100,10 @@ Global
{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.Build.0 = Debug|Any CPU {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Release|Any CPU.ActiveCfg = Release|Any CPU {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Release|Any CPU.Build.0 = Release|Any CPU {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Release|Any CPU.Build.0 = Release|Any CPU
{48278E42-17BB-442B-9877-ACCE0C02C268}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48278E42-17BB-442B-9877-ACCE0C02C268}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48278E42-17BB-442B-9877-ACCE0C02C268}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48278E42-17BB-442B-9877-ACCE0C02C268}.Release|Any CPU.Build.0 = Release|Any CPU
{95B55357-F8F0-48C2-A1C2-5EA997651783}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {95B55357-F8F0-48C2-A1C2-5EA997651783}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{95B55357-F8F0-48C2-A1C2-5EA997651783}.Debug|Any CPU.Build.0 = Debug|Any CPU {95B55357-F8F0-48C2-A1C2-5EA997651783}.Debug|Any CPU.Build.0 = Debug|Any CPU
{95B55357-F8F0-48C2-A1C2-5EA997651783}.Release|Any CPU.ActiveCfg = Release|Any CPU {95B55357-F8F0-48C2-A1C2-5EA997651783}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -178,6 +184,7 @@ Global
{AEE8B77E-AB07-423F-9981-8CD829ACB834} = {F5878792-64C8-4ECF-A075-66341FF97127} {AEE8B77E-AB07-423F-9981-8CD829ACB834} = {F5878792-64C8-4ECF-A075-66341FF97127}
{6C3DB1E5-F695-4D70-8F3A-78C2957274BE} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {6C3DB1E5-F695-4D70-8F3A-78C2957274BE} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{48278E42-17BB-442B-9877-ACCE0C02C268} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{435D7981-DFDA-46A0-8CD8-CD8C117935D7} = {D781E00D-8563-4102-A0CD-477A679193B5} {435D7981-DFDA-46A0-8CD8-CD8C117935D7} = {D781E00D-8563-4102-A0CD-477A679193B5}
{A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {8AC8FB6C-033A-4626-820F-ED0F908756B2} {A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}

View File

@@ -92,7 +92,6 @@ Use volumes to persist either the whole `/data` folder, or just `/data/data` if
* **Agent Configuration** * **Agent Configuration**
- `MAX_INSTANCES` is the number of instances that can be created. - `MAX_INSTANCES` is the number of instances that can be created.
- `MAX_MEMORY` is the maximum amount of RAM that can be distributed among all instances. Use a positive integer with an optional suffix 'M' for MB, or 'G' for GB. Examples: `4096M`, `16G` - `MAX_MEMORY` is the maximum amount of RAM that can be distributed among all instances. Use a positive integer with an optional suffix 'M' for MB, or 'G' for GB. Examples: `4096M`, `16G`
- `MAX_CONCURRENT_BACKUP_COMPRESSION_TASKS` is how many backup compression tasks can run at the same time. Limiting concurrent compression tasks limits memory usage of compression, but it increases time between backups because the next backup is only scheduled once the current one completes. Default: `1`
* **Minecraft Configuration** * **Minecraft Configuration**
- `JAVA_SEARCH_PATH` is a path to a folder which will be searched for Java runtime installations. Linux default: `/usr/lib/jvm` - `JAVA_SEARCH_PATH` is a path to a folder which will be searched for Java runtime installations. Linux default: `/usr/lib/jvm`
- `ALLOWED_SERVER_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft Server ports. Example: `25565,25900,26000-27000` - `ALLOWED_SERVER_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft Server ports. Example: `25565,25900,26000-27000`

View File

@@ -15,7 +15,7 @@ public enum AuditLogEventType {
InstanceCommandExecuted InstanceCommandExecuted
} }
static class AuditLogEventTypeExtensions { public static class AuditLogEventTypeExtensions {
private static readonly Dictionary<AuditLogEventType, AuditLogSubjectType> SubjectTypes = new () { private static readonly Dictionary<AuditLogEventType, AuditLogSubjectType> SubjectTypes = new () {
{ AuditLogEventType.AdministratorUserCreated, AuditLogSubjectType.User }, { AuditLogEventType.AdministratorUserCreated, AuditLogSubjectType.User },
{ AuditLogEventType.AdministratorUserModified, AuditLogSubjectType.User }, { AuditLogEventType.AdministratorUserModified, AuditLogSubjectType.User },

View File

@@ -10,7 +10,7 @@ public enum EventLogEventType {
InstanceBackupFailed, InstanceBackupFailed,
} }
static class EventLogEventTypeExtensions { internal static class EventLogEventTypeExtensions {
private static readonly Dictionary<EventLogEventType, EventLogSubjectType> SubjectTypes = new () { private static readonly Dictionary<EventLogEventType, EventLogSubjectType> SubjectTypes = new () {
{ EventLogEventType.InstanceLaunchSucceded, EventLogSubjectType.Instance }, { EventLogEventType.InstanceLaunchSucceded, EventLogSubjectType.Instance },
{ EventLogEventType.InstanceLaunchFailed, EventLogSubjectType.Instance }, { EventLogEventType.InstanceLaunchFailed, EventLogSubjectType.Instance },

View File

@@ -11,14 +11,14 @@ using Serilog.Events;
namespace Phantom.Server.Rpc; namespace Phantom.Server.Rpc;
public sealed class RpcLauncher : RpcRuntime<ServerSocket> { public sealed class RpcLauncher : RpcRuntime<ServerSocket> {
public static Task Launch(RpcConfiguration config, Func<RpcClientConnection, IMessageToServerListener> listenerFactory, CancellationToken cancellationToken) { public static async Task Launch(RpcConfiguration config, Func<RpcClientConnection, IMessageToServerListener> listenerFactory, CancellationToken cancellationToken) {
var socket = new ServerSocket(); var socket = new ServerSocket();
var options = socket.Options; var options = socket.Options;
options.CurveServer = true; options.CurveServer = true;
options.CurveCertificate = config.ServerCertificate; options.CurveCertificate = config.ServerCertificate;
return new RpcLauncher(config, socket, listenerFactory, cancellationToken).Launch(); await new RpcLauncher(config, socket, listenerFactory, cancellationToken).Launch();
} }
private readonly RpcConfiguration config; private readonly RpcConfiguration config;

View File

@@ -25,7 +25,9 @@ public sealed partial class AuditLog {
} }
public Task AddUserRolesChangedEvent(IdentityUser user, List<string> addedToRoles, List<string> removedFromRoles) { public Task AddUserRolesChangedEvent(IdentityUser user, List<string> addedToRoles, List<string> removedFromRoles) {
var extra = new Dictionary<string, object?>(); var extra = new Dictionary<string, object?> {
{ "username", user.UserName },
};
if (addedToRoles.Count > 0) { if (addedToRoles.Count > 0) {
extra["addedToRoles"] = addedToRoles; extra["addedToRoles"] = addedToRoles;
@@ -35,7 +37,7 @@ public sealed partial class AuditLog {
extra["removedFromRoles"] = removedFromRoles; extra["removedFromRoles"] = removedFromRoles;
} }
return AddItem(AuditLogEventType.UserRolesChanged, user.Id, extra); return AddItem(AuditLogEventType.UserDeleted, user.Id, extra);
} }
public Task AddUserDeletedEvent(IdentityUser user) { public Task AddUserDeletedEvent(IdentityUser user) {

View File

@@ -2,12 +2,12 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Java;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Messages; using Phantom.Common.Messages;
using Phantom.Common.Messages.ToAgent; using Phantom.Common.Messages.ToAgent;
using Phantom.Common.Minecraft;
using Phantom.Server.Database; using Phantom.Server.Database;
using Phantom.Server.Database.Entities; using Phantom.Server.Database.Entities;
using Phantom.Server.Minecraft; using Phantom.Server.Minecraft;

View File

@@ -12,6 +12,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Minecraft\Phantom.Common.Minecraft.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" />
<ProjectReference Include="..\Phantom.Server.Database\Phantom.Server.Database.csproj" /> <ProjectReference Include="..\Phantom.Server.Database\Phantom.Server.Database.csproj" />

View File

@@ -2,6 +2,8 @@
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Phantom.Server.Web.Identity.Authentication; namespace Phantom.Server.Web.Identity.Authentication;

View File

@@ -1,7 +1,10 @@
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Phantom.Server.Database; using Phantom.Server.Database;
using Phantom.Server.Web.Identity.Authentication; using Phantom.Server.Web.Identity.Authentication;
using Phantom.Server.Web.Identity.Authorization; using Phantom.Server.Web.Identity.Authorization;

View File

@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Http;
using Phantom.Server.Web.Identity.Authentication; using Phantom.Server.Web.Identity.Authentication;
using Phantom.Server.Web.Identity.Interfaces; using Phantom.Server.Web.Identity.Interfaces;

View File

@@ -1,4 +1,5 @@
@using Phantom.Common.Data.Minecraft @using Phantom.Common.Data.Minecraft
@using Phantom.Common.Minecraft
@using Phantom.Server.Minecraft @using Phantom.Server.Minecraft
@using Phantom.Server.Services.Agents @using Phantom.Server.Services.Agents
@using Phantom.Server.Services.Audit @using Phantom.Server.Services.Audit
@@ -6,10 +7,10 @@
@using System.Collections.Immutable @using System.Collections.Immutable
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using System.Diagnostics.CodeAnalysis @using System.Diagnostics.CodeAnalysis
@using Phantom.Common.Data.Java
@using Phantom.Server.Web.Components.Utils @using Phantom.Server.Web.Components.Utils
@using Phantom.Server.Web.Identity.Interfaces @using Phantom.Server.Web.Identity.Interfaces
@using Phantom.Common.Data.Instance @using Phantom.Common.Data.Instance
@using Phantom.Common.Data.Java
@using Phantom.Common.Data @using Phantom.Common.Data
@inject INavigation Nav @inject INavigation Nav
@inject MinecraftVersions MinecraftVersions @inject MinecraftVersions MinecraftVersions

View File

@@ -68,14 +68,10 @@ public sealed class Table<TRow, TKey> : IReadOnlyList<TRow>, IReadOnlyDictionary
} }
} }
public List<TRow>.Enumerator GetEnumerator() { public IEnumerator<TRow> GetEnumerator() {
return rowList.GetEnumerator(); return rowList.GetEnumerator();
} }
IEnumerator<TRow> IEnumerable<TRow>.GetEnumerator() {
return GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator() { IEnumerator IEnumerable.GetEnumerator() {
return GetEnumerator(); return GetEnumerator();
} }

View File

@@ -1,39 +0,0 @@
namespace Phantom.Utils.Collections;
public sealed class ThreadSafeLinkedList<T> : IDisposable {
private readonly LinkedList<T> list = new ();
private readonly SemaphoreSlim semaphore = new (1, 1);
public async Task Add(T item, bool toFront, CancellationToken cancellationToken) {
await semaphore.WaitAsync(cancellationToken);
try {
if (toFront) {
list.AddFirst(item);
}
else {
list.AddLast(item);
}
} finally {
semaphore.Release();
}
}
public async Task<T?> TryTakeFromFront(CancellationToken cancellationToken) {
await semaphore.WaitAsync(cancellationToken);
try {
var firstNode = list.First;
if (firstNode == null) {
return default;
}
list.RemoveFirst();
return firstNode.Value;
} finally {
semaphore.Release();
}
}
public void Dispose() {
semaphore.Dispose();
}
}

View File

@@ -19,7 +19,7 @@ public abstract class MessageHandler<TListener> {
internal void Enqueue<TMessage, TReply>(uint sequenceId, TMessage message) where TMessage : IMessage<TListener, TReply> { internal void Enqueue<TMessage, TReply>(uint sequenceId, TMessage message) where TMessage : IMessage<TListener, TReply> {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
taskManager.Run("Handle message " + message.GetType().Name, async () => { taskManager.Run("Handle message {Type}" + message.GetType().Name, async () => {
try { try {
await Handle<TMessage, TReply>(sequenceId, message); await Handle<TMessage, TReply>(sequenceId, message);
} catch (Exception e) { } catch (Exception e) {

View File

@@ -8,7 +8,7 @@ static class MessageSerializer {
private static readonly MemoryPackSerializerOptions SerializerOptions = MemoryPackSerializerOptions.Utf8; private static readonly MemoryPackSerializerOptions SerializerOptions = MemoryPackSerializerOptions.Utf8;
public static byte[] Serialize<T>(T message) { public static byte[] Serialize<T>(T message) {
return MemoryPackSerializer.Serialize(message, SerializerOptions); return MemoryPackSerializer.Serialize(typeof(T), message, SerializerOptions);
} }
public static void Serialize<T>(IBufferWriter<byte> destination, T message) { public static void Serialize<T>(IBufferWriter<byte> destination, T message) {

View File

@@ -8,18 +8,9 @@ public abstract class CancellableBackgroundTask {
protected ILogger Logger { get; } protected ILogger Logger { get; }
protected CancellationToken CancellationToken { get; } protected CancellationToken CancellationToken { get; }
private readonly TaskManager taskManager;
private readonly string taskName;
protected CancellableBackgroundTask(ILogger logger, TaskManager taskManager, string taskName) { protected CancellableBackgroundTask(ILogger logger, TaskManager taskManager, string taskName) {
this.Logger = logger; this.Logger = logger;
this.CancellationToken = cancellationTokenSource.Token; this.CancellationToken = cancellationTokenSource.Token;
this.taskManager = taskManager;
this.taskName = taskName;
}
protected void Start() {
taskManager.Run(taskName, Run); taskManager.Run(taskName, Run);
} }
@@ -34,14 +25,11 @@ public abstract class CancellableBackgroundTask {
Logger.Fatal(e, "Caught exception in task."); Logger.Fatal(e, "Caught exception in task.");
} finally { } finally {
cancellationTokenSource.Dispose(); cancellationTokenSource.Dispose();
Dispose();
Logger.Debug("Task stopped."); Logger.Debug("Task stopped.");
} }
} }
protected abstract Task RunTask(); protected abstract Task RunTask();
protected abstract void Dispose();
public void Stop() { public void Stop() {
try { try {

View File

@@ -0,0 +1,28 @@
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();
}
}

View File

@@ -1,28 +0,0 @@
namespace Phantom.Utils.Runtime;
public sealed class ThreadSafeStructRef<T> : IDisposable where T : struct {
private T? value;
private readonly SemaphoreSlim semaphore = new (1, 1);
public async Task<T?> Get(CancellationToken cancellationToken) {
await semaphore.WaitAsync(cancellationToken);
try {
return value;
} finally {
semaphore.Release();
}
}
public async Task Set(T? value, CancellationToken cancellationToken) {
await semaphore.WaitAsync(cancellationToken);
try {
this.value = value;
} finally {
semaphore.Release();
}
}
public void Dispose() {
semaphore.Dispose();
}
}