using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using NUnit.Framework.Internal; using Robust.Shared; using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Robust.UnitTesting.Pool; public abstract class BasePoolManager { /// /// Stores a global count for pair IDs, so they're unique across the entire run regardless of how many pools we /// create and use. /// /// This ensures things like gravestone files are truly unique. /// internal static int PairIdCounter = 0; internal abstract void Return(ITestPair pair); public abstract Assembly[] ClientAssemblies { get; } public abstract Assembly[] ServerAssemblies { get; } public readonly List TestPrototypes = new(); // default cvar overrides to use when creating test pairs. public readonly List<(string cvar, string value)> DefaultCvars = [ (CVars.NetPVS.Name, "false"), (CVars.ThreadParallelCount.Name, "1"), (CVars.ReplayClientRecordingEnabled.Name, "false"), (CVars.ReplayServerRecordingEnabled.Name, "false"), (CVars.NetBufferSize.Name, "0"), (CVars.UIObeyUpdateLimits.Name, "false") ]; } [Virtual] public class PoolManager : BasePoolManager where TPair : class, ITestPair, new() { private readonly Lock _pairLock = new(); private bool _initialized; /// /// Set of all pairs, and whether they are currently in-use /// protected readonly Dictionary Pairs = new(); private bool _dead; private Exception? _poolFailureReason; private Assembly[] _clientAssemblies = []; private Assembly[] _serverAssemblies = []; public override Assembly[] ClientAssemblies => _clientAssemblies; public override Assembly[] ServerAssemblies => _serverAssemblies; /// /// Initialize the pool manager. Override this to configure what assemblies should get loaded. /// public virtual void Startup(params Assembly[] extraAssemblies) { // By default, load no content assemblies, but make both server & client load the testing assembly. Startup([], [], extraAssemblies); } protected void Startup(Assembly[] clientAssemblies, Assembly[] serverAssemblies, Assembly[] sharedAssemblies) { if (_initialized) throw new InvalidOperationException("Already initialized"); DebugTools.AssertEqual(clientAssemblies.Intersect(sharedAssemblies).Count(), 0); DebugTools.AssertEqual(serverAssemblies.Intersect(sharedAssemblies).Count(), 0); DebugTools.AssertEqual(serverAssemblies.Intersect(clientAssemblies).Count(), 0); foreach (var assembly in sharedAssemblies) { DiscoverTestPrototypes(assembly); } foreach (var assembly in clientAssemblies) { DiscoverTestPrototypes(assembly); } foreach (var assembly in serverAssemblies) { DiscoverTestPrototypes(assembly); } _initialized = true; _clientAssemblies = clientAssemblies.Concat(sharedAssemblies).ToArray(); _serverAssemblies = serverAssemblies.Concat(sharedAssemblies).ToArray(); } /// /// This shuts down the pool, and disposes all the server/client pairs. /// This is a one time operation to be used when the testing program is exiting. /// public void Shutdown() { List localPairs; lock (_pairLock) { if (_dead) return; _dead = true; localPairs = Pairs.Keys.ToList(); } foreach (var pair in localPairs) { pair.Kill(); } _initialized = false; TestPrototypes.Clear(); } protected virtual string GetDefaultTestName(ITestContextLike testContext) { return testContext.FullName.Replace("Robust.UnitTesting.", ""); } public string DeathReport() { lock (_pairLock) { var builder = new StringBuilder(); var pairs = Pairs.Keys.OrderBy(pair => pair.Id); foreach (var pair in pairs) { var borrowed = Pairs[pair]; builder.AppendLine($"Pair {pair.Id}, Tests Run: {pair.ExtendedTestHistory.Count}, Borrowed: {borrowed}"); for (var i = 0; i < pair.ExtendedTestHistory.Count; i++) { builder.AppendLine($"#{i}: {pair.ExtendedTestHistory[i]}"); } } return builder.ToString(); } } public virtual PairSettings DefaultSettings => new(); public async Task GetPair( PairSettings? settings = null, ITestContextLike? testContext = null) { if (!_initialized) throw new InvalidOperationException($"Pool manager has not been initialized"); settings ??= DefaultSettings; testContext ??= new NUnitTestContextWrap(TestContext.CurrentContext, TestContext.Out); var testOut = testContext.Out; DieIfPoolFailure(); var currentTestName = settings.TestName ?? GetDefaultTestName(testContext); var watch = new Stopwatch(); await testOut.WriteLineAsync($"{nameof(GetPair)}: Called by test {currentTestName}"); TPair? pair = null; watch.Start(); if (settings.MustBeNew) { await testOut.WriteLineAsync( $"{nameof(GetPair)}: Creating pair, because settings of pool settings"); pair = await CreateServerClientPair(settings, testContext, testOut); } else { await testOut.WriteLineAsync($"{nameof(GetPair)}: Looking in pool for a suitable pair"); pair = GrabOptimalPair(settings); if (pair != null) { pair.ActivateContext(testOut); await testOut.WriteLineAsync($"{nameof(GetPair)}: Suitable pair found (Pair {pair.Id})"); if (pair.Settings.CanFastRecycle(settings)) { await testOut.WriteLineAsync($"{nameof(GetPair)}: Cleanup not needed, Skipping cleanup of pair"); await pair.ApplySettings(settings); } else { await testOut.WriteLineAsync($"{nameof(GetPair)}: Cleaning existing pair"); await pair.RecycleInternal(settings, testOut); } await pair.RunTicksSync(5); await pair.SyncTicks(targetDelta: 1); } else { await testOut.WriteLineAsync($"{nameof(GetPair)}: Creating a new pair, no suitable pair found in pool"); pair = await CreateServerClientPair(settings, testContext, testOut); } } await testOut.WriteLineAsync($"{nameof(GetPair)}: Retrieving pair {pair.Id} from pool took {watch.Elapsed.TotalMilliseconds} ms"); await pair.AddToHistory(currentTestName); pair.ValidateSettings(settings); pair.ClearModifiedCvars(); pair.Settings = settings; pair.SetupSeed(); await testOut.WriteLineAsync($"{nameof(GetPair)}: Returning pair {pair.Id} with client/server seeds: {pair.ClientSeed}/{pair.ServerSeed}"); pair.Watch.Restart(); return pair; } private TPair? GrabOptimalPair(PairSettings poolSettings) { lock (_pairLock) { TPair? fallback = null; foreach (var pair in Pairs.Keys) { if (Pairs[pair]) continue; if (!pair.Settings.CanFastRecycle(poolSettings)) { fallback = pair; continue; } pair.Use(); Pairs[pair] = true; return pair; } if (fallback == null) return null; fallback.Use(); Pairs[fallback!] = true; return fallback; } } /// /// Used by TestPair after checking the server/client pair, Don't use this. /// internal override void Return(ITestPair pair) { lock (_pairLock) { if (pair.State == PairState.Dead) Pairs.Remove((TPair)pair); else if (pair.State == PairState.Ready) Pairs[(TPair) pair] = false; else throw new InvalidOperationException($"Attempted to return a pair in an invalid state. Pair: {pair.Id}. State: {pair.State}."); } } private void DieIfPoolFailure() { if (_poolFailureReason != null) { // If the _poolFailureReason is not null, we can assume at least one test failed. // So we say inconclusive so we don't add more failed tests to search through. Assert.Inconclusive(@$" In a different test, the pool manager had an exception when trying to create a server/client pair. Instead of risking that the pool manager will fail at creating a server/client pairs for every single test, we are just going to end this here to save a lot of time. This is the exception that started this:\n {_poolFailureReason}"); } if (_dead) { // If Pairs is null, we ran out of time, we can't assume a test failed. // So we are going to tell it all future tests are a failure. Assert.Fail("The pool was shut down"); } } private async Task CreateServerClientPair(PairSettings settings, ITestContextLike context, TextWriter testOut) { try { var id = Interlocked.Increment(ref PairIdCounter); var pair = new TPair(); TextWriter? gravestone = null; if (context is NUnitTestContextWrap) gravestone = File.CreateText($"{TestContext.CurrentContext.WorkDirectory}/gravestone-{id}.txt"); await pair.Init(id, this, settings, testOut, gravestone); pair.Use(); await pair.RunTicksSync(5); await pair.SyncTicks(targetDelta: 1); return pair; } catch (Exception ex) { _poolFailureReason = ex; throw; } } private void DiscoverTestPrototypes(Assembly assembly) { const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly; foreach (var type in assembly.GetTypes()) { foreach (var field in type.GetFields(flags)) { if (!field.HasCustomAttribute()) continue; var val = field.GetValue(null); if (val is not string str) throw new Exception($"{nameof(TestPrototypesAttribute)} is only valid on non-null string fields"); TestPrototypes.Add(str); } } } }