using System.Text.Json; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Session; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public sealed class StartupConnectionRecoveryTests { private static readonly DateTimeOffset Now=DateTimeOffset.UtcNow; private static readonly GameSessionTarget Client=new(10,100,"game","launcher",false); private static NativeSessionFrame Failure(long sequence=1,double seconds=0)=>new(sequence,Now.AddSeconds(seconds),"FailedToConnect","FailedToConnect",[]); private static TimeSpan Immediate(int count)=>TimeSpan.Zero; [Test]public void FailureNeedsAdvancingFreshStableFramesAndNeverGuessesLoadingOrUnknownStates() { var observer=new NativeConnectionFailureObserver();Check.That(!observer.Observe(Failure(),Now)); Check.That(!observer.Observe(Failure(),Now.AddMilliseconds(100))); for(var index=1;index<20;index++)Check.That(!observer.Observe(Failure(index+1,index*.1),Now.AddSeconds(index*.1))); Check.That(observer.Observe(Failure(21,2),Now.AddSeconds(2))); foreach(var state in new[]{"EnteringLocation","SaveGameOnEnterLocation","MultiplayerLoad","Unknown",""}) { var next=new NativeConnectionFailureObserver{ConfirmationPeriod=TimeSpan.FromMilliseconds(1)}; Check.That(!next.Observe(Failure() with{State=state,NextState=state},Now)); Check.That(!next.Observe(Failure(2,.01) with{State=state,NextState=state},Now.AddMilliseconds(10))); } foreach(var invalid in new[]{Failure() with{Available=false},Failure() with{NextState="MainMenu"},Failure() with{ObservedAt=Now.AddSeconds(-2)}}) Check.That(!new NativeConnectionFailureObserver{ConfirmationPeriod=TimeSpan.Zero}.Observe(invalid,Now)); } private static JsonElement Menu(string state="FailedToConnect",bool available=true)=>JsonSerializer.SerializeToElement(new {schema=1,name="XFEEDToolBox.Session",processId=10,gameSha256="hash",available,sequence=1,observedAtUnixMilliseconds=Now.ToUnixTimeMilliseconds(),state,nextState=state,actions=Array.Empty()}); private static JsonElement Navigation(bool inGame=false,bool pose=false,bool heat=false,string hash="hash")=>JsonSerializer.SerializeToElement(new {schema=1,name="XFEEDToolBox.Navigation",processId=10,gameSha256=hash,verified=true,inGame,continuousPose=pose,continuousHeat=heat,ready=true}); [Test]public void AnInitializedServiceCanBeFailedWhileAnyLiveFlightOrWrongIdentityRefusesTermination() { Check.That(NativeSessionStartupReadiness.FailedWithoutLiveFlight(Menu(),Navigation(),10,"hash",Now)); foreach(var navigation in new[]{Navigation(inGame:true),Navigation(pose:true),Navigation(heat:true),Navigation(hash:"other")}) Check.That(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(Menu(),navigation,10,"hash",Now)); foreach(var menu in new[]{Menu("EnteringLocation"),Menu(available:false),Menu("Unknown")}) Check.That(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(menu,Navigation(),10,"hash",Now)); Check.That(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(Menu(),Navigation(),10,"hash",Now.AddSeconds(1))); } [Test]public void ALoadGameEventAndOldCargoLocationNeverProveTheNewCockpitReady() { var frame=new NativeSessionFrame(1,Now,"InGame","InGame",[]); var player=new PlayerState{InGame=true,LoadGameAt=Now,LocationAt=Now.AddMinutes(-1),CargoAt=Now.AddMinutes(-1),CargoReliable=true,SystemName="Old station",Navigation=new(){CurrentSystemAddress=42}}; Check.That(!NativeSessionStartupReadiness.Ready(player,frame,Now,Now.AddMinutes(-1))); player=player with{LocationAt=Now,CargoAt=Now};Check.That(NativeSessionStartupReadiness.Ready(player,frame,Now,Now.AddMinutes(-1))); Check.That(!NativeSessionStartupReadiness.Ready(player,Failure(),Now,Now.AddMinutes(-1))); Check.That(!NativeSessionStartupReadiness.Ready(player with{CargoReliable=false},frame,Now)); } [Test]public async Task OwnedConnectionFailureRetriesTheWorkflowWithoutReplayingAnyOtherException() { var attempts=0;var recoveries=0; await StartupConnectionRecovery.RunAsync((own,token)=> {own(Client);if(++attempts==1)throw new NativeSessionConnectionFailedException(Failure(),Client);return Task.CompletedTask;}, (client,error,owned,count,token)=>{Check.That(owned&&client==Client&&count==1);recoveries++;return Task.CompletedTask;},default,retryDelay:Immediate); Check.Equal(2,attempts);Check.Equal(1,recoveries); foreach(var error in new Exception[]{new IOException("unknown I/O"),new TimeoutException("normal loading"),new InvalidDataException("identity changed")}) { var count=0;try{await StartupConnectionRecovery.RunAsync((own,token)=>{count++;own(Client);throw error;}, (_,_,_,_,_)=>throw new Exception("Must not recover"),default,retryDelay:Immediate);}catch(Exception caught){Check.That(ReferenceEquals(error,caught));} Check.Equal(1,count); } } [Test]public async Task ExistingClientsNeedExplicitAdoptionAndTheRecoveryBudgetIsFinite() { var attempts=0;var recoveries=0; try{await StartupConnectionRecovery.RunAsync((_,_)=>{attempts++;throw new NativeSessionConnectionFailedException(Failure(),Client);}, (_,_,_,_,_)=>{recoveries++;return Task.CompletedTask;},default,retryDelay:Immediate);}catch(NativeSessionConnectionFailedException){} Check.Equal(1,attempts);Check.Equal(0,recoveries);attempts=0; try{await StartupConnectionRecovery.RunAsync((_,_)=>{attempts++;throw new NativeSessionConnectionFailedException(Failure(),Client);}, (_,_,owned,_,_)=>{Check.That(!owned);recoveries++;return Task.CompletedTask;},default,allowVerifiedExisting:true,retryDelay:Immediate);}catch(StartupConnectionRecoveryExhaustedException){} Check.Equal(3,attempts);Check.Equal(2,recoveries); } [Test]public async Task CancellationAndTheGlobalDeadlineAlwaysWinBeforeAnotherRestart() { using var stop=new CancellationTokenSource();var recoveries=0; try{await StartupConnectionRecovery.RunAsync((own,_)=>{own(Client);stop.Cancel();throw new NativeSessionConnectionFailedException(Failure(),Client);}, (_,_,_,_,_)=>{recoveries++;return Task.CompletedTask;},stop.Token,retryDelay:Immediate);}catch(NativeSessionConnectionFailedException){} Check.Equal(0,recoveries); var timed=false; try{await StartupConnectionRecovery.RunAsync(async (_,token)=>await Task.Delay(10000,token),(_,_,_,_,_)=>Task.CompletedTask, default,totalTimeout:TimeSpan.FromMilliseconds(20));}catch(TimeoutException){timed=true;} Check.That(timed); } [Test]public async Task LongRunningSupervisionOnlyWaitsForAnExhaustedTypedConnectionFailure() { var attempts=0;var waits=0; await StartupConnectionRecovery.SuperviseAsync(_=> {if(++attempts==1)throw new StartupConnectionRecoveryExhaustedException(new(Failure(),Client),2);return Task.CompletedTask;}, (_,count,_)=>{waits++;Check.Equal(1,count);return Task.CompletedTask;},default); Check.Equal(2,attempts);Check.Equal(1,waits); foreach(var error in new Exception[]{new TimeoutException("unknown loading"),new InvalidDataException("identity"),new NativeSessionConnectionFailedException(Failure())}) { try{await StartupConnectionRecovery.SuperviseAsync(_=>throw error,(_,_,_)=>throw new Exception("Must not supervise this failure"),default);} catch(Exception caught){Check.That(ReferenceEquals(caught,error));} } using var stop=new CancellationTokenSource();var cancelled=false; try{await StartupConnectionRecovery.SuperviseAsync(_=>throw new StartupConnectionRecoveryExhaustedException(new(Failure(),Client),2), async(_,_,token)=>{stop.Cancel();await Task.Delay(1000,token);},stop.Token);}catch(OperationCanceledException){cancelled=true;} Check.That(cancelled); } [Test]public async Task AChangedMenuIdentityOrCancellationAfterEvidenceWritesNeverTerminatesTheClient() { foreach(var cause in new[]{"changed-menu","identity","cancel","unchanged"}) { using var stop=new CancellationTokenSource();var terminations=0;var rejected=false; try { await StartupConnectionRecovery.TerminateWithFreshEvidenceAsync(_=> { if(cause=="identity")throw new InvalidDataException("PID/start/hash changed"); if(cause=="cancel")stop.Cancel(); return Task.FromResult(cause!="changed-menu"); },_=>{terminations++;return Task.CompletedTask;},stop.Token); } catch(InvalidDataException)when(cause is "changed-menu" or "identity"){rejected=true;} catch(OperationCanceledException)when(cause=="cancel"){rejected=true;} Check.Equal(cause=="unchanged"?1:0,terminations);Check.Equal(cause!="unchanged",rejected); } } }