Compare commits

...

2 Commits

10 changed files with 128 additions and 45 deletions

View File

@ -3,11 +3,12 @@ using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Minecraft.Server;
public static class ServerStatusProtocol {
public static async Task<int> GetOnlinePlayerCount(ushort serverPort, CancellationToken cancellationToken) {
public static async Task<InstancePlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) {
using var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
var tcpStream = tcpClient.GetStream();
@ -17,7 +18,7 @@ public static class ServerStatusProtocol {
await tcpStream.FlushAsync(cancellationToken);
short messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
return await ReadOnlinePlayerCount(tcpStream, messageLength * 2, cancellationToken);
return await ReadPlayerCounts(tcpStream, messageLength * 2, cancellationToken);
}
private static async Task<short> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
@ -40,35 +41,53 @@ public static class ServerStatusProtocol {
}
}
private static async Task<int> ReadOnlinePlayerCount(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
private static async Task<InstancePlayerCounts> ReadPlayerCounts(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
try {
await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
// Valid response separator encoded in UTF-16BE is 0x00 0xA7 (§).
const byte SeparatorSecondByte = 0xA7;
static bool IsValidSeparator(ReadOnlySpan<byte> buffer, int index) {
return index > 0 && buffer[index - 1] == 0x00;
}
int separator2 = Array.LastIndexOf(messageBuffer, SeparatorSecondByte);
int separator1 = separator2 == -1 ? -1 : Array.LastIndexOf(messageBuffer, SeparatorSecondByte, separator2 - 1);
if (!IsValidSeparator(messageBuffer, separator1) || !IsValidSeparator(messageBuffer, separator2)) {
throw new ProtocolException("Could not find message separators in response from server.");
}
string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer.AsSpan((separator1 + 1)..(separator2 - 1)));
if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
throw new ProtocolException("Could not parse online player count in response from server: " + onlinePlayerCountStr);
}
return onlinePlayerCount;
return ReadPlayerCountsFromResponse(messageBuffer.AsSpan(0, messageLength));
} finally {
ArrayPool<byte>.Shared.Return(messageBuffer);
}
}
/// <summary>
/// Legacy query protocol uses the paragraph symbol (§) as separator encoded in UTF-16BE.
/// </summary>
private static readonly byte[] Separator = { 0x00, 0xA7 };
private static InstancePlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) {
int lastSeparator = messageBuffer.LastIndexOf(Separator);
int middleSeparator = messageBuffer[..lastSeparator].LastIndexOf(Separator);
if (lastSeparator == -1 || middleSeparator == -1) {
throw new ProtocolException("Could not find message separators in response from server.");
}
var onlinePlayerCountBuffer = messageBuffer[(middleSeparator + Separator.Length)..lastSeparator];
var maximumPlayerCountBuffer = messageBuffer[(lastSeparator + Separator.Length)..];
// Player counts are integers, whose maximum string length is 10 characters.
Span<char> integerStringBuffer = stackalloc char[10];
return new InstancePlayerCounts(
DecodeAndParsePlayerCount(onlinePlayerCountBuffer, integerStringBuffer, "online"),
DecodeAndParsePlayerCount(maximumPlayerCountBuffer, integerStringBuffer, "maximum")
);
}
private static int DecodeAndParsePlayerCount(ReadOnlySpan<byte> inputBuffer, Span<char> tempCharBuffer, string countType) {
if (!Encoding.BigEndianUnicode.TryGetChars(inputBuffer, tempCharBuffer, out int charCount)) {
throw new ProtocolException("Could not decode " + countType + " player count in response from server.");
}
if (!int.TryParse(tempCharBuffer, out int playerCount)) {
throw new ProtocolException("Could not parse " + countType + " player count in response from server: " + tempCharBuffer[..charCount].ToString());
}
return playerCount;
}
public sealed class ProtocolException : Exception {
internal ProtocolException(string message) : base(message) {}
}

View File

@ -1,5 +1,8 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Phantom.Utils.Threading;
@ -7,32 +10,35 @@ using Phantom.Utils.Threading;
namespace Phantom.Agent.Services.Instances.State;
sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
private readonly InstanceProcess process;
private readonly ControllerConnection controllerConnection;
private readonly Guid instanceGuid;
private readonly ushort serverPort;
private readonly InstanceProcess process;
private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource();
private readonly ManualResetEventSlim serverOutputEvent = new ();
private int? onlinePlayerCount;
private InstancePlayerCounts? playerCounts;
public int? OnlinePlayerCount {
public InstancePlayerCounts? PlayerCounts {
get {
lock (this) {
return onlinePlayerCount;
return playerCounts;
}
}
private set {
EventHandler<int?>? onlinePlayerCountChanged;
lock (this) {
if (onlinePlayerCount == value) {
if (playerCounts == value) {
return;
}
onlinePlayerCount = value;
playerCounts = value;
onlinePlayerCountChanged = OnlinePlayerCountChanged;
}
onlinePlayerCountChanged?.Invoke(this, value);
onlinePlayerCountChanged?.Invoke(this, value?.Online);
controllerConnection.Send(new ReportInstancePlayerCountsMessage(instanceGuid, value));
}
}
@ -41,6 +47,8 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
private bool isDisposed = false;
public InstancePlayerCountTracker(InstanceContext context, InstanceProcess process, ushort serverPort) : base(PhantomLogger.Create<InstancePlayerCountTracker>(context.ShortName)) {
this.controllerConnection = context.Services.ControllerConnection;
this.instanceGuid = context.InstanceGuid;
this.process = process;
this.serverPort = serverPort;
Start();
@ -56,7 +64,7 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
while (!CancellationToken.IsCancellationRequested) {
serverOutputEvent.Reset();
OnlinePlayerCount = await TryGetOnlinePlayerCount();
PlayerCounts = await TryGetPlayerCounts();
if (!firstDetection.Task.IsCompleted) {
firstDetection.SetResult();
@ -68,11 +76,11 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
}
}
private async Task<int?> TryGetOnlinePlayerCount() {
private async Task<InstancePlayerCounts?> TryGetPlayerCounts() {
try {
int newOnlinePlayerCount = await ServerStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken);
Logger.Debug("Detected {OnlinePlayerCount} online player(s).", newOnlinePlayerCount);
return newOnlinePlayerCount;
var result = await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken);
Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", result.Online, result.Maximum);
return result;
} catch (ServerStatusProtocol.ProtocolException e) {
Logger.Error(e.Message);
return null;
@ -88,12 +96,12 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
var onlinePlayersDetected = AsyncTasks.CreateCompletionSource();
lock (this) {
if (onlinePlayerCount == null) {
throw new InvalidOperationException();
}
else if (onlinePlayerCount > 0) {
if (playerCounts is { Online: > 0 }) {
return;
}
else if (playerCounts == null) {
throw new InvalidOperationException();
}
OnlinePlayerCountChanged += OnOnlinePlayerCountChanged;
@ -123,7 +131,7 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
protected override void Dispose() {
lock (this) {
isDisposed = true;
onlinePlayerCount = null;
playerCounts = null;
}
process.RemoveOutputListener(OnOutput);

View File

@ -8,9 +8,10 @@ public sealed partial record Instance(
[property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
[property: MemoryPackOrder(2)] IInstanceStatus Status,
[property: MemoryPackOrder(3)] bool LaunchAutomatically
[property: MemoryPackOrder(3)] InstancePlayerCounts? PlayerCounts,
[property: MemoryPackOrder(4)] bool LaunchAutomatically
) {
public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) {
return new Instance(instanceGuid, configuration, InstanceStatus.Offline, launchAutomatically);
return new Instance(instanceGuid, configuration, InstanceStatus.Offline, PlayerCounts: null, launchAutomatically);
}
}

View File

@ -0,0 +1,9 @@
using MemoryPack;
namespace Phantom.Common.Data.Instance;
[MemoryPackable(GenerateType.VersionTolerant)]
public readonly partial record struct InstancePlayerCounts(
[property: MemoryPackOrder(0)] int Online,
[property: MemoryPackOrder(1)] int Maximum
);

View File

@ -31,6 +31,7 @@ public static class AgentMessageRegistries {
ToController.Add<InstanceOutputMessage>(5);
ToController.Add<ReportAgentStatusMessage>(6);
ToController.Add<ReportInstanceEventMessage>(7);
ToController.Add<ReportInstancePlayerCountsMessage>(8);
ToController.Add<ReplyMessage>(127);
}

View File

@ -0,0 +1,10 @@
using MemoryPack;
using Phantom.Common.Data.Instance;
namespace Phantom.Common.Messages.Agent.ToController;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record ReportInstancePlayerCountsMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] InstancePlayerCounts? PlayerCounts
) : IMessageToController;

View File

@ -96,6 +96,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes);
ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance);
Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus);
Receive<UpdateInstancePlayerCountsCommand>(UpdateInstancePlayerCounts);
ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand);
@ -159,7 +160,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() {
var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
foreach (var (instanceGuid, instanceConfiguration, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) {
foreach (var (instanceGuid, instanceConfiguration, _, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) {
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken);
configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically));
}
@ -186,6 +187,8 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
public sealed record CreateOrUpdateInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>;
public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand;
public sealed record UpdateInstancePlayerCountsCommand(Guid InstanceGuid, InstancePlayerCounts? PlayerCounts) : ICommand;
public sealed record LaunchInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
@ -341,6 +344,10 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
private void UpdateInstanceStatus(UpdateInstanceStatusCommand command) {
TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status));
}
private void UpdateInstancePlayerCounts(UpdateInstancePlayerCountsCommand command) {
TellInstance(command.InstanceGuid, new InstanceActor.SetPlayerCountsCommand(command.PlayerCounts));
}
private Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) {
return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.LoggedInUserGuid));

View File

@ -26,6 +26,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
private InstanceConfiguration configuration;
private IInstanceStatus status;
private InstancePlayerCounts? playerCounts;
private bool launchAutomatically;
private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor;
@ -35,11 +36,12 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
this.agentConnection = init.AgentConnection;
this.cancellationToken = init.CancellationToken;
(this.instanceGuid, this.configuration, this.status, this.launchAutomatically) = init.Instance;
(this.instanceGuid, this.configuration, this.status, this.playerCounts, this.launchAutomatically) = init.Instance;
this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
Receive<SetStatusCommand>(SetStatus);
Receive<SetPlayerCountsCommand>(SetPlayerCounts);
ReceiveAsyncAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
ReceiveAsyncAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAsyncAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
@ -47,7 +49,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
}
private void NotifyInstanceUpdated() {
agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, launchAutomatically)));
agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, playerCounts, launchAutomatically)));
}
private void SetLaunchAutomatically(bool newValue) {
@ -65,6 +67,8 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
public interface ICommand {}
public sealed record SetStatusCommand(IInstanceStatus Status) : ICommand;
public sealed record SetPlayerCountsCommand(InstancePlayerCounts? PlayerCounts) : ICommand;
public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
@ -76,6 +80,16 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
private void SetStatus(SetStatusCommand command) {
status = command.Status;
if (!status.IsRunning() && status != InstanceStatus.Offline /* Guard against temporary disconnects */) {
playerCounts = null;
}
NotifyInstanceUpdated();
}
private void SetPlayerCounts(SetPlayerCountsCommand command) {
playerCounts = command.PlayerCounts;
NotifyInstanceUpdated();
}

View File

@ -39,6 +39,7 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes);
Receive<ReportAgentStatusMessage>(HandleReportAgentStatus);
Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus);
Receive<ReportInstancePlayerCountsMessage>(HandleReportInstancePlayerCounts);
Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent);
Receive<InstanceOutputMessage>(HandleInstanceOutput);
Receive<ReplyMessage>(HandleReply);
@ -74,6 +75,10 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus));
}
private void HandleReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts));
}
private void HandleReportInstanceEvent(ReportInstanceEventMessage message) {
message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid));
}

View File

@ -21,6 +21,7 @@
<Column Width="40%">Agent</Column>
<Column Width="40%">Name</Column>
<Column MinWidth="215px">Status</Column>
<Column Class="text-center" MinWidth="120px">Players</Column>
<Column Width="20%">Version</Column>
<Column Class="text-center" MinWidth="110px">Server Port</Column>
<Column Class="text-center" MinWidth="110px">Rcon Port</Column>
@ -40,6 +41,14 @@
<Cell>
<InstanceStatusText Status="instance.Status" />
</Cell>
<Cell class="text-center">
@if (instance.PlayerCounts is var (online, maximum)) {
<p class="font-monospace">@online.ToString() / @maximum.ToString()</p>
}
else {
<p class="font-monospace">-</p>
}
</Cell>
<Cell>@configuration.MinecraftServerKind @configuration.MinecraftVersion</Cell>
<Cell class="text-center">
<p class="font-monospace">@configuration.ServerPort.ToString()</p>