Skip to content

Commit 12ae79a

Browse files
authored
Added server restart command and port binding checks (SubnauticaNitrox#1306)
* Added server restart command * Formatted Program.cs * Improved logging of timeout and warnings * Added mutex to prevent multiple servers initializing at the same time * Changed port message
1 parent 2abf283 commit 12ae79a

7 files changed

Lines changed: 224 additions & 41 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using System;
2+
using System.Reflection;
3+
using System.Runtime.InteropServices;
4+
using System.Security.AccessControl;
5+
using System.Security.Principal;
6+
using System.Threading;
7+
using NitroxModel.Helper;
8+
9+
namespace NitroxServer_Subnautica
10+
{
11+
public static class AppMutex
12+
{
13+
private static readonly SemaphoreSlim mutexReleaseGate = new SemaphoreSlim(1);
14+
private static readonly SemaphoreSlim callerGate = new SemaphoreSlim(1);
15+
16+
public static void Hold(Action onWaitingForMutex = null, int timeoutInMs = 5000)
17+
{
18+
Validate.IsTrue(timeoutInMs >= 5000, "Timeout must be at least 5 seconds.");
19+
20+
using CancellationTokenSource acquireSource = new CancellationTokenSource(timeoutInMs);
21+
CancellationToken token = acquireSource.Token;
22+
Thread thread = new Thread(o =>
23+
{
24+
bool first = true;
25+
string appGuid = ((GuidAttribute)Assembly.GetExecutingAssembly().GetCustomAttributes(typeof(GuidAttribute), false).GetValue(0)).Value;
26+
string mutexId = $@"Global\{{{appGuid}}}";
27+
MutexAccessRule allowEveryoneRule = new MutexAccessRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null),
28+
MutexRights.FullControl,
29+
AccessControlType.Allow
30+
);
31+
MutexSecurity securitySettings = new MutexSecurity();
32+
securitySettings.AddAccessRule(allowEveryoneRule);
33+
34+
Mutex mutex = new Mutex(false, mutexId, out bool _, securitySettings);
35+
try
36+
{
37+
try
38+
{
39+
while (!mutex.WaitOne(100, false))
40+
{
41+
token.ThrowIfCancellationRequested();
42+
if (first)
43+
{
44+
first = false;
45+
onWaitingForMutex?.Invoke();
46+
}
47+
}
48+
}
49+
catch (AbandonedMutexException)
50+
{
51+
// Mutex was abandoned in another process, it will still get acquired
52+
}
53+
}
54+
finally
55+
{
56+
callerGate.Release();
57+
mutexReleaseGate.Wait(-1);
58+
mutex.ReleaseMutex();
59+
}
60+
});
61+
mutexReleaseGate.Wait(-1, token);
62+
callerGate.Wait(0, token);
63+
thread.Start();
64+
65+
while (!callerGate.Wait(100, token))
66+
{
67+
}
68+
}
69+
70+
public static void Release()
71+
{
72+
mutexReleaseGate.Release();
73+
}
74+
}
75+
}

NitroxServer-Subnautica/NitroxServer-Subnautica.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
<Compile Include="Serialization\Resources\ResourceAssetsParser.cs" />
108108
<Compile Include="Serialization\SubnauticaServerJsonSerializer.cs" />
109109
<Compile Include="Serialization\SubnauticaServerProtoBufSerializer.cs" />
110+
<Compile Include="AppMutex.cs" />
110111
<Compile Include="SubnauticaServerAutoFacRegistrar.cs" />
111112
</ItemGroup>
112113
<ItemGroup>

NitroxServer-Subnautica/Program.cs

Lines changed: 103 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
using System.Diagnostics;
44
using System.Globalization;
55
using System.IO;
6+
using System.Net;
7+
using System.Net.Sockets;
68
using System.Reflection;
79
using System.Runtime.InteropServices;
810
using System.Threading;
11+
using System.Threading.Tasks;
912
using NitroxModel.Core;
1013
using NitroxModel.DataStructures.GameLogic;
1114
using NitroxModel.DataStructures.Util;
1215
using NitroxModel.Discovery;
16+
using NitroxModel.Helper;
1317
using NitroxModel.Logger;
1418
using NitroxModel_Subnautica.Helper;
1519
using NitroxServer;
@@ -22,54 +26,126 @@ public class Program
2226
private static readonly Dictionary<string, Assembly> resolvedAssemblyCache = new Dictionary<string, Assembly>();
2327
private static Lazy<string> gameInstallDir;
2428

25-
private static void Main(string[] args)
29+
// Prevents Garbage Collection freeing this callback's memory. Causing an exception to occur for this handle.
30+
private static readonly ConsoleEventDelegate consoleCtrlCheckDelegate = ConsoleEventCallback;
31+
32+
private static async Task Main(string[] args)
2633
{
27-
ConfigureCultureInfo();
2834
Log.Setup();
35+
AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException;
2936

37+
ConfigureCultureInfo();
3038
ConfigureConsoleWindow();
3139

32-
// Allow game path to be given as command argument
33-
if (args.Length > 0 && Directory.Exists(args[0]) && File.Exists(Path.Combine(args[0], "Subnautica.exe")))
40+
AppMutex.Hold(() =>
3441
{
35-
string gameDir = Path.GetFullPath(args[0]);
36-
Log.Info($"Using game files from: {gameDir}");
37-
gameInstallDir = new Lazy<string>(() => gameDir);
38-
}
39-
else
42+
Log.Info("Waiting for 30 seconds on other Nitrox servers to initialize before starting..");
43+
}, 30000);
44+
Server server;
45+
try
4046
{
41-
gameInstallDir = new Lazy<string>(() =>
47+
// Allow game path to be given as command argument
48+
if (args.Length > 0 && Directory.Exists(args[0]) && File.Exists(Path.Combine(args[0], "Subnautica.exe")))
4249
{
43-
string gameDir = GameInstallationFinder.Instance.FindGame();
50+
string gameDir = Path.GetFullPath(args[0]);
4451
Log.Info($"Using game files from: {gameDir}");
45-
return gameDir;
46-
});
47-
}
52+
gameInstallDir = new Lazy<string>(() => gameDir);
53+
}
54+
else
55+
{
56+
gameInstallDir = new Lazy<string>(() =>
57+
{
58+
string gameDir = GameInstallationFinder.Instance.FindGame();
59+
Log.Info($"Using game files from: {gameDir}");
60+
return gameDir;
61+
});
62+
}
4863

49-
AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException;
50-
AppDomain.CurrentDomain.AssemblyResolve += CurrentDomainOnAssemblyResolve;
51-
AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += CurrentDomainOnAssemblyResolve;
64+
AppDomain.CurrentDomain.AssemblyResolve += CurrentDomainOnAssemblyResolve;
65+
AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += CurrentDomainOnAssemblyResolve;
66+
67+
Map.Main = new SubnauticaMap();
5268

53-
NitroxModel.Helper.Map.Main = new SubnauticaMap();
69+
NitroxServiceLocator.InitializeDependencyContainer(new SubnauticaServerAutoFacRegistrar());
70+
NitroxServiceLocator.BeginNewLifetimeScope();
5471

55-
NitroxServiceLocator.InitializeDependencyContainer(new SubnauticaServerAutoFacRegistrar());
56-
NitroxServiceLocator.BeginNewLifetimeScope();
72+
server = NitroxServiceLocator.LocateService<Server>();
73+
await WaitForAvailablePortAsync(server.Port);
74+
if (!server.Start())
75+
{
76+
throw new Exception("Unable to start server.");
77+
}
78+
Log.Info("Server is waiting for players!");
5779

58-
Server server = NitroxServiceLocator.LocateService<Server>();
59-
if (!server.Start())
80+
CatchExitEvent();
81+
}
82+
finally
6083
{
61-
throw new Exception("Unable to start server.");
84+
// Allow other servers to start initializing.
85+
AppMutex.Release();
6286
}
6387

64-
CatchExitEvent();
65-
88+
Log.Info("To get help for commands, run help in console or /help in chatbox\n");
6689
ConsoleCommandProcessor cmdProcessor = NitroxServiceLocator.LocateService<ConsoleCommandProcessor>();
6790
while (server.IsRunning)
6891
{
6992
cmdProcessor.ProcessCommand(Console.ReadLine(), Optional.Empty, Perms.CONSOLE);
7093
}
7194
}
7295

96+
private static async Task WaitForAvailablePortAsync(int port, int timeoutInSeconds = 30)
97+
{
98+
void PrintPortWarn(int timeRemaining)
99+
{
100+
Log.Warn($"Port {port} UDP is already in use. Retrying for {timeRemaining} seconds until it is available..");
101+
}
102+
103+
Validate.IsTrue(timeoutInSeconds >= 5, "Timeout must be at least 5 seconds.");
104+
105+
DateTimeOffset time = DateTimeOffset.UtcNow;
106+
bool first = true;
107+
using CancellationTokenSource source = new CancellationTokenSource(timeoutInSeconds * 1000);
108+
using Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.IP);
109+
110+
try
111+
{
112+
while (true)
113+
{
114+
source.Token.ThrowIfCancellationRequested();
115+
try
116+
{
117+
socket.Bind(new IPEndPoint(IPAddress.Any, port));
118+
break;
119+
}
120+
catch (SocketException ex)
121+
{
122+
if (ex.SocketErrorCode != SocketError.AddressAlreadyInUse)
123+
{
124+
throw;
125+
}
126+
127+
if (first)
128+
{
129+
first = false;
130+
PrintPortWarn(timeoutInSeconds);
131+
}
132+
else if (Environment.UserInteractive)
133+
{
134+
Console.CursorTop--;
135+
Console.CursorLeft = 0;
136+
PrintPortWarn(timeoutInSeconds - (DateTimeOffset.UtcNow - time).Seconds);
137+
}
138+
await Task.Delay(500, source.Token);
139+
}
140+
}
141+
}
142+
catch (OperationCanceledException ex)
143+
{
144+
Log.Error(ex, "Port availability timeout reached.");
145+
throw;
146+
}
147+
}
148+
73149
private static void CurrentDomainOnUnhandledException(object sender, UnhandledExceptionEventArgs e)
74150
{
75151
if (e.ExceptionObject is Exception ex)
@@ -80,7 +156,7 @@ private static void CurrentDomainOnUnhandledException(object sender, UnhandledEx
80156
{
81157
return;
82158
}
83-
159+
84160
Console.WriteLine("Press L to open log file before closing. Press any other key to close . . .");
85161
ConsoleKeyInfo key = Console.ReadKey(true);
86162
if (key.Key == ConsoleKey.L)
@@ -94,7 +170,7 @@ private static void CurrentDomainOnUnhandledException(object sender, UnhandledEx
94170
};
95171
Process.Start(fileOpenerProgram, Log.FileName);
96172
}
97-
173+
98174
Environment.Exit(1);
99175
}
100176

@@ -180,9 +256,6 @@ private static void CatchExitEvent()
180256
[DllImport("kernel32.dll", SetLastError = true)]
181257
private static extern bool SetConsoleCtrlHandler(ConsoleEventDelegate callback, bool add);
182258

183-
// Prevents Garbage Collection freeing this callback's memory. Causing an exception to occur for this handle.
184-
private static readonly ConsoleEventDelegate consoleCtrlCheckDelegate = ConsoleEventCallback;
185-
186259
private static bool ConsoleEventCallback(int eventType)
187260
{
188261
if (eventType == 2) // close

NitroxServer/ConsoleCommands/Processor/ConsoleCommandProcessor.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,7 @@ public void ProcessCommand(string msg, Optional<Player> player, Perms perms)
4545
}
4646

4747
string[] parts = msg.Split(splitChar, StringSplitOptions.RemoveEmptyEntries);
48-
49-
Command cmd;
50-
51-
if (!commands.TryGetValue(parts[0], out cmd))
48+
if (!commands.TryGetValue(parts[0], out Command cmd))
5249
{
5350
string errorMessage = "Command Not Found: " + parts[0];
5451
Log.Info(errorMessage);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.Diagnostics;
2+
using NitroxModel.DataStructures.GameLogic;
3+
using NitroxModel.Logger;
4+
using NitroxServer.ConsoleCommands.Abstract;
5+
6+
namespace NitroxServer.ConsoleCommands
7+
{
8+
internal sealed class RestartCommand : Command
9+
{
10+
private readonly Server server;
11+
12+
public RestartCommand(Server server) : base("restart", Perms.CONSOLE, "Restarts the server")
13+
{
14+
this.server = server;
15+
}
16+
17+
protected override void Execute(CallArgs args)
18+
{
19+
if (Debugger.IsAttached)
20+
{
21+
Log.Error("Cannot restart server while debugger is attached.");
22+
return;
23+
}
24+
string program = Process.GetCurrentProcess().MainModule?.FileName;
25+
if (program == null)
26+
{
27+
Log.Error("Failed to get location of server.");
28+
return;
29+
}
30+
31+
using Process proc = Process.Start(program);
32+
server.Stop();
33+
}
34+
}
35+
}

NitroxServer/NitroxServer.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
<Compile Include="ConsoleCommands\ChangeAdminPasswordCommand.cs" />
154154
<Compile Include="ConsoleCommands\DirectoryCommand.cs" />
155155
<Compile Include="ConsoleCommands\HelpCommand.cs" />
156+
<Compile Include="ConsoleCommands\RestartCommand.cs" />
156157
<Compile Include="ConsoleCommands\TeleportCommand.cs" />
157158
<Compile Include="ConsoleCommands\WarpCommand.cs" />
158159
<Compile Include="ConsoleCommands\WhoisCommand.cs" />

NitroxServer/Server.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
using System.Timers;
22
using NitroxModel.Logger;
3-
using NitroxModel.Server;
43
using NitroxServer.Serialization.World;
5-
using System.Configuration;
64
using System.IO;
75
using System.Text;
86
using System.Linq;
@@ -24,6 +22,8 @@ public class Server
2422
public bool IsRunning { get; private set; }
2523
public bool IsSaving { get; private set; }
2624

25+
public int Port => serverConfig?.ServerPort ?? -1;
26+
2727
public Server(WorldPersistence worldPersistence, World world, ServerConfig serverConfig, Communication.NetworkingLayer.NitroxServer server)
2828
{
2929
this.worldPersistence = worldPersistence;
@@ -66,7 +66,11 @@ public void Save()
6666
return;
6767
}
6868

69-
PropertiesWriter.Serialize(serverConfig);
69+
// Don't overwrite config changes that users made to file
70+
if (!File.Exists(serverConfig.FileName))
71+
{
72+
PropertiesWriter.Serialize(serverConfig);
73+
}
7074
IsSaving = true;
7175
worldPersistence.Save(world, serverConfig.SaveName);
7276
IsSaving = false;
@@ -79,17 +83,14 @@ public bool Start()
7983
return false;
8084
}
8185

86+
Log.Info($"Server is listening on port {Port} UDP");
8287
Log.Info($"Using {serverConfig.SerializerMode} as save file serializer");
8388
Log.InfoSensitive("Server Password: {password}", string.IsNullOrEmpty(serverConfig.ServerPassword) ? "None. Public Server." : serverConfig.ServerPassword);
8489
Log.InfoSensitive("Admin Password: {password}", serverConfig.AdminPassword);
8590
Log.Info($"Autosave: {(serverConfig.DisableAutoSave ? "DISABLED" : $"ENABLED ({serverConfig.SaveInterval / 60000} min)")}");
8691
Log.Info($"World GameMode: {serverConfig.GameMode}");
87-
8892
Log.Info($"Loaded save\n{SaveSummary}");
8993

90-
Log.Info("Nitrox Server Started");
91-
Log.Info("To get help for commands, run help in console or /help in chatbox\n");
92-
9394
PauseServer();
9495

9596
IsRunning = true;

0 commit comments

Comments
 (0)