mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2025-05-04 00:34:05 +02:00
Rework Agent instance state management into a procedure-based system
This commit is contained in:
parent
def6c41a77
commit
01d6648b1f
Agent/Phantom.Agent.Services
AgentServices.cs
Instances
Common/Phantom.Common.Data/Replies
Utils
@ -35,9 +35,7 @@ public sealed class AgentServices {
|
||||
public async Task Shutdown() {
|
||||
Logger.Information("Stopping services...");
|
||||
|
||||
await InstanceSessionManager.StopAll();
|
||||
InstanceSessionManager.Dispose();
|
||||
|
||||
await InstanceSessionManager.DisposeAsync();
|
||||
await TaskManager.Stop();
|
||||
|
||||
BackupManager.Dispose();
|
||||
|
25
Agent/Phantom.Agent.Services/Instances/IInstanceContext.cs
Normal file
25
Agent/Phantom.Agent.Services/Instances/IInstanceContext.cs
Normal file
@ -0,0 +1,25 @@
|
||||
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));
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
using Phantom.Agent.Minecraft.Launcher;
|
||||
using Phantom.Agent.Rpc;
|
||||
using Phantom.Agent.Services.Instances.Procedures;
|
||||
using Phantom.Agent.Services.Instances.States;
|
||||
using Phantom.Common.Data.Instance;
|
||||
using Phantom.Common.Data.Minecraft;
|
||||
@ -10,18 +11,12 @@ using Serilog;
|
||||
|
||||
namespace Phantom.Agent.Services.Instances;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
sealed class Instance : IAsyncDisposable {
|
||||
private InstanceServices Services { get; }
|
||||
|
||||
public InstanceConfiguration Configuration { get; private set; }
|
||||
private IServerLauncher Launcher { get; set; }
|
||||
private readonly SemaphoreSlim configurationSemaphore = new (1, 1);
|
||||
|
||||
private readonly string shortName;
|
||||
private readonly ILogger logger;
|
||||
@ -30,14 +25,14 @@ sealed class Instance : IDisposable {
|
||||
private int statusUpdateCounter;
|
||||
|
||||
private IInstanceState currentState;
|
||||
private readonly SemaphoreSlim stateTransitioningActionSemaphore = new (1, 1);
|
||||
|
||||
public bool IsRunning => currentState is not InstanceNotRunningState;
|
||||
|
||||
public event EventHandler? IsRunningChanged;
|
||||
|
||||
private readonly InstanceProcedureManager procedureManager;
|
||||
|
||||
public Instance(InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
|
||||
this.shortName = GetLoggerName(configuration.InstanceGuid);
|
||||
public Instance(string shortName, InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
|
||||
this.shortName = shortName;
|
||||
this.logger = PhantomLogger.Create<Instance>(shortName);
|
||||
|
||||
this.Services = services;
|
||||
@ -46,6 +41,8 @@ sealed class Instance : IDisposable {
|
||||
|
||||
this.currentState = new InstanceNotRunningState();
|
||||
this.currentStatus = InstanceStatus.NotRunning;
|
||||
|
||||
this.procedureManager = new InstanceProcedureManager(this, new Context(this), services.TaskManager);
|
||||
}
|
||||
|
||||
private void TryUpdateStatus(string taskName, Func<Task> getUpdateTask) {
|
||||
@ -76,7 +73,7 @@ sealed class Instance : IDisposable {
|
||||
Services.TaskManager.Run("Report event for instance " + shortName, async () => await ServerMessaging.Send(message));
|
||||
}
|
||||
|
||||
private void TransitionState(IInstanceState newState) {
|
||||
internal void TransitionState(IInstanceState newState) {
|
||||
if (currentState == newState) {
|
||||
return;
|
||||
}
|
||||
@ -96,114 +93,92 @@ sealed class Instance : IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
await stateTransitioningActionSemaphore.WaitAsync(cancellationToken);
|
||||
await configurationSemaphore.WaitAsync(cancellationToken);
|
||||
try {
|
||||
Configuration = configuration;
|
||||
Launcher = launcher;
|
||||
} finally {
|
||||
stateTransitioningActionSemaphore.Release();
|
||||
configurationSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LaunchInstanceResult> Launch(CancellationToken shutdownCancellationToken) {
|
||||
await stateTransitioningActionSemaphore.WaitAsync(shutdownCancellationToken);
|
||||
public async Task<LaunchInstanceResult> Launch(CancellationToken cancellationToken) {
|
||||
if (IsRunning) {
|
||||
return LaunchInstanceResult.InstanceAlreadyRunning;
|
||||
}
|
||||
|
||||
if (await procedureManager.GetCurrentProcedure(cancellationToken) is LaunchInstanceProcedure) {
|
||||
return LaunchInstanceResult.InstanceAlreadyLaunching;
|
||||
}
|
||||
|
||||
LaunchInstanceProcedure procedure;
|
||||
|
||||
await configurationSemaphore.WaitAsync(cancellationToken);
|
||||
try {
|
||||
return TransitionStateAndReturn(currentState.Launch(new InstanceContextImpl(this, shutdownCancellationToken)));
|
||||
} catch (Exception e) {
|
||||
logger.Error(e, "Caught exception while launching instance.");
|
||||
return LaunchInstanceResult.UnknownError;
|
||||
procedure = new LaunchInstanceProcedure(Configuration, Launcher);
|
||||
} finally {
|
||||
stateTransitioningActionSemaphore.Release();
|
||||
configurationSemaphore.Release();
|
||||
}
|
||||
|
||||
await procedureManager.Enqueue(procedure);
|
||||
return LaunchInstanceResult.LaunchInitiated;
|
||||
}
|
||||
|
||||
public async Task<StopInstanceResult> Stop(MinecraftStopStrategy stopStrategy) {
|
||||
await stateTransitioningActionSemaphore.WaitAsync();
|
||||
try {
|
||||
return TransitionStateAndReturn(currentState.Stop(stopStrategy));
|
||||
} catch (Exception e) {
|
||||
logger.Error(e, "Caught exception while stopping instance.");
|
||||
return StopInstanceResult.UnknownError;
|
||||
} finally {
|
||||
stateTransitioningActionSemaphore.Release();
|
||||
public async Task<StopInstanceResult> Stop(MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) {
|
||||
if (!IsRunning) {
|
||||
return StopInstanceResult.InstanceAlreadyStopped;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (await procedureManager.GetCurrentProcedure(cancellationToken) is StopInstanceProcedure) {
|
||||
return StopInstanceResult.InstanceAlreadyStopping;
|
||||
}
|
||||
|
||||
await procedureManager.Enqueue(new StopInstanceProcedure(stopStrategy));
|
||||
return StopInstanceResult.StopInitiated;
|
||||
}
|
||||
|
||||
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
|
||||
return await currentState.SendCommand(command, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class InstanceContextImpl : InstanceContext {
|
||||
private readonly Instance instance;
|
||||
private readonly CancellationToken shutdownCancellationToken;
|
||||
|
||||
public InstanceContextImpl(Instance instance, CancellationToken shutdownCancellationToken) : base(instance.Services, instance.Configuration, instance.Launcher) {
|
||||
this.instance = instance;
|
||||
this.shutdownCancellationToken = shutdownCancellationToken;
|
||||
public async ValueTask DisposeAsync() {
|
||||
await procedureManager.DisposeAsync();
|
||||
|
||||
while (currentState is not InstanceNotRunningState) {
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), CancellationToken.None);
|
||||
}
|
||||
|
||||
public override ILogger Logger => instance.logger;
|
||||
public override string ShortName => instance.shortName;
|
||||
|
||||
public override void SetStatus(IInstanceStatus newStatus) {
|
||||
instance.ReportAndSetStatus(newStatus);
|
||||
}
|
||||
|
||||
public override void ReportEvent(IInstanceEvent instanceEvent) {
|
||||
instance.ReportEvent(instanceEvent);
|
||||
}
|
||||
|
||||
public override void TransitionState(Func<(IInstanceState, IInstanceStatus?)> newStateAndStatus) {
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
public Context(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public void SetStatus(IInstanceStatus newStatus) {
|
||||
instance.ReportAndSetStatus(newStatus);
|
||||
}
|
||||
|
||||
public void ReportEvent(IInstanceEvent instanceEvent) {
|
||||
instance.ReportEvent(instanceEvent);
|
||||
}
|
||||
|
||||
public void EnqueueProcedure(IInstanceProcedure procedure, bool immediate) {
|
||||
Services.TaskManager.Run("Enqueue procedure for instance " + instance.shortName, () => instance.procedureManager.Enqueue(procedure, immediate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,35 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
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;
|
||||
}
|
||||
|
||||
public async Task CancelCurrentProcedure() {
|
||||
(await currentProcedure.Get(shutdownCancellationTokenSource.Token))?.CancellationTokenSource.Cancel();
|
||||
}
|
||||
|
||||
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 CancelCurrentProcedure();
|
||||
await procedureQueueFinished.WaitHandle.WaitOneAsync();
|
||||
|
||||
currentProcedure.Dispose();
|
||||
procedureQueue.Dispose();
|
||||
procedureQueueReady.Dispose();
|
||||
procedureQueueFinished.Dispose();
|
||||
shutdownCancellationTokenSource.Dispose();
|
||||
}
|
||||
}
|
@ -21,9 +21,9 @@ using Serilog;
|
||||
|
||||
namespace Phantom.Agent.Services.Instances;
|
||||
|
||||
sealed class InstanceSessionManager : IDisposable {
|
||||
sealed class InstanceSessionManager : IAsyncDisposable {
|
||||
private static readonly ILogger Logger = PhantomLogger.Create<InstanceSessionManager>();
|
||||
|
||||
|
||||
private readonly AgentInfo agentInfo;
|
||||
private readonly string basePath;
|
||||
|
||||
@ -34,15 +34,17 @@ sealed class InstanceSessionManager : IDisposable {
|
||||
private readonly CancellationToken shutdownCancellationToken;
|
||||
private readonly SemaphoreSlim semaphore = new (1, 1);
|
||||
|
||||
private uint instanceLoggerSequenceId = 0;
|
||||
|
||||
public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager, BackupManager backupManager) {
|
||||
this.agentInfo = agentInfo;
|
||||
this.basePath = agentFolders.InstancesFolderPath;
|
||||
this.shutdownCancellationToken = shutdownCancellationTokenSource.Token;
|
||||
|
||||
|
||||
var minecraftServerExecutables = new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath);
|
||||
var launchServices = new LaunchServices(minecraftServerExecutables, javaRuntimeRepository);
|
||||
var portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
|
||||
|
||||
|
||||
this.instanceServices = new InstanceServices(taskManager, portManager, backupManager, launchServices);
|
||||
}
|
||||
|
||||
@ -76,7 +78,7 @@ sealed class InstanceSessionManager : IDisposable {
|
||||
var instanceGuid = configuration.InstanceGuid;
|
||||
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
|
||||
Directories.Create(instanceFolder, Chmod.URWX_GRX);
|
||||
|
||||
|
||||
var heapMegabytes = configuration.MemoryAllocation.InMegabytes;
|
||||
var jvmProperties = new JvmProperties(
|
||||
InitialHeapMegabytes: heapMegabytes / 2,
|
||||
@ -103,15 +105,15 @@ sealed class InstanceSessionManager : IDisposable {
|
||||
if (instances.TryGetValue(instanceGuid, out var instance)) {
|
||||
await instance.Reconfigure(configuration, launcher, shutdownCancellationToken);
|
||||
Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
|
||||
|
||||
|
||||
if (alwaysReportStatus) {
|
||||
instance.ReportLastStatus();
|
||||
}
|
||||
}
|
||||
else {
|
||||
instances[instanceGuid] = instance = new Instance(instanceServices, configuration, launcher);
|
||||
instances[instanceGuid] = instance = new Instance(GetInstanceLoggerName(instanceGuid), instanceServices, configuration, launcher);
|
||||
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
|
||||
|
||||
|
||||
instance.ReportLastStatus();
|
||||
instance.IsRunningChanged += OnInstanceIsRunningChanged;
|
||||
}
|
||||
@ -124,6 +126,11 @@ sealed class InstanceSessionManager : IDisposable {
|
||||
});
|
||||
}
|
||||
|
||||
private string GetInstanceLoggerName(Guid guid) {
|
||||
var prefix = guid.ToString();
|
||||
return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref instanceLoggerSequenceId);
|
||||
}
|
||||
|
||||
private ImmutableArray<Instance> GetRunningInstancesInternal() {
|
||||
return instances.Values.Where(static instance => instance.IsRunning).ToImmutableArray();
|
||||
}
|
||||
@ -167,38 +174,23 @@ sealed class InstanceSessionManager : IDisposable {
|
||||
}
|
||||
|
||||
public Task<InstanceActionResult<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
|
||||
return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy));
|
||||
return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy, shutdownCancellationToken));
|
||||
}
|
||||
|
||||
public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
|
||||
return AcquireSemaphoreAndRunWithInstance(instanceGuid, async instance => await instance.SendCommand(command, shutdownCancellationToken) ? SendCommandToInstanceResult.Success : SendCommandToInstanceResult.UnknownError);
|
||||
}
|
||||
|
||||
public async Task StopAll() {
|
||||
shutdownCancellationTokenSource.Cancel();
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
Logger.Information("Stopping all instances...");
|
||||
|
||||
shutdownCancellationTokenSource.Cancel();
|
||||
|
||||
await semaphore.WaitAsync(CancellationToken.None);
|
||||
try {
|
||||
await Task.WhenAll(instances.Values.Select(static instance => instance.StopAndWait(TimeSpan.FromSeconds(30))));
|
||||
DisposeAllInstances();
|
||||
} finally {
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
DisposeAllInstances();
|
||||
await Task.WhenAll(instances.Values.Select(static instance => instance.DisposeAsync().AsTask()));
|
||||
instances.Clear();
|
||||
|
||||
shutdownCancellationTokenSource.Dispose();
|
||||
semaphore.Dispose();
|
||||
}
|
||||
|
||||
private void DisposeAllInstances() {
|
||||
foreach (var (_, instance) in instances) {
|
||||
instance.Dispose();
|
||||
}
|
||||
|
||||
instances.Clear();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
using Phantom.Agent.Services.Instances.States;
|
||||
|
||||
namespace Phantom.Agent.Services.Instances.Procedures;
|
||||
|
||||
interface IInstanceProcedure {
|
||||
Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken);
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,110 @@
|
||||
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) {
|
||||
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("Session stopping now.");
|
||||
|
||||
// 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 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
@ -1,11 +1,6 @@
|
||||
using Phantom.Common.Data.Minecraft;
|
||||
using Phantom.Common.Data.Replies;
|
||||
|
||||
namespace Phantom.Agent.Services.Instances.States;
|
||||
namespace Phantom.Agent.Services.Instances.States;
|
||||
|
||||
interface IInstanceState {
|
||||
void Initialize();
|
||||
(IInstanceState, LaunchInstanceResult) Launch(InstanceContext context);
|
||||
(IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy);
|
||||
Task<bool> SendCommand(string command, CancellationToken cancellationToken);
|
||||
}
|
||||
|
@ -1,127 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
@ -1,34 +1,8 @@
|
||||
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 InstanceNotRunningState : IInstanceState {
|
||||
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) {
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
@ -1,133 +1,81 @@
|
||||
using Phantom.Agent.Minecraft.Command;
|
||||
using Phantom.Agent.Minecraft.Instance;
|
||||
using Phantom.Agent.Minecraft.Instance;
|
||||
using Phantom.Agent.Minecraft.Launcher;
|
||||
using Phantom.Agent.Services.Backups;
|
||||
using Phantom.Agent.Services.Instances.Sessions;
|
||||
using Phantom.Agent.Services.Instances.Procedures;
|
||||
using Phantom.Common.Data.Backups;
|
||||
using Phantom.Common.Data.Instance;
|
||||
using Phantom.Common.Data.Minecraft;
|
||||
using Phantom.Common.Data.Replies;
|
||||
|
||||
namespace Phantom.Agent.Services.Instances.States;
|
||||
|
||||
sealed class InstanceRunningState : IInstanceState {
|
||||
private readonly InstanceContext context;
|
||||
private readonly InstanceProcess process;
|
||||
private readonly BackupScheduler backupScheduler;
|
||||
private readonly RunningSessionDisposer runningSessionDisposer;
|
||||
|
||||
private readonly CancellationTokenSource delayedStopCancellationTokenSource = new ();
|
||||
private bool stateOwnsDelayedStopCancellationTokenSource = true;
|
||||
private bool isStopping;
|
||||
sealed class InstanceRunningState : IInstanceState, IDisposable {
|
||||
public InstanceProcess Process { get; }
|
||||
|
||||
public InstanceRunningState(InstanceContext context, InstanceProcess process, InstanceSession session) {
|
||||
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 bool isDisposed;
|
||||
|
||||
public InstanceRunningState(InstanceConfiguration configuration, IServerLauncher launcher, InstanceProcess process, IInstanceContext context) {
|
||||
this.configuration = configuration;
|
||||
this.launcher = launcher;
|
||||
this.context = context;
|
||||
this.process = process;
|
||||
this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, process, context.Configuration.ServerPort, context.ShortName);
|
||||
this.Process = process;
|
||||
|
||||
this.logSender = new InstanceLogSender(context.Services.TaskManager, configuration.InstanceGuid, context.ShortName);
|
||||
|
||||
this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, process, configuration.ServerPort, context.ShortName);
|
||||
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
|
||||
this.runningSessionDisposer = new RunningSessionDisposer(this, session);
|
||||
}
|
||||
|
||||
public void Initialize() {
|
||||
process.Ended += ProcessEnded;
|
||||
|
||||
if (process.HasEnded) {
|
||||
if (runningSessionDisposer.Dispose()) {
|
||||
Process.Ended += ProcessEnded;
|
||||
|
||||
if (Process.HasEnded) {
|
||||
if (TryDispose()) {
|
||||
context.Logger.Warning("Session ended immediately after it was started.");
|
||||
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)));
|
||||
context.EnqueueProcedure(new SetInstanceToNotRunningStateProcedure(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)), immediate: true);
|
||||
}
|
||||
}
|
||||
else {
|
||||
context.SetStatus(InstanceStatus.Running);
|
||||
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) {
|
||||
if (!runningSessionDisposer.Dispose()) {
|
||||
if (!TryDispose()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStopping) {
|
||||
context.Logger.Information("Session ended.");
|
||||
context.ReportEvent(InstanceEvent.Stopped);
|
||||
context.TransitionState(new InstanceNotRunningState(), InstanceStatus.NotRunning);
|
||||
if (IsStopping) {
|
||||
context.EnqueueProcedure(new SetInstanceToNotRunningStateProcedure(InstanceStatus.NotRunning), immediate: true);
|
||||
}
|
||||
else {
|
||||
context.Logger.Information("Session ended unexpectedly, restarting...");
|
||||
context.ReportEvent(InstanceEvent.Crashed);
|
||||
context.TransitionState(new InstanceLaunchingState(context), InstanceStatus.Restarting);
|
||||
context.EnqueueProcedure(new LaunchInstanceProcedure(configuration, launcher, IsRestarting: true));
|
||||
}
|
||||
}
|
||||
|
||||
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
|
||||
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());
|
||||
private void OnScheduledBackupCompleted(object? sender, BackupCreationResult e) {
|
||||
context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings));
|
||||
}
|
||||
|
||||
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
|
||||
try {
|
||||
context.Logger.Information("Sending command: {Command}", command);
|
||||
await process.SendCommand(command, cancellationToken);
|
||||
await Process.SendCommand(command, cancellationToken);
|
||||
return true;
|
||||
} catch (OperationCanceledException) {
|
||||
return false;
|
||||
@ -137,42 +85,25 @@ sealed class InstanceRunningState : IInstanceState {
|
||||
}
|
||||
}
|
||||
|
||||
private void OnScheduledBackupCompleted(object? sender, BackupCreationResult e) {
|
||||
context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings));
|
||||
private bool TryDispose() {
|
||||
lock (this) {
|
||||
if (isDisposed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
isDisposed = true;
|
||||
}
|
||||
|
||||
logSender.Stop();
|
||||
backupScheduler.Stop();
|
||||
|
||||
Process.Dispose();
|
||||
context.Services.PortManager.Release(configuration);
|
||||
|
||||
return 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;
|
||||
}
|
||||
|
||||
public bool Dispose() {
|
||||
lock (this) {
|
||||
if (isDisposed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
isDisposed = true;
|
||||
}
|
||||
|
||||
if (state.stateOwnsDelayedStopCancellationTokenSource) {
|
||||
state.delayedStopCancellationTokenSource.Dispose();
|
||||
}
|
||||
else {
|
||||
state.CancelDelayedStop();
|
||||
}
|
||||
|
||||
session.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() {
|
||||
Dispose();
|
||||
}
|
||||
public void Dispose() {
|
||||
TryDispose();
|
||||
}
|
||||
}
|
||||
|
@ -1,89 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
@ -1,13 +1,11 @@
|
||||
namespace Phantom.Common.Data.Replies;
|
||||
|
||||
public enum LaunchInstanceResult : byte {
|
||||
UnknownError,
|
||||
LaunchInitiated,
|
||||
InstanceAlreadyLaunching,
|
||||
InstanceAlreadyRunning,
|
||||
InstanceIsStopping,
|
||||
InstanceLimitExceeded,
|
||||
MemoryLimitExceeded
|
||||
LaunchInitiated = 1,
|
||||
InstanceAlreadyLaunching = 2,
|
||||
InstanceAlreadyRunning = 3,
|
||||
InstanceLimitExceeded = 4,
|
||||
MemoryLimitExceeded = 5
|
||||
}
|
||||
|
||||
public static class LaunchInstanceResultExtensions {
|
||||
@ -16,7 +14,6 @@ public static class LaunchInstanceResultExtensions {
|
||||
LaunchInstanceResult.LaunchInitiated => "Launch initiated.",
|
||||
LaunchInstanceResult.InstanceAlreadyLaunching => "Instance is already launching.",
|
||||
LaunchInstanceResult.InstanceAlreadyRunning => "Instance is already running.",
|
||||
LaunchInstanceResult.InstanceIsStopping => "Instance is stopping.",
|
||||
LaunchInstanceResult.InstanceLimitExceeded => "Agent does not have any more available instances.",
|
||||
LaunchInstanceResult.MemoryLimitExceeded => "Agent does not have enough available memory.",
|
||||
_ => "Unknown error."
|
||||
|
@ -1,10 +1,9 @@
|
||||
namespace Phantom.Common.Data.Replies;
|
||||
|
||||
public enum StopInstanceResult : byte {
|
||||
UnknownError,
|
||||
StopInitiated,
|
||||
InstanceAlreadyStopping,
|
||||
InstanceAlreadyStopped
|
||||
StopInitiated = 1,
|
||||
InstanceAlreadyStopping = 2,
|
||||
InstanceAlreadyStopped = 3
|
||||
}
|
||||
|
||||
public static class StopInstanceResultExtensions {
|
||||
|
39
Utils/Phantom.Utils.Collections/ThreadSafeLinkedList.cs
Normal file
39
Utils/Phantom.Utils.Collections/ThreadSafeLinkedList.cs
Normal file
@ -0,0 +1,39 @@
|
||||
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();
|
||||
}
|
||||
}
|
28
Utils/Phantom.Utils.Runtime/ThreadSafeStructRef.cs
Normal file
28
Utils/Phantom.Utils.Runtime/ThreadSafeStructRef.cs
Normal file
@ -0,0 +1,28 @@
|
||||
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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user