tgstation-server 6.12.0
The /tg/station 13 server suite
Loading...
Searching...
No Matches
Watchdog.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Generic;
3using System.Diagnostics;
4using System.Globalization;
5using System.IO;
6using System.IO.Compression;
7using System.Linq;
8using System.Net.Http;
9using System.Reflection;
10using System.Runtime.InteropServices;
11using System.Text.Json;
12using System.Threading;
13using System.Threading.Tasks;
14
15using Microsoft.Extensions.Logging;
18
20{
23 sealed class Watchdog : IWatchdog
24 {
26 public Task<Version> InitialHostVersion => initialHostVersionTcs.Task;
27
32
36 readonly ILogger<Watchdog> logger;
37
41 readonly TaskCompletionSource<Version> initialHostVersionTcs;
42
48 public Watchdog(ISignalChecker signalChecker, ILogger<Watchdog> logger)
49 {
50 this.signalChecker = signalChecker ?? throw new ArgumentNullException(nameof(signalChecker));
51 this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
52
53 initialHostVersionTcs = new TaskCompletionSource<Version>();
54 }
55
57#pragma warning disable CA1502 // TODO: Decomplexify
58#pragma warning disable CA1506
59 public async ValueTask<bool> RunAsync(bool runConfigure, string[] args, CancellationToken cancellationToken)
60 {
61 logger.LogInformation("Host watchdog starting...");
62 int currentProcessId;
63 using (var currentProc = Process.GetCurrentProcess())
64 currentProcessId = currentProc.Id;
65
66 logger.LogDebug("PID: {pid}", currentProcessId);
67 string? updateDirectory = null;
68 try
69 {
70 var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
71 var dotnetPath = DotnetHelper.GetPotentialDotnetPaths(isWindows)
72 .Where(potentialDotnetPath =>
73 {
74 logger.LogTrace("Checking for dotnet at {potentialDotnetPath}", potentialDotnetPath);
75 return File.Exists(potentialDotnetPath);
76 })
77 .FirstOrDefault();
78
79 if (dotnetPath == default)
80 {
81 logger.LogCritical("Unable to locate dotnet executable in PATH! Please ensure the .NET Core runtime is installed and is in your PATH!");
82 return false;
83 }
84
85 logger.LogInformation("Detected dotnet executable at {dotnetPath}", dotnetPath);
86
87 var executingAssembly = Assembly.GetExecutingAssembly();
88 var rootLocation = Path.GetDirectoryName(executingAssembly.Location);
89 if (rootLocation == null)
90 {
91 logger.LogCritical("Failed to get the directory name of the executing assembly: {location}", executingAssembly.Location);
92 return false;
93 }
94
95 var bootstrappable = args.Contains("--bootstrap", StringComparer.OrdinalIgnoreCase);
96 var homeDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".tgstation-server");
97
98 var assemblyStoragePath = Path.Combine(bootstrappable ? homeDirectory : rootLocation, "lib"); // always always next to watchdog
99
100 var defaultAssemblyPath = Path.GetFullPath(Path.Combine(assemblyStoragePath, "Default"));
101
102 if (Debugger.IsAttached)
103 {
104 // VS special tactics
105 // just copy the shit where it belongs
106 if (Directory.Exists(assemblyStoragePath))
107 Directory.Delete(assemblyStoragePath, true);
108 Directory.CreateDirectory(defaultAssemblyPath);
109
110 var sourcePath = Path.GetFullPath(
111 Path.Combine(
112 rootLocation,
113 "../../../../Tgstation.Server.Host/bin/Debug/net8.0"));
114 foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories))
115 Directory.CreateDirectory(dirPath.Replace(sourcePath, defaultAssemblyPath, StringComparison.Ordinal));
116
117 foreach (string newPath in Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories))
118 File.Copy(newPath, newPath.Replace(sourcePath, defaultAssemblyPath, StringComparison.Ordinal), true);
119
120 const string AppSettingsYaml = "appsettings.yml";
121 var rootYaml = Path.Combine(rootLocation, AppSettingsYaml);
122 File.Delete(rootYaml);
123 File.Move(Path.Combine(defaultAssemblyPath, AppSettingsYaml), rootYaml);
124 }
125 else
126 Directory.CreateDirectory(assemblyStoragePath);
127
128 var bootstrapperSettingsFile = Path.Combine(homeDirectory, "bootstrap.json");
129 BootstrapSettings? bootstrapSettings = null;
130 if (!Directory.Exists(defaultAssemblyPath))
131 {
132 if (!bootstrappable)
133 {
134 logger.LogCritical("Unable to locate host assembly directory!");
135 return false;
136 }
137
138 if (File.Exists(bootstrapperSettingsFile))
139 {
140 logger.LogInformation("Loading bootstrap settings...");
141 var bootstrapperSettingsJson = await File.ReadAllTextAsync(bootstrapperSettingsFile, cancellationToken);
142 bootstrapSettings = JsonSerializer.Deserialize<BootstrapSettings>(bootstrapperSettingsJson);
143 if (bootstrapSettings == null)
144 {
145 logger.LogCritical("Failed to deserialize {settingsFile}!", bootstrapperSettingsFile);
146 return false;
147 }
148 }
149 else
150 {
151 logger.LogInformation("Using default bootstrap settings...");
152 bootstrapSettings = new BootstrapSettings(); // defaults
153 }
154
155 if (bootstrapSettings.FileVersion.Major != BootstrapSettings.FileMajorVersion)
156 {
157 logger.LogCritical("Unable to parse bootstrapper file! Expected version: {expected}.X.X", BootstrapSettings.FileMajorVersion);
158 return false;
159 }
160
161 string downloadUrl = bootstrapSettings.ServerUpdatePackageUrlFormatter.Replace(BootstrapSettings.VersionSubstitutionToken, bootstrapSettings.TgsVersion.ToString(), StringComparison.Ordinal);
162 logger.LogInformation("Bootstrapping from: {url}", downloadUrl);
163 using var httpClient = new HttpClient();
164 await using var zipData = await httpClient.GetStreamAsync(new Uri(downloadUrl), cancellationToken);
165 using var archive = new ZipArchive(zipData, ZipArchiveMode.Read, true);
166 archive.ExtractToDirectory(defaultAssemblyPath);
167 }
168
169 var assemblyName = String.Join(".", nameof(Tgstation), nameof(Server), nameof(Host), "dll");
170 var assemblyPath = Path.Combine(defaultAssemblyPath, assemblyName);
171
172 if (assemblyPath.Contains('"', StringComparison.Ordinal))
173 {
174 logger.LogCritical("Running from paths with \"'s in the name is not supported!");
175 return false;
176 }
177
178 var watchdogVersion = executingAssembly.GetName().Version?.Semver().ToString();
179
180 while (!cancellationToken.IsCancellationRequested)
181 {
182 if (!File.Exists(assemblyPath))
183 {
184 logger.LogCritical("Unable to locate host assembly!");
185 return false;
186 }
187
188 var fileVersion = FileVersionInfo.GetVersionInfo(assemblyPath).FileVersion;
189 if (fileVersion == null)
190 {
191 logger.LogCritical("Failed to parse version info from {assemblyPath}!", assemblyPath);
192 return false;
193 }
194
195 if (bootstrappable)
196 {
197 if (!Version.TryParse(fileVersion, out var bootstrappedVersion))
198 {
199 logger.LogCritical("Failed to parse bootstrapped version prior to launch: {fileVersion}", fileVersion);
200 }
201 else
202 {
203 // save bootstrapper settings
204 var oldUrl = bootstrapSettings?.ServerUpdatePackageUrlFormatter;
205 bootstrapSettings = new BootstrapSettings
206 {
207 TgsVersion = bootstrappedVersion.Semver(),
208 };
209
210 bootstrapSettings.ServerUpdatePackageUrlFormatter = oldUrl ?? bootstrapSettings.ServerUpdatePackageUrlFormatter;
211
212 Directory.CreateDirectory(homeDirectory);
213 await File.WriteAllTextAsync(
214 bootstrapperSettingsFile,
215 JsonSerializer.Serialize(
216 bootstrapSettings,
217 new JsonSerializerOptions
218 {
219 WriteIndented = true,
220 }),
221 cancellationToken);
222 }
223 }
224
225 using (logger.BeginScope("Host invocation"))
226 {
227 initialHostVersionTcs.SetResult(
228 Version.Parse(
229 fileVersion));
230
231 updateDirectory = Path.GetFullPath(Path.Combine(assemblyStoragePath, Guid.NewGuid().ToString()));
232 logger.LogInformation("Update path set to {updateDirectory}", updateDirectory);
233 using (var process = new Process())
234 {
235 process.StartInfo.FileName = dotnetPath;
236 process.StartInfo.WorkingDirectory = rootLocation; // for appsettings
237
238 var arguments = new List<string>
239 {
240 $"\"{assemblyPath}\"",
241 $"\"{updateDirectory}\"",
242 $"\"{watchdogVersion}\"",
243 };
244
245 if (args.Any(x => x.Equals("--attach-host-debugger", StringComparison.OrdinalIgnoreCase)))
246 arguments.Add("--attach-debugger");
247
248 if (runConfigure)
249 {
250 logger.LogInformation("Running configuration check and wizard...");
251 arguments.Add("--General:SetupWizardMode=Only");
252 }
253
254 arguments.AddRange(args);
255
256 process.StartInfo.Arguments = String.Join(" ", arguments);
257
258 process.StartInfo.UseShellExecute = false; // runs in the same console
259
260 var killedHostProcess = false;
261 try
262 {
263 Task? processTask = null;
264 (int, Task) StartProcess(string? additionalArg)
265 {
266 if (additionalArg != null)
267 process.StartInfo.Arguments += $" {additionalArg}";
268
269 logger.LogInformation("Launching host with arguments: {arguments}", process.StartInfo.Arguments);
270
271 process.Start();
272 return (process.Id, processTask = process.WaitForExitAsync(cancellationToken));
273 }
274
275 using (var processCts = new CancellationTokenSource())
276 using (cancellationToken.Register(() =>
277 {
278 if (!Directory.Exists(updateDirectory))
279 {
280 logger.LogInformation("Cancellation requested! Writing shutdown lock file...");
281 File.WriteAllBytes(updateDirectory, Array.Empty<byte>());
282 }
283 else
284 logger.LogWarning("Cancellation requested while update directory exists!");
285
286 logger.LogInformation("Will force close host process if it doesn't exit in 10 seconds...");
287
288 try
289 {
290 processCts.CancelAfter(TimeSpan.FromSeconds(10));
291 }
292 catch (ObjectDisposedException ex)
293 {
294 // race conditions
295 logger.LogWarning(ex, "Error triggering timeout!");
296 }
297 }))
298 {
299 using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
300
301 var checkerTask = signalChecker.CheckSignals(StartProcess, cts.Token);
302 try
303 {
304 await processTask!;
305 }
306 finally
307 {
308 cts.Cancel();
309 await checkerTask;
310 }
311 }
312 }
313 catch (InvalidOperationException ex)
314 {
315 logger.LogWarning(ex, "Error triggering timeout!");
316 }
317 finally
318 {
319 try
320 {
321 if (!process.HasExited)
322 {
323 killedHostProcess = true;
324 process.Kill();
325 process.WaitForExit();
326 }
327 }
328 catch (InvalidOperationException ex2)
329 {
330 logger.LogWarning(ex2, "Error killing host process!");
331 }
332
333 try
334 {
335 if (File.Exists(updateDirectory))
336 File.Delete(updateDirectory);
337 }
338 catch (Exception ex2)
339 {
340 logger.LogWarning(ex2, "Error deleting comms file!");
341 }
342
343 logger.LogInformation("Host exited!");
344 }
345
346 if (runConfigure)
347 {
348 logger.LogInformation("Exiting due to configure intent...");
349 return true;
350 }
351
352 switch ((HostExitCode)process.ExitCode)
353 {
354 case HostExitCode.CompleteExecution:
355 return true;
356 case HostExitCode.RestartRequested:
357 if (!cancellationToken.IsCancellationRequested)
358 logger.LogInformation("Watchdog will restart host..."); // just a restart
359 else
360 logger.LogWarning("Host requested restart but watchdog shutdown is in progress!");
361 break;
362 case HostExitCode.Error:
363 // update path is now an exception document
364 logger.LogCritical("Host crashed, propagating exception dump...");
365
366 var data = "(NOT PRESENT)";
367 if (File.Exists(updateDirectory))
368 data = File.ReadAllText(updateDirectory);
369
370 try
371 {
372 File.Delete(updateDirectory);
373 }
374 catch (Exception e)
375 {
376 logger.LogWarning(e, "Unable to delete exception dump file at {updateDirectory}!", updateDirectory);
377 }
378
379#pragma warning disable CA2201 // Do not raise reserved exception types
380 throw new Exception(String.Format(CultureInfo.InvariantCulture, "Host propagated exception: {0}", data));
381#pragma warning restore CA2201 // Do not raise reserved exception types
382 default:
383 if (killedHostProcess)
384 {
385 logger.LogWarning("Watchdog forced to kill host process!");
386 cancellationToken.ThrowIfCancellationRequested();
387 }
388
389#pragma warning disable CA2201 // Do not raise reserved exception types
390 throw new Exception(String.Format(CultureInfo.InvariantCulture, "Host crashed with exit code {0}!", process.ExitCode));
391#pragma warning restore CA2201 // Do not raise reserved exception types
392 }
393 }
394
395 // HEY YOU
396 // BE WARNED THAT IF YOU DEBUGGED THE HOST PROCESS THAT JUST LAUNCHED THE DEBUGGER WILL HOLD A LOCK ON THE DIRECTORY
397 // THIS MEANS THE FIRST DIRECTORY.MOVE WILL THROW
398 if (Directory.Exists(updateDirectory))
399 {
400 logger.LogInformation("Applying server update...");
401 if (isWindows)
402 {
403 // windows dick sucking resource unlocking
404 GC.Collect(Int32.MaxValue, GCCollectionMode.Default, true);
405 await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
406 }
407
408 var tempPath = Path.Combine(assemblyStoragePath, Guid.NewGuid().ToString());
409 try
410 {
411 Directory.Move(defaultAssemblyPath, tempPath);
412 try
413 {
414 Directory.Move(updateDirectory, defaultAssemblyPath);
415 logger.LogInformation("Server update complete, deleting old server...");
416 try
417 {
418 Directory.Delete(tempPath, true);
419 }
420 catch (Exception e)
421 {
422 logger.LogWarning(e, "Error deleting old server at {tempPath}!", tempPath);
423 }
424 }
425 catch (Exception e)
426 {
427 logger.LogError(e, "Error moving updated server directory, attempting revert!");
428 Directory.Delete(defaultAssemblyPath, true);
429 Directory.Move(tempPath, defaultAssemblyPath);
430 logger.LogInformation("Revert successful!");
431 }
432 }
433 catch (Exception e)
434 {
435 logger.LogWarning(e, "Failed to move out active host assembly!");
436 }
437 }
438 }
439 }
440 }
441 catch (OperationCanceledException ex)
442 {
443 logger.LogDebug(ex, "Exiting due to cancellation...");
444 if (updateDirectory != null)
445 if (!Directory.Exists(updateDirectory))
446 File.Delete(updateDirectory);
447 else
448 Directory.Delete(updateDirectory, true);
449 }
450 catch (Exception ex)
451 {
452 logger.LogCritical(ex, "Host watchdog error!");
453 return false;
454 }
455 finally
456 {
457 logger.LogInformation("Host watchdog exiting...");
458 }
459
460 return true;
461 }
462#pragma warning restore CA1502
463#pragma warning restore CA1506
464 }
465}
Settings for the bootstrapper feature.
const int FileMajorVersion
The current supported major version of FileVersion.
Version TgsVersion
The Version of TGS last launched in the lib/Default directory.
string ServerUpdatePackageUrlFormatter
The URL to format with TgsVersion to get the download URL.
const string VersionSubstitutionToken
The token used to substitute ServerUpdatePackageUrlFormatter.
Version FileVersion
The version of the boostrapper file.
Watchdog(ISignalChecker signalChecker, ILogger< Watchdog > logger)
Initializes a new instance of the Watchdog class.
Definition Watchdog.cs:48
readonly ILogger< Watchdog > logger
The ILogger for the Watchdog.
Definition Watchdog.cs:36
readonly TaskCompletionSource< Version > initialHostVersionTcs
Backing TaskCompletionSource<TResult> for InitialHostVersion.
Definition Watchdog.cs:41
Task< Version > InitialHostVersion
Gets a Task<TResult> resulting in the current version of the host process. Guaranteed to complete onc...
Definition Watchdog.cs:26
readonly ISignalChecker signalChecker
The ISignalChecker for the Watchdog.
Definition Watchdog.cs:31
async ValueTask< bool > RunAsync(bool runConfigure, string[] args, CancellationToken cancellationToken)
Run the IWatchdog.A ValueTask<TResult> resulting in true if there were no errors, false otherwise.
Definition Watchdog.cs:59
For relaying signals received to the host process.
ValueTask CheckSignals(Func< string?,(int Pid, Task ChildLifetime)> startChildAndGetPid, CancellationToken cancellationToken)
Relays signals received to the host process.
HostExitCode
Represents the exit code of the Host program.