using System; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using NUnit.Framework; using NUnit.Framework.Internal; using Robust.Client; using Robust.Shared; using Robust.Shared.Exceptions; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Utility; namespace Robust.UnitTesting.Pool; // This partial file contains logic related to recycling & disposing test pairs. public partial class TestPair { private void ReportErrorLogs() { using (Assert.EnterMultipleScope()) { foreach (var log in ServerLogHandler.FailingLogs) Assert.Fail(log); foreach (var log in ClientLogHandler.FailingLogs) Assert.Fail(log); } } private async Task OnDirtyDispose() { var usageTime = Watch.Elapsed; Watch.Restart(); await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Test gave back pair {Id} in {usageTime.TotalMilliseconds} ms"); Kill(); var disposeTime = Watch.Elapsed; await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Disposed pair {Id} in {disposeTime.TotalMilliseconds} ms"); ReportErrorLogs(); // Test pairs should only dirty dispose if they are failing. If they are not failing, this probably happened // because someone forgot to clean-return the pair. Assert.Warn("Test was dirty-disposed."); } /// /// This method gets called before any test pair gets returned to the pool. /// protected virtual async Task Cleanup() { if (TestMap != null) { await Server.WaitPost(() => Server.EntMan.DeleteEntity(TestMap.MapUid)); TestMap = null; } } private async Task OnCleanDispose() { await Server.WaitIdleAsync(); await Client.WaitIdleAsync(); await Cleanup(); await Server.Cleanup(); await Client.Cleanup(); await RevertModifiedCvars(); var usageTime = Watch.Elapsed; Watch.Restart(); await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Test borrowed pair {Id} for {usageTime.TotalMilliseconds} ms"); // Let any last minute failures the test cause happen. await ReallyBeIdle(); if (!Settings.Destructive) { if (Client.IsAlive == false) throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the client in pair {Id}:", Client.UnhandledException); if (Server.IsAlive == false) throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the server in pair {Id}:", Server.UnhandledException); } if (Settings.MustNotBeReused) { Kill(); await ReallyBeIdle(); await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Clean disposed in {Watch.Elapsed.TotalMilliseconds} ms"); return; } try { var sRuntimeLog = Server.Resolve(); if (sRuntimeLog.ExceptionCount > 0) throw new Exception($"{nameof(CleanReturnAsync)}: Server logged exceptions"); var cRuntimeLog = Client.Resolve(); if (cRuntimeLog.ExceptionCount > 0) throw new Exception($"{nameof(CleanReturnAsync)}: Client logged exceptions"); ReportErrorLogs(); } catch (Exception) { Kill(); await ReallyBeIdle(); await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Dirty disposed in {Watch.Elapsed.TotalMilliseconds} ms"); Assert.Warn("Test was dirty-disposed."); throw; } var returnTime = Watch.Elapsed; await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: PoolManager took {returnTime.TotalMilliseconds} ms to put pair {Id} back into the pool"); State = PairState.Ready; } [HandlesResourceDisposal] public async ValueTask CleanReturnAsync() { if (State != PairState.InUse) throw new Exception($"{nameof(CleanReturnAsync)}: Unexpected state. Pair: {Id}. State: {State}."); await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Return of pair {Id} started"); State = PairState.CleanDisposed; try { await OnCleanDispose(); } finally { DebugTools.Assert(State is PairState.Dead or PairState.Ready); ClearContext(); Manager.Return(this); } } public async ValueTask DisposeAsync() { switch (State) { case PairState.Dead: case PairState.Ready: break; case PairState.InUse: await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Dirty return of pair {Id} started"); try { await OnDirtyDispose(); } finally { ClearContext(); Manager.Return(this); } break; default: throw new Exception($"{nameof(DisposeAsync)}: Unexpected state. Pair: {Id}. State: {State}."); } } /// /// This method gets called when a previously used test pair is being retrieved from the pool. /// Note that in some instances this method may get skipped (See ). /// public async Task RecycleInternal(PairSettings settings, TextWriter testOut) { Watch.Restart(); await testOut.WriteLineAsync($"Recycling..."); await RunTicksSync(1); // Disconnect the client if they are connected. if (Client.CNetMan.IsConnected) { await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Disconnecting client."); await Client.WaitPost(() => Client.CNetMan.ClientDisconnect("Test pooling cleanup disconnect")); await RunTicksSync(1); } await Recycle(settings, testOut); ClearModifiedCvars(); // (possibly) reconnect the client if (settings.Connected) { await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Connecting client"); await Client.Connect(Server); } Settings = default!; await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Idling"); await ReallyBeIdle(); await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Done recycling"); } /// /// This method gets called when a previously used test pair is being retrieved from the pool. /// If the next settings are compatible with the previous settings, this step may get skipped (See ). /// In general, this method should also call . /// protected virtual async Task Recycle(PairSettings next, TextWriter testOut) { //Apply Cvars await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Setting CVar "); await ApplySettings(next); await RunTicksSync(1); // flush server entities. await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Flushing server entities"); await Server.WaitPost(() => Server.EntMan.FlushEntities()); await RunTicksSync(1); } /// /// Apply settings to the test pair. This method is always called when a pair is fetched from the pool. There should /// be no need to apply settings that require a pair to be recycled, as in those cases the /// should have caused to be invoked, which should /// already have applied those settings. /// public async Task ApplySettings(PairSettings next) { await ApplySettings(Client, next); await ApplySettings(Server, next); } /// [MustCallBase] protected internal virtual async Task ApplySettings(IIntegrationInstance instance, PairSettings next) { if (instance.CfgMan.IsCVarRegistered(CVars.NetInterp.Name)) await instance.WaitPost(() => instance.CfgMan.SetCVar(CVars.NetInterp, !next.DisableInterpolate)); } /// /// Invoked after a test pair has been recycled to validate that the settings have been properly applied. /// [MustCallBase] public virtual void ValidateSettings(PairSettings settings) { var netMan = Client.Resolve(); Assert.That(netMan.IsConnected, Is.EqualTo(settings.Connected)); if (!settings.Connected) return; var baseClient = Client.Resolve(); var cPlayer = Client.Resolve(); var sPlayer = Server.Resolve(); Assert.That(baseClient.RunLevel, Is.EqualTo(ClientRunLevel.InGame)); Assert.That(sPlayer.Sessions.Length, Is.EqualTo(1)); var session = sPlayer.Sessions.Single(); Assert.That(cPlayer.LocalSession?.UserId, Is.EqualTo(session.UserId)); } }