tgstation-server 6.14.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 var serializerOptions = new JsonSerializerOptions
181 {
182 WriteIndented = true,
183 };
184 while (!cancellationToken.IsCancellationRequested)
185 {
186 if (!File.Exists(assemblyPath))
187 {
188 logger.LogCritical("Unable to locate host assembly!");
189 return false;
190 }
191
192 var fileVersion = FileVersionInfo.GetVersionInfo(assemblyPath).FileVersion;
193 if (fileVersion == null)
194 {
195 logger.LogCritical("Failed to parse version info from {assemblyPath}!", assemblyPath);
196 return false;
197 }
198
199 if (bootstrappable)
200 {
201 if (!Version.TryParse(fileVersion, out var bootstrappedVersion))
202 {
203 logger.LogCritical("Failed to parse bootstrapped version prior to launch: {fileVersion}", fileVersion);
204 }
205 else
206 {
207 // save bootstrapper settings
208 var oldUrl = bootstrapSettings?.ServerUpdatePackageUrlFormatter;
209 bootstrapSettings = new BootstrapSettings
210 {
211 TgsVersion = bootstrappedVersion.Semver(),
212 };
213
214 bootstrapSettings.ServerUpdatePackageUrlFormatter = oldUrl ?? bootstrapSettings.ServerUpdatePackageUrlFormatter;
215
216 Directory.CreateDirectory(homeDirectory);
217 await File.WriteAllTextAsync(
218 bootstrapperSettingsFile,
219 JsonSerializer.Serialize(
220 bootstrapSettings,
221 serializerOptions),
222 cancellationToken);
223 }
224 }
225
226 using (logger.BeginScope("Host invocation"))
227 {
228 initialHostVersionTcs.SetResult(
229 Version.Parse(
230 fileVersion));
231
232 updateDirectory = Path.GetFullPath(Path.Combine(assemblyStoragePath, Guid.NewGuid().ToString()));
233 logger.LogInformation("Update path set to {updateDirectory}", updateDirectory);
234 using (var process = new Process())
235 {
236 process.StartInfo.FileName = dotnetPath;
237 process.StartInfo.WorkingDirectory = rootLocation; // for appsettings
238
239 var arguments = new List<string>
240 {
241 $"\"{assemblyPath}\"",
242 $"\"{updateDirectory}\"",
243 $"\"{watchdogVersion}\"",
244 };
245
246 if (args.Any(x => x.Equals("--attach-host-debugger", StringComparison.OrdinalIgnoreCase)))
247 arguments.Add("--attach-debugger");
248
249 if (runConfigure)
250 {
251 logger.LogInformation("Running configuration check and wizard...");
252 arguments.Add("--General:SetupWizardMode=Only");
253 }
254
255 arguments.AddRange(args);
256
257 process.StartInfo.Arguments = String.Join(" ", arguments);
258
259 process.StartInfo.UseShellExecute = false; // runs in the same console
260
261 var killedHostProcess = false;
262 var createdShutdownFile = false;
263 try
264 {
265 Task? processTask = null;
266 (int, Task) StartProcess(string? additionalArg)
267 {
268 if (additionalArg != null)
269 process.StartInfo.Arguments += $" {additionalArg}";
270
271 logger.LogInformation("Launching host with arguments: {arguments}", process.StartInfo.Arguments);
272
273 process.Start();
274 return (process.Id, processTask = process.WaitForExitAsync(cancellationToken));
275 }
276
277 using (var processCts = new CancellationTokenSource())
278 using (cancellationToken.Register(() =>
279 {
280 if (!Directory.Exists(updateDirectory))
281 {
282 logger.LogInformation("Cancellation requested! Writing shutdown lock file...");
283 File.WriteAllBytes(updateDirectory, Array.Empty<byte>());
284 createdShutdownFile = true;
285 }
286 else
287 logger.LogWarning("Cancellation requested while update directory exists!");
288
289 logger.LogInformation("Will force close host process if it doesn't exit in 10 seconds...");
290
291 try
292 {
293 processCts.CancelAfter(TimeSpan.FromSeconds(10));
294 }
295 catch (ObjectDisposedException ex)
296 {
297 // race conditions
298 logger.LogWarning(ex, "Error triggering timeout!");
299 }
300 }))
301 {
302 using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
303
304 var checkerTask = signalChecker.CheckSignals(StartProcess, cts.Token);
305 try
306 {
307 await processTask!;
308 }
309 finally
310 {
311 cts.Cancel();
312 await checkerTask;
313 }
314 }
315 }
316 catch (InvalidOperationException ex)
317 {
318 logger.LogWarning(ex, "Error triggering timeout!");
319 }
320 finally
321 {
322 try
323 {
324 if (!process.HasExited)
325 {
326 killedHostProcess = true;
327 process.Kill();
328 process.WaitForExit();
329 }
330 }
331 catch (InvalidOperationException ex2)
332 {
333 logger.LogWarning(ex2, "Error killing host process!");
334 }
335
336 if (createdShutdownFile)
337 try
338 {
339 if (File.Exists(updateDirectory))
340 File.Delete(updateDirectory);
341 }
342 catch (Exception ex2)
343 {
344 logger.LogWarning(ex2, "Error deleting comms file!");
345 }
346
347 logger.LogInformation("Host exited!");
348 }
349
350 if (runConfigure)
351 {
352 logger.LogInformation("Exiting due to configure intent...");
353 return true;
354 }
355
356 switch ((HostExitCode)process.ExitCode)
357 {
358 case HostExitCode.CompleteExecution:
359 return true;
360 case HostExitCode.RestartRequested:
361 if (!cancellationToken.IsCancellationRequested)
362 logger.LogInformation("Watchdog will restart host..."); // just a restart
363 else
364 logger.LogWarning("Host requested restart but watchdog shutdown is in progress!");
365 break;
366 case HostExitCode.Error:
367 // update path is now an exception document
368 logger.LogCritical("Host crashed, propagating exception dump...");
369
370 var data = "(NOT PRESENT)";
371 if (File.Exists(updateDirectory))
372 data = File.ReadAllText(updateDirectory);
373
374 try
375 {
376 File.Delete(updateDirectory);
377 }
378 catch (Exception e)
379 {
380 logger.LogWarning(e, "Unable to delete exception dump file at {updateDirectory}!", updateDirectory);
381 }
382
383#pragma warning disable CA2201 // Do not raise reserved exception types
384 throw new Exception(String.Format(CultureInfo.InvariantCulture, "Host propagated exception: {0}", data));
385#pragma warning restore CA2201 // Do not raise reserved exception types
386 default:
387 if (killedHostProcess)
388 {
389 logger.LogWarning("Watchdog forced to kill host process!");
390 cancellationToken.ThrowIfCancellationRequested();
391 }
392
393#pragma warning disable CA2201 // Do not raise reserved exception types
394 throw new Exception(String.Format(CultureInfo.InvariantCulture, "Host crashed with exit code {0}!", process.ExitCode));
395#pragma warning restore CA2201 // Do not raise reserved exception types
396 }
397 }
398
399 // HEY YOU
400 // BE WARNED THAT IF YOU DEBUGGED THE HOST PROCESS THAT JUST LAUNCHED THE DEBUGGER WILL HOLD A LOCK ON THE DIRECTORY
401 // THIS MEANS THE FIRST DIRECTORY.MOVE WILL THROW
402 if (Directory.Exists(updateDirectory))
403 {
404 logger.LogInformation("Applying server update...");
405 if (isWindows)
406 {
407 // windows dick sucking resource unlocking
408 GC.Collect(Int32.MaxValue, GCCollectionMode.Default, true);
409 await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
410 }
411
412 var tempPath = Path.Combine(assemblyStoragePath, Guid.NewGuid().ToString());
413 try
414 {
415 Directory.Move(defaultAssemblyPath, tempPath);
416 try
417 {
418 Directory.Move(updateDirectory, defaultAssemblyPath);
419 logger.LogInformation("Server update complete, deleting old server...");
420 try
421 {
422 Directory.Delete(tempPath, true);
423 }
424 catch (Exception e)
425 {
426 logger.LogWarning(e, "Error deleting old server at {tempPath}!", tempPath);
427 }
428 }
429 catch (Exception e)
430 {
431 logger.LogError(e, "Error moving updated server directory, attempting revert!");
432 Directory.Delete(defaultAssemblyPath, true);
433 Directory.Move(tempPath, defaultAssemblyPath);
434 logger.LogInformation("Revert successful!");
435 }
436 }
437 catch (Exception e)
438 {
439 logger.LogWarning(e, "Failed to move out active host assembly!");
440 }
441 }
442 }
443 }
444 }
445 catch (OperationCanceledException ex)
446 {
447 logger.LogDebug(ex, "Exiting due to cancellation...");
448 if (updateDirectory != null)
449 if (!Directory.Exists(updateDirectory))
450 File.Delete(updateDirectory);
451 else
452 Directory.Delete(updateDirectory, true);
453 }
454 catch (Exception ex)
455 {
456 logger.LogCritical(ex, "Host watchdog error!");
457 return false;
458 }
459 finally
460 {
461 logger.LogInformation("Host watchdog exiting...");
462 }
463
464 return true;
465 }
466#pragma warning restore CA1502
467#pragma warning restore CA1506
468 }
469}
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.