using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.IO.Abstractions; using System.Linq; using System.Net; using System.Net.Sockets; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; using Byond.TopicSender; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Mono.Unix; using Mono.Unix.Native; using Moq; using Newtonsoft.Json; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Client; using Tgstation.Server.Client.Components; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Components.Watchdog; using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.System; using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Tests.Live.Instance { sealed class WatchdogTest : JobsRequiredTest { static readonly ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); builder.SetMinimumLevel(LogLevel.Trace); }); public static readonly TopicClient StaticTopicClient = new(new SocketParameters { SendTimeout = TimeSpan.FromSeconds(30), ReceiveTimeout = TimeSpan.FromSeconds(30), ConnectTimeout = TimeSpan.FromSeconds(30), DisconnectTimeout = TimeSpan.FromSeconds(30) }, loggerFactory.CreateLogger($"WatchdogTest.TopicClient.Static")); readonly IInstanceClient instanceClient; readonly InstanceManager instanceManager; readonly ushort serverPort; readonly ushort ddPort; readonly bool highPrioDD; readonly TopicClient topicClient; readonly EngineVersion testVersion; readonly bool watchdogRestartsProcess; bool ranTimeoutTest = false; const string BaseAdditionalParameters = "expect_chat_channels=1&expect_static_files=1"; public WatchdogTest(EngineVersion testVersion, IInstanceClient instanceClient, InstanceManager instanceManager, ushort serverPort, bool highPrioDD, ushort ddPort, bool watchdogRestartsProcess) : base(instanceClient.Jobs) { this.instanceClient = instanceClient ?? throw new ArgumentNullException(nameof(instanceClient)); this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.serverPort = serverPort; this.highPrioDD = highPrioDD; this.ddPort = ddPort; this.testVersion = testVersion ?? throw new ArgumentNullException(nameof(testVersion)); this.watchdogRestartsProcess = watchdogRestartsProcess || testVersion.Engine.Value == EngineType.OpenDream; topicClient = new(new SocketParameters { SendTimeout = TimeSpan.FromSeconds(30), ReceiveTimeout = TimeSpan.FromSeconds(30), ConnectTimeout = TimeSpan.FromSeconds(30), DisconnectTimeout = TimeSpan.FromSeconds(30) }, loggerFactory.CreateLogger($"WatchdogTest.TopicClient.{instanceClient.Metadata.Name}")); } public async Task Run(CancellationToken cancellationToken) { try { await RunInt(cancellationToken); } catch { System.Console.WriteLine($"WATCHDOG TEST FAILING INSTANCE ID {instanceClient.Metadata.Id.Value}"); throw; } } async Task RunInt(CancellationToken cancellationToken) { System.Console.WriteLine($"TEST: START WATCHDOG TESTS {instanceClient.Metadata.Name}"); async Task CheckByondVersions() { var listTask = instanceClient.Engine.InstalledVersions(null, cancellationToken); var list = await listTask; Assert.AreEqual(1, list.Count); var byondVersion = list[0]; Assert.AreEqual(1, byondVersion.EngineVersion.CustomIteration); Assert.AreEqual(testVersion.Engine, byondVersion.EngineVersion.Engine); if (testVersion.Version != null) { Assert.AreEqual(testVersion.Version.Major, byondVersion.EngineVersion.Version.Major); Assert.AreEqual(testVersion.Version.Minor, byondVersion.EngineVersion.Version.Minor); } else { Assert.IsNull(byondVersion.EngineVersion.Version); Assert.AreEqual(testVersion.SourceSHA, byondVersion.EngineVersion.SourceSHA); } } async Task UpdateDDSettings() { for (var i = 0; i < 5; ++i) try { // Increase startup timeout, disable heartbeats, enable map threads because we've tested without for years global::System.Console.WriteLine($"PORT REUSE BUG 4: Setting I-{instanceClient.Metadata.Id} DD to {ddPort}"); var updated = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { StartupTimeout = 60, HealthCheckSeconds = 0, Port = ddPort, MapThreads = 2, OpenDreamTopicPort = 47, LogOutput = false, AdditionalParameters = BaseAdditionalParameters }, cancellationToken); Assert.AreEqual(47, updated.OpenDreamTopicPort); Assert.IsFalse(updated.ImmediateMemoryUsage.HasValue); } catch (ConflictException ex) when (ex.ErrorCode == ErrorCode.PortNotAvailable) { if (i == 4) throw; // I have no idea why this happens sometimes await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); } var updated2 = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { OpenDreamTopicPort = 0, }, cancellationToken); Assert.AreEqual(0, updated2.OpenDreamTopicPort); } global::System.Console.WriteLine($"PORT REUSE BUG 4: Expect error. Setting I-{instanceClient.Metadata.Id} DD to 0"); await Task.WhenAll( UpdateDDSettings(), CheckByondVersions(), ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest { SoftShutdown = true, SoftRestart = true }, cancellationToken), ErrorCode.GameServerDoubleSoft).AsTask(), ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest { Port = 0 }, cancellationToken), ErrorCode.ModelValidationFailure).AsTask(), ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.CreateDump(cancellationToken), ErrorCode.WatchdogNotRunning).AsTask(), ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.Restart(cancellationToken), ErrorCode.WatchdogNotRunning).AsTask()); await RunBasicTest(false, cancellationToken); // hardlinks and DMAPI checks don't play well together bool linuxAdvancedWatchdogWeirdness = testVersion.Engine.Value == EngineType.Byond && !new PlatformIdentifier().IsWindows && !watchdogRestartsProcess; if (!linuxAdvancedWatchdogWeirdness) await RunBasicTest(true, cancellationToken); await TestDMApiFreeDeploy(cancellationToken); // long running test likes consistency with the channels DummyChatProvider.RandomDisconnections(false); await RunLongRunningTestThenUpdate(cancellationToken); await RunLongRunningTestThenUpdateWithNewDme(cancellationToken); await RunLongRunningTestThenUpdateWithByondVersionSwitch(cancellationToken); // no chatty bullshit while we test health checks var tcs = new TaskCompletionSource(); var oldTask = Interlocked.Exchange(ref DummyChatProvider.MessageGuard, tcs.Task); await RunHealthCheckTest(true, cancellationToken); await RunHealthCheckTest(false, cancellationToken); async void Cleanup() { await oldTask; tcs.SetResult(); } Cleanup(); await InteropTestsForLongRunningDme(cancellationToken); await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { AdditionalParameters = String.Empty }, cancellationToken); // for the restart staging tests await DeployTestDme("LongRunning/long_running_test", DreamDaemonSecurity.Trusted, DMApiValidationMode.Required, cancellationToken); System.Console.WriteLine($"TEST: END WATCHDOG TESTS {instanceClient.Metadata.Name}"); } async ValueTask RegressionTest1686(CancellationToken cancellationToken) { async ValueTask RunTest(bool useTrusted) { System.Console.WriteLine($"TEST: RegressionTest1686 {useTrusted}..."); var ddUpdateTask = instanceClient.DreamDaemon.Update(new DreamDaemonRequest { SecurityLevel = useTrusted ? DreamDaemonSecurity.Trusted : DreamDaemonSecurity.Safe, }, cancellationToken); var currentStatus = await DeployTestDme("long_running_test_rooted", DreamDaemonSecurity.Trusted, DMApiValidationMode.Required, cancellationToken); await ddUpdateTask; Assert.AreEqual(WatchdogStatus.Offline, currentStatus.Status); var startJob = await StartDD(cancellationToken); await WaitForJob(startJob, 40, false, null, cancellationToken); currentStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { SoftShutdown = true, }, cancellationToken); ValidateSessionId(currentStatus, true); Assert.AreEqual(WatchdogStatus.Online, currentStatus.Status); // reimplement TellWorldToReboot because it expects a new deployment and we don't care System.Console.WriteLine("TEST: Hack world reboot topic..."); var result = await SendTestTopic( "tgs_integration_test_special_tactics=1", cancellationToken); Assert.AreEqual("ack", result.StringData); using var tempCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var tempToken = tempCts.Token; using (tempToken.Register(() => System.Console.WriteLine("TEST ERROR: Timeout in RegressionTest1686!"))) { tempCts.CancelAfter(TimeSpan.FromMinutes(2)); do { await Task.Delay(TimeSpan.FromSeconds(1), tempToken); currentStatus = await instanceClient.DreamDaemon.Read(tempToken); } while (currentStatus.Status != WatchdogStatus.Offline); } await CheckDMApiFail(currentStatus.ActiveCompileJob, cancellationToken); } await RunTest(true); if (new PlatformIdentifier().IsWindows || !watchdogRestartsProcess) await RunTest(false); } ValueTask SendTestTopic(string queryString, CancellationToken cancellationToken) => SendTestTopic(queryString, topicClient, instanceManager.GetInstanceReference(instanceClient.Metadata), FindTopicPort(), cancellationToken); public static async ValueTask SendTestTopic(string queryString, ITopicClient topicClient, IInstanceReference instanceReference, ushort topicPort, CancellationToken cancellationToken) { using (instanceReference) { using var loggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); builder.SetMinimumLevel(LogLevel.Trace); }); var watchdog = instanceReference?.Watchdog; var session = (SessionController)watchdog?.GetType().GetMethod("GetActiveController", BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(watchdog, null); using (session != null ? await session.TopicSendSemaphore.Lock(cancellationToken) : null) return await topicClient.SendWithOptionalPriority( new AsyncDelayer(loggerFactory.CreateLogger()), loggerFactory.CreateLogger(), queryString, topicPort, true, cancellationToken); } } async ValueTask BroadcastTest(CancellationToken cancellationToken) { var topicRequestResult = await SendTestTopic("tgs_integration_test_tactics_broadcast=1", cancellationToken); Assert.IsNotNull(topicRequestResult); Assert.AreEqual("!!NULL!!", topicRequestResult.StringData); const string TestBroadcastMessage = "TGS: THIS IS A TEST OF THE EMERGENCY BROADCAST SYSTEM!"; await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { BroadcastMessage = TestBroadcastMessage, }, cancellationToken); topicRequestResult = await SendTestTopic( "tgs_integration_test_tactics_broadcast=1", cancellationToken); Assert.IsNotNull(topicRequestResult); Assert.AreEqual(TestBroadcastMessage, topicRequestResult.StringData); } async Task InteropTestsForLongRunningDme(CancellationToken cancellationToken) { await RegressionTest1686(cancellationToken); await ApiAssert.ThrowsExactly(() => instanceClient.DreamDaemon.Update(new DreamDaemonRequest { BroadcastMessage = "ksjfdksjf", }, cancellationToken), ErrorCode.BroadcastFailure); await StartAndLeaveRunning(cancellationToken); await BroadcastTest(cancellationToken); await RegressionTest1550(cancellationToken); await TestLegacyBridgeEndpoint(cancellationToken); await TestDeploymentTrigger(cancellationToken); var deleteJobTask = TestDeleteByondInstallErrorCasesAndQueing(cancellationToken); SessionController.LogTopicRequests = false; await WhiteBoxChatCommandTest(cancellationToken); await SendChatOverloadCommand(cancellationToken); await ValidateTopicLimits(cancellationToken); SessionController.LogTopicRequests = true; // This one fucks with the access_identifer, run it in isolation await WhiteBoxValidateBridgeRequestLimitAndTestChunking(cancellationToken); var ddInfo = await instanceClient.DreamDaemon.Read(cancellationToken); await CheckDMApiFail(ddInfo.ActiveCompileJob, cancellationToken); var deleteJob = await deleteJobTask; // And this freezes DD (also restarts it) await DumpTests(false, cancellationToken); await DumpTests(true, cancellationToken); await WaitForJob(deleteJob, 15, false, null, cancellationToken); } async ValueTask RegressionTest1550(CancellationToken cancellationToken) { // Previous test, StartAndLeaveRunning, has SoftRestart set. We don't want that. var restartJob = await instanceClient.DreamDaemon.Restart(cancellationToken); await WaitForJob(restartJob, 10, false, null, cancellationToken); // we need to cycle deployments twice because TGS holds the initial deployment var currentStatus = await DeployTestDme("LongRunning/long_running_test", DreamDaemonSecurity.Trusted, DMApiValidationMode.Required, cancellationToken); Assert.AreEqual(WatchdogStatus.Online, currentStatus.Status); Assert.IsNotNull(currentStatus.StagedCompileJob); ValidateSessionId(currentStatus, true); var expectedStaged = currentStatus.StagedCompileJob; Assert.AreNotEqual(expectedStaged.Id, currentStatus.ActiveCompileJob.Id); Assert.IsFalse(currentStatus.SoftShutdown); currentStatus = await TellWorldToReboot(true, cancellationToken); ValidateSessionId(currentStatus, true); Assert.AreEqual(expectedStaged.Id, currentStatus.ActiveCompileJob.Id); var topicRequestResult = await SendTestTopic( "shadow_wizard_money_gang=1", cancellationToken); Assert.IsNotNull(topicRequestResult); Assert.AreEqual("we love casting spells", topicRequestResult.StringData); currentStatus = await DeployTestDme("LongRunning/long_running_test", DreamDaemonSecurity.Trusted, DMApiValidationMode.Required, cancellationToken); Assert.AreEqual(watchdogRestartsProcess, currentStatus.SoftRestart); ValidateSessionId(currentStatus, false); Assert.AreEqual(WatchdogStatus.Online, currentStatus.Status); Assert.IsNotNull(currentStatus.StagedCompileJob); Assert.AreEqual(expectedStaged.Id, currentStatus.ActiveCompileJob.Id); expectedStaged = currentStatus.StagedCompileJob; Assert.AreNotEqual(expectedStaged.Id, currentStatus.ActiveCompileJob.Id); currentStatus = await TellWorldToReboot(true, cancellationToken); ValidateSessionId(currentStatus, watchdogRestartsProcess); Assert.AreEqual(WatchdogStatus.Online, currentStatus.Status); Assert.IsNull(currentStatus.StagedCompileJob); Assert.AreEqual(expectedStaged.Id, currentStatus.ActiveCompileJob.Id); await CheckDMApiFail(currentStatus.ActiveCompileJob, cancellationToken, false); await CheckDMApiFail(expectedStaged, cancellationToken, false); } async Task TestDeleteByondInstallErrorCasesAndQueing(CancellationToken cancellationToken) { var currentByond = await instanceClient.Engine.ActiveVersion(cancellationToken); Assert.IsNotNull(currentByond); Assert.AreEqual(testVersion, currentByond.EngineVersion); // Change the active version and check we get delayed while deleting the old one because the watchdog is using it var setActiveResponse = await instanceClient.Engine.SetActiveVersion( new EngineVersionRequest { EngineVersion = new EngineVersion { Version = testVersion.Version, SourceSHA = testVersion.SourceSHA, Engine = testVersion.Engine, CustomIteration = 1, } }, null, cancellationToken); Assert.IsNotNull(setActiveResponse); Assert.IsNull(setActiveResponse.InstallJob); var deleteJob = await instanceClient.Engine.DeleteVersion( new EngineVersionDeleteRequest { EngineVersion = new EngineVersion { Version = testVersion.Version, SourceSHA = testVersion.SourceSHA, Engine = testVersion.Engine, } }, cancellationToken); Assert.IsNotNull(deleteJob); deleteJob = await WaitForJobProgress(deleteJob, 15, cancellationToken); Assert.IsNotNull(deleteJob); Assert.IsNotNull(deleteJob.Stage); Assert.IsTrue(deleteJob.Stage.Contains("Waiting")); // then change it back and check it fails the job because it's active again setActiveResponse = await instanceClient.Engine.SetActiveVersion( new EngineVersionRequest { EngineVersion = new EngineVersion { Version = testVersion.Version, Engine = testVersion.Engine, SourceSHA = testVersion.SourceSHA } }, null, cancellationToken); Assert.IsNotNull(setActiveResponse); Assert.IsNull(setActiveResponse.InstallJob); await WaitForJob(deleteJob, 5, true, ErrorCode.EngineCannotDeleteActiveVersion, cancellationToken); // finally, queue the last delete job which should complete when the watchdog restarts with a newly deployed .dmb // queue the byond change followed by the deployment for that first setActiveResponse = await instanceClient.Engine.SetActiveVersion( new EngineVersionRequest { EngineVersion = new EngineVersion { Version = testVersion.Version, Engine = testVersion.Engine, SourceSHA = testVersion.SourceSHA, CustomIteration = 1, } }, null, cancellationToken); Assert.IsNotNull(setActiveResponse); Assert.IsNull(setActiveResponse.InstallJob); deleteJob = await instanceClient.Engine.DeleteVersion( new EngineVersionDeleteRequest { EngineVersion = new EngineVersion { Version = testVersion.Version, Engine = testVersion.Engine, SourceSHA = testVersion.SourceSHA, } }, cancellationToken); Assert.IsNotNull(deleteJob); await DeployTestDme("LongRunning/long_running_test", DreamDaemonSecurity.Safe, DMApiValidationMode.Required, cancellationToken); return deleteJob; } async Task SendChatOverloadCommand(CancellationToken cancellationToken) { // for the code coverage really... var topicRequestResult = await SendTestTopic( "tgs_integration_test_tactics5=1", cancellationToken); Assert.IsNotNull(topicRequestResult); Assert.AreEqual("sent", topicRequestResult.StringData); } async Task DumpTests(bool mini, CancellationToken cancellationToken) { System.Console.WriteLine("TEST: WATCHDOG DUMP TESTS"); var updated = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { Minidumps = mini, }, cancellationToken); Assert.AreEqual(mini, updated.Minidumps); var dumpJob = await instanceClient.DreamDaemon.CreateDump(cancellationToken); await WaitForJob(dumpJob, 60, false, null, cancellationToken); var dumpFiles = Directory.GetFiles(Path.Combine( instanceClient.Metadata.Path, "Diagnostics", "ProcessDumps"), testVersion.Engine == EngineType.OpenDream ? "*.net.dmp" : "*.dmp"); Assert.AreEqual(1, dumpFiles.Length); File.Delete(dumpFiles.Single()); // fuck this test, it's flakey as a motherfucker if (Environment.NewLine == null) { if (testVersion.Engine != EngineType.OpenDream) { JobResponse job; while (true) { KillDD(true); var jobTcs = new TaskCompletionSource(); var killTaskStarted = new TaskCompletionSource(); var killThread = new Thread(() => { killTaskStarted.SetResult(); while (!jobTcs.Task.IsCompleted) KillDD(false); }) { Priority = ThreadPriority.AboveNormal }; killThread.Start(); try { await killTaskStarted.Task; var dumpTask = instanceClient.DreamDaemon.CreateDump(cancellationToken); job = await WaitForJob(await dumpTask, 20, true, null, cancellationToken); } finally { jobTcs.SetResult(); killThread.Join(); } // these can also happen if (!(new PlatformIdentifier().IsWindows && (job.ExceptionDetails.Contains("Access is denied.") || job.ExceptionDetails.Contains("The handle is invalid.") || job.ExceptionDetails.Contains("Unknown error") || job.ExceptionDetails.Contains("No process is associated with this object.") || job.ExceptionDetails.Contains("The program issued a command but the command length is incorrect.") || job.ExceptionDetails.Contains("Only part of a ReadProcessMemory or WriteProcessMemory request was completed.") || job.ExceptionDetails.Contains("Unknown error")))) break; var restartJob = await instanceClient.DreamDaemon.Restart(cancellationToken); await WaitForJob(restartJob, 20, false, null, cancellationToken); } Assert.IsTrue(job.ErrorCode == ErrorCode.GameServerOffline || job.ErrorCode == ErrorCode.GCoreFailure, $"{job.ErrorCode}: {job.ExceptionDetails}"); } } var restartJob2 = await instanceClient.DreamDaemon.Restart(cancellationToken); await WaitForJob(restartJob2, 20, false, null, cancellationToken); } async Task TestDMApiFreeDeploy(CancellationToken cancellationToken) { System.Console.WriteLine("TEST: WATCHDOG API FREE TEST"); var daemonStatus = await DeployTestDme("ApiFree/api_free", DreamDaemonSecurity.Safe, DMApiValidationMode.Optional, cancellationToken); Assert.AreEqual(WatchdogStatus.Offline, daemonStatus.Status.Value); Assert.IsNotNull(daemonStatus.ActiveCompileJob); Assert.IsNull(daemonStatus.StagedCompileJob); Assert.IsNull(daemonStatus.ActiveCompileJob.DMApiVersion); Assert.AreEqual(DreamDaemonSecurity.Ultrasafe, daemonStatus.ActiveCompileJob.MinimumSecurityLevel); await ExpectGameDirectoryCount(1, cancellationToken); var startJob = await StartDD(cancellationToken); await WaitForJob(startJob, 40, false, null, cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); ValidateSessionId(daemonStatus, true); await CheckDDPriority(); Assert.AreEqual(false, daemonStatus.SoftRestart); Assert.AreEqual(false, daemonStatus.SoftShutdown); Assert.AreEqual(string.Empty, daemonStatus.AdditionalParameters); Assert.AreEqual(false, daemonStatus.SoftRestart); var initialCompileJob = daemonStatus.ActiveCompileJob; await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken); daemonStatus = await DeployTestDme("BasicOperation/basic operation_test", DreamDaemonSecurity.Trusted, DMApiValidationMode.Required, cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); Assert.AreEqual(false, daemonStatus.SoftRestart); // dme name change triggered, instant reboot Assert.AreEqual(initialCompileJob.Id, daemonStatus.ActiveCompileJob.Id); var newerCompileJob = daemonStatus.StagedCompileJob; await ExpectGameDirectoryCount(2, cancellationToken); Assert.IsNotNull(newerCompileJob); Assert.AreNotEqual(initialCompileJob.Id, newerCompileJob.Id); Assert.AreEqual(DreamDaemonSecurity.Trusted, newerCompileJob.MinimumSecurityLevel); Assert.AreEqual(DMApiConstants.InteropVersion, daemonStatus.StagedCompileJob.DMApiVersion); await instanceClient.DreamDaemon.Shutdown(cancellationToken); await ExpectGameDirectoryCount(1, cancellationToken); } public async ValueTask ExpectGameDirectoryCount(int expected, CancellationToken cancellationToken) { string[] lastDirectories; int CountNonLiveDirs() { lastDirectories = Directory.GetDirectories(Path.Combine(instanceClient.Metadata.Path, "Game")); return lastDirectories.Where(directory => Path.GetFileName(directory) != "Live").Count(); } int nonLiveDirs = 0; // cleanup task is async for (var i = 0; i < 20; ++i) { nonLiveDirs = CountNonLiveDirs(); if (expected == nonLiveDirs) return; await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); } nonLiveDirs = CountNonLiveDirs(); Assert.AreEqual(expected, nonLiveDirs, $"Directories present: {String.Join(", ", lastDirectories.Select(Path.GetFileName))}"); } async Task RunBasicTest(bool skipApiValidation, CancellationToken cancellationToken) { System.Console.WriteLine("TEST: WATCHDOG BASIC TEST"); var daemonStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { AdditionalParameters = "test=bababooey" }, cancellationToken); Assert.AreEqual("test=bababooey", daemonStatus.AdditionalParameters); daemonStatus = await DeployTestDme($"BasicOperation/basic operation_test{(skipApiValidation ? "_nov3" : String.Empty)}", DreamDaemonSecurity.Trusted, skipApiValidation ? DMApiValidationMode.Skipped : DMApiValidationMode.Required, cancellationToken); Assert.AreEqual(WatchdogStatus.Offline, daemonStatus.Status.Value); Assert.IsNotNull(daemonStatus.ActiveCompileJob); if (skipApiValidation) { Assert.IsNull(daemonStatus.ActiveCompileJob.DMApiVersion); Assert.AreEqual(DreamDaemonSecurity.Ultrasafe, daemonStatus.ActiveCompileJob.MinimumSecurityLevel); } else { Assert.AreEqual(DMApiConstants.InteropVersion, daemonStatus.ActiveCompileJob.DMApiVersion); Assert.AreEqual(DreamDaemonSecurity.Trusted, daemonStatus.ActiveCompileJob.MinimumSecurityLevel); } Assert.IsFalse(daemonStatus.SessionId.HasValue); Assert.IsFalse(daemonStatus.LaunchTime.HasValue); Assert.IsNull(daemonStatus.StagedCompileJob); Assert.AreNotEqual(DreamDaemonSecurity.Ultrasafe, daemonStatus.SecurityLevel); await ExpectGameDirectoryCount(1, cancellationToken); JobResponse startJob; if (new PlatformIdentifier().IsWindows) // Can't get address reuse to trigger on linux for some reason using (var blockSocket = new Socket( testVersion.Engine.Value == EngineType.OpenDream ? SocketType.Dgram : SocketType.Stream, testVersion.Engine.Value == EngineType.OpenDream ? ProtocolType.Udp : ProtocolType.Tcp)) { blockSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ExclusiveAddressUse, true); blockSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false); if (testVersion.Engine.Value != EngineType.OpenDream) blockSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.DontLinger, true); blockSocket.Bind(new IPEndPoint(IPAddress.Any, ddPort)); // Don't use StartDD here startJob = await instanceClient.DreamDaemon.Start(cancellationToken); await WaitForJob(startJob, 40, true, ErrorCode.GameServerPortInUse, cancellationToken); } startJob = await StartDD(cancellationToken); await WaitForJob(startJob, 40, false, null, cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); ValidateSessionId(daemonStatus, true); await CheckDDPriority(); Assert.AreEqual(false, daemonStatus.SoftRestart); Assert.AreEqual(false, daemonStatus.SoftShutdown); Assert.AreEqual(skipApiValidation, !daemonStatus.WorldIteration.HasValue); Assert.IsTrue(daemonStatus.ImmediateMemoryUsage.HasValue); Assert.AreNotEqual(0, daemonStatus.ImmediateMemoryUsage.Value); if (skipApiValidation) Assert.IsFalse(daemonStatus.ClientCount.HasValue); await GracefulWatchdogShutdown(cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Offline, daemonStatus.Status.Value); Assert.IsFalse(daemonStatus.SessionId.HasValue); Assert.IsFalse(daemonStatus.LaunchTime.HasValue); await ExpectGameDirectoryCount(1, cancellationToken); await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken, false, false); if (!skipApiValidation && !watchdogRestartsProcess) { daemonStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { AdditionalParameters = "basic_reboot=yes", }, cancellationToken); Assert.AreEqual("basic_reboot=yes", daemonStatus.AdditionalParameters); startJob = await StartDD(cancellationToken); await WaitForJob(startJob, 40, false, null, cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); var initialWorldIteration = daemonStatus.WorldIteration; var initialSessionId = daemonStatus.SessionId.Value; Assert.IsTrue(initialWorldIteration.HasValue); for (int i = 0; i < 60 && daemonStatus.WorldIteration == initialWorldIteration; ++i) { await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); } Assert.IsTrue(daemonStatus.WorldIteration.HasValue); Assert.AreEqual(initialSessionId, daemonStatus.SessionId.Value); Assert.IsTrue(initialWorldIteration.Value < daemonStatus.WorldIteration.Value); await GracefulWatchdogShutdown(cancellationToken); } daemonStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { AdditionalParameters = string.Empty, LogOutput = true, }, cancellationToken); Assert.AreEqual(string.Empty, daemonStatus.AdditionalParameters); Assert.IsFalse(daemonStatus.SessionId.HasValue); Assert.IsFalse(daemonStatus.LaunchTime.HasValue); } long? sessionIdTracker; void ValidateSessionId(DreamDaemonResponse daemonStatus, bool? knownIncrease) { Assert.IsTrue(daemonStatus.SessionId.HasValue, $"Expected a session ID in the DreamDaemonResponse"); Assert.IsTrue(daemonStatus.LaunchTime.HasValue); Assert.IsTrue(daemonStatus.LaunchTime.Value >= DateTimeOffset.UtcNow.AddHours(-1)); if (daemonStatus.ClientCount.HasValue) Assert.AreEqual(0U, daemonStatus.ClientCount.Value); if (sessionIdTracker.HasValue) if (knownIncrease.HasValue) if (knownIncrease.Value) Assert.IsTrue(daemonStatus.SessionId.Value > sessionIdTracker.Value, $"Expected a session ID > {sessionIdTracker.Value}, got {daemonStatus.SessionId.Value} instead"); else Assert.AreEqual(sessionIdTracker.Value, daemonStatus.SessionId.Value); else Assert.IsTrue(daemonStatus.SessionId.Value >= sessionIdTracker.Value, $"Expected a session ID >= {sessionIdTracker.Value}, got {daemonStatus.SessionId.Value} instead"); sessionIdTracker = daemonStatus.SessionId.Value; } void TestLinuxIsntBeingFuckingCheekyAboutFilePaths(DreamDaemonResponse currentStatus, CompileJobResponse previousStatus) { if (new PlatformIdentifier().IsWindows || watchdogRestartsProcess) return; Assert.IsNotNull(currentStatus.ActiveCompileJob); Assert.IsTrue(currentStatus.ActiveCompileJob.DmeName.Contains("long_running_test")); Assert.AreEqual(WatchdogStatus.Online, currentStatus.Status); var procs = TestLiveServer.GetEngineServerProcessesOnPort(testVersion.Engine.Value, currentStatus.Port.Value); Assert.AreEqual(1, procs.Count); var failingLinks = new List(); using var proc = procs[0]; var pid = proc.Id; var foundLivePath = false; var allPaths = new List(); var features = new PosixProcessFeatures( new Lazy(Mock.Of()), new DefaultIOManager(new FileSystem()), Mock.Of>()); features.SuspendProcess(proc); try { Assert.IsFalse(proc.HasExited); foreach (var fd in Directory.GetFiles($"/proc/{pid}/fd")) { var sb = new StringBuilder(UInt16.MaxValue); if (Syscall.readlink(fd, sb) == -1) throw new UnixIOException(Stdlib.GetLastError()); var path = sb.ToString(); allPaths.Add($"Path: {path}"); if (path.Contains($"Game/{previousStatus.DirectoryName}")) failingLinks.Add($"Found fd {fd} resolving to previous absolute path game dir path: {path}"); if (path.Contains($"Game/{currentStatus.ActiveCompileJob.DirectoryName}")) failingLinks.Add($"Found fd {fd} resolving to current absolute path game dir path: {path}"); if (path.Contains($"Game/Live")) foundLivePath = true; } if (!foundLivePath) failingLinks.Add($"Failed to find a path containing the 'Live' directory!"); } finally { features.ResumeProcess(proc); } Assert.IsTrue(failingLinks.Count == 0, String.Join(Environment.NewLine, failingLinks.Concat(allPaths))); } async Task RunHealthCheckTest(bool checkDump, CancellationToken cancellationToken) { System.Console.WriteLine("TEST: WATCHDOG HEALTH CHECK TEST"); // enable health checks var status = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { HealthCheckSeconds = 1, DumpOnHealthCheckRestart = checkDump, }, cancellationToken); Assert.AreEqual(checkDump, status.DumpOnHealthCheckRestart); Assert.AreEqual(1U, status.HealthCheckSeconds.Value); var startJob = await StartDD(cancellationToken); await WaitForJob(startJob, 40, false, null, cancellationToken); await CheckDDPriority(); // lock on to DD and pause it so it can't health check var ddProcs = TestLiveServer.GetEngineServerProcessesOnPort(testVersion.Engine.Value, ddPort).Where(x => !x.HasExited).ToList(); if (ddProcs.Count != 1) Assert.Fail($"Incorrect number of DD processes: {ddProcs.Count}"); using var ddProc = ddProcs.Single(); ProcessExecutor executor = null; executor = new ProcessExecutor( RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? new WindowsProcessFeatures(Mock.Of>()) : new PosixProcessFeatures(new Lazy(() => executor), new DefaultIOManager(new FileSystem()), Mock.Of>()), Mock.Of(), Mock.Of>(), LoggerFactory.Create(x => { })); await using var ourProcessHandler = executor .GetProcess(ddProc.Id); // Ensure it's responding to health checks await Task.WhenAny(Task.Delay(7000, cancellationToken), ourProcessHandler.Lifetime); Assert.IsFalse(ddProc.HasExited); var daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(0U, daemonStatus.ClientCount); // check DD agrees var topicRequestResult = await SendTestTopic( "tgs_integration_test_tactics8=1", cancellationToken); Assert.IsNotNull(topicRequestResult); Assert.AreEqual(TopicResponseType.StringResponse, topicRequestResult.ResponseType); Assert.IsNotNull(topicRequestResult.StringData); Assert.AreEqual("received health check", topicRequestResult.StringData); var ddStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { SoftShutdown = true }, cancellationToken); ValidateSessionId(ddStatus, true); global::System.Console.WriteLine($"WATCHDOG TEST {instanceClient.Metadata.Id}: COMMENCE PROCESS SUSPEND FOR HEALTH CHECK DEATH PID {ourProcessHandler.Id}."); ourProcessHandler.SuspendProcess(); global::System.Console.WriteLine($"WATCHDOG TEST {instanceClient.Metadata.Id}: FINISH PROCESS SUSPEND FOR HEALTH CHECK DEATH. WAITING FOR LIFETIME {ourProcessHandler.Id}."); if (testVersion.Engine == EngineType.OpenDream && checkDump) { // because dotnet diagnostics relies on the engine process to write its own dump, we actually have to unpause it after the watchdog has decided to kill it // incredibly cursed, because we don't have the means to accurately tell when that will happen. ESP in CI return; // CBA rn /* await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); ourProcessHandler.ResumeProcess(); global::System.Console.WriteLine($"WATCHDOG TEST {instanceClient.Metadata.Id}: PROCESS RESUMING FOR DOTNET DUMP. WAITING FOR LIFETIME {ourProcessHandler.Id}.");*/ } await Task.WhenAny(ourProcessHandler.Lifetime, Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); if (testVersion.Engine == EngineType.OpenDream && checkDump && !ourProcessHandler.Lifetime.IsCompleted) return; Assert.IsTrue(ourProcessHandler.Lifetime.IsCompleted); var timeout = 20; do { ddStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(1U, ddStatus.HealthCheckSeconds.Value); if (ddStatus.Status.Value == WatchdogStatus.Offline) { await CheckDMApiFail(ddStatus.ActiveCompileJob, cancellationToken); break; } if (--timeout == 0) Assert.Fail("DreamDaemon didn't shutdown within the timeout!"); await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); } while (timeout > 0); // disable health checks ddStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { HealthCheckSeconds = 0, }, cancellationToken); Assert.AreEqual(0U, ddStatus.HealthCheckSeconds.Value); if (checkDump) { // check the dump happened var dumpFiles = Directory.GetFiles(Path.Combine( instanceClient.Metadata.Path, "Diagnostics", "ProcessDumps"), "*.dmp"); Assert.AreEqual(1, dumpFiles.Length); File.Delete(dumpFiles.Single()); } } async Task StartDD(CancellationToken cancellationToken) { // integration tests may take a while to release the port using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromMinutes(1)); while (true) { try { SocketExtensions.BindTest(new PlatformIdentifier(), ddPort, false, testVersion.Engine == EngineType.OpenDream); break; } catch { try { await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); continue; } catch (OperationCanceledException) { } throw; } } return await instanceClient.DreamDaemon.Start(cancellationToken); } class TestData { public string Size { get; set; } public string Payload { get; set; } } // - Uses instance manager concrete // - Injects a custom bridge handler into the bridge registrar and makes the test hack into the DMAPI and change its access_identifier async Task WhiteBoxValidateBridgeRequestLimitAndTestChunking(CancellationToken cancellationToken) { // first check the bridge limits var bridgeTestsTcs = new TaskCompletionSource(); BridgeController.TemporarilyDisableContentLogging(); using (var loggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); builder.SetMinimumLevel(LogLevel.Trace); })) { var accessIdentifier = $"tgs_integration_test_for_instance_{instanceClient.Metadata.Name}"; var bridgeProcessor = new TestBridgeHandler(bridgeTestsTcs, loggerFactory.CreateLogger(), accessIdentifier, serverPort); using var bridgeRegistration = instanceManager.RegisterHandler(bridgeProcessor); System.Console.WriteLine("TEST: Sending Bridge tests topic..."); var bridgeTestTopicResult = await SendTestTopic( $"tgs_integration_test_tactics2={accessIdentifier}", cancellationToken); Assert.AreEqual("ack2", bridgeTestTopicResult.StringData); await bridgeTestsTcs.Task.WaitAsync(cancellationToken); } BridgeController.ReenableContentLogging(); // Time for DD to revert the bridge access identifier change await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); } async Task ValidateTopicLimits(CancellationToken cancellationToken) { // Time for topic tests // Request System.Console.WriteLine("TEST: Sending Topic tests topics..."); var nextPow = 0; var lastSize = 0; var baseTopic = new TestData { Size = 0.ToString().PadLeft(6, '0'), Payload = "", }; var json = JsonConvert.SerializeObject(baseTopic, DMApiConstants.SerializerSettings); var baseSize = (int)(DMApiConstants.MaximumTopicRequestLength - 1); var topicString = $"tgs_integration_test_tactics3={topicClient.SanitizeString(json)}"; var wrappingSize = topicString.Length; while (!cancellationToken.IsCancellationRequested) { var currentSize = baseSize + (int)Math.Pow(2, nextPow); var payloadSize = currentSize - wrappingSize; var topic = new TestData { Size = payloadSize.ToString().PadLeft(6, '0'), Payload = new string('a', payloadSize), }; TopicResponse topicRequestResult = null; try { System.Console.WriteLine($"Topic send limit test S:{currentSize}..."); topicRequestResult = await SendTestTopic( $"tgs_integration_test_tactics3={topicClient.SanitizeString(JsonConvert.SerializeObject(topic, DMApiConstants.SerializerSettings))}", cancellationToken); } catch (ArgumentOutOfRangeException) { } if (topicRequestResult == null || topicRequestResult.ResponseType != TopicResponseType.StringResponse || topicRequestResult.StringData != "pass") { if (topicRequestResult != null) { Assert.AreEqual(TopicResponseType.StringResponse, topicRequestResult.ResponseType, $"String data is: {topicRequestResult.StringData ?? "<>"}"); Assert.AreEqual("fail", topicRequestResult.StringData); } if (currentSize == lastSize + 1) break; baseSize = lastSize; nextPow = 0; continue; } lastSize = currentSize; ++nextPow; } cancellationToken.ThrowIfCancellationRequested(); Assert.AreEqual(DMApiConstants.MaximumTopicRequestLength, (uint)lastSize); System.Console.WriteLine("TEST: Receiving Topic tests topics..."); // Receive baseSize = (int)(DMApiConstants.MaximumTopicResponseLength - 1); nextPow = 0; lastSize = 0; while (!cancellationToken.IsCancellationRequested) { var currentSize = baseSize + (int)Math.Pow(2, nextPow); System.Console.WriteLine($"Topic recieve limit test S:{currentSize}..."); var topicRequestResult = await SendTestTopic( $"tgs_integration_test_tactics4={topicClient.SanitizeString(currentSize.ToString())}", cancellationToken); if (topicRequestResult.ResponseType != TopicResponseType.StringResponse || new string('a', currentSize) != topicRequestResult.StringData) { if (currentSize == lastSize + 1) break; baseSize = lastSize; nextPow = 0; continue; } lastSize = currentSize; ++nextPow; } cancellationToken.ThrowIfCancellationRequested(); Assert.AreEqual(DMApiConstants.MaximumTopicResponseLength, (uint)lastSize); } ushort FindTopicPort() { using var instanceReference = instanceManager.GetInstanceReference(instanceClient.Metadata); var watchdog = instanceReference.Watchdog; var sessionObj = watchdog.GetType().GetProperty("Server", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(watchdog); Assert.IsNotNull(sessionObj); var session = (ISessionController)sessionObj; return session.ReattachInformation.TopicPort ?? session.ReattachInformation.Port; } // - Uses instance manager concrete // - Injects a custom bridge handler into the bridge registrar and makes the test hack into the DMAPI and change its access_identifier async Task WhiteBoxChatCommandTest(CancellationToken cancellationToken) { var ddInfo = await instanceClient.DreamDaemon.Read(cancellationToken); for (int i = 0; ddInfo.Status != WatchdogStatus.Online && i < 15; ++i) { await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); ddInfo = await instanceClient.DreamDaemon.Read(cancellationToken); } Assert.AreEqual(WatchdogStatus.Online, ddInfo.Status); MessageContent embedsResponse, overloadResponse, overloadResponse2, embedsResponse2; var startTime = DateTimeOffset.UtcNow - TimeSpan.FromSeconds(5); using (var instanceReference = instanceManager.GetInstanceReference(instanceClient.Metadata)) { var mockChatUser = new ChatUser( new ChannelRepresentation("test_connection", "Test Connection", 42) { IsAdminChannel = true, EmbedsSupported = true, IsPrivateChannel = false, }, "Test Sender", "test_user_mention", 1234); var embedsResponseTask = ((WatchdogBase)instanceReference.Watchdog).HandleChatCommand( "embeds_test", string.Empty, mockChatUser, cancellationToken); var embedsResponseTask2 = ((WatchdogBase)instanceReference.Watchdog).HandleChatCommand( "embeds_test", new string('a', (int)DMApiConstants.MaximumTopicRequestLength * 3), mockChatUser, cancellationToken); var overloadResponseTask2 = ((WatchdogBase)instanceReference.Watchdog).HandleChatCommand( "response_overload_test", string.Empty, mockChatUser, cancellationToken); overloadResponse = await ((WatchdogBase)instanceReference.Watchdog).HandleChatCommand( "response_overload_test", string.Empty, mockChatUser, cancellationToken); overloadResponse2 = await overloadResponseTask2; embedsResponse = await embedsResponseTask; embedsResponse2 = await embedsResponseTask2; } var endTime = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5); ddInfo = await instanceClient.DreamDaemon.Read(cancellationToken); await CheckDMApiFail(ddInfo.ActiveCompileJob, cancellationToken); CheckEmbedsTest(embedsResponse, startTime, endTime); CheckEmbedsTest(embedsResponse2, startTime, endTime); var expectedString = new string('a', (int)DMApiConstants.MaximumTopicResponseLength * 3); Assert.IsNotNull(overloadResponse); Assert.AreEqual(expectedString, overloadResponse.Text); Assert.IsNotNull(overloadResponse2); Assert.AreEqual(expectedString, overloadResponse2.Text); } static void CheckEmbedsTest(MessageContent embedsResponse, DateTimeOffset startTime, DateTimeOffset endTime) { Assert.IsNotNull(embedsResponse); Assert.AreEqual("Embed support test2", embedsResponse.Text); Assert.AreEqual("desc", embedsResponse.Embed.Description); Assert.AreEqual("title", embedsResponse.Embed.Title); Assert.AreEqual("#0000FF", embedsResponse.Embed.Colour); Assert.AreEqual("Dominion", embedsResponse.Embed.Author?.Name); Assert.AreEqual("https://github.com/Cyberboss", embedsResponse.Embed.Author.Url); Assert.IsTrue(DateTimeOffset.TryParse(embedsResponse.Embed.Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var timestamp)); var timestampCheck = startTime < timestamp && endTime > timestamp; Assert.IsTrue(timestampCheck, $"Expected between {startTime.ToString("O")} and {endTime.ToString("O")}, got {timestamp.ToString("O")}"); Assert.AreEqual("https://github.com/tgstation/tgstation-server", embedsResponse.Embed.Url); Assert.AreEqual(3, embedsResponse.Embed.Fields?.Count); Assert.AreEqual("field1", embedsResponse.Embed.Fields.ElementAt(0).Name); Assert.AreEqual("value1", embedsResponse.Embed.Fields.ElementAt(0).Value); Assert.IsNull(embedsResponse.Embed.Fields.ElementAt(0).IsInline); Assert.AreEqual("field2", embedsResponse.Embed.Fields.ElementAt(1).Name); Assert.AreEqual("value2", embedsResponse.Embed.Fields.ElementAt(1).Value); Assert.IsTrue(embedsResponse.Embed.Fields.ElementAt(1).IsInline); Assert.AreEqual("field3", embedsResponse.Embed.Fields.ElementAt(2).Name); Assert.AreEqual("value3", embedsResponse.Embed.Fields.ElementAt(2).Value); Assert.IsTrue(embedsResponse.Embed.Fields.ElementAt(2).IsInline); Assert.AreEqual("Footer text", embedsResponse.Embed.Footer?.Text); } async ValueTask CheckDDPriority() { await Task.Yield(); var allProcesses = TestLiveServer.GetEngineServerProcessesOnPort(testVersion.Engine.Value, ddPort).Where(x => !x.HasExited).ToList(); if (allProcesses.Count == 0) Assert.Fail("Expected engine server to be running here"); if (allProcesses.Count > 1) Assert.Fail("Multiple engine server-like processes running!"); using var process = allProcesses[0]; Assert.AreEqual( highPrioDD ? System.Diagnostics.ProcessPriorityClass.AboveNormal : System.Diagnostics.ProcessPriorityClass.Normal, process.PriorityClass); } async Task RunLongRunningTestThenUpdate(CancellationToken cancellationToken) { System.Console.WriteLine("TEST: WATCHDOG LONG RUNNING WITH UPDATE TEST"); const string DmeName = "LongRunning/long_running_test"; var daemonStatus = await DeployTestDme(DmeName, DreamDaemonSecurity.Trusted, DMApiValidationMode.Required, cancellationToken); var initialCompileJob = daemonStatus.ActiveCompileJob; Assert.IsNotNull(initialCompileJob); Assert.AreEqual(WatchdogStatus.Offline, daemonStatus.Status.Value); Assert.IsNotNull(daemonStatus.ActiveCompileJob); Assert.IsNull(daemonStatus.StagedCompileJob); Assert.AreEqual(DMApiConstants.InteropVersion, daemonStatus.ActiveCompileJob.DMApiVersion); Assert.AreEqual(DreamDaemonSecurity.Safe, daemonStatus.ActiveCompileJob.MinimumSecurityLevel); var startJob = await StartDD(cancellationToken); await WaitForJob(startJob, 40, false, null, cancellationToken); daemonStatus = await DeployTestDme(DmeName, DreamDaemonSecurity.Safe, DMApiValidationMode.Required, cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); ValidateSessionId(daemonStatus, true); await CheckDDPriority(); Assert.AreEqual(initialCompileJob.Id, daemonStatus.ActiveCompileJob.Id); var newerCompileJob = daemonStatus.StagedCompileJob; Assert.IsNotNull(newerCompileJob); Assert.AreNotEqual(initialCompileJob.Id, newerCompileJob.Id); Assert.AreEqual(DreamDaemonSecurity.Safe, newerCompileJob.MinimumSecurityLevel); await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken); daemonStatus = await TellWorldToReboot(true, cancellationToken); ValidateSessionId(daemonStatus, watchdogRestartsProcess); Assert.AreNotEqual(initialCompileJob.Id, daemonStatus.ActiveCompileJob.Id); Assert.IsNull(daemonStatus.StagedCompileJob); TestLinuxIsntBeingFuckingCheekyAboutFilePaths(daemonStatus, initialCompileJob); await instanceClient.DreamDaemon.Shutdown(cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Offline, daemonStatus.Status.Value); await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken); } async Task RunLongRunningTestThenUpdateWithNewDme(CancellationToken cancellationToken) { System.Console.WriteLine("TEST: WATCHDOG LONG RUNNING WITH NEW DME TEST"); var daemonStatus = await DeployTestDme("LongRunning/long_running_test", DreamDaemonSecurity.Trusted, DMApiValidationMode.Required, cancellationToken); var initialCompileJob = daemonStatus.ActiveCompileJob; Assert.AreEqual(WatchdogStatus.Offline, daemonStatus.Status.Value); Assert.IsNotNull(daemonStatus.ActiveCompileJob); Assert.IsNull(daemonStatus.StagedCompileJob); Assert.AreEqual(DMApiConstants.InteropVersion, daemonStatus.ActiveCompileJob.DMApiVersion); Assert.AreEqual(DreamDaemonSecurity.Safe, daemonStatus.ActiveCompileJob.MinimumSecurityLevel); Assert.AreEqual(false, daemonStatus.SoftRestart); var startJob = await StartDD(cancellationToken); await WaitForJob(startJob, 40, false, null, cancellationToken); daemonStatus = await DeployTestDme("LongRunning/long_running_test_copy", DreamDaemonSecurity.Safe, DMApiValidationMode.Required, cancellationToken); ValidateSessionId(daemonStatus, true); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); Assert.AreEqual(true, daemonStatus.SoftRestart); await CheckDDPriority(); Assert.AreEqual(initialCompileJob.Id, daemonStatus.ActiveCompileJob.Id); var newerCompileJob = daemonStatus.StagedCompileJob; Assert.IsNotNull(newerCompileJob); Assert.AreNotEqual(initialCompileJob.Id, newerCompileJob.Id); Assert.AreEqual(DreamDaemonSecurity.Safe, newerCompileJob.MinimumSecurityLevel); await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken); daemonStatus = await TellWorldToReboot(true, cancellationToken); ValidateSessionId(daemonStatus, true); // remember, dme name change triggers reboot Assert.AreNotEqual(initialCompileJob.Id, daemonStatus.ActiveCompileJob.Id); Assert.AreEqual(false, daemonStatus.SoftRestart); Assert.IsNull(daemonStatus.StagedCompileJob); await instanceClient.DreamDaemon.Shutdown(cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Offline, daemonStatus.Status.Value); await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken); } async Task RunLongRunningTestThenUpdateWithByondVersionSwitch(CancellationToken cancellationToken) { System.Console.WriteLine("TEST: WATCHDOG BYOND VERSION UPDATE TEST"); var versionToInstall = testVersion; var currentByondVersion = await instanceClient.Engine.ActiveVersion(cancellationToken); Assert.AreNotEqual(versionToInstall, currentByondVersion.EngineVersion); var initialStatus = await instanceClient.DreamDaemon.Read(cancellationToken); var startJob = await StartDD(cancellationToken); await WaitForJob(startJob, 70, false, null, cancellationToken); await CheckDDPriority(); var byondInstallJobTask = instanceClient.Engine.SetActiveVersion( new EngineVersionRequest { EngineVersion = new EngineVersion { Version = versionToInstall.Version, Engine = versionToInstall.Engine, SourceSHA = versionToInstall.SourceSHA, } }, null, cancellationToken); var byondInstallJob = await byondInstallJobTask; // This used to be the case but it gets deleted now that we have and test that // Assert.IsNull(byondInstallJob.InstallJob); await WaitForJob(byondInstallJob.InstallJob, EngineTest.EngineInstallationTimeout(versionToInstall) + 30, false, null, cancellationToken); const string DmeName = "LongRunning/long_running_test"; await DeployTestDme(DmeName, DreamDaemonSecurity.Safe, DMApiValidationMode.Required, cancellationToken); var daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); Assert.IsNotNull(daemonStatus.ActiveCompileJob); ValidateSessionId(daemonStatus, true); Assert.IsTrue(daemonStatus.ImmediateMemoryUsage.HasValue); Assert.AreNotEqual(0, daemonStatus.ImmediateMemoryUsage.Value); Assert.AreEqual(initialStatus.ActiveCompileJob.Id, daemonStatus.ActiveCompileJob.Id); var newerCompileJob = daemonStatus.StagedCompileJob; Assert.AreNotEqual(daemonStatus.ActiveCompileJob.EngineVersion, newerCompileJob.EngineVersion); Assert.AreEqual(versionToInstall, newerCompileJob.EngineVersion); Assert.AreEqual(true, daemonStatus.SoftRestart); await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken); daemonStatus = await TellWorldToReboot(true, cancellationToken); Assert.AreEqual(versionToInstall, daemonStatus.ActiveCompileJob.EngineVersion); Assert.IsNull(daemonStatus.StagedCompileJob); ValidateSessionId(daemonStatus, true); await instanceClient.DreamDaemon.Shutdown(cancellationToken); await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Offline, daemonStatus.Status.Value); } public async Task StartAndLeaveRunning(CancellationToken cancellationToken) { System.Console.WriteLine("TEST: WATCHDOG STARTING ENDLESS"); var startJob = await StartDD(cancellationToken); await WaitForJob(startJob, 40, false, null, cancellationToken); var daemonStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { AdditionalParameters = "slow_start=1", }, cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status); Assert.IsTrue(daemonStatus.SoftRestart); await CheckDDPriority(); Assert.AreEqual(ddPort, daemonStatus.CurrentPort); // Try killing the DD process to ensure it gets set to the restoring state bool firstTime = true; do { if (!firstTime) Assert.IsFalse(daemonStatus.SoftRestart); ValidateSessionId(daemonStatus, true); KillDD(firstTime); firstTime = false; await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); } while (daemonStatus.Status == WatchdogStatus.Online); Assert.AreEqual(WatchdogStatus.Restoring, daemonStatus.Status); // Kill it again do { KillDD(false); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); } while (daemonStatus.Status == WatchdogStatus.Online || daemonStatus.Status == WatchdogStatus.Restoring); Assert.AreEqual(WatchdogStatus.DelayedRestart, daemonStatus.Status); await Task.Delay(TimeSpan.FromSeconds(15), cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { AdditionalParameters = String.Empty, }, cancellationToken); ValidateSessionId(daemonStatus, true); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status); Assert.IsTrue(daemonStatus.SoftRestart); await CheckDMApiFail(daemonStatus.ActiveCompileJob, cancellationToken); } bool KillDD(bool require) { var ddProcs = TestLiveServer.GetEngineServerProcessesOnPort(testVersion.Engine.Value, ddPort).Where(x => !x.HasExited).ToList(); if (require && ddProcs.Count == 0 || ddProcs.Count > 1) Assert.Fail($"Incorrect number of DD processes: {ddProcs.Count}"); using var ddProc = ddProcs.SingleOrDefault(); ddProc?.Kill(); ddProc?.WaitForExit(); return ddProc != null; } public Task TellWorldToReboot(bool waitForOnlineIfRestoring, CancellationToken cancellationToken, [CallerLineNumber] int source = 0) => TellWorldToReboot2(instanceClient, instanceManager, topicClient, FindTopicPort(), waitForOnlineIfRestoring || testVersion.Engine.Value == EngineType.OpenDream, cancellationToken, source); public static async Task TellWorldToReboot2(IInstanceClient instanceClient, IInstanceManager instanceManager, ITopicClient topicClient, ushort topicPort, bool waitForOnlineIfRestoring, CancellationToken cancellationToken, [CallerLineNumber] int source = 0, [CallerFilePath] string path = null) { var daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.IsNotNull(daemonStatus.StagedCompileJob); var initialSession = daemonStatus.ActiveCompileJob; var initialSessionId = daemonStatus.SessionId.Value; var initialIteration = daemonStatus.WorldIteration; System.Console.WriteLine($"TEST: Sending world reboot topic @ {path}#L{source}"); var result = await SendTestTopic("tgs_integration_test_special_tactics=1", topicClient, instanceManager.GetInstanceReference(instanceClient.Metadata), topicPort, cancellationToken); Assert.AreEqual("ack", result.StringData); using var tempCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var tempToken = tempCts.Token; bool SameSession() => initialSessionId == daemonStatus.SessionId && initialIteration == daemonStatus.WorldIteration; using (tempToken.Register(() => System.Console.WriteLine("TEST ERROR: Timeout in TellWorldToReboot!"))) { tempCts.CancelAfter(TimeSpan.FromMinutes(2)); do { await Task.Delay(TimeSpan.FromSeconds(1), tempToken); daemonStatus = await instanceClient.DreamDaemon.Read(tempToken); } while (initialSession.Id == daemonStatus.ActiveCompileJob.Id || SameSession()); } if (waitForOnlineIfRestoring && daemonStatus.Status == WatchdogStatus.Restoring) { do { await Task.Delay(TimeSpan.FromSeconds(1), tempToken); daemonStatus = await instanceClient.DreamDaemon.Read(tempToken); } while (daemonStatus.Status == WatchdogStatus.Restoring); } return daemonStatus; } async Task DeployTestDme(string dmeName, DreamDaemonSecurity deploymentSecurity, DMApiValidationMode dmApiValidationMode, CancellationToken cancellationToken) { var refreshed = await instanceClient.DreamMaker.Update(new DreamMakerRequest { ApiValidationSecurityLevel = deploymentSecurity, ProjectName = dmeName.Contains("rooted") ? dmeName : $"tests/DMAPI/{dmeName}", DMApiValidationMode = dmApiValidationMode, Timeout = !ranTimeoutTest ? TimeSpan.FromMilliseconds(1) : TimeSpan.FromMinutes(5), }, cancellationToken); JobResponse compileJobJob; if (!ranTimeoutTest) { Assert.AreEqual(deploymentSecurity, refreshed.ApiValidationSecurityLevel); Assert.AreEqual(dmApiValidationMode, refreshed.DMApiValidationMode); Assert.AreEqual(TimeSpan.FromMilliseconds(1), refreshed.Timeout); compileJobJob = await instanceClient.DreamMaker.Compile(cancellationToken); await WaitForJob(compileJobJob, 90, true, ErrorCode.DeploymentTimeout, cancellationToken); await instanceClient.DreamMaker.Update(new DreamMakerRequest { Timeout = TimeSpan.FromMinutes(5), }, cancellationToken); ranTimeoutTest = true; } compileJobJob = await instanceClient.DreamMaker.Compile(cancellationToken); await WaitForJob(compileJobJob, 90, false, null, cancellationToken); // annoying but, with signalR instant job updates, this running task can get queued before the task that processes the watchdog's monitor activation for (var i = 0; i < 10; ++i) await Task.Yield(); var ddInfo = await instanceClient.DreamDaemon.Read(cancellationToken); var targetJob = ddInfo.StagedCompileJob ?? ddInfo.ActiveCompileJob; Assert.IsNotNull(targetJob); if (dmApiValidationMode == DMApiValidationMode.Required) Assert.IsNotNull(targetJob.DMApiVersion); else Assert.IsNull(targetJob.DMApiVersion); return ddInfo; } async Task GracefulWatchdogShutdown(CancellationToken cancellationToken) { await instanceClient.DreamDaemon.Update(new DreamDaemonRequest { SoftShutdown = true }, cancellationToken); var newStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.IsTrue(newStatus.SoftShutdown.Value || newStatus.Status.Value == WatchdogStatus.Offline); var timeout = 40; do { await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); var ddStatus = await instanceClient.DreamDaemon.Read(cancellationToken); if (ddStatus.Status.Value == WatchdogStatus.Offline) break; if (--timeout == 0) Assert.Fail("DreamDaemon didn't shutdown within the timeout!"); } while (timeout > 0); } async Task CheckDMApiFail(CompileJobResponse compileJob, CancellationToken cancellationToken, bool checkLogs = true, bool expectInitialBridgeFailure = false) { var gameDir = Path.Combine(instanceClient.Metadata.Path, "Game", compileJob.DirectoryName.Value.ToString(), Path.GetDirectoryName(compileJob.DmeName)); var failFile = Path.Combine(gameDir, "test_fail_reason.txt"); if (!File.Exists(failFile)) { var bridgeFailFile = Path.Combine(gameDir, "initial_bridge_failed.txt"); var initialBridgeFailed = File.Exists(bridgeFailFile); System.Console.WriteLine($"Files in game dir:{Environment.NewLine}{String.Join(Environment.NewLine, Directory.GetFiles(gameDir))}"); Assert.AreEqual(expectInitialBridgeFailure, initialBridgeFailed, $"Initial bridge failure expectancy not met in {gameDir}"); var successFile = Path.Combine(gameDir, "test_success.txt"); Assert.IsTrue(File.Exists(successFile)); } else { var text = await File.ReadAllTextAsync(failFile, cancellationToken); Assert.Fail(text); } if (!checkLogs) return; var daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); if (daemonStatus.Status != WatchdogStatus.Offline || !daemonStatus.LogOutput.Value) return; var outerLogsDir = Path.Combine(instanceClient.Metadata.Path, "Diagnostics", "DreamDaemonLogs"); var logsDir = new DirectoryInfo(outerLogsDir).GetDirectories().OrderByDescending(x => x.CreationTime).FirstOrDefault(); Assert.IsNotNull(logsDir); var logfile = logsDir.GetFiles().OrderByDescending(x => x.CreationTime).FirstOrDefault(); Assert.IsNotNull(logfile); var logtext = await File.ReadAllTextAsync(logfile.FullName, cancellationToken); Assert.IsFalse(String.IsNullOrWhiteSpace(logtext)); } async ValueTask TestLegacyBridgeEndpoint(CancellationToken cancellationToken) { System.Console.WriteLine("TEST: TestLegacyBridgeEndpoint"); var result = await SendTestTopic( "im_out_of_memes=1", cancellationToken); Assert.IsNotNull(result); Assert.AreEqual("all gucci", result.StringData); await CheckDMApiFail((await instanceClient.DreamDaemon.Read(cancellationToken)).ActiveCompileJob, cancellationToken); } async ValueTask TestDeploymentTrigger(CancellationToken cancellationToken) { System.Console.WriteLine("TEST: TestDeploymentTrigger"); var initialJobs = await instanceClient.Jobs.List(new PaginationSettings { RetrieveCount = 1, }, cancellationToken); var result = await SendTestTopic( "test_deployment_trigger=1", cancellationToken); Assert.IsNotNull(result); Assert.AreEqual("all gucci", result.StringData); var newJobs = await instanceClient.Jobs.List(new PaginationSettings { RetrieveCount = 1, }, cancellationToken); newJobs.RemoveAll(job => initialJobs.Any(initialJob => initialJob.Id == job.Id)); var deploymentJob = newJobs.SingleOrDefault(job => job.JobCode == JobCode.AutomaticDeployment); Assert.IsNotNull(deploymentJob); await CheckDMApiFail((await instanceClient.DreamDaemon.Read(cancellationToken)).ActiveCompileJob, cancellationToken); await instanceClient.Jobs.Cancel(deploymentJob, cancellationToken); } } }