1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-12-25 22:38:24 +01:00

1 Commits

Author SHA1 Message Date
101ca865fe WIP 2023-03-03 22:50:52 +01:00
649 changed files with 8219 additions and 16774 deletions

View File

@@ -3,11 +3,10 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "9.0.9",
"version": "7.0.0-rc.1.22426.7",
"commands": [
"dotnet-ef"
],
"rollForward": false
]
}
}
}

1
.gitignore vendored
View File

@@ -192,6 +192,7 @@ ClientBin/
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs

View File

@@ -1,30 +1,28 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Agent 1" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Agent/debug/Phantom.Agent.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/bin/Debug/net7.0/Phantom.Agent.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" />
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 1" />
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="3" />
<env name="MAX_MEMORY" value="12G" />
<env name="SERVER_HOST" value="localhost" />
</envs>
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net9.0" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>

View File

@@ -1,30 +1,28 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Agent 2" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Agent/debug/Phantom.Agent.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/bin/Debug/net7.0/Phantom.Agent.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" />
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 2" />
<env name="ALLOWED_RCON_PORTS" value="27002-27006" />
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="5" />
<env name="MAX_MEMORY" value="10G" />
<env name="SERVER_HOST" value="localhost" />
</envs>
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net9.0" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>

View File

@@ -1,30 +1,28 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Agent 3" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Agent/debug/Phantom.Agent.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/bin/Debug/net7.0/Phantom.Agent.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" />
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 3" />
<env name="ALLOWED_RCON_PORTS" value="27007" />
<env name="ALLOWED_SERVER_PORTS" value="26007" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="1" />
<env name="MAX_MEMORY" value="2560M" />
<env name="SERVER_HOST" value="localhost" />
</envs>
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net9.0" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Compile Bootstrap" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/Web/Phantom.Web.Bootstrap/package.json" />
<package-json value="$PROJECT_DIR$/Server/Phantom.Server.Web.Bootstrap/package.json" />
<command value="run" />
<scripts>
<script value="compile" />

View File

@@ -1,8 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Controller + Web + Agent" type="CompoundRunConfigurationType">
<toRun name="Agent 1" type="DotNetProject" />
<toRun name="Controller" type="DotNetProject" />
<toRun name="Web" type="DotNetProject" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,10 +1,9 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Controller + Web + Agent x3" type="CompoundRunConfigurationType">
<configuration default="false" name="Server + Agent x3" type="CompoundRunConfigurationType">
<toRun name="Agent 1" type="DotNetProject" />
<toRun name="Agent 2" type="DotNetProject" />
<toRun name="Agent 3" type="DotNetProject" />
<toRun name="Controller" type="DotNetProject" />
<toRun name="Web" type="DotNetProject" />
<toRun name="Server" type="DotNetProject" />
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Server + Agent" type="CompoundRunConfigurationType">
<toRun name="Agent 1" type="DotNetProject" />
<toRun name="Server" type="DotNetProject" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,32 +1,28 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Controller" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Controller/debug/Phantom.Controller.exe" />
<configuration default="false" name="Server" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Server/Phantom.Server/bin/Debug/net7.0/Phantom.Server.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Controller" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Server" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_RPC_SERVER_HOST" value="localhost" />
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="PG_DATABASE" value="postgres" />
<env name="PG_HOST" value="localhost" />
<env name="PG_PASS" value="development" />
<env name="PG_PORT" value="9403" />
<env name="PG_PORT" value="9402" />
<env name="PG_USER" value="postgres" />
<env name="WEB_RPC_SERVER_HOST" value="localhost" />
<env name="RPC_SERVER_HOST" value="localhost" />
<env name="WEB_SERVER_HOST" value="localhost" />
</envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Controller/Phantom.Controller/Phantom.Controller.csproj" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Server/Phantom.Server/Phantom.Server.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net9.0" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>

View File

@@ -1,29 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Web" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Web/debug/Phantom.Web.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Web" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="WEB_KEY" value="G9WXPDGCGHJD9W9XBPMNYWN6YTK7NKRWHT29P2XKNDCBWKHWXP2YQRFB2GCTKHSWT5CZFDTFKW52TCM9GDRW" />
<env name="WEB_SERVER_HOST" value="localhost" />
</envs>
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net9.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

View File

@@ -1 +0,0 @@


View File

@@ -1,2 +0,0 @@
F<EFBFBD>bq_<EFBFBD>,K<>
<EFBFBD>AU<EFBFBD>|WU<1A>un<75>Ɯ=<3D>

View File

@@ -1,2 +0,0 @@
q<EFBFBD><EFBFBD>h4<EFBFBD><EFBFBD>H<EFBFBD><18>7<EFBFBD><37><EFBFBD><EFBFBD>H`<60><>W
<EFBFBD>4u`G

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
<EFBFBD>Z<EFBFBD>t<>MPI<49>GMZ<4D><5A><EFBFBD><EFBFBD>kN<6B>VF1X<><58>p

View File

@@ -5,4 +5,4 @@ if [%1]==[] (
exit
)
dotnet ef migrations add %~1 --project Controller/Phantom.Controller.Database.Postgres --msbuildprojectextensionspath .artifacts/obj/Phantom.Controller.Database.Postgres
dotnet ef migrations add %~1 --project Server/Phantom.Server.Database.Postgres

View File

@@ -3,4 +3,4 @@ if [ -z "$1" ]; then
exit 1
fi
dotnet ef migrations add "$1" --project Controller/Phantom.Controller.Database.Postgres --msbuildprojectextensionspath .artifacts/obj/Phantom.Controller.Database.Postgres
dotnet ef migrations add "$1" --project Server/Phantom.Server.Database.Postgres

View File

@@ -1,289 +0,0 @@
using System.Collections.Immutable;
using NUnit.Framework;
using Phantom.Agent.Minecraft.Java;
using Phantom.Utils.Collections;
namespace Phantom.Agent.Minecraft.Tests.Java;
[TestFixture]
public sealed class JavaPropertiesStreamTests {
public sealed class Reader {
private static async Task<ImmutableArray<KeyValuePair<string, string>>> Parse(string contents) {
using var stream = new MemoryStream(JavaPropertiesStream.Encoding.GetBytes(contents));
using var properties = new JavaPropertiesStream.Reader(stream);
return await properties.ReadProperties(CancellationToken.None).ToImmutableArrayAsync();
}
private static ImmutableArray<KeyValuePair<string, string>> KeyValue(string key, string value) {
return [new KeyValuePair<string, string>(key, value)];
}
[TestCase("")]
[TestCase("\n")]
public async Task EmptyLinesAreIgnored(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(ImmutableArray<KeyValuePair<string, string>>.Empty));
}
[TestCase("# Comment")]
[TestCase("! Comment")]
[TestCase("# Comment\n! Comment")]
public async Task CommentsAreIgnored(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(ImmutableArray<KeyValuePair<string, string>>.Empty));
}
[TestCase("key=value")]
[TestCase("key= value")]
[TestCase("key =value")]
[TestCase("key = value")]
[TestCase("key:value")]
[TestCase("key: value")]
[TestCase("key :value")]
[TestCase("key : value")]
[TestCase("key value")]
[TestCase("key\tvalue")]
[TestCase("key\fvalue")]
[TestCase("key \t\fvalue")]
public async Task SimpleKeyValue(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", "value")));
}
[TestCase("key")]
[TestCase(" key")]
[TestCase(" key ")]
[TestCase("key=")]
[TestCase("key:")]
public async Task KeyWithoutValue(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", "")));
}
[TestCase(@"\#key=value", "#key")]
[TestCase(@"\!key=value", "!key")]
public async Task KeyBeginsWithEscapedComment(string contents, string expectedKey) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue(expectedKey, "value")));
}
[TestCase(@"\=key=value", "=key")]
[TestCase(@"\:key=value", ":key")]
[TestCase(@"\ key=value", " key")]
[TestCase("\\\tkey=value", "\tkey")]
[TestCase("\\\fkey=value", "\fkey")]
public async Task KeyBeginsWithEscapedDelimiter(string contents, string expectedKey) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue(expectedKey, "value")));
}
[TestCase(@"start\=end=value", "start=end")]
[TestCase(@"start\:end:value", "start:end")]
[TestCase(@"start\ end value", "start end")]
[TestCase(@"start\ \:\=end = value", "start :=end")]
[TestCase("start\\ \\\t\\\fend = value", "start \t\fend")]
public async Task KeyContainsEscapedDelimiter(string contents, string expectedKey) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue(expectedKey, "value")));
}
[TestCase(@"key = \ value", " value")]
[TestCase("key = \\\tvalue", "\tvalue")]
[TestCase("key = \\\fvalue", "\fvalue")]
[TestCase("key=\\ \\\t\\\fvalue", " \t\fvalue")]
public async Task ValueBeginsWithEscapedWhitespace(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase(@"key = value\", "value")]
public async Task ValueEndsWithTrailingBackslash(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase("key=\\\0", "\0")]
[TestCase(@"key=\\", "\\")]
[TestCase(@"key=\t", "\t")]
[TestCase(@"key=\n", "\n")]
[TestCase(@"key=\r", "\r")]
[TestCase(@"key=\f", "\f")]
[TestCase(@"key=\u3053\u3093\u306b\u3061\u306f", "こんにちは")]
[TestCase(@"key=\u3053\u3093\u306B\u3061\u306F", "こんにちは")]
[TestCase("key=\\\0\\\\\\t\\n\\r\\f\\u3053", "\0\\\t\n\r\fこ")]
public async Task ValueContainsEscapedSpecialCharacters(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase("key=first\\\nsecond", "first\nsecond")]
[TestCase("key=first\\\n second", "first\nsecond")]
[TestCase("key=first\\\n#second", "first\n#second")]
[TestCase("key=first\\\n!second", "first\n!second")]
public async Task ValueContainsNewLine(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase("key=first\\\n \\ second", "first\n second")]
[TestCase("key=first\\\n \\\tsecond", "first\n\tsecond")]
[TestCase("key=first\\\n \\\fsecond", "first\n\fsecond")]
[TestCase("key=first\\\n \t\f\\ second", "first\n second")]
public async Task ValueContainsNewLineWithEscapedLeadingWhitespace(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[Test]
public async Task ExampleFile() {
// From Wikipedia: https://en.wikipedia.org/wiki/.properties
const string ExampleFile = """
# You are reading a comment in ".properties" file.
! The exclamation mark ('!') can also be used for comments.
# Comments are ignored.
# Blank lines are also ignored.
# Lines with "properties" contain a key and a value separated by a delimiting character.
# There are 3 delimiting characters: equal ('='), colon (':') and whitespace (' ', '\t' and '\f').
website = https://en.wikipedia.org/
language : English
topic .properties files
# A word on a line will just create a key with no value.
empty
# Whitespace that appears between the key, the delimiter and the value is ignored.
# This means that the following are equivalent (other than for readability).
hello=hello
hello = hello
# To start the value with whitespace, escape it with a backslash ('\').
whitespaceStart = \ <-This space is not ignored.
# Keys with the same name will be overwritten by the key that is the furthest in a file.
# For example the final value for "duplicateKey" will be "second".
duplicateKey = first
duplicateKey = second
# To use the delimiter characters inside a key, you need to escape them with a ('\').
# However, there is no need to do this in the value.
delimiterCharacters\:\=\ = This is the value for the key "delimiterCharacters\:\=\ "
# Adding a backslash ('\') at the end of a line means that the value continues on the next line.
multiline = This line \
continues
# If you want your value to include a backslash ('\'), it should be escaped by another backslash ('\').
path = c:\\wiki\\templates
# This means that if the number of backslashes ('\') at the end of the line is even, the next line is not included in the value.
# In the following example, the value for "evenKey" is "This is on one line\".
evenKey = This is on one line\\
# This line is a normal comment and is not included in the value for "evenKey".
# If the number of backslash ('\') is odd, then the next line is included in the value.
# In the following example, the value for "oddKey" is "This is line one and\# This is line two".
oddKey = This is line one and\\\
# This is line two
# Whitespace characters at the beginning of a line is removed.
# Make sure to add the spaces you need before the backslash ('\') on the first line.
# If you add them at the beginning of the next line, they will be removed.
# In the following example, the value for "welcome" is "Welcome to Wikipedia!".
welcome = Welcome to \
Wikipedia!
# If you need to add newlines and carriage returns, they need to be escaped using ('\n') and ('\r') respectively.
# You can also optionally escape tabs with ('\t') for readability purposes.
valueWithEscapes = This is a newline\n and a carriage return\r and a tab\t.
# You can also use Unicode escape characters (maximum of four hexadecimal digits).
# In the following example, the value for "encodedHelloInJapanese" is "こんにちは".
encodedHelloInJapanese = \u3053\u3093\u306b\u3061\u306f
""";
ImmutableArray<KeyValuePair<string, string>> result = [
new ("website", "https://en.wikipedia.org/"),
new ("language", "English"),
new ("topic", ".properties files"),
new ("empty", ""),
new ("hello", "hello"),
new ("hello", "hello"),
new ("whitespaceStart", @" <-This space is not ignored."),
new ("duplicateKey", "first"),
new ("duplicateKey", "second"),
new ("delimiterCharacters:= ", @"This is the value for the key ""delimiterCharacters:= """),
new ("multiline", "This line \ncontinues"),
new ("path", @"c:\wiki\templates"),
new ("evenKey", @"This is on one line\"),
new ("oddKey", "This is line one and\\\n# This is line two"),
new ("welcome", "Welcome to \nWikipedia!"),
new ("valueWithEscapes", "This is a newline\n and a carriage return\r and a tab\t."),
new ("encodedHelloInJapanese", "こんにちは"),
];
Assert.That(await Parse(ExampleFile), Is.EquivalentTo(result));
}
}
public sealed class Writer {
private static async Task<string> Write(Func<JavaPropertiesStream.Writer, Task> write) {
using var stream = new MemoryStream();
await using (var writer = new JavaPropertiesStream.Writer(stream)) {
await write(writer);
}
return JavaPropertiesStream.Encoding.GetString(stream.ToArray());
}
[TestCase("one line comment", "# one line comment\n")]
[TestCase("こんにちは", "# \\u3053\\u3093\\u306B\\u3061\\u306F\n")]
[TestCase("first line\nsecond line\r\nthird line", "# first line\n# second line\n# third line\n")]
public async Task Comment(string comment, string contents) {
Assert.That(await Write(writer => writer.WriteComment(comment, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("key", "value", "key=value\n")]
[TestCase("key", "", "key=\n")]
[TestCase("", "value", "=value\n")]
public async Task SimpleKeyValue(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("#key", "value", "\\#key=value\n")]
[TestCase("!key", "value", "\\!key=value\n")]
public async Task KeyBeginsWithEscapedComment(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("=key", "value", "\\=key=value\n")]
[TestCase(":key", "value", "\\:key=value\n")]
[TestCase(" key", "value", "\\ key=value\n")]
[TestCase("\tkey", "value", "\\tkey=value\n")]
[TestCase("\fkey", "value", "\\fkey=value\n")]
public async Task KeyBeginsWithEscapedDelimiter(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("start=end", "value", "start\\=end=value\n")]
[TestCase("start:end", "value", "start\\:end=value\n")]
[TestCase("start end", "value", "start\\ end=value\n")]
[TestCase("start :=end", "value", "start\\ \\:\\=end=value\n")]
[TestCase("start \t\fend", "value", "start\\ \\t\\fend=value\n")]
public async Task KeyContainsEscapedDelimiter(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("\\", "value", "\\\\=value\n")]
[TestCase("\t", "value", "\\t=value\n")]
[TestCase("\n", "value", "\\n=value\n")]
[TestCase("\r", "value", "\\r=value\n")]
[TestCase("\f", "value", "\\f=value\n")]
[TestCase("こんにちは", "value", "\\u3053\\u3093\\u306B\\u3061\\u306F=value\n")]
[TestCase("\\\t\n\r\fこ", "value", "\\\\\\t\\n\\r\\f\\u3053=value\n")]
[TestCase("first-line\nsecond-line\r\nthird-line", "value", "first-line\\nsecond-line\\r\\nthird-line=value\n")]
public async Task KeyContainsEscapedSpecialCharacters(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("key", "\\", "key=\\\\\n")]
[TestCase("key", "\t", "key=\\t\n")]
[TestCase("key", "\n", "key=\\n\n")]
[TestCase("key", "\r", "key=\\r\n")]
[TestCase("key", "\f", "key=\\f\n")]
[TestCase("key", "こんにちは", "key=\\u3053\\u3093\\u306B\\u3061\\u306F\n")]
[TestCase("key", "\\\t\n\r\fこ", "key=\\\\\\t\\n\\r\\f\\u3053\n")]
[TestCase("key", "first line\nsecond line\r\nthird line", "key=first line\\nsecond line\\r\\nthird line\n")]
public async Task ValueContainsEscapedSpecialCharacters(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[Test]
public async Task ExampleFile() {
string contents = await Write(static async writer => {
await writer.WriteComment("Comment", CancellationToken.None);
await writer.WriteProperty("key", "value", CancellationToken.None);
await writer.WriteProperty("multiline", "first line\nsecond line", CancellationToken.None);
});
Assert.That(contents, Is.EqualTo("# Comment\nkey=value\nmultiline=first line\\nsecond line\n"));
}
}
}

View File

@@ -1,20 +1,19 @@
using Phantom.Utils.Collections;
using Phantom.Utils.Processes;
using Phantom.Utils.Tasks;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Minecraft.Instance;
public sealed class InstanceProcess : IDisposable {
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;
public event EventHandler? Ended;
public bool HasEnded { get; private set; }
private readonly Process process;
private readonly TaskCompletionSource processExited = AsyncTasks.CreateCompletionSource();
internal InstanceProcess(InstanceProperties instanceProperties, Process process) {
this.InstanceProperties = instanceProperties;
@@ -48,19 +47,21 @@ public sealed class InstanceProcess : IDisposable {
OutputEvent = null;
HasEnded = true;
Ended?.Invoke(this, EventArgs.Empty);
processExited.SetResult();
}
public void Kill() {
process.Kill(true);
}
public async Task WaitForExit(TimeSpan timeout) {
await processExited.Task.WaitAsync(timeout);
public async Task WaitForExit(CancellationToken cancellationToken) {
if (!HasEnded) {
await process.WaitForExitAsync(cancellationToken);
}
}
public void Dispose() {
process.Dispose();
BackupSemaphore.Dispose();
OutputEvent = null;
Ended = null;
}

View File

@@ -1,52 +0,0 @@
namespace Phantom.Agent.Minecraft.Java;
sealed class JavaPropertiesFileEditor {
private readonly Dictionary<string, string> overriddenProperties = new ();
public void Set(string key, string value) {
overriddenProperties[key] = value;
}
public async Task EditOrCreate(string filePath, string comment, CancellationToken cancellationToken) {
if (File.Exists(filePath)) {
string tmpFilePath = filePath + ".tmp";
await Edit(filePath, tmpFilePath, comment, cancellationToken);
File.Move(tmpFilePath, filePath, overwrite: true);
}
else {
await Create(filePath, comment, cancellationToken);
}
}
private async Task Create(string targetFilePath, string comment, CancellationToken cancellationToken) {
await using var targetWriter = new JavaPropertiesStream.Writer(targetFilePath);
await targetWriter.WriteComment(comment, cancellationToken);
foreach ((string key, string value) in overriddenProperties) {
await targetWriter.WriteProperty(key, value, cancellationToken);
}
}
private async Task Edit(string sourceFilePath, string targetFilePath, string comment, CancellationToken cancellationToken) {
using var sourceReader = new JavaPropertiesStream.Reader(sourceFilePath);
await using var targetWriter = new JavaPropertiesStream.Writer(targetFilePath);
await targetWriter.WriteComment(comment, cancellationToken);
var remainingOverriddenPropertyKeys = new HashSet<string>(overriddenProperties.Keys);
await foreach ((string key, string value) in sourceReader.ReadProperties(cancellationToken)) {
if (remainingOverriddenPropertyKeys.Remove(key)) {
await targetWriter.WriteProperty(key, overriddenProperties[key], cancellationToken);
}
else {
await targetWriter.WriteProperty(key, value, cancellationToken);
}
}
foreach (string key in remainingOverriddenPropertyKeys) {
await targetWriter.WriteProperty(key, overriddenProperties[key], cancellationToken);
}
}
}

View File

@@ -1,284 +0,0 @@
using System.Buffers;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using Phantom.Utils.Collections;
namespace Phantom.Agent.Minecraft.Java;
static class JavaPropertiesStream {
internal static readonly Encoding Encoding = Encoding.GetEncoding("ISO-8859-1");
private static FileStreamOptions CreateFileStreamOptions(FileMode mode, FileAccess access) {
return new FileStreamOptions {
Mode = mode,
Access = access,
Share = FileShare.Read,
Options = FileOptions.SequentialScan,
};
}
internal sealed class Reader : IDisposable {
private static readonly SearchValues<char> LineStartWhitespace = SearchValues.Create(' ', '\t', '\f');
private static readonly SearchValues<char> KeyValueDelimiter = SearchValues.Create('=', ':', ' ', '\t', '\f');
private static readonly SearchValues<char> Backslash = SearchValues.Create('\\');
private readonly StreamReader reader;
public Reader(Stream stream) {
this.reader = new StreamReader(stream, Encoding, leaveOpen: false);
}
public Reader(string path) {
this.reader = new StreamReader(path, Encoding, detectEncodingFromByteOrderMarks: false, CreateFileStreamOptions(FileMode.Open, FileAccess.Read));
}
public async IAsyncEnumerable<KeyValuePair<string, string>> ReadProperties([EnumeratorCancellation] CancellationToken cancellationToken) {
await foreach (string line in ReadLogicalLines(cancellationToken)) {
yield return ParseLine(line.AsSpan());
}
}
private async IAsyncEnumerable<string> ReadLogicalLines([EnumeratorCancellation] CancellationToken cancellationToken) {
StringBuilder nextLogicalLine = new StringBuilder();
while (await reader.ReadLineAsync(cancellationToken) is {} line) {
var span = line.AsSpan();
int startIndex = span.IndexOfAnyExcept(LineStartWhitespace);
if (startIndex == -1) {
continue;
}
if (nextLogicalLine.Length == 0 && (span[0] == '#' || span[0] == '!')) {
continue;
}
span = span[startIndex..];
if (IsEndEscaped(span)) {
nextLogicalLine.Append(span[..^1]);
nextLogicalLine.Append('\n');
}
else {
nextLogicalLine.Append(span);
yield return nextLogicalLine.ToString();
nextLogicalLine.Clear();
}
}
if (nextLogicalLine.Length > 0) {
yield return nextLogicalLine.ToString(startIndex: 0, nextLogicalLine.Length - 1); // Remove trailing new line.
}
}
private static KeyValuePair<string, string> ParseLine(ReadOnlySpan<char> line) {
int delimiterIndex = -1;
foreach (int candidateIndex in line.IndicesOf(KeyValueDelimiter)) {
if (candidateIndex == 0 || !IsEndEscaped(line[..candidateIndex])) {
delimiterIndex = candidateIndex;
break;
}
}
if (delimiterIndex == -1) {
return new KeyValuePair<string, string>(line.ToString(), string.Empty);
}
string key = ReadPropertyComponent(line[..delimiterIndex]);
line = line[(delimiterIndex + 1)..];
int valueStartIndex = line.IndexOfAnyExcept(KeyValueDelimiter);
string value = valueStartIndex == -1 ? string.Empty : ReadPropertyComponent(line[valueStartIndex..]);
return new KeyValuePair<string, string>(key, value);
}
private static string ReadPropertyComponent(ReadOnlySpan<char> component) {
StringBuilder builder = new StringBuilder();
int nextStartIndex = 0;
foreach (int backslashIndex in component.IndicesOf(Backslash)) {
if (backslashIndex == component.Length - 1) {
break;
}
if (backslashIndex < nextStartIndex) {
continue;
}
builder.Append(component[nextStartIndex..backslashIndex]);
int escapedIndex = backslashIndex + 1;
int escapedLength = 1;
char c = component[escapedIndex];
switch (c) {
case 't':
builder.Append('\t');
break;
case 'n':
builder.Append('\n');
break;
case 'r':
builder.Append('\r');
break;
case 'f':
builder.Append('\f');
break;
case 'u':
escapedLength += 4;
int hexRangeStart = escapedIndex + 1;
int hexRangeEnd = hexRangeStart + 4;
if (hexRangeEnd - 1 < component.Length) {
var hexString = component[hexRangeStart..hexRangeEnd];
int hexValue = int.Parse(hexString, NumberStyles.HexNumber);
builder.Append((char) hexValue);
}
else {
throw new FormatException("Malformed \\uxxxx encoding.");
}
break;
default:
builder.Append(c);
break;
}
nextStartIndex = escapedIndex + escapedLength;
}
builder.Append(component[nextStartIndex..]);
return builder.ToString();
}
private static bool IsEndEscaped(ReadOnlySpan<char> span) {
if (span.EndsWith('\\')) {
int trailingBackslashCount = span.Length - span.TrimEnd('\\').Length;
return trailingBackslashCount % 2 == 1;
}
else {
return false;
}
}
public void Dispose() {
reader.Dispose();
}
}
internal sealed class Writer : IAsyncDisposable {
private const string CommentStart = "# ";
private readonly StreamWriter writer;
private readonly Memory<char> oneCharBuffer = new char[1];
public Writer(Stream stream) {
this.writer = new StreamWriter(stream, Encoding, leaveOpen: false);
}
public Writer(string path) {
this.writer = new StreamWriter(path, Encoding, CreateFileStreamOptions(FileMode.Create, FileAccess.Write));
}
public async Task WriteComment(string comment, CancellationToken cancellationToken) {
await Write(CommentStart, cancellationToken);
for (int index = 0; index < comment.Length; index++) {
char c = comment[index];
switch (c) {
case var _ when c > 31 && c < 127:
await Write(c, cancellationToken);
break;
case '\n':
case '\r':
await Write(c: '\n', cancellationToken);
await Write(CommentStart, cancellationToken);
if (index < comment.Length - 1 && comment[index + 1] == '\n') {
index++;
}
break;
default:
await Write("\\u", cancellationToken);
await Write(((int) c).ToString("X4"), cancellationToken);
break;
}
}
await Write(c: '\n', cancellationToken);
}
public async Task WriteProperty(string key, string value, CancellationToken cancellationToken) {
await WritePropertyComponent(key, escapeSpaces: true, cancellationToken);
await Write(c: '=', cancellationToken);
await WritePropertyComponent(value, escapeSpaces: false, cancellationToken);
await Write(c: '\n', cancellationToken);
}
private async Task WritePropertyComponent(string component, bool escapeSpaces, CancellationToken cancellationToken) {
for (int index = 0; index < component.Length; index++) {
char c = component[index];
switch (c) {
case '\\':
case '#':
case '!':
case '=':
case ':':
case ' ' when escapeSpaces || index == 0:
await Write(c: '\\', cancellationToken);
await Write(c, cancellationToken);
break;
case var _ when c > 31 && c < 127:
await Write(c, cancellationToken);
break;
case '\t':
await Write("\\t", cancellationToken);
break;
case '\n':
await Write("\\n", cancellationToken);
break;
case '\r':
await Write("\\r", cancellationToken);
break;
case '\f':
await Write("\\f", cancellationToken);
break;
default:
await Write("\\u", cancellationToken);
await Write(((int) c).ToString("X4"), cancellationToken);
break;
}
}
}
private Task Write(char c, CancellationToken cancellationToken) {
oneCharBuffer.Span[0] = c;
return writer.WriteAsync(oneCharBuffer, cancellationToken);
}
private Task Write(string value, CancellationToken cancellationToken) {
return writer.WriteAsync(value.AsMemory(), cancellationToken);
}
public async ValueTask DisposeAsync() {
await writer.DisposeAsync();
}
}
}

View File

@@ -1,9 +1,7 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Phantom.Common.Data.Java;
using Phantom.Utils.Collections;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Minecraft.Java;
@@ -21,14 +19,13 @@ public sealed class JavaRuntimeDiscovery {
return null;
}
public static async Task<JavaRuntimeRepository> Scan(string folderPath, CancellationToken cancellationToken) {
var runtimes = await new JavaRuntimeDiscovery().ScanInternal(folderPath, cancellationToken).ToImmutableArrayAsync(cancellationToken);
return new JavaRuntimeRepository(runtimes);
public static IAsyncEnumerable<JavaRuntimeExecutable> Scan(string folderPath) {
return new JavaRuntimeDiscovery().ScanInternal(folderPath);
}
private readonly Dictionary<string, int> duplicateDisplayNames = new ();
private async IAsyncEnumerable<JavaRuntimeExecutable> ScanInternal(string folderPath, [EnumeratorCancellation] CancellationToken cancellationToken) {
private async IAsyncEnumerable<JavaRuntimeExecutable> ScanInternal(string folderPath) {
Logger.Information("Starting Java runtime scan in: {FolderPath}", folderPath);
string javaExecutableName = OperatingSystem.IsWindows() ? "java.exe" : "java";
@@ -38,10 +35,8 @@ public sealed class JavaRuntimeDiscovery {
RecurseSubdirectories = true,
ReturnSpecialDirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint | FileAttributes.System,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint | FileAttributes.System
}).Order()) {
cancellationToken.ThrowIfCancellationRequested();
var javaExecutablePath = Paths.NormalizeSlashes(Path.Combine(binFolderPath, javaExecutableName));
FileAttributes javaExecutableAttributes;
@@ -59,7 +54,7 @@ public sealed class JavaRuntimeDiscovery {
JavaRuntime? foundRuntime;
try {
foundRuntime = await TryReadJavaRuntimeInformationFromProcess(javaExecutablePath, cancellationToken);
foundRuntime = await TryReadJavaRuntimeInformationFromProcess(javaExecutablePath);
} catch (OperationCanceledException) {
Logger.Error("Java process did not exit in time.");
continue;
@@ -78,37 +73,41 @@ public sealed class JavaRuntimeDiscovery {
}
}
private async Task<JavaRuntime?> TryReadJavaRuntimeInformationFromProcess(string javaExecutablePath, CancellationToken cancellationToken) {
private async Task<JavaRuntime?> TryReadJavaRuntimeInformationFromProcess(string javaExecutablePath) {
var startInfo = new ProcessStartInfo {
FileName = javaExecutablePath,
WorkingDirectory = Path.GetDirectoryName(javaExecutablePath),
Arguments = "-XshowSettings:properties -version",
RedirectStandardInput = false,
RedirectStandardOutput = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = false
};
using var timeoutCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var combinedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, cancellationToken);
var process = new Process { StartInfo = startInfo };
var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
try {
process.Start();
JavaRuntimeBuilder runtimeBuilder = new ();
JavaRuntimeBuilder runtimeBuilder = new ();
while (await process.StandardError.ReadLineAsync(combinedCancellationTokenSource.Token) is {} line) {
ExtractJavaVersionPropertiesFromLine(line, runtimeBuilder);
while (await process.StandardError.ReadLineAsync(cancellationTokenSource.Token) is {} line) {
ExtractJavaVersionPropertiesFromLine(line, runtimeBuilder);
JavaRuntime? runtime = runtimeBuilder.TryBuild(duplicateDisplayNames);
if (runtime != null) {
return runtime;
JavaRuntime? runtime = runtimeBuilder.TryBuild(duplicateDisplayNames);
if (runtime != null) {
return runtime;
}
}
}
await process.WaitForExitAsync(combinedCancellationTokenSource.Token);
return null;
await process.WaitForExitAsync(cancellationTokenSource.Token);
return null;
} finally {
process.Dispose();
cancellationTokenSource.Dispose();
}
}
private static void ExtractJavaVersionPropertiesFromLine(ReadOnlySpan<char> line, JavaRuntimeBuilder runtimeBuilder) {

View File

@@ -2,4 +2,12 @@
namespace Phantom.Agent.Minecraft.Java;
sealed record JavaRuntimeExecutable(string ExecutablePath, JavaRuntime Runtime);
public sealed class JavaRuntimeExecutable {
internal string ExecutablePath { get; }
internal JavaRuntime Runtime { get; }
internal JavaRuntimeExecutable(string executablePath, JavaRuntime runtime) {
this.ExecutablePath = executablePath;
this.Runtime = runtime;
}
}

View File

@@ -6,27 +6,41 @@ using Phantom.Utils.Cryptography;
namespace Phantom.Agent.Minecraft.Java;
public sealed class JavaRuntimeRepository {
private readonly ImmutableDictionary<Guid, JavaRuntimeExecutable> runtimesByGuid;
internal JavaRuntimeRepository(ImmutableArray<JavaRuntimeExecutable> runtimes) {
var runtimesByGuidBuilder = ImmutableDictionary.CreateBuilder<Guid, JavaRuntimeExecutable>();
foreach (JavaRuntimeExecutable runtime in runtimes) {
runtimesByGuidBuilder.Add(GenerateStableGuid(runtime.ExecutablePath), runtime);
}
runtimesByGuid = runtimesByGuidBuilder.ToImmutable();
}
private readonly Dictionary<string, Guid> guidsByPath = new ();
private readonly Dictionary<Guid, JavaRuntimeExecutable> runtimesByGuid = new ();
private readonly ReaderWriterLockSlim rwLock = new (LockRecursionPolicy.NoRecursion);
public ImmutableArray<TaggedJavaRuntime> All {
[SuppressMessage("ReSharper", "UseCollectionExpression")]
get => runtimesByGuid.Select(static kvp => new TaggedJavaRuntime(kvp.Key, kvp.Value.Runtime))
.OrderBy(static taggedRuntime => taggedRuntime.Runtime)
.ToImmutableArray();
get {
rwLock.EnterReadLock();
try {
return runtimesByGuid.Select(static kvp => new TaggedJavaRuntime(kvp.Key, kvp.Value.Runtime)).OrderBy(static taggedRuntime => taggedRuntime.Runtime).ToImmutableArray();
} finally {
rwLock.ExitReadLock();
}
}
}
internal bool TryGetByGuid(Guid guid, [MaybeNullWhen(false)] out JavaRuntimeExecutable runtime) {
return runtimesByGuid.TryGetValue(guid, out runtime);
public void Include(JavaRuntimeExecutable runtime) {
rwLock.EnterWriteLock();
try {
if (!guidsByPath.TryGetValue(runtime.ExecutablePath, out var guid)) {
guidsByPath[runtime.ExecutablePath] = guid = GenerateStableGuid(runtime.ExecutablePath);
}
runtimesByGuid[guid] = runtime;
} finally {
rwLock.ExitWriteLock();
}
}
public bool TryGetByGuid(Guid guid, [MaybeNullWhen(false)] out JavaRuntimeExecutable runtime) {
rwLock.EnterReadLock();
try {
return runtimesByGuid.TryGetValue(guid, out runtime);
} finally {
rwLock.ExitReadLock();
}
}
private static Guid GenerateStableGuid(string executablePath) {

View File

@@ -1,12 +1,18 @@
using System.Collections.ObjectModel;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
namespace Phantom.Agent.Minecraft.Java;
sealed class JvmArgumentBuilder(JvmProperties basicProperties) {
private readonly List<string> customArguments = [];
sealed class JvmArgumentBuilder {
private readonly JvmProperties basicProperties;
private readonly List<string> customArguments = new ();
public void Add(string argument) {
customArguments.Add(argument);
public JvmArgumentBuilder(JvmProperties basicProperties, ImmutableArray<string> customArguments) {
this.basicProperties = basicProperties;
foreach (var jvmArgument in customArguments) {
this.customArguments.Add(jvmArgument);
}
}
public void AddProperty(string key, string value) {
@@ -18,8 +24,8 @@ sealed class JvmArgumentBuilder(JvmProperties basicProperties) {
target.Add(property);
}
// In case of duplicate JVM arguments, typically the last one wins.
target.Add("-Xms" + basicProperties.InitialHeapMegabytes + "M");
target.Add("-Xmx" + basicProperties.MaximumHeapMegabytes + "M");
target.Add("-Xrs");
}
}

View File

@@ -1,8 +1,10 @@
using System.Text;
using Kajabity.Tools.Java;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server;
using Phantom.Utils.Processes;
using Phantom.Common.Minecraft;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher;
@@ -21,6 +23,10 @@ public abstract class BaseLauncher : IServerLauncher {
return new LaunchResult.InvalidJavaRuntime();
}
if (JvmArgumentsHelper.Validate(instanceProperties.JvmArguments) != null) {
return new LaunchResult.InvalidJvmArguments();
}
var vanillaServerJarPath = await services.ServerExecutables.DownloadAndGetPath(instanceProperties.LaunchProperties.ServerDownloadInfo, MinecraftVersion, downloadProgressEventHandler, cancellationToken);
if (vanillaServerJarPath == null) {
return new LaunchResult.CouldNotDownloadMinecraftServer();
@@ -43,7 +49,7 @@ public abstract class BaseLauncher : IServerLauncher {
try {
await AcceptEula(instanceProperties);
await UpdateServerProperties(instanceProperties, cancellationToken);
await UpdateServerProperties(instanceProperties);
} catch (Exception e) {
logger.Error(e, "Caught exception while configuring the server.");
return new LaunchResult.CouldNotConfigureMinecraftServer();
@@ -53,11 +59,19 @@ public abstract class BaseLauncher : IServerLauncher {
FileName = javaRuntimeExecutable.ExecutablePath,
WorkingDirectory = instanceProperties.InstanceFolder,
RedirectInput = true,
UseShellExecute = false,
UseShellExecute = false
};
var jvmArguments = new JvmArgumentBuilder(instanceProperties.JvmProperties, instanceProperties.JvmArguments);
CustomizeJvmArguments(jvmArguments);
var processArguments = processConfigurator.ArgumentList;
PrepareJvmArguments(serverJar).Build(processArguments);
jvmArguments.Build(processArguments);
foreach (var extraArgument in serverJar.ExtraArgs) {
processArguments.Add(extraArgument);
}
processArguments.Add("-jar");
processArguments.Add(serverJar.FilePath);
processArguments.Add("nogui");
@@ -82,21 +96,6 @@ public abstract class BaseLauncher : IServerLauncher {
return new LaunchResult.Success(instanceProcess);
}
private JvmArgumentBuilder PrepareJvmArguments(ServerJarInfo serverJar) {
var builder = new JvmArgumentBuilder(instanceProperties.JvmProperties);
foreach (string argument in instanceProperties.JvmArguments) {
builder.Add(argument);
}
foreach (var argument in serverJar.ExtraArgs) {
builder.Add(argument);
}
CustomizeJvmArguments(builder);
return builder;
}
private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {}
private protected virtual Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) {
@@ -105,12 +104,25 @@ public abstract class BaseLauncher : IServerLauncher {
private static async Task AcceptEula(InstanceProperties instanceProperties) {
var eulaFilePath = Path.Combine(instanceProperties.InstanceFolder, "eula.txt");
await File.WriteAllLinesAsync(eulaFilePath, ["# EULA", "eula=true"], Encoding.UTF8);
await File.WriteAllLinesAsync(eulaFilePath, new [] { "# EULA", "eula=true" }, Encoding.UTF8);
}
private static async Task UpdateServerProperties(InstanceProperties instanceProperties, CancellationToken cancellationToken) {
var serverPropertiesEditor = new JavaPropertiesFileEditor();
instanceProperties.ServerProperties.SetTo(serverPropertiesEditor);
await serverPropertiesEditor.EditOrCreate(Path.Combine(instanceProperties.InstanceFolder, "server.properties"), comment: "server.properties", cancellationToken);
private static async Task UpdateServerProperties(InstanceProperties instanceProperties) {
var serverPropertiesFilePath = Path.Combine(instanceProperties.InstanceFolder, "server.properties");
var serverPropertiesData = new JavaProperties();
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

@@ -9,6 +9,8 @@ public abstract record LaunchResult {
public sealed record InvalidJavaRuntime : LaunchResult;
public sealed record InvalidJvmArguments : LaunchResult;
public sealed record CouldNotDownloadMinecraftServer : LaunchResult;
public sealed record CouldNotPrepareMinecraftServerLauncher : LaunchResult;

View File

@@ -1,4 +1,5 @@
using Phantom.Agent.Minecraft.Instance;
using System.Collections.Immutable;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Utils.IO;
using Serilog;
@@ -19,7 +20,7 @@ public sealed class FabricLauncher : BaseLauncher {
await DownloadLauncher(logger, launcherJarPath, cancellationToken);
}
return new ServerJarInfo(launcherJarPath, ["-Dfabric.installer.server.gameJar=" + Paths.NormalizeSlashes(serverJarPath)]);
return new ServerJarInfo(launcherJarPath, ImmutableArray.Create("-Dfabric.installer.server.gameJar=" + Paths.NormalizeSlashes(serverJarPath)));
}
private async Task DownloadLauncher(ILogger logger, string targetFilePath, CancellationToken cancellationToken) {

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

@@ -1,18 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Phantom.Agent.Minecraft.Tests" />
<PackageReference Include="Kajabity.Tools.Java" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Logging\Phantom.Utils.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.Cryptography\Phantom.Utils.Cryptography.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
</ItemGroup>
</Project>

View File

@@ -18,5 +18,4 @@ static class MinecraftServerProperties {
public static readonly MinecraftServerProperty<ushort> ServerPort = new UnsignedShort("server-port");
public static readonly MinecraftServerProperty<ushort> RconPort = new UnsignedShort("rcon.port");
public static readonly MinecraftServerProperty<bool> EnableRcon = new Boolean("enable-rcon");
public static readonly MinecraftServerProperty<bool> SyncChunkWrites = new Boolean("sync-chunk-writes");
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using System.Text.RegularExpressions;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Minecraft.Server;
@@ -10,7 +10,7 @@ public sealed partial class MinecraftServerExecutables {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutables>();
[GeneratedRegex(@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled)]
private static partial Regex SanitizePathRegex();
private static partial Regex VersionFolderSanitizeRegex();
private readonly string basePath;
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) {
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");
if (File.Exists(serverExecutableFilePath)) {

View File

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

@@ -0,0 +1,40 @@
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Serilog;
namespace Phantom.Agent.Rpc;
sealed class KeepAliveLoop {
private static readonly ILogger Logger = PhantomLogger.Create<KeepAliveLoop>();
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(10);
private readonly RpcServerConnection connection;
private readonly CancellationTokenSource cancellationTokenSource = new ();
public KeepAliveLoop(RpcServerConnection connection) {
this.connection = connection;
Task.Run(Run);
}
private async Task Run() {
var cancellationToken = cancellationTokenSource.Token;
Logger.Information("Started keep-alive loop.");
try {
while (true) {
await Task.Delay(KeepAliveInterval, cancellationToken);
await connection.Send(new AgentIsAliveMessage());
}
} catch (OperationCanceledException) {
// Ignore.
} finally {
cancellationTokenSource.Dispose();
Logger.Information("Stopped keep-alive loop.");
}
}
public void Cancel() {
cancellationTokenSource.Cancel();
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,107 @@
using NetMQ;
using NetMQ.Sockets;
using Phantom.Common.Data.Agent;
using Phantom.Common.Messages;
using Phantom.Common.Messages.BiDirectional;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Runtime;
using Serilog;
using Serilog.Events;
namespace Phantom.Agent.Rpc;
public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
public static async Task Launch(RpcConfiguration config, AgentAuthToken authToken, AgentInfo agentInfo, Func<RpcServerConnection, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) {
var socket = new ClientSocket();
var options = socket.Options;
options.CurveServerCertificate = config.ServerCertificate;
options.CurveCertificate = new NetMQCertificate();
options.HelloMessage = MessageRegistries.ToServer.Write(new RegisterAgentMessage(authToken, agentInfo)).ToArray();
await new RpcLauncher(config, socket, agentInfo.Guid, listenerFactory, disconnectSemaphore, receiveCancellationToken).Launch();
}
private readonly RpcConfiguration config;
private readonly Guid agentGuid;
private readonly Func<RpcServerConnection, IMessageToAgentListener> messageListenerFactory;
private readonly SemaphoreSlim disconnectSemaphore;
private readonly CancellationToken receiveCancellationToken;
private RpcLauncher(RpcConfiguration config, ClientSocket socket, Guid agentGuid, Func<RpcServerConnection, IMessageToAgentListener> messageListenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) : base(config, socket) {
this.config = config;
this.agentGuid = agentGuid;
this.messageListenerFactory = messageListenerFactory;
this.disconnectSemaphore = disconnectSemaphore;
this.receiveCancellationToken = receiveCancellationToken;
}
protected override void Connect(ClientSocket socket) {
var logger = config.RuntimeLogger;
var url = config.TcpUrl;
logger.Information("Starting ZeroMQ client and connecting to {Url}...", url);
socket.Connect(url);
logger.Information("ZeroMQ client ready.");
}
protected override void Run(ClientSocket socket, MessageReplyTracker replyTracker, TaskManager taskManager) {
var connection = new RpcServerConnection(socket, replyTracker);
ServerMessaging.SetCurrentConnection(connection);
var logger = config.RuntimeLogger;
var handler = new MessageToAgentHandler(messageListenerFactory(connection), logger, taskManager, receiveCancellationToken);
var keepAliveLoop = new KeepAliveLoop(connection);
try {
while (!receiveCancellationToken.IsCancellationRequested) {
var data = socket.Receive(receiveCancellationToken);
LogMessageType(logger, data);
if (data.Length > 0) {
MessageRegistries.ToAgent.Handle(data, handler);
}
}
} catch (OperationCanceledException) {
// Ignore.
} finally {
logger.Debug("ZeroMQ client stopped receiving messages.");
disconnectSemaphore.Wait(CancellationToken.None);
keepAliveLoop.Cancel();
}
}
private static void LogMessageType(ILogger logger, ReadOnlyMemory<byte> data) {
if (!logger.IsEnabled(LogEventLevel.Verbose)) {
return;
}
if (data.Length > 0 && MessageRegistries.ToAgent.TryGetType(data, out var type)) {
logger.Verbose("Received {MessageType} ({Bytes} B) from server.", type.Name, data.Length);
}
else {
logger.Verbose("Received {Bytes} B message from server.", data.Length);
}
}
protected override async Task Disconnect() {
var unregisterTimeoutTask = Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None);
var finishedTask = await Task.WhenAny(ServerMessaging.Send(new UnregisterAgentMessage(agentGuid)), unregisterTimeoutTask);
if (finishedTask == unregisterTimeoutTask) {
config.RuntimeLogger.Error("Timed out communicating agent shutdown with the server.");
}
}
private sealed class MessageToAgentHandler : MessageHandler<IMessageToAgentListener> {
public MessageToAgentHandler(IMessageToAgentListener listener, ILogger logger, TaskManager taskManager, CancellationToken cancellationToken) : base(listener, logger, taskManager, cancellationToken) {}
protected override Task SendReply(uint sequenceId, byte[] serializedReply) {
return ServerMessaging.Send(new ReplyMessage(sequenceId, serializedReply));
}
}
}

View File

@@ -0,0 +1,41 @@
using NetMQ;
using NetMQ.Sockets;
using Phantom.Common.Messages;
using Phantom.Common.Messages.BiDirectional;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Agent.Rpc;
public sealed class RpcServerConnection {
private readonly ClientSocket socket;
private readonly MessageReplyTracker replyTracker;
internal RpcServerConnection(ClientSocket socket, MessageReplyTracker replyTracker) {
this.socket = socket;
this.replyTracker = replyTracker;
}
internal async Task Send<TMessage>(TMessage message) where TMessage : IMessageToServer {
var bytes = MessageRegistries.ToServer.Write(message).ToArray();
if (bytes.Length > 0) {
await socket.SendAsync(bytes);
}
}
internal async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToServer<TReply> where TReply : class {
var sequenceId = replyTracker.RegisterReply();
var bytes = MessageRegistries.ToServer.Write<TMessage, TReply>(sequenceId, message).ToArray();
if (bytes.Length == 0) {
replyTracker.ForgetReply(sequenceId);
return null;
}
await socket.SendAsync(bytes);
return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
}
public void Receive(ReplyMessage message) {
replyTracker.ReceiveReply(message.SequenceId, message.SerializedReply);
}
}

View File

@@ -0,0 +1,34 @@
using Phantom.Common.Logging;
using Phantom.Common.Messages;
using Serilog;
namespace Phantom.Agent.Rpc;
public static class ServerMessaging {
private static readonly ILogger Logger = PhantomLogger.Create(nameof(ServerMessaging));
private static RpcServerConnection? CurrentConnection { get; set; }
private static RpcServerConnection CurrentConnectionOrThrow => CurrentConnection ?? throw new InvalidOperationException("Server connection not ready.");
private static readonly object SetCurrentConnectionLock = new ();
internal static void SetCurrentConnection(RpcServerConnection connection) {
lock (SetCurrentConnectionLock) {
if (CurrentConnection != null) {
throw new InvalidOperationException("Server connection can only be set once.");
}
CurrentConnection = connection;
}
Logger.Information("Server connection ready.");
}
public static Task Send<TMessage>(TMessage message) where TMessage : IMessageToServer {
return CurrentConnectionOrThrow.Send(message);
}
public static Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToServer<TReply> where TReply : class {
return CurrentConnectionOrThrow.Send<TMessage, TReply>(message, waitForReplyTime, waitForReplyCancellationToken);
}
}

View File

@@ -1,5 +1,5 @@
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent.Services;

View File

@@ -1,85 +0,0 @@
using System.Collections.Immutable;
using Phantom.Agent.Services.Instances;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Phantom.Utils.Threading;
using Serilog;
namespace Phantom.Agent.Services;
public sealed class AgentRegistrationHandler {
private readonly ILogger logger = PhantomLogger.Create<AgentRegistrationHandler>();
private readonly ManualResetEventSlim newSessionEvent = new ();
private ImmutableArray<ConfigureInstanceMessage> lastConfigureInstanceMessages;
internal void OnRegistrationComplete(ImmutableArray<ConfigureInstanceMessage> configureInstanceMessages) {
ImmutableInterlocked.InterlockedExchange(ref lastConfigureInstanceMessages, configureInstanceMessages);
}
internal void OnNewSession() {
newSessionEvent.Set();
}
public async Task<bool> Start(AgentServices agentServices, CancellationToken cancellationToken) {
var configureInstanceMessages = ImmutableInterlocked.InterlockedExchange(ref lastConfigureInstanceMessages, value: default);
if (configureInstanceMessages.IsDefault) {
logger.Fatal("Handshake failed.");
return false;
}
foreach (var configureInstanceMessage in configureInstanceMessages) {
var configureInstanceResult = await agentServices.InstanceManager.Request(GetCommand(configureInstanceMessage), cancellationToken);
if (!configureInstanceResult.Is(ConfigureInstanceResult.Success)) {
logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configureInstanceMessage.Configuration.InstanceName, configureInstanceMessage.InstanceGuid);
return false;
}
}
agentServices.InstanceTicketManager.RefreshAgentStatus();
_ = HandleNewSessionRegistrations(agentServices, cancellationToken);
return true;
}
private async Task HandleNewSessionRegistrations(AgentServices agentServices, CancellationToken cancellationToken) {
while (cancellationToken.Check()) {
await newSessionEvent.WaitHandle.WaitOneAsync(cancellationToken);
newSessionEvent.Reset();
try {
await HandleNewSessionRegistration(agentServices, cancellationToken);
} catch (Exception e) {
logger.Error(e, "Could not configure instances after re-registration.");
}
}
}
private async Task HandleNewSessionRegistration(AgentServices agentServices, CancellationToken cancellationToken) {
var configureInstanceMessages = ImmutableInterlocked.InterlockedExchange(ref lastConfigureInstanceMessages, value: default);
if (configureInstanceMessages.IsDefaultOrEmpty) {
return;
}
foreach (var configureInstanceMessage in configureInstanceMessages) {
var configureInstanceResult = await agentServices.InstanceManager.Request(GetCommand(configureInstanceMessage), cancellationToken);
if (!configureInstanceResult.Is(ConfigureInstanceResult.Success)) {
logger.Error("Unable to configure instance \"{Name}\" (GUID {Guid}).", configureInstanceMessage.Configuration.InstanceName, configureInstanceMessage.InstanceGuid);
}
}
agentServices.InstanceTicketManager.RefreshAgentStatus();
}
private static InstanceManagerActor.ConfigureInstanceCommand GetCommand(ConfigureInstanceMessage configureInstanceMessage) {
return new InstanceManagerActor.ConfigureInstanceCommand(
configureInstanceMessage.InstanceGuid,
configureInstanceMessage.Configuration,
configureInstanceMessage.LaunchProperties,
configureInstanceMessage.LaunchNow,
AlwaysReportStatus: true
);
}
}

View File

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

View File

@@ -1,11 +1,9 @@
using Akka.Actor;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Agent;
using Phantom.Utils.Actor;
using Phantom.Utils.Logging;
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Services;
@@ -13,38 +11,34 @@ namespace Phantom.Agent.Services;
public sealed class AgentServices {
private static readonly ILogger Logger = PhantomLogger.Create<AgentServices>();
public ActorSystem ActorSystem { get; }
private AgentState AgentState { get; }
private AgentFolders AgentFolders { get; }
private TaskManager TaskManager { get; }
private BackupManager BackupManager { get; }
internal JavaRuntimeRepository JavaRuntimeRepository { get; }
internal InstanceTicketManager InstanceTicketManager { get; }
internal ActorRef<InstanceManagerActor.ICommand> InstanceManager { get; }
internal InstanceSessionManager InstanceSessionManager { get; }
public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders, AgentServiceConfiguration serviceConfiguration, ControllerConnection controllerConnection, JavaRuntimeRepository javaRuntimeRepository) {
this.ActorSystem = ActorSystemFactory.Create("Agent");
public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders) {
this.AgentFolders = agentFolders;
this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, AgentServices>());
this.BackupManager = new BackupManager(agentFolders);
this.JavaRuntimeRepository = new JavaRuntimeRepository();
this.InstanceSessionManager = new InstanceSessionManager(agentInfo, agentFolders, JavaRuntimeRepository, TaskManager, BackupManager);
}
this.AgentState = new AgentState();
this.BackupManager = new BackupManager(agentFolders, serviceConfiguration.MaxConcurrentCompressionTasks);
this.JavaRuntimeRepository = javaRuntimeRepository;
this.InstanceTicketManager = new InstanceTicketManager(agentInfo, controllerConnection);
var instanceManagerInit = new InstanceManagerActor.Init(controllerConnection, agentFolders, AgentState, JavaRuntimeRepository, InstanceTicketManager, BackupManager);
this.InstanceManager = ActorSystem.ActorOf(InstanceManagerActor.Factory(instanceManagerInit), "InstanceManager");
public async Task Initialize() {
await foreach (var runtime in JavaRuntimeDiscovery.Scan(AgentFolders.JavaSearchFolderPath)) {
JavaRuntimeRepository.Include(runtime);
}
}
public async Task Shutdown() {
Logger.Information("Stopping services...");
await InstanceManager.Stop(new InstanceManagerActor.ShutdownCommand());
await InstanceTicketManager.Shutdown();
await InstanceSessionManager.StopAll();
InstanceSessionManager.Dispose();
BackupManager.Dispose();
await ActorSystem.Terminate();
ActorSystem.Dispose();
await TaskManager.Stop();
Logger.Information("Services stopped.");
}

View File

@@ -1,15 +0,0 @@
using System.Collections.Immutable;
using Phantom.Agent.Services.Instances;
using Phantom.Utils.Actor.Event;
namespace Phantom.Agent.Services;
sealed class AgentState {
private readonly ObservableState<ImmutableDictionary<Guid, Instance>> instancesByGuid = new (ImmutableDictionary<Guid, Instance>.Empty);
public ImmutableDictionary<Guid, Instance> InstancesByGuid => instancesByGuid.State;
public void UpdateInstance(Instance instance) {
instancesByGuid.PublisherSide.Publish(static (instancesByGuid, instance) => instancesByGuid.SetItem(instance.InstanceGuid, instance), instance);
}
}

View File

@@ -3,8 +3,8 @@ using System.Diagnostics.CodeAnalysis;
using System.Formats.Tar;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Backups;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services.Backups;
@@ -108,7 +108,7 @@ sealed class BackupArchiver {
private async Task<bool> CreateTarArchive(string sourceFolderPath, string backupFilePath) {
try {
await TarFile.CreateFromDirectoryAsync(sourceFolderPath, backupFilePath, includeBaseDirectory: false, cancellationToken);
await TarFile.CreateFromDirectoryAsync(sourceFolderPath, backupFilePath, false, cancellationToken);
return true;
} catch (Exception e) {
logger.Error(e, "Could not create archive.");
@@ -135,7 +135,7 @@ sealed class BackupArchiver {
foreach (FileInfo file in sourceFolder.EnumerateFiles()) {
var filePath = relativePath.Add(file.Name);
if (IsFileSkipped(filePath)) {
logger.Debug("Skipping file: {File}", string.Join(separator: '/', filePath));
logger.Debug("Skipping file: {File}", string.Join('/', filePath));
continue;
}
@@ -150,7 +150,7 @@ sealed class BackupArchiver {
foreach (DirectoryInfo directory in sourceFolder.EnumerateDirectories()) {
var folderPath = relativePath.Add(directory.Name);
if (IsFolderSkipped(folderPath)) {
logger.Debug("Skipping folder: {Folder}", string.Join(separator: '/', folderPath));
logger.Debug("Skipping folder: {Folder}", string.Join('/', folderPath));
continue;
}
@@ -172,7 +172,7 @@ sealed class BackupArchiver {
}
else {
logger.Warning("Failed copying file {File}, retrying...", sourceFile.FullName);
await Task.Delay(millisecondsDelay: 200, cancellationToken);
await Task.Delay(200, cancellationToken);
}
}
}

View File

@@ -1,5 +1,5 @@
using Phantom.Utils.Logging;
using Phantom.Utils.Processes;
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Services.Backups;
@@ -47,11 +47,13 @@ static class BackupCompressor {
Quality,
Memory,
Threads,
"-c",
"--rm",
"--no-progress",
"-c",
"-o", destinationFilePath,
"--", sourceFilePath,
},
"--", sourceFilePath
}
};
static void OnZstdOutput(object? sender, Process.Output output) {

View File

@@ -1,38 +1,48 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Backups;
using Phantom.Utils.Logging;
using Phantom.Common.Logging;
using Serilog;
namespace Phantom.Agent.Services.Backups;
sealed class BackupManager : IDisposable {
sealed class BackupManager {
private readonly string destinationBasePath;
private readonly string temporaryBasePath;
private readonly SemaphoreSlim compressionSemaphore;
public BackupManager(AgentFolders agentFolders, int maxConcurrentCompressionTasks) {
public BackupManager(AgentFolders agentFolders) {
this.destinationBasePath = agentFolders.BackupsFolderPath;
this.temporaryBasePath = Path.Combine(agentFolders.TemporaryFolderPath, "backups");
this.compressionSemaphore = new SemaphoreSlim(maxConcurrentCompressionTasks, maxConcurrentCompressionTasks);
}
public Task<BackupCreationResult> CreateBackup(string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
return new BackupCreator(this, loggerName, process, cancellationToken).CreateBackup();
}
public async Task<BackupCreationResult> CreateBackup(string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
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() {
compressionSemaphore.Dispose();
try {
return await new BackupCreator(destinationBasePath, temporaryBasePath, loggerName, process, cancellationToken).CreateBackup();
} finally {
process.BackupSemaphore.Release();
}
}
private sealed class BackupCreator {
private readonly BackupManager manager;
private readonly string destinationBasePath;
private readonly string temporaryBasePath;
private readonly string loggerName;
private readonly ILogger logger;
private readonly InstanceProcess process;
private readonly CancellationToken cancellationToken;
public BackupCreator(BackupManager manager, string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
this.manager = manager;
public BackupCreator(string destinationBasePath, string temporaryBasePath, string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
this.destinationBasePath = destinationBasePath;
this.temporaryBasePath = temporaryBasePath;
this.loggerName = loggerName;
this.logger = PhantomLogger.Create<BackupManager>(loggerName);
this.process = process;
@@ -62,15 +72,11 @@ sealed class BackupManager : IDisposable {
try {
await dispatcher.DisableAutomaticSaving();
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) {
resultBuilder.Kind = BackupCreationResultKind.BackupCancelled;
logger.Warning("Backup creation was cancelled.");
return null;
} catch (TimeoutException) {
resultBuilder.Kind = BackupCreationResultKind.BackupTimedOut;
logger.Warning("Backup creation timed out.");
return null;
} catch (Exception e) {
resultBuilder.Kind = BackupCreationResultKind.UnknownError;
logger.Error(e, "Caught exception while creating an instance backup.");
@@ -79,10 +85,7 @@ sealed class BackupManager : IDisposable {
try {
await dispatcher.EnableAutomaticSaving();
} catch (OperationCanceledException) {
// Ignore.
} catch (TimeoutException) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Warning("Timed out waiting for automatic saving to be re-enabled.");
// ignore
} catch (Exception e) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Error(e, "Caught exception while enabling automatic saving after creating an instance backup.");
@@ -91,25 +94,15 @@ sealed class BackupManager : IDisposable {
}
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...");
try {
var compressedFilePath = await BackupCompressor.Compress(filePath, cancellationToken);
if (compressedFilePath == null) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotCompressWorldArchive;
}
} finally {
manager.compressionSemaphore.Release();
var compressedFilePath = await BackupCompressor.Compress(filePath, cancellationToken);
if (compressedFilePath == null) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotCompressWorldArchive;
}
}
private void LogBackupResult(BackupCreationResult result) {
if (result.Kind != BackupCreationResultKind.Success) {
logger.Warning("Backup failed: {Reason}", DescribeResult(result.Kind));
logger.Warning("Backup failed: {Reason}", result.Kind.ToSentence());
return;
}
@@ -121,20 +114,5 @@ sealed class BackupManager : IDisposable {
logger.Information("Backup finished successfully.");
}
}
private static string DescribeResult(BackupCreationResultKind kind) {
return kind switch {
BackupCreationResultKind.Success => "Backup created successfully.",
BackupCreationResultKind.InstanceNotRunning => "Instance is not running.",
BackupCreationResultKind.BackupCancelled => "Backup cancelled.",
BackupCreationResultKind.BackupTimedOut => "Backup timed out.",
BackupCreationResultKind.BackupAlreadyRunning => "A backup is already being created.",
BackupCreationResultKind.BackupFileAlreadyExists => "Backup with the same name already exists.",
BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.",
BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder => "Could not copy world to temporary folder.",
BackupCreationResultKind.CouldNotCreateWorldArchive => "Could not create world archive.",
_ => "Unknown error.",
};
}
}
}

View File

@@ -1,8 +1,8 @@
using Phantom.Agent.Services.Instances;
using Phantom.Agent.Services.Instances.State;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data.Backups;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Services.Backups;
@@ -12,19 +12,21 @@ sealed class BackupScheduler : CancellableBackgroundTask {
private static readonly TimeSpan BackupInterval = TimeSpan.FromMinutes(30);
private static readonly TimeSpan BackupFailureRetryDelay = TimeSpan.FromMinutes(5);
private readonly string loggerName;
private readonly BackupManager backupManager;
private readonly InstanceContext context;
private readonly SemaphoreSlim backupSemaphore = new (initialCount: 1, maxCount: 1);
private readonly InstanceProcess process;
private readonly int serverPort;
private readonly ServerStatusProtocol serverStatusProtocol;
private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
private readonly InstancePlayerCountTracker playerCountTracker;
public event EventHandler<BackupCreationResult>? BackupCompleted;
public BackupScheduler(InstanceContext context, InstancePlayerCountTracker playerCountTracker) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName)) {
this.backupManager = context.Services.BackupManager;
this.context = context;
this.playerCountTracker = playerCountTracker;
Start();
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.process = process;
this.serverPort = serverPort;
this.serverStatusProtocol = new ServerStatusProtocol(loggerName);
}
protected override async Task RunTask() {
@@ -48,38 +50,47 @@ sealed class BackupScheduler : CancellableBackgroundTask {
}
private async Task<BackupCreationResult> CreateBackup() {
if (!await backupSemaphore.WaitAsync(TimeSpan.FromSeconds(1))) {
return new BackupCreationResult(BackupCreationResultKind.BackupAlreadyRunning);
}
try {
context.ActorCancellationToken.ThrowIfCancellationRequested();
return await context.Actor.Request(new InstanceActor.BackupInstanceCommand(backupManager), context.ActorCancellationToken);
} catch (OperationCanceledException) {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
} finally {
backupSemaphore.Release();
}
return await backupManager.CreateBackup(loggerName, process, CancellationToken.None);
}
private async Task WaitForOnlinePlayers() {
var task = playerCountTracker.WaitForOnlinePlayers(CancellationToken);
if (!task.IsCompleted) {
Logger.Information("Waiting for someone to join before starting a new backup.");
}
bool needsToLogOfflinePlayersMessage = true;
process.AddOutputListener(ServerOutputListener, maxLinesToReadFromHistory: 0);
try {
await task;
Logger.Information("Players are online, starting a new backup.");
} catch (OperationCanceledException) {
throw;
} catch (Exception) {
Logger.Warning("Could not detect whether any players are online, starting a new backup.");
while (!CancellationToken.IsCancellationRequested) {
serverOutputWhileWaitingForOnlinePlayers.Reset();
var onlinePlayerCount = await serverStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken);
if (onlinePlayerCount == null) {
Logger.Warning("Could not detect whether any players are online, starting a new backup.");
break;
}
if (onlinePlayerCount > 0) {
Logger.Information("Players are online, starting a new backup.");
break;
}
if (needsToLogOfflinePlayersMessage) {
needsToLogOfflinePlayersMessage = false;
Logger.Information("No players are online, waiting for someone to join before starting a new backup.");
}
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
Logger.Debug("Waiting for server output before checking for online players again...");
await serverOutputWhileWaitingForOnlinePlayers.WaitHandle.WaitOneAsync(CancellationToken);
}
} finally {
process.RemoveOutputListener(ServerOutputListener);
}
}
protected override void Dispose() {
backupSemaphore.Dispose();
serverOutputWhileWaitingForOnlinePlayers.Dispose();
private void ServerOutputListener(object? sender, string line) {
if (!serverOutputWhileWaitingForOnlinePlayers.IsSet) {
serverOutputWhileWaitingForOnlinePlayers.Set();
Logger.Debug("Detected server output, signalling to check for online players again.");
}
}
}

View File

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

View File

@@ -1,5 +1,209 @@
using Phantom.Common.Data.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed record Instance(Guid InstanceGuid, IInstanceStatus Status);
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; }
public InstanceConfiguration Configuration { get; private set; }
private IServerLauncher Launcher { get; set; }
private readonly string shortName;
private readonly ILogger logger;
private IInstanceStatus currentStatus;
private int statusUpdateCounter;
private IInstanceState currentState;
private readonly SemaphoreSlim stateTransitioningActionSemaphore = new (1, 1);
public bool IsRunning => currentState is not InstanceNotRunningState;
public event EventHandler? IsRunningChanged;
public Instance(InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
this.shortName = GetLoggerName(configuration.InstanceGuid);
this.logger = PhantomLogger.Create<Instance>(shortName);
this.Services = services;
this.Configuration = configuration;
this.Launcher = launcher;
this.currentState = new InstanceNotRunningState();
this.currentStatus = InstanceStatus.NotRunning;
}
private void TryUpdateStatus(string taskName, Func<Task> getUpdateTask) {
int myStatusUpdateCounter = Interlocked.Increment(ref statusUpdateCounter);
Services.TaskManager.Run(taskName, async () => {
if (myStatusUpdateCounter == statusUpdateCounter) {
await getUpdateTask();
}
});
}
public void ReportLastStatus() {
TryUpdateStatus("Report last status of instance " + shortName, async () => {
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, currentStatus));
});
}
private void ReportAndSetStatus(IInstanceStatus status) {
TryUpdateStatus("Report status of instance " + shortName + " as " + status.GetType().Name, async () => {
currentStatus = status;
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, status));
});
}
private void ReportEvent(IInstanceEvent instanceEvent) {
var message = new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, Configuration.InstanceGuid, instanceEvent);
Services.TaskManager.Run("Report event for instance " + shortName, async () => await ServerMessaging.Send(message));
}
private void TransitionState(IInstanceState newState) {
if (currentState == newState) {
return;
}
if (currentState is IDisposable disposable) {
disposable.Dispose();
}
logger.Debug("Transitioning instance state to: {NewState}", newState.GetType().Name);
var wasRunning = IsRunning;
currentState = newState;
currentState.Initialize();
if (IsRunning != wasRunning) {
IsRunningChanged?.Invoke(this, EventArgs.Empty);
}
}
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);
try {
Configuration = configuration;
Launcher = launcher;
} finally {
stateTransitioningActionSemaphore.Release();
}
}
public async Task<LaunchInstanceResult> Launch(CancellationToken shutdownCancellationToken) {
await stateTransitioningActionSemaphore.WaitAsync(shutdownCancellationToken);
try {
return TransitionStateAndReturn(currentState.Launch(new InstanceContextImpl(this, shutdownCancellationToken)));
} catch (Exception e) {
logger.Error(e, "Caught exception while launching instance.");
return LaunchInstanceResult.UnknownError;
} finally {
stateTransitioningActionSemaphore.Release();
}
}
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 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);
}
}
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 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();
}
}
}

View File

@@ -1,174 +0,0 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances.State;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Actor;
using Phantom.Utils.Actor.Mailbox;
using Phantom.Utils.Logging;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
public readonly record struct Init(AgentState AgentState, Guid InstanceGuid, string ShortName, InstanceServices InstanceServices, InstanceTicketManager InstanceTicketManager, CancellationToken ShutdownCancellationToken);
public static Props<ICommand> Factory(Init init) {
return Props<ICommand>.Create(() => new InstanceActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name });
}
private readonly AgentState agentState;
private readonly CancellationToken shutdownCancellationToken;
private readonly Guid instanceGuid;
private readonly InstanceTicketManager instanceTicketManager;
private readonly InstanceContext context;
private readonly ControllerSendQueue<ReportInstanceStatusMessage> reportStatusQueue;
private readonly ControllerSendQueue<ReportInstanceEventMessage> reportEventsQueue;
private readonly CancellationTokenSource actorCancellationTokenSource = new ();
private IInstanceStatus currentStatus = InstanceStatus.NotRunning;
private InstanceRunningState? runningState = null;
private InstanceActor(Init init) {
InstanceServices services = init.InstanceServices;
this.agentState = init.AgentState;
this.instanceGuid = init.InstanceGuid;
this.instanceTicketManager = init.InstanceTicketManager;
this.shutdownCancellationToken = init.ShutdownCancellationToken;
this.reportStatusQueue = new ControllerSendQueue<ReportInstanceStatusMessage>(services.ControllerConnection, init.ShortName + "-Status", capacity: 1, singleWriter: true);
this.reportEventsQueue = new ControllerSendQueue<ReportInstanceEventMessage>(services.ControllerConnection, init.ShortName + "-Events", capacity: 1000, singleWriter: true);
var logger = PhantomLogger.Create<InstanceActor>(init.ShortName);
this.context = new InstanceContext(instanceGuid, init.ShortName, logger, services, reportEventsQueue, SelfTyped, actorCancellationTokenSource.Token);
Receive<ReportInstanceStatusCommand>(ReportInstanceStatus);
ReceiveAsync<LaunchInstanceCommand>(LaunchInstance);
ReceiveAsync<StopInstanceCommand>(StopInstance);
ReceiveAsyncAndReply<SendCommandToInstanceCommand, SendCommandToInstanceResult>(SendCommandToInstance);
ReceiveAsyncAndReply<BackupInstanceCommand, BackupCreationResult>(BackupInstance);
Receive<HandleProcessEndedCommand>(HandleProcessEnded);
ReceiveAsync<ShutdownCommand>(Shutdown);
}
private void SetAndReportStatus(IInstanceStatus status) {
currentStatus = status;
ReportCurrentStatus();
}
private void ReportCurrentStatus() {
agentState.UpdateInstance(new Instance(instanceGuid, currentStatus));
reportStatusQueue.Enqueue(new ReportInstanceStatusMessage(instanceGuid, currentStatus));
}
private void TransitionState(InstanceRunningState? newState) {
if (runningState == newState) {
return;
}
runningState?.Dispose();
runningState = newState;
runningState?.Initialize();
}
public interface ICommand;
public sealed record ReportInstanceStatusCommand : ICommand;
public sealed record LaunchInstanceCommand(InstanceConfiguration Configuration, IServerLauncher Launcher, InstanceTicketManager.Ticket Ticket, bool IsRestarting) : ICommand;
public sealed record StopInstanceCommand(MinecraftStopStrategy StopStrategy) : ICommand;
public sealed record SendCommandToInstanceCommand(string Command) : ICommand, ICanReply<SendCommandToInstanceResult>;
public sealed record BackupInstanceCommand(BackupManager BackupManager) : ICommand, ICanReply<BackupCreationResult>;
public sealed record HandleProcessEndedCommand(IInstanceStatus Status) : ICommand, IJumpAhead;
public sealed record ShutdownCommand : ICommand;
private void ReportInstanceStatus(ReportInstanceStatusCommand command) {
ReportCurrentStatus();
}
private async Task LaunchInstance(LaunchInstanceCommand command) {
if (command.IsRestarting || runningState is null) {
SetAndReportStatus(command.IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching);
var newState = await InstanceLaunchProcedure.Run(context, command.Configuration, command.Launcher, instanceTicketManager, command.Ticket, SetAndReportStatus, shutdownCancellationToken);
if (newState is null) {
instanceTicketManager.Release(command.Ticket);
}
TransitionState(newState);
}
}
private async Task StopInstance(StopInstanceCommand command) {
if (runningState is null) {
return;
}
IInstanceStatus oldStatus = currentStatus;
SetAndReportStatus(InstanceStatus.Stopping);
if (await InstanceStopProcedure.Run(context, command.StopStrategy, runningState, SetAndReportStatus, shutdownCancellationToken)) {
instanceTicketManager.Release(runningState.Ticket);
TransitionState(null);
}
else {
SetAndReportStatus(oldStatus);
}
}
private async Task<SendCommandToInstanceResult> SendCommandToInstance(SendCommandToInstanceCommand command) {
if (runningState is null) {
return SendCommandToInstanceResult.InstanceNotRunning;
}
else {
return await runningState.SendCommand(command.Command, shutdownCancellationToken);
}
}
private async Task<BackupCreationResult> BackupInstance(BackupInstanceCommand command) {
if (runningState is null || runningState.Process.HasEnded) {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
}
else {
SetAndReportStatus(InstanceStatus.BackingUp);
try {
return await command.BackupManager.CreateBackup(context.ShortName, runningState.Process, shutdownCancellationToken);
} finally {
SetAndReportStatus(InstanceStatus.Running);
}
}
}
private void HandleProcessEnded(HandleProcessEndedCommand command) {
if (runningState is { Process.HasEnded: true }) {
SetAndReportStatus(command.Status);
context.ReportEvent(InstanceEvent.Stopped);
instanceTicketManager.Release(runningState.Ticket);
TransitionState(null);
}
}
private async Task Shutdown(ShutdownCommand command) {
await StopInstance(new StopInstanceCommand(MinecraftStopStrategy.Instant));
await actorCancellationTokenSource.CancelAsync();
await Task.WhenAll(
reportStatusQueue.Shutdown(TimeSpan.FromSeconds(5)),
reportEventsQueue.Shutdown(TimeSpan.FromSeconds(5))
);
Context.Stop(Self);
}
}

View File

@@ -1,21 +1,35 @@
using Phantom.Agent.Services.Rpc;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Actor;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed record InstanceContext(
Guid InstanceGuid,
string ShortName,
ILogger Logger,
InstanceServices Services,
ControllerSendQueue<ReportInstanceEventMessage> ReportEventQueue,
ActorRef<InstanceActor.ICommand> Actor,
CancellationToken ActorCancellationToken
) {
public void ReportEvent(IInstanceEvent instanceEvent) {
ReportEventQueue.Enqueue(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, InstanceGuid, instanceEvent));
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

@@ -0,0 +1,71 @@
using System.Collections.Immutable;
using Phantom.Agent.Rpc;
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Collections;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceLogSender : CancellableBackgroundTask {
private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200);
private readonly Guid instanceGuid;
private readonly SemaphoreSlim semaphore = new (1, 1);
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) {
this.instanceGuid = instanceGuid;
}
protected override async Task RunTask() {
try {
while (!CancellationToken.IsCancellationRequested) {
await SendOutputToServer(await DequeueOrThrow());
await Task.Delay(SendDelay, CancellationToken);
}
} catch (OperationCanceledException) {
// Ignore.
}
// Flush remaining lines.
await SendOutputToServer(DequeueWithoutSemaphore());
}
private async Task SendOutputToServer(ImmutableArray<string> lines) {
if (!lines.IsEmpty) {
await ServerMessaging.Send(new InstanceOutputMessage(instanceGuid, lines));
}
}
private ImmutableArray<string> DequeueWithoutSemaphore() {
ImmutableArray<string> lines = buffer.Count > 0 ? buffer.EnumerateLast(uint.MaxValue).ToImmutableArray() : ImmutableArray<string>.Empty;
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) {
try {
semaphore.Wait(CancellationToken);
} catch (Exception) {
return;
}
try {
buffer.Add(line);
} finally {
semaphore.Release();
}
}
}

View File

@@ -1,204 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Launcher.Types;
using Phantom.Agent.Minecraft.Properties;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Utils.Actor;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand> {
private static readonly ILogger Logger = PhantomLogger.Create<InstanceManagerActor>();
public readonly record struct Init(ControllerConnection ControllerConnection, AgentFolders AgentFolders, AgentState AgentState, JavaRuntimeRepository JavaRuntimeRepository, InstanceTicketManager InstanceTicketManager, BackupManager BackupManager);
public static Props<ICommand> Factory(Init init) {
return Props<ICommand>.Create(() => new InstanceManagerActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
}
private readonly AgentState agentState;
private readonly string basePath;
private readonly InstanceServices instanceServices;
private readonly InstanceTicketManager instanceTicketManager;
private readonly Dictionary<Guid, InstanceInfo> instances = new ();
private readonly CancellationTokenSource shutdownCancellationTokenSource = new ();
private readonly CancellationToken shutdownCancellationToken;
private uint instanceLoggerSequenceId = 0;
private InstanceManagerActor(Init init) {
this.agentState = init.AgentState;
this.basePath = init.AgentFolders.InstancesFolderPath;
this.instanceTicketManager = init.InstanceTicketManager;
this.shutdownCancellationToken = shutdownCancellationTokenSource.Token;
var minecraftServerExecutables = new MinecraftServerExecutables(init.AgentFolders.ServerExecutableFolderPath);
var launchServices = new LaunchServices(minecraftServerExecutables, init.JavaRuntimeRepository);
this.instanceServices = new InstanceServices(init.ControllerConnection, init.BackupManager, launchServices);
ReceiveAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
ReceiveAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
ReceiveAsyncAndReply<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendCommandToInstance);
ReceiveAsync<ShutdownCommand>(Shutdown);
}
private sealed record InstanceInfo(ActorRef<InstanceActor.ICommand> Actor, InstanceConfiguration Configuration, IServerLauncher Launcher);
public interface ICommand;
public sealed record ConfigureInstanceCommand(Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool LaunchNow, bool AlwaysReportStatus) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
public sealed record LaunchInstanceCommand(Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
public sealed record StopInstanceCommand(Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;
public sealed record SendCommandToInstanceCommand(Guid InstanceGuid, string Command) : ICommand, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>;
public sealed record ShutdownCommand : ICommand;
private Result<ConfigureInstanceResult, InstanceActionFailure> ConfigureInstance(ConfigureInstanceCommand command) {
var instanceGuid = command.InstanceGuid;
var configuration = command.Configuration;
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,
MaximumHeapMegabytes: heapMegabytes
);
var properties = new InstanceProperties(
instanceGuid,
configuration.JavaRuntimeGuid,
jvmProperties,
configuration.JvmArguments,
instanceFolder,
configuration.MinecraftVersion,
new ServerProperties(configuration.ServerPort, configuration.RconPort),
command.LaunchProperties
);
IServerLauncher launcher = configuration.MinecraftServerKind switch {
MinecraftServerKind.Vanilla => new VanillaLauncher(properties),
MinecraftServerKind.Fabric => new FabricLauncher(properties),
_ => InvalidLauncher.Instance,
};
if (instances.TryGetValue(instanceGuid, out var instance)) {
instances[instanceGuid] = instance with {
Configuration = configuration,
Launcher = launcher,
};
Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid);
if (command.AlwaysReportStatus) {
instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand());
}
}
else {
var instanceLoggerName = PhantomLogger.ShortenGuid(instanceGuid) + "/" + Interlocked.Increment(ref instanceLoggerSequenceId);
var instanceInit = new InstanceActor.Init(agentState, instanceGuid, instanceLoggerName, instanceServices, instanceTicketManager, shutdownCancellationToken);
instances[instanceGuid] = instance = new InstanceInfo(Context.ActorOf(InstanceActor.Factory(instanceInit), "Instance-" + instanceGuid), configuration, launcher);
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid);
instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand());
}
if (command.LaunchNow) {
LaunchInstance(new LaunchInstanceCommand(instanceGuid));
}
return ConfigureInstanceResult.Success;
}
private Result<LaunchInstanceResult, InstanceActionFailure> LaunchInstance(LaunchInstanceCommand command) {
var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) {
return InstanceActionFailure.InstanceDoesNotExist;
}
var ticket = instanceTicketManager.Reserve(instanceInfo.Configuration);
if (!ticket) {
return ticket.Error;
}
if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) {
var status = instance.Status;
if (status.IsRunning()) {
return LaunchInstanceResult.InstanceAlreadyRunning;
}
else if (status.IsLaunching()) {
return LaunchInstanceResult.InstanceAlreadyLaunching;
}
}
instanceInfo.Actor.Tell(new InstanceActor.LaunchInstanceCommand(instanceInfo.Configuration, instanceInfo.Launcher, ticket.Value, IsRestarting: false));
return LaunchInstanceResult.LaunchInitiated;
}
private Result<StopInstanceResult, InstanceActionFailure> StopInstance(StopInstanceCommand command) {
var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) {
return InstanceActionFailure.InstanceDoesNotExist;
}
if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) {
var status = instance.Status;
if (status.IsStopping()) {
return StopInstanceResult.InstanceAlreadyStopping;
}
else if (!status.CanStop()) {
return StopInstanceResult.InstanceAlreadyStopped;
}
}
instanceInfo.Actor.Tell(new InstanceActor.StopInstanceCommand(command.StopStrategy));
return StopInstanceResult.StopInitiated;
}
private async Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> SendCommandToInstance(SendCommandToInstanceCommand command) {
var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) {
return InstanceActionFailure.InstanceDoesNotExist;
}
try {
return await instanceInfo.Actor.Request(new InstanceActor.SendCommandToInstanceCommand(command.Command), shutdownCancellationToken);
} catch (OperationCanceledException) {
return InstanceActionFailure.AgentShuttingDown;
}
}
private async Task Shutdown(ShutdownCommand command) {
Logger.Information("Stopping all instances...");
await shutdownCancellationTokenSource.CancelAsync();
await Task.WhenAll(instances.Values.Select(static instance => instance.Actor.Stop(new InstanceActor.ShutdownCommand())));
instances.Clear();
shutdownCancellationTokenSource.Dispose();
Logger.Information("All instances stopped.");
Context.Stop(Self);
}
}

View File

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

View File

@@ -0,0 +1,204 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Launcher.Types;
using Phantom.Agent.Minecraft.Properties;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Backups;
using Phantom.Common.Data;
using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.IO;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceSessionManager : IDisposable {
private static readonly ILogger Logger = PhantomLogger.Create<InstanceSessionManager>();
private readonly AgentInfo agentInfo;
private readonly string basePath;
private readonly InstanceServices instanceServices;
private readonly Dictionary<Guid, Instance> instances = new ();
private readonly CancellationTokenSource shutdownCancellationTokenSource = new ();
private readonly CancellationToken shutdownCancellationToken;
private readonly SemaphoreSlim semaphore = new (1, 1);
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);
}
private async Task<InstanceActionResult<T>> AcquireSemaphoreAndRun<T>(Func<Task<InstanceActionResult<T>>> func) {
try {
await semaphore.WaitAsync(shutdownCancellationToken);
try {
return await func();
} finally {
semaphore.Release();
}
} catch (OperationCanceledException) {
return InstanceActionResult.General<T>(InstanceActionGeneralResult.AgentShuttingDown);
}
}
[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")]
private Task<InstanceActionResult<T>> AcquireSemaphoreAndRunWithInstance<T>(Guid instanceGuid, Func<Instance, Task<T>> func) {
return AcquireSemaphoreAndRun(async () => {
if (instances.TryGetValue(instanceGuid, out var instance)) {
return InstanceActionResult.Concrete(await func(instance));
}
else {
return InstanceActionResult.General<T>(InstanceActionGeneralResult.InstanceDoesNotExist);
}
});
}
public async Task<InstanceActionResult<ConfigureInstanceResult>> Configure(InstanceConfiguration configuration, InstanceLaunchProperties launchProperties, bool launchNow, bool alwaysReportStatus) {
return await AcquireSemaphoreAndRun(async () => {
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,
MaximumHeapMegabytes: heapMegabytes
);
var properties = new InstanceProperties(
instanceGuid,
configuration.JavaRuntimeGuid,
jvmProperties,
configuration.JvmArguments,
instanceFolder,
configuration.MinecraftVersion,
new ServerProperties(configuration.ServerPort, configuration.RconPort),
launchProperties
);
IServerLauncher launcher = configuration.MinecraftServerKind switch {
MinecraftServerKind.Vanilla => new VanillaLauncher(properties),
MinecraftServerKind.Fabric => new FabricLauncher(properties),
_ => InvalidLauncher.Instance
};
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);
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
instance.ReportLastStatus();
instance.IsRunningChanged += OnInstanceIsRunningChanged;
}
if (launchNow) {
await LaunchInternal(instance);
}
return InstanceActionResult.Concrete(ConfigureInstanceResult.Success);
});
}
private ImmutableArray<Instance> GetRunningInstancesInternal() {
return instances.Values.Where(static instance => instance.IsRunning).ToImmutableArray();
}
private void OnInstanceIsRunningChanged(object? sender, EventArgs e) {
instanceServices.TaskManager.Run("Handle instance running state changed event", RefreshAgentStatus);
}
public async Task RefreshAgentStatus() {
try {
await semaphore.WaitAsync(shutdownCancellationToken);
try {
var runningInstances = GetRunningInstancesInternal();
var runningInstanceCount = runningInstances.Length;
var runningInstanceMemory = runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
await ServerMessaging.Send(new ReportAgentStatusMessage(runningInstanceCount, runningInstanceMemory));
} finally {
semaphore.Release();
}
} catch (OperationCanceledException) {
// ignore
}
}
public Task<InstanceActionResult<LaunchInstanceResult>> Launch(Guid instanceGuid) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, LaunchInternal);
}
private async Task<LaunchInstanceResult> LaunchInternal(Instance instance) {
var runningInstances = GetRunningInstancesInternal();
if (runningInstances.Length + 1 > agentInfo.MaxInstances) {
return LaunchInstanceResult.InstanceLimitExceeded;
}
var availableMemory = agentInfo.MaxMemory - runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
if (availableMemory < instance.Configuration.MemoryAllocation) {
return LaunchInstanceResult.MemoryLimitExceeded;
}
return await instance.Launch(shutdownCancellationToken);
}
public Task<InstanceActionResult<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy));
}
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();
Logger.Information("Stopping all instances...");
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();
shutdownCancellationTokenSource.Dispose();
semaphore.Dispose();
}
private void DisposeAllInstances() {
foreach (var (_, instance) in instances) {
instance.Dispose();
}
instances.Clear();
}
}

View File

@@ -1,97 +0,0 @@
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data;
using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceTicketManager(AgentInfo agentInfo, ControllerConnection controllerConnection) {
private static readonly ILogger Logger = PhantomLogger.Create<InstanceTicketManager>();
private readonly ControllerSendQueue<ReportAgentStatusMessage> reportStatusQueue = new (controllerConnection, nameof(InstanceTicketManager), capacity: 1, singleWriter: true);
private readonly HashSet<Guid> activeTicketGuids = [];
private readonly HashSet<ushort> usedPorts = [];
private RamAllocationUnits usedMemory = new ();
public Result<Ticket, LaunchInstanceResult> Reserve(InstanceConfiguration configuration) {
var memoryAllocation = configuration.MemoryAllocation;
var serverPort = configuration.ServerPort;
var rconPort = configuration.RconPort;
if (!agentInfo.AllowedServerPorts.Contains(serverPort)) {
return LaunchInstanceResult.ServerPortNotAllowed;
}
if (!agentInfo.AllowedRconPorts.Contains(rconPort)) {
return LaunchInstanceResult.RconPortNotAllowed;
}
lock (this) {
if (activeTicketGuids.Count + 1 > agentInfo.MaxInstances) {
return LaunchInstanceResult.InstanceLimitExceeded;
}
if (usedMemory + memoryAllocation > agentInfo.MaxMemory) {
return LaunchInstanceResult.MemoryLimitExceeded;
}
if (usedPorts.Contains(serverPort)) {
return LaunchInstanceResult.ServerPortAlreadyInUse;
}
if (usedPorts.Contains(rconPort)) {
return LaunchInstanceResult.RconPortAlreadyInUse;
}
var ticket = new Ticket(Guid.NewGuid(), memoryAllocation, serverPort, rconPort);
activeTicketGuids.Add(ticket.TicketGuid);
usedMemory += memoryAllocation;
usedPorts.Add(serverPort);
usedPorts.Add(rconPort);
RefreshAgentStatus();
Logger.Debug("Reserved ticket {TicketGuid} (server port {ServerPort}, rcon port {RconPort}, memory allocation {MemoryAllocation} MB).", ticket.TicketGuid, ticket.ServerPort, ticket.RconPort, ticket.MemoryAllocation.InMegabytes);
return ticket;
}
}
public bool IsValid(Ticket ticket) {
lock (this) {
return activeTicketGuids.Contains(ticket.TicketGuid);
}
}
public void Release(Ticket ticket) {
lock (this) {
if (!activeTicketGuids.Remove(ticket.TicketGuid)) {
return;
}
usedMemory -= ticket.MemoryAllocation;
usedPorts.Remove(ticket.ServerPort);
usedPorts.Remove(ticket.RconPort);
RefreshAgentStatus();
Logger.Debug("Released ticket {TicketGuid} (server port {ServerPort}, rcon port {RconPort}, memory allocation {MemoryAllocation} MB).", ticket.TicketGuid, ticket.ServerPort, ticket.RconPort, ticket.MemoryAllocation.InMegabytes);
}
}
public void RefreshAgentStatus() {
lock (this) {
reportStatusQueue.Enqueue(new ReportAgentStatusMessage(activeTicketGuids.Count, usedMemory));
}
}
public async Task Shutdown() {
await reportStatusQueue.Shutdown(TimeSpan.FromSeconds(5));
}
public sealed record Ticket(Guid TicketGuid, RamAllocationUnits MemoryAllocation, ushort ServerPort, ushort RconPort);
}

View File

@@ -0,0 +1,58 @@
using Phantom.Common.Data;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances;
sealed class PortManager {
private readonly AllowedPorts allowedServerPorts;
private readonly AllowedPorts allowedRconPorts;
private readonly HashSet<ushort> usedPorts = new ();
public PortManager(AllowedPorts allowedServerPorts, AllowedPorts allowedRconPorts) {
this.allowedServerPorts = allowedServerPorts;
this.allowedRconPorts = allowedRconPorts;
}
public Result Reserve(InstanceConfiguration configuration) {
var serverPort = configuration.ServerPort;
var rconPort = configuration.RconPort;
if (!allowedServerPorts.Contains(serverPort)) {
return Result.ServerPortNotAllowed;
}
if (!allowedRconPorts.Contains(rconPort)) {
return Result.RconPortNotAllowed;
}
lock (usedPorts) {
if (usedPorts.Contains(serverPort)) {
return Result.ServerPortAlreadyInUse;
}
if (usedPorts.Contains(rconPort)) {
return Result.RconPortAlreadyInUse;
}
usedPorts.Add(serverPort);
usedPorts.Add(rconPort);
}
return Result.Success;
}
public void Release(InstanceConfiguration configuration) {
lock (usedPorts) {
usedPorts.Remove(configuration.ServerPort);
usedPorts.Remove(configuration.RconPort);
}
}
public enum Result {
Success,
ServerPortNotAllowed,
ServerPortAlreadyInUse,
RconPortNotAllowed,
RconPortAlreadyInUse
}
}

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,86 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.State;
static class InstanceLaunchProcedure {
public static async Task<InstanceRunningState?> Run(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager ticketManager, InstanceTicketManager.Ticket ticket, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) {
context.Logger.Information("Session starting...");
Result<InstanceProcess, InstanceLaunchFailReason> result;
if (ticketManager.IsValid(ticket)) {
try {
result = await LaunchInstance(context, launcher, reportStatus, cancellationToken);
} catch (OperationCanceledException) {
reportStatus(InstanceStatus.NotRunning);
return null;
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while launching instance.");
result = InstanceLaunchFailReason.UnknownError;
}
}
else {
context.Logger.Error("Attempted to launch instance with an invalid ticket!");
result = InstanceLaunchFailReason.UnknownError;
}
if (result) {
reportStatus(InstanceStatus.Running);
context.ReportEvent(InstanceEvent.LaunchSucceeded);
return new InstanceRunningState(context, configuration, launcher, ticket, result.Value, cancellationToken);
}
else {
reportStatus(InstanceStatus.Failed(result.Error));
context.ReportEvent(new InstanceLaunchFailedEvent(result.Error));
return null;
}
}
private static async Task<Result<InstanceProcess, InstanceLaunchFailReason>> LaunchInstance(InstanceContext context, IServerLauncher launcher, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) {
cancellationToken.ThrowIfCancellationRequested();
byte lastDownloadProgress = byte.MaxValue;
void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) {
byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, val2: 100);
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
reportStatus(InstanceStatus.Downloading(progress));
}
}
switch (await launcher.Launch(context.Logger, context.Services.LaunchServices, OnDownloadProgress, cancellationToken)) {
case LaunchResult.Success launchSuccess:
return launchSuccess.Process;
case LaunchResult.InvalidJavaRuntime:
context.Logger.Error("Session failed to launch, invalid Java runtime.");
return InstanceLaunchFailReason.JavaRuntimeNotFound;
case LaunchResult.CouldNotDownloadMinecraftServer:
context.Logger.Error("Session failed to launch, could not download Minecraft server.");
return InstanceLaunchFailReason.CouldNotDownloadMinecraftServer;
case LaunchResult.CouldNotPrepareMinecraftServerLauncher:
context.Logger.Error("Session failed to launch, could not prepare Minecraft server launcher.");
return InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher;
case LaunchResult.CouldNotConfigureMinecraftServer:
context.Logger.Error("Session failed to launch, could not configure Minecraft server.");
return InstanceLaunchFailReason.CouldNotConfigureMinecraftServer;
case LaunchResult.CouldNotStartMinecraftServer:
context.Logger.Error("Session failed to launch, could not start Minecraft server.");
return InstanceLaunchFailReason.CouldNotStartMinecraftServer;
default:
context.Logger.Error("Session failed to launch.");
return InstanceLaunchFailReason.UnknownError;
}
}
}

View File

@@ -1,100 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Channels;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
namespace Phantom.Agent.Services.Instances.State;
sealed class InstanceLogSender : CancellableBackgroundTask {
private static readonly BoundedChannelOptions BufferOptions = new (capacity: 200) {
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.DropNewest,
};
private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200);
private readonly ControllerConnection controllerConnection;
private readonly Guid instanceGuid;
private readonly Channel<string> outputChannel;
private int droppedLinesSinceLastSend;
public InstanceLogSender(ControllerConnection controllerConnection, Guid instanceGuid, string loggerName) : base(PhantomLogger.Create<InstanceLogSender>(loggerName)) {
this.controllerConnection = controllerConnection;
this.instanceGuid = instanceGuid;
this.outputChannel = Channel.CreateBounded<string>(BufferOptions, OnLineDropped);
Start();
}
protected override async Task RunTask() {
var lineReader = outputChannel.Reader;
var lineBuilder = ImmutableArray.CreateBuilder<string>();
using var sendOutputCancellationTokenSource = new CancellationTokenSource();
await using var sendOutputCancellationRegistration = CancellationToken.Register([SuppressMessage("ReSharper", "AccessToDisposedClosure")]() => {
sendOutputCancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(10));
});
var sendOutputCancellationToken = sendOutputCancellationTokenSource.Token;
try {
while (await lineReader.WaitToReadAsync(CancellationToken)) {
await Task.Delay(SendDelay, CancellationToken);
await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder), sendOutputCancellationToken);
}
} catch (OperationCanceledException) {
// Ignore.
}
// Flush remaining lines.
try {
await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder), sendOutputCancellationToken);
} catch (OperationCanceledException) {
// Ignore.
}
}
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, value: 0);
if (droppedLines > 0) {
builder.Add($"Dropped {droppedLines} {(droppedLines == 1 ? "line" : "lines")} due to buffer overflow.");
}
return builder.ToImmutable();
}
private ValueTask SendOutputToServer(ImmutableArray<string> lines, CancellationToken cancellationToken) {
if (lines.IsEmpty) {
return ValueTask.CompletedTask;
}
else {
return controllerConnection.Send(new InstanceOutputMessage(instanceGuid, lines), cancellationToken);
}
}
private void OnLineDropped(string line) {
Logger.Warning("Buffer is full, dropped line: {Line}", line);
Interlocked.Increment(ref droppedLinesSinceLastSend);
}
public void Enqueue(string line) {
outputChannel.Writer.TryWrite(line);
}
protected override void Dispose() {
if (!outputChannel.Writer.TryComplete()) {
Logger.Error("Could not mark channel as completed.");
}
}
}

View File

@@ -1,150 +0,0 @@
using System.Net.Sockets;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Phantom.Utils.Threading;
namespace Phantom.Agent.Services.Instances.State;
sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
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 bool WaitingForFirstDetection => !firstDetection.Task.IsCompleted;
private InstancePlayerCounts? playerCounts;
private event EventHandler<int?>? OnlinePlayerCountChanged;
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();
}
protected override async Task RunTask() {
// Give the server time to start accepting connections.
await Task.Delay(TimeSpan.FromSeconds(5), CancellationToken);
serverOutputEvent.Set();
process.AddOutputListener(OnOutput, maxLinesToReadFromHistory: 0);
while (CancellationToken.Check()) {
serverOutputEvent.Reset();
InstancePlayerCounts? latestPlayerCounts = await TryGetPlayerCounts();
UpdatePlayerCounts(latestPlayerCounts);
if (latestPlayerCounts == null) {
await Task.Delay(WaitingForFirstDetection ? TimeSpan.FromSeconds(5) : TimeSpan.FromSeconds(10), CancellationToken);
}
else {
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
await serverOutputEvent.WaitHandle.WaitOneAsync(CancellationToken);
await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken);
}
}
}
private async Task<InstancePlayerCounts?> TryGetPlayerCounts() {
try {
return await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken);
} catch (ServerStatusProtocol.ProtocolException e) {
Logger.Error("{Message}", e.Message);
return null;
} catch (SocketException e) {
bool waitingForServerStart = e.SocketErrorCode == SocketError.ConnectionRefused && WaitingForFirstDetection;
if (!waitingForServerStart) {
Logger.Warning("Could not check online player count. Socket error {ErrorCode} ({ErrorCodeName}), reason: {ErrorMessage}", e.ErrorCode, e.SocketErrorCode, e.Message);
}
return null;
} catch (Exception e) {
Logger.Error(e, "Caught exception while checking online player count.");
return null;
}
}
private void UpdatePlayerCounts(InstancePlayerCounts? newPlayerCounts) {
if (newPlayerCounts is {} value) {
Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", value.Online, value.Maximum);
firstDetection.TrySetResult();
}
EventHandler<int?>? onlinePlayerCountChanged;
lock (this) {
if (playerCounts == newPlayerCounts) {
return;
}
playerCounts = newPlayerCounts;
onlinePlayerCountChanged = OnlinePlayerCountChanged;
}
onlinePlayerCountChanged?.Invoke(this, newPlayerCounts?.Online);
if (!controllerConnection.TrySend(new ReportInstancePlayerCountsMessage(instanceGuid, newPlayerCounts))) {
Logger.Warning("Could not report online player count to Controller.");
}
}
public async Task WaitForOnlinePlayers(CancellationToken cancellationToken) {
await firstDetection.Task.WaitAsync(cancellationToken);
var onlinePlayersDetected = AsyncTasks.CreateCompletionSource();
lock (this) {
if (playerCounts is { Online: > 0 }) {
return;
}
else if (playerCounts == null) {
throw new InvalidOperationException();
}
OnlinePlayerCountChanged += OnOnlinePlayerCountChanged;
void OnOnlinePlayerCountChanged(object? sender, int? newPlayerCount) {
if (newPlayerCount == null) {
onlinePlayersDetected.TrySetException(new InvalidOperationException());
OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
}
else if (newPlayerCount > 0) {
onlinePlayersDetected.TrySetResult();
OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
}
}
}
await onlinePlayersDetected.Task;
}
private void OnOutput(object? sender, string? line) {
lock (this) {
if (!isDisposed) {
serverOutputEvent.Set();
}
}
}
protected override void Dispose() {
lock (this) {
isDisposed = true;
playerCounts = null;
}
process.RemoveOutputListener(OnOutput);
serverOutputEvent.Dispose();
}
}

View File

@@ -1,123 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups;
using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.State;
sealed class InstanceRunningState : IDisposable {
public InstanceTicketManager.Ticket Ticket { get; }
public InstanceProcess Process { get; }
internal bool IsStopping { get; set; }
private readonly InstanceContext context;
private readonly InstanceConfiguration configuration;
private readonly IServerLauncher launcher;
private readonly CancellationToken cancellationToken;
private readonly InstanceLogSender logSender;
private readonly InstancePlayerCountTracker playerCountTracker;
private readonly BackupScheduler backupScheduler;
private bool isDisposed;
public InstanceRunningState(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager.Ticket ticket, InstanceProcess process, CancellationToken cancellationToken) {
this.context = context;
this.configuration = configuration;
this.launcher = launcher;
this.Ticket = ticket;
this.Process = process;
this.cancellationToken = cancellationToken;
this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName);
this.playerCountTracker = new InstancePlayerCountTracker(context, process, configuration.ServerPort);
this.backupScheduler = new BackupScheduler(context, playerCountTracker);
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
}
public void Initialize() {
Process.Ended += ProcessEnded;
if (Process.HasEnded) {
if (TryDispose()) {
context.Logger.Warning("Session ended immediately after it was started.");
context.Actor.Tell(new InstanceActor.HandleProcessEndedCommand(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)));
}
}
else {
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 (!TryDispose()) {
return;
}
if (cancellationToken.IsCancellationRequested) {
return;
}
if (IsStopping) {
context.Actor.Tell(new InstanceActor.HandleProcessEndedCommand(InstanceStatus.NotRunning));
}
else {
context.Logger.Information("Session ended unexpectedly, restarting...");
context.ReportEvent(InstanceEvent.Crashed);
context.Actor.Tell(new InstanceActor.LaunchInstanceCommand(configuration, launcher, Ticket, IsRestarting: true));
}
}
private void OnScheduledBackupCompleted(object? sender, BackupCreationResult e) {
context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings));
}
public async Task<SendCommandToInstanceResult> SendCommand(string command, CancellationToken cancellationToken) {
try {
context.Logger.Information("Sending command: {Command}", command);
await Process.SendCommand(command, cancellationToken);
return SendCommandToInstanceResult.Success;
} catch (OperationCanceledException) {
return SendCommandToInstanceResult.UnknownError;
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending command.");
return SendCommandToInstanceResult.UnknownError;
}
}
public void OnStopInitiated() {
backupScheduler.Stop();
playerCountTracker.Stop();
}
private bool TryDispose() {
lock (this) {
if (isDisposed) {
return false;
}
isDisposed = true;
}
OnStopInitiated();
logSender.Stop();
Process.Dispose();
return true;
}
public void Dispose() {
TryDispose();
}
}

View File

@@ -1,99 +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;
namespace Phantom.Agent.Services.Instances.State;
static class InstanceStopProcedure {
private static readonly ushort[] Stops = [60, 30, 10, 5, 4, 3, 2, 1, 0];
public static async Task<bool> Run(InstanceContext context, MinecraftStopStrategy stopStrategy, InstanceRunningState runningState, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) {
var process = runningState.Process;
runningState.IsStopping = true;
var seconds = stopStrategy.Seconds;
if (seconds > 0) {
try {
await CountDownWithAnnouncements(context, process, seconds, cancellationToken);
} catch (OperationCanceledException) {
runningState.IsStopping = false;
return false;
}
}
try {
// Too late to cancel the stop procedure now.
runningState.OnStopInitiated();
if (!process.HasEnded) {
context.Logger.Information("Session stopping now.");
await DoStop(context, process);
}
} finally {
context.Logger.Information("Session stopped.");
reportStatus(InstanceStatus.NotRunning);
context.ReportEvent(InstanceEvent.Stopped);
}
return true;
}
private static async Task CountDownWithAnnouncements(InstanceContext 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 static async Task DoStop(InstanceContext 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 static async Task TrySendStopCommand(InstanceContext 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 (IOException e) when (e.HResult == -2147024664 /* The pipe is being closed */) {
// Ignore.
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending stop command.");
}
}
private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) {
try {
await process.WaitForExit(TimeSpan.FromSeconds(55));
} catch (TimeoutException) {
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,11 @@
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
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);
}

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

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

View File

@@ -0,0 +1,178 @@
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances.Sessions;
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;
public InstanceRunningState(InstanceContext context, InstanceProcess process, InstanceSession session) {
this.context = context;
this.process = process;
this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, process, context.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()) {
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)));
}
}
else {
context.SetStatus(InstanceStatus.Running);
context.Logger.Information("Session started.");
}
}
private void ProcessEnded(object? sender, EventArgs e) {
if (!runningSessionDisposer.Dispose()) {
return;
}
if (isStopping) {
context.Logger.Information("Session ended.");
context.ReportEvent(InstanceEvent.Stopped);
context.TransitionState(new InstanceNotRunningState(), InstanceStatus.NotRunning);
}
else {
context.Logger.Information("Session ended unexpectedly, restarting...");
context.ReportEvent(InstanceEvent.Crashed);
context.TransitionState(new InstanceLaunchingState(context), InstanceStatus.Restarting);
}
}
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());
}
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
try {
context.Logger.Information("Sending command: {Command}", command);
await process.SendCommand(command, cancellationToken);
return true;
} catch (OperationCanceledException) {
return false;
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending command.");
return false;
}
}
private void OnScheduledBackupCompleted(object? sender, BackupCreationResult e) {
context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings));
}
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();
}
}
}

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

@@ -1,13 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" />
<ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Agent.Rpc\Phantom.Agent.Rpc.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,19 +0,0 @@
using Phantom.Common.Messages.Agent;
using Phantom.Utils.Actor;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerConnection(MessageSender<IMessageToController> sender) {
internal bool TrySend<TMessage>(TMessage message) where TMessage : IMessageToController {
return sender.TrySend(message);
}
internal ValueTask Send<TMessage>(TMessage message, CancellationToken cancellationToken) where TMessage : IMessageToController {
return sender.Send(message, cancellationToken);
}
internal Task<TReply> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken cancellationToken) where TMessage : IMessageToController, ICanReply<TReply> {
return sender.Send<TMessage, TReply>(message, waitForReplyTime, cancellationToken);
}
}

View File

@@ -1,50 +0,0 @@
using System.Collections.Immutable;
using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Rpc.Runtime;
using Phantom.Utils.Rpc.Runtime.Client;
using Serilog;
namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerHandshake(AgentRegistration registration, AgentRegistrationHandler registrationHandler) : IRpcClientHandshake {
private const int MaxInstances = 100_000;
private const int MaxMessageBytes = 1024 * 1024 * 8;
private readonly ILogger logger = PhantomLogger.Create<ControllerHandshake>();
public async Task Perform(RpcStream stream, CancellationToken cancellationToken) {
logger.Information("Registering with the controller...");
ReadOnlyMemory<byte> serializedRegistration = MessageSerialization.Serialize(registration);
await stream.WriteSignedInt(serializedRegistration.Length, cancellationToken);
await stream.WriteBytes(serializedRegistration, cancellationToken);
await stream.Flush(cancellationToken);
if (await stream.ReadByte(cancellationToken) == 0) {
return;
}
uint configureInstanceMessageCount = await stream.ReadUnsignedInt(cancellationToken);
if (configureInstanceMessageCount > MaxInstances) {
throw new InvalidOperationException("Trying to configure too many instances (" + configureInstanceMessageCount + " > " + MaxInstances + ").");
}
var configureInstanceMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
for (int index = 0; index < configureInstanceMessageCount; index++) {
int serializedMessageLength = await stream.ReadSignedInt(cancellationToken);
if (serializedMessageLength is < 0 or > MaxMessageBytes) {
throw new InvalidOperationException("Message must be between 0 and " + MaxMessageBytes + " bytes.");
}
var serializedMessage = await stream.ReadBytes(serializedMessageLength, cancellationToken);
configureInstanceMessages.Add(MessageSerialization.Deserialize<ConfigureInstanceMessage>(serializedMessage));
}
registrationHandler.OnRegistrationComplete(configureInstanceMessages.ToImmutable());
logger.Information("Registration complete.");
}
}

View File

@@ -1,43 +0,0 @@
using Phantom.Agent.Services.Instances;
using Phantom.Common.Data;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Utils.Actor;
namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent> {
public readonly record struct Init(AgentServices Agent);
public static Props<IMessageToAgent> Factory(Init init) {
return Props<IMessageToAgent>.Create(() => new ControllerMessageHandlerActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
}
private readonly AgentServices agent;
private ControllerMessageHandlerActor(Init init) {
this.agent = init.Agent;
ReceiveAndReplyLater<ConfigureInstanceMessage, Result<ConfigureInstanceResult, InstanceActionFailure>>(HandleConfigureInstance);
ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, InstanceActionFailure>>(HandleLaunchInstance);
ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(HandleStopInstance);
ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(HandleSendCommandToInstance);
}
private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> HandleConfigureInstance(ConfigureInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.ConfigureInstanceCommand(message.InstanceGuid, message.Configuration, message.LaunchProperties, message.LaunchNow, AlwaysReportStatus: false));
}
private async Task<Result<LaunchInstanceResult, InstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.LaunchInstanceCommand(message.InstanceGuid));
}
private async Task<Result<StopInstanceResult, InstanceActionFailure>> HandleStopInstance(StopInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.StopInstanceCommand(message.InstanceGuid, message.StopStrategy));
}
private async Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.SendCommandToInstanceCommand(message.InstanceGuid, message.Command));
}
}

View File

@@ -1,11 +0,0 @@
using Phantom.Common.Messages.Agent;
using Phantom.Utils.Actor;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerMessageReceiver(ActorRef<IMessageToAgent> actor, AgentRegistrationHandler agentRegistrationHandler) : IMessageReceiver<IMessageToAgent>.Actor(actor) {
public override void OnSessionRestarted() {
agentRegistrationHandler.OnNewSession();
}
}

View File

@@ -1,53 +0,0 @@
using System.Threading.Channels;
using Phantom.Common.Messages.Agent;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services.Rpc;
sealed class ControllerSendQueue<TMessage> where TMessage : IMessageToController {
private readonly ILogger logger;
private readonly Channel<TMessage> channel;
private readonly Task sendTask;
private readonly CancellationTokenSource shutdownTokenSource = new ();
public ControllerSendQueue(ControllerConnection controllerConnection, string loggerName, int capacity, bool singleWriter) {
this.logger = PhantomLogger.Create<ControllerSendQueue<TMessage>>(loggerName);
this.channel = Channel.CreateBounded<TMessage>(new BoundedChannelOptions(capacity) {
AllowSynchronousContinuations = false,
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = singleWriter,
});
this.sendTask = Send(controllerConnection, shutdownTokenSource.Token);
}
private async Task Send(ControllerConnection controllerConnection, CancellationToken cancellationToken) {
await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken)) {
await controllerConnection.Send(message, cancellationToken);
}
}
public void Enqueue(TMessage message) {
channel.Writer.TryWrite(message);
}
public async Task Shutdown(TimeSpan gracefulTimeout) {
channel.Writer.TryComplete();
try {
await sendTask.WaitAsync(gracefulTimeout);
} catch (TimeoutException) {
logger.Warning("Timed out waiting for queue to finish processing.");
} catch (Exception) {
// Ignore.
}
await shutdownTokenSource.CancelAsync();
await sendTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
shutdownTokenSource.Dispose();
}
}

View File

@@ -0,0 +1,86 @@
using Phantom.Agent.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies;
using Phantom.Common.Logging;
using Phantom.Common.Messages;
using Phantom.Common.Messages.BiDirectional;
using Phantom.Common.Messages.ToAgent;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Rpc.Message;
using Serilog;
namespace Phantom.Agent.Services.Rpc;
public sealed class MessageListener : IMessageToAgentListener {
private static ILogger Logger { get; } = PhantomLogger.Create<MessageListener>();
private readonly RpcServerConnection connection;
private readonly AgentServices agent;
private readonly CancellationTokenSource shutdownTokenSource;
public MessageListener(RpcServerConnection connection, AgentServices agent, CancellationTokenSource shutdownTokenSource) {
this.connection = connection;
this.agent = agent;
this.shutdownTokenSource = shutdownTokenSource;
}
public async Task<NoReply> HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message) {
Logger.Information("Agent authentication successful.");
void ShutdownAfterConfigurationFailed(InstanceConfiguration configuration) {
Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configuration.InstanceName, configuration.InstanceGuid);
shutdownTokenSource.Cancel();
}
foreach (var configureInstanceMessage in message.InitialInstanceConfigurations) {
var result = await HandleConfigureInstance(configureInstanceMessage, alwaysReportStatus: true);
if (!result.Is(ConfigureInstanceResult.Success)) {
ShutdownAfterConfigurationFailed(configureInstanceMessage.Configuration);
return NoReply.Instance;
}
}
await ServerMessaging.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All));
await agent.InstanceSessionManager.RefreshAgentStatus();
return NoReply.Instance;
}
public Task<NoReply> HandleRegisterAgentFailure(RegisterAgentFailureMessage message) {
string errorMessage = message.FailureKind switch {
RegisterAgentFailure.ConnectionAlreadyHasAnAgent => "This connection already has an associated agent.",
RegisterAgentFailure.InvalidToken => "Invalid token.",
_ => "Unknown error " + (byte) message.FailureKind + "."
};
Logger.Fatal("Agent authentication failed: {Error}", errorMessage);
Environment.Exit(1);
return Task.FromResult(NoReply.Instance);
}
private Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message, bool alwaysReportStatus) {
return agent.InstanceSessionManager.Configure(message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus);
}
public async Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message) {
return await HandleConfigureInstance(message, alwaysReportStatus: false);
}
public async Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) {
return await agent.InstanceSessionManager.Launch(message.InstanceGuid);
}
public async Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) {
return await agent.InstanceSessionManager.Stop(message.InstanceGuid, message.StopStrategy);
}
public async Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
return await agent.InstanceSessionManager.SendCommand(message.InstanceGuid, message.Command);
}
public Task<NoReply> HandleReply(ReplyMessage message) {
connection.Receive(message);
return Task.FromResult(NoReply.Instance);
}
}

View File

@@ -1,8 +1,8 @@
using System.Text;
using Phantom.Common.Data;
using NetMQ;
using Phantom.Common.Data.Agent;
using Phantom.Common.Logging;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent;
@@ -10,7 +10,7 @@ namespace Phantom.Agent;
static class AgentKey {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(AgentKey));
public static Task<ConnectionKey?> Load(string? agentKeyToken, string? agentKeyFilePath) {
public static Task<(NetMQCertificate, AgentAuthToken)?> Load(string? agentKeyToken, string? agentKeyFilePath) {
if (agentKeyFilePath != null) {
return LoadFromFile(agentKeyFilePath);
}
@@ -22,19 +22,18 @@ static class AgentKey {
}
}
private static async Task<ConnectionKey?> LoadFromFile(string agentKeyFilePath) {
private static async Task<(NetMQCertificate, AgentAuthToken)?> LoadFromFile(string agentKeyFilePath) {
if (!File.Exists(agentKeyFilePath)) {
Logger.Fatal("Missing agent key file: {AgentKeyFilePath}", agentKeyFilePath);
return null;
}
try {
Files.RequireMaximumFileSize(agentKeyFilePath, maximumBytes: 64);
string[] lines = await File.ReadAllLinesAsync(agentKeyFilePath, Encoding.UTF8);
return LoadFromToken(lines[0]);
Files.RequireMaximumFileSize(agentKeyFilePath, 64);
return LoadFromBytes(await File.ReadAllBytesAsync(agentKeyFilePath));
} catch (IOException e) {
Logger.Fatal("Error loading agent key from file: {AgentKeyFilePath}", agentKeyFilePath);
Logger.Fatal("{Message}", e.Message);
Logger.Fatal(e.Message);
return null;
} catch (Exception) {
Logger.Fatal("File does not contain a valid agent key: {AgentKeyFilePath}", agentKeyFilePath);
@@ -42,7 +41,7 @@ static class AgentKey {
}
}
private static ConnectionKey? LoadFromToken(string agentKey) {
private static (NetMQCertificate, AgentAuthToken)? LoadFromToken(string agentKey) {
try {
return LoadFromBytes(TokenGenerator.DecodeBytes(agentKey));
} catch (Exception) {
@@ -51,9 +50,11 @@ static class AgentKey {
}
}
private static ConnectionKey? LoadFromBytes(byte[] agentKey) {
var connectionKey = ConnectionKey.FromBytes(agentKey);
private static (NetMQCertificate, AgentAuthToken)? LoadFromBytes(byte[] agentKey) {
var (publicKey, agentToken) = AgentKeyData.FromBytes(agentKey);
var serverCertificate = NetMQCertificate.FromPublicKey(publicKey);
Logger.Information("Loaded agent key.");
return connectionKey;
return (serverCertificate, agentToken);
}
}

View File

@@ -0,0 +1,44 @@
using System.Text;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent;
static class GuidFile {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(GuidFile));
private const string GuidFileName = "agent.guid";
public static async Task<Guid?> CreateOrLoad(string folderPath) {
string filePath = Path.Combine(folderPath, GuidFileName);
if (File.Exists(filePath)) {
try {
var guid = await LoadGuidFromFile(filePath);
Logger.Information("Loaded existing agent GUID file.");
return guid;
} catch (Exception e) {
Logger.Fatal("Error reading agent GUID file: {Message}", e.Message);
return null;
}
}
Logger.Information("Creating agent GUID file: {FilePath}", filePath);
try {
var guid = Guid.NewGuid();
await File.WriteAllTextAsync(filePath, guid.ToString(), Encoding.ASCII);
return guid;
} catch (Exception e) {
Logger.Fatal("Error creating agent GUID file: {Message}", e.Message);
return null;
}
}
private static async Task<Guid> LoadGuidFromFile(string filePath) {
Files.RequireMaximumFileSize(filePath, 128);
string contents = await File.ReadAllTextAsync(filePath, Encoding.ASCII);
return Guid.Parse(contents.Trim());
}
}

View File

@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -11,7 +12,8 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
<ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Agent.Services\Phantom.Agent.Services.csproj" />
</ItemGroup>

View File

@@ -1,25 +1,18 @@
using System.Reflection;
using Phantom.Agent;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Rpc;
using Phantom.Agent.Services;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Agent;
using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Utils.Actor;
using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime.Client;
using Phantom.Common.Logging;
using Phantom.Utils.Rpc;
using Phantom.Utils.Runtime;
using Phantom.Utils.Threading;
const int ProtocolVersion = 1;
var shutdownCancellationTokenSource = new CancellationTokenSource();
var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
ProgramCulture.UseInvariantCulture();
ThreadPool.SetMinThreads(workerThreads: 2, completionPortThreads: 1);
PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, static () => {
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel agent...");
});
@@ -30,70 +23,52 @@ try {
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop();
var (serverHost, serverPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts) = Variables.LoadOrExit();
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
if (agentKey == null) {
return 1;
Environment.Exit(1);
}
var folders = new AgentFolders("./data", "./temp", javaSearchPath);
if (!folders.TryCreate()) {
return 1;
Environment.Exit(1);
}
var agentInfo = new AgentInfo(ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var javaRuntimeRepository = await JavaRuntimeDiscovery.Scan(folders.JavaSearchFolderPath, shutdownCancellationToken);
var agentRegistrationHandler = new AgentRegistrationHandler();
var controllerHandshake = new ControllerHandshake(new AgentRegistration(agentInfo, javaRuntimeRepository.All), agentRegistrationHandler);
var rpcClientConnectionParameters = new RpcClientConnectionParameters(
Host: controllerHost,
Port: controllerPort,
DistinguishedName: "phantom-controller",
CertificateThumbprint: agentKey.Value.CertificateThumbprint,
AuthToken: agentKey.Value.AuthToken,
Handshake: controllerHandshake,
MessageQueueCapacity: 250,
FrameQueueCapacity: 500,
MaxConcurrentlyHandledMessages: 50
);
using var rpcClient = await RpcClient<IMessageToController, IMessageToAgent>.Connect("Controller", rpcClientConnectionParameters, AgentMessageRegistries.Registries, shutdownCancellationToken);
if (rpcClient == null) {
PhantomLogger.Root.Fatal("Could not connect to Phantom Controller, shutting down.");
return 1;
var agentGuid = await GuidFile.CreateOrLoad(folders.DataFolderPath);
if (agentGuid == null) {
Environment.Exit(1);
}
var (serverCertificate, agentToken) = agentKey.Value;
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var agentServices = new AgentServices(agentInfo, folders);
MessageListener MessageListenerFactory(RpcServerConnection connection) {
return new MessageListener(connection, agentServices, shutdownCancellationTokenSource);
}
PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent...");
await agentServices.Initialize();
var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1);
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), serverHost, serverPort, serverCertificate);
var rpcTask = RpcLauncher.Launch(rpcConfiguration, agentToken, agentInfo, MessageListenerFactory, rpcDisconnectSemaphore, shutdownCancellationToken);
try {
PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent...");
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks), new ControllerConnection(rpcClient.MessageSender), javaRuntimeRepository);
var rpcMessageHandlerInit = new ControllerMessageHandlerActor.Init(agentServices);
var rpcMessageHandlerActor = agentServices.ActorSystem.ActorOf(ControllerMessageHandlerActor.Factory(rpcMessageHandlerInit), "ControllerMessageHandler");
rpcClient.StartListening(new ControllerMessageReceiver(rpcMessageHandlerActor, agentRegistrationHandler));
if (await agentRegistrationHandler.Start(agentServices, shutdownCancellationToken)) {
PhantomLogger.Root.Information("Phantom Panel agent is ready.");
await shutdownCancellationToken.WaitHandle.WaitOneAsync();
}
await agentServices.Shutdown();
await rpcTask.WaitAsync(shutdownCancellationToken);
} finally {
await rpcClient.Shutdown();
}
shutdownCancellationTokenSource.Cancel();
await agentServices.Shutdown();
return 0;
rpcDisconnectSemaphore.Release();
await rpcTask;
rpcDisconnectSemaphore.Dispose();
}
} catch (OperationCanceledException) {
return 0;
} catch (StopProcedureException) {
return 1;
// Ignore.
} catch (Exception e) {
PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
return 1;
} finally {
shutdownCancellationTokenSource.Dispose();

View File

@@ -1,37 +1,37 @@
using Phantom.Agent.Minecraft.Java;
using Phantom.Common.Data;
using Phantom.Utils.Logging;
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
namespace Phantom.Agent;
sealed record Variables(
string ControllerHost,
ushort ControllerPort,
string ServerHost,
ushort ServerPort,
string JavaSearchPath,
string? AgentKeyToken,
string? AgentKeyFilePath,
string AgentName,
ushort MaxInstances,
RamAllocationUnits MaxMemory,
AllowedPorts AllowedServerPorts,
AllowedPorts AllowedRconPorts,
ushort MaxConcurrentBackupCompressionTasks
AllowedPorts AllowedRconPorts
) {
private static Variables LoadOrThrow() {
var (agentKeyToken, agentKeyFilePath) = EnvironmentVariables.GetEitherString("AGENT_KEY", "AGENT_KEY_FILE").Require;
var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").WithDefaultGetter(GetDefaultJavaSearchPath);
return new Variables(
EnvironmentVariables.GetString("CONTROLLER_HOST").Require,
EnvironmentVariables.GetPortNumber("CONTROLLER_PORT").WithDefault(9401),
EnvironmentVariables.GetString("SERVER_HOST").Require,
EnvironmentVariables.GetPortNumber("SERVER_PORT").WithDefault(9401),
javaSearchPath,
agentKeyToken,
agentKeyFilePath,
EnvironmentVariables.GetString("AGENT_NAME").Require,
(ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require,
EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_SERVER_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)
EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).Require
);
}
@@ -39,12 +39,13 @@ sealed record Variables(
return JavaRuntimeDiscovery.GetSystemSearchPath() ?? throw new Exception("Could not automatically determine the path to Java installations on this system. Please set the JAVA_SEARCH_PATH environment variable to the folder containing Java installations.");
}
public static Variables LoadOrStop() {
public static Variables LoadOrExit() {
try {
return LoadOrThrow();
} catch (Exception e) {
PhantomLogger.Root.Fatal(e.Message);
throw StopProcedureException.Instance;
Environment.Exit(1);
throw;
}
}
}

View File

@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -14,6 +15,7 @@
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>

View File

@@ -67,22 +67,22 @@ public sealed class RamAllocationUnitsTests {
Assert.That(CallFromString("123A5M"), Throws.Exception.TypeOf<ArgumentOutOfRangeException>().With.Message.StartsWith("Must begin with a number."));
}
[TestCase("0m", arg2: 0)]
[TestCase("256m", arg2: 256)]
[TestCase("256M", arg2: 256)]
[TestCase("512M", arg2: 512)]
[TestCase("65536M", arg2: 65536)]
[TestCase("0m", 0)]
[TestCase("256m", 256)]
[TestCase("256M", 256)]
[TestCase("512M", 512)]
[TestCase("65536M", 65536)]
[TestCase("16776960M", 16777216 - 256)]
public void ValidDefinitionInMegabytesIsParsedCorrectly(string definition, int megabytes) {
Assert.That(RamAllocationUnits.FromString(definition).InMegabytes, Is.EqualTo(megabytes));
}
[TestCase("0g", arg2: 0)]
[TestCase("1g", arg2: 1024)]
[TestCase("1G", arg2: 1024)]
[TestCase("8G", arg2: 8192)]
[TestCase("64G", arg2: 65536)]
[TestCase("16383G", arg2: 16776192)]
[TestCase("0g", 0)]
[TestCase("1g", 1024)]
[TestCase("1G", 1024)]
[TestCase("8G", 8192)]
[TestCase("64G", 65536)]
[TestCase("16383G", 16776192)]
public void ValidDefinitionInGigabytesIsParsedCorrectly(string definition, int megabytes) {
Assert.That(RamAllocationUnits.FromString(definition).InMegabytes, Is.EqualTo(megabytes));
}

View File

@@ -1,18 +0,0 @@
using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Agent(
[property: MemoryPackOrder(0)] Guid AgentGuid,
[property: MemoryPackOrder(1)] string Name,
[property: MemoryPackOrder(2)] ImmutableArray<byte> ConnectionKey,
[property: MemoryPackOrder(3)] AgentConfiguration Configuration,
[property: MemoryPackOrder(4)] AgentStats? Stats,
[property: MemoryPackOrder(5)] IAgentConnectionStatus ConnectionStatus
) {
[MemoryPackIgnore]
public RamAllocationUnits? AvailableMemory => Configuration.MaxMemory - Stats?.RunningInstanceMemory;
}

View File

@@ -1,18 +0,0 @@
using MemoryPack;
using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentConfiguration(
[property: MemoryPackOrder(0)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] string BuildVersion,
[property: MemoryPackOrder(2)] ushort MaxInstances,
[property: MemoryPackOrder(3)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(4)] AllowedPorts? AllowedServerPorts = null,
[property: MemoryPackOrder(5)] AllowedPorts? AllowedRconPorts = null
) {
public static AgentConfiguration From(AgentInfo agentInfo) {
return new AgentConfiguration(agentInfo.ProtocolVersion, agentInfo.BuildVersion, agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
}
}

View File

@@ -1,27 +0,0 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(AgentIsOffline))]
[MemoryPackUnion(tag: 1, typeof(AgentIsDisconnected))]
[MemoryPackUnion(tag: 2, typeof(AgentIsOnline))]
public partial interface IAgentConnectionStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentIsOffline : IAgentConnectionStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentIsDisconnected([property: MemoryPackOrder(0)] DateTimeOffset LastPingTime) : IAgentConnectionStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentIsOnline : IAgentConnectionStatus;
public static class AgentConnectionStatus {
public static readonly IAgentConnectionStatus Offline = new AgentIsOffline();
public static readonly IAgentConnectionStatus Online = new AgentIsOnline();
public static IAgentConnectionStatus Disconnected(DateTimeOffset lastPingTime) {
return new AgentIsDisconnected(lastPingTime);
}
}

View File

@@ -1,14 +0,0 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.AuditLog;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AuditLogItem(
[property: MemoryPackOrder(0)] DateTime UtcTime,
[property: MemoryPackOrder(1)] Guid? UserGuid,
[property: MemoryPackOrder(2)] string? UserName,
[property: MemoryPackOrder(3)] AuditLogEventType EventType,
[property: MemoryPackOrder(4)] AuditLogSubjectType SubjectType,
[property: MemoryPackOrder(5)] string? SubjectId,
[property: MemoryPackOrder(6)] string? JsonData
);

View File

@@ -1,6 +0,0 @@
namespace Phantom.Common.Data.Web.AuditLog;
public enum AuditLogSubjectType {
User,
Instance,
}

View File

@@ -1,13 +0,0 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.EventLog;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record EventLogItem(
[property: MemoryPackOrder(0)] DateTime UtcTime,
[property: MemoryPackOrder(1)] Guid? AgentGuid,
[property: MemoryPackOrder(2)] EventLogEventType EventType,
[property: MemoryPackOrder(3)] EventLogSubjectType SubjectType,
[property: MemoryPackOrder(4)] string SubjectId,
[property: MemoryPackOrder(5)] string? JsonData
);

View File

@@ -1,5 +0,0 @@
namespace Phantom.Common.Data.Web.EventLog;
public enum EventLogSubjectType {
Instance,
}

View File

@@ -1,23 +0,0 @@
namespace Phantom.Common.Data.Web.Instance;
public enum CreateOrUpdateInstanceResult : byte {
UnknownError,
Success,
InstanceNameMustNotBeEmpty,
InstanceMemoryMustNotBeZero,
MinecraftVersionDownloadInfoNotFound,
AgentNotFound,
}
public static class CreateOrUpdateInstanceResultExtensions {
public static string ToSentence(this CreateOrUpdateInstanceResult reason) {
return reason switch {
CreateOrUpdateInstanceResult.Success => "Success.",
CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty => "Instance name must not be empty.",
CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.",
CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound => "Could not find download information for the selected Minecraft version.",
CreateOrUpdateInstanceResult.AgentNotFound => "Agent not found.",
_ => "Unknown error.",
};
}
}

View File

@@ -1,17 +0,0 @@
using MemoryPack;
using Phantom.Common.Data.Instance;
namespace Phantom.Common.Data.Web.Instance;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Instance(
[property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
[property: MemoryPackOrder(2)] IInstanceStatus Status,
[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, PlayerCounts: null, launchAutomatically);
}
}

View File

@@ -1,8 +0,0 @@
namespace Phantom.Common.Data.Web.Users;
public enum AddRoleError : byte {
NameIsEmpty,
NameIsTooLong,
NameAlreadyExists,
UnknownError,
}

View File

@@ -1,25 +0,0 @@
using System.Collections.Immutable;
using MemoryPack;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(NameIsInvalid))]
[MemoryPackUnion(tag: 1, typeof(PasswordIsInvalid))]
[MemoryPackUnion(tag: 2, typeof(NameAlreadyExists))]
[MemoryPackUnion(tag: 3, typeof(UnknownError))]
public abstract partial record AddUserError {
private AddUserError() {}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record NameIsInvalid([property: MemoryPackOrder(0)] UsernameRequirementViolation Violation) : AddUserError;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record PasswordIsInvalid([property: MemoryPackOrder(0)] ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record NameAlreadyExists : AddUserError;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record UnknownError : AddUserError;
}

Some files were not shown because too many files have changed in this diff Show More