using System.Net; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Hosting; using AcDream.Headless.Platform; using AcDream.Headless.Policies; using AcDream.Runtime; using AcDream.Runtime.Session; namespace AcDream.Headless.Tests; public sealed class HeadlessProcessSchedulerTests { [Fact] public void AbsoluteDeadlinesTickSessionsInStableElapsedTime() { var time = new ManualTimeProvider(); var operations = new FixtureSessionOperations(); using HeadlessSessionHost first = CreateSession("first", time, operations); using HeadlessSessionHost second = CreateSession("second", time, operations); Assert.Equal( RuntimeSessionStartStatus.Connected, first.Start().Status); Assert.Equal( RuntimeSessionStartStatus.Connected, second.Start().Status); var scheduler = new HeadlessProcessScheduler( [first, second], time); Assert.False(scheduler.DispatchDue(time.GetTimestamp())); time.Advance(TimeSpan.FromMilliseconds(15)); Assert.True(scheduler.DispatchDue(time.GetTimestamp())); Assert.Equal(1UL, first.Runtime.Clock.FrameNumber); Assert.Equal(1UL, second.Runtime.Clock.FrameNumber); Assert.Equal( 0.015d, first.Runtime.Clock.SimulationTimeSeconds, precision: 9); HeadlessSchedulerSnapshot snapshot = scheduler.CaptureSnapshot(); Assert.Equal(2, snapshot.TurnCount); Assert.Equal(0, snapshot.CatchUpCollapseCount); } [Fact] public void LongPauseUsesBoundedCatchUpAndPreservesElapsedTime() { var time = new ManualTimeProvider(); var operations = new FixtureSessionOperations(); using HeadlessSessionHost session = CreateSession("catch-up", time, operations); Assert.Equal( RuntimeSessionStartStatus.Connected, session.Start().Status); var scheduler = new HeadlessProcessScheduler( [session], time, maximumCatchUpTurns: 4); time.Advance(TimeSpan.FromSeconds(1)); Assert.True(scheduler.DispatchDue(time.GetTimestamp())); Assert.Equal(4UL, session.Runtime.Clock.FrameNumber); Assert.Equal( 1d, session.Runtime.Clock.SimulationTimeSeconds, precision: 9); Assert.Equal( 1, scheduler.CaptureSnapshot().CatchUpCollapseCount); } [Fact] public void SchedulerMeasuresDeadlineLatenessWithoutAllocatingPerTurn() { var time = new ManualTimeProvider(); var operations = new FixtureSessionOperations(); using HeadlessSessionHost first = CreateSession("late-first", time, operations); using HeadlessSessionHost second = CreateSession("late-second", time, operations); Assert.Equal( RuntimeSessionStartStatus.Connected, first.Start().Status); Assert.Equal( RuntimeSessionStartStatus.Connected, second.Start().Status); var scheduler = new HeadlessProcessScheduler( [first, second], time); time.Advance(TimeSpan.FromMilliseconds(20)); Assert.True(scheduler.DispatchDue(time.GetTimestamp())); HeadlessSchedulerSnapshot snapshot = scheduler.CaptureSnapshot(); Assert.Equal(2L, snapshot.LateDeadlineCount); Assert.Equal(5d, snapshot.MeanLatenessMilliseconds); Assert.Equal(5d, snapshot.MaximumLatenessMilliseconds); } [Fact] public void ProcessObservationUsesOneAbsoluteDeadlineAndCollapsesPauses() { var time = new ManualTimeProvider(); var operations = new FixtureSessionOperations(); using HeadlessSessionHost session = CreateSession("observation", time, operations); Assert.Equal( RuntimeSessionStartStatus.Connected, session.Start().Status); int observationCount = 0; var scheduler = new HeadlessProcessScheduler( [session], time, observation: _ => observationCount++, observationPeriod: TimeSpan.FromSeconds(1)); time.Advance(TimeSpan.FromMilliseconds(999)); Assert.False( scheduler.DispatchObservationDue(time.GetTimestamp())); time.Advance(TimeSpan.FromMilliseconds(1)); Assert.True( scheduler.DispatchObservationDue(time.GetTimestamp())); Assert.False( scheduler.DispatchObservationDue(time.GetTimestamp())); time.Advance(TimeSpan.FromMilliseconds(2500)); Assert.True( scheduler.DispatchObservationDue(time.GetTimestamp())); Assert.False( scheduler.DispatchObservationDue(time.GetTimestamp())); Assert.Equal(2, observationCount); } [Fact] public void SessionStartupRebaseExcludesSequentialConnectTime() { var time = new ManualTimeProvider(); var operations = new FixtureSessionOperations(); using HeadlessSessionHost first = CreateSession("rebase-first", time, operations); using HeadlessSessionHost second = CreateSession("rebase-second", time, operations); var scheduler = new HeadlessProcessScheduler( [first, second], time); time.Advance(TimeSpan.FromSeconds(4)); Assert.Equal( RuntimeSessionStartStatus.Connected, first.Start().Status); time.Advance(TimeSpan.FromSeconds(6)); Assert.Equal( RuntimeSessionStartStatus.Connected, second.Start().Status); scheduler.RebaseDeadlinesAfterSessionStart(); Assert.False(scheduler.DispatchDue(time.GetTimestamp())); time.Advance(TimeSpan.FromMilliseconds(15)); Assert.True(scheduler.DispatchDue(time.GetTimestamp())); HeadlessSchedulerSnapshot snapshot = scheduler.CaptureSnapshot(); Assert.Equal(2L, snapshot.TurnCount); Assert.Equal(0L, snapshot.LateDeadlineCount); Assert.Equal(0L, snapshot.CatchUpCollapseCount); Assert.Throws( scheduler.RebaseDeadlinesAfterSessionStart); } [Theory] [InlineData(0, 0)] [InlineData(1, TimeSpan.TicksPerMillisecond)] [InlineData( TimeSpan.TicksPerMillisecond - 1, TimeSpan.TicksPerMillisecond)] [InlineData( TimeSpan.TicksPerMillisecond, TimeSpan.TicksPerMillisecond)] [InlineData( TimeSpan.TicksPerMillisecond + 1, TimeSpan.TicksPerMillisecond + 1)] public void SchedulerNeverBusyLoopsOnSubMillisecondTimerDelay( long sourceTicks, long expectedTicks) { TimeSpan normalized = HeadlessProcessScheduler.NormalizeTimerDelay( TimeSpan.FromTicks(sourceTicks)); Assert.Equal(expectedTicks, normalized.Ticks); } [Fact] public async Task SystemTimerCadenceDoesNotBusyLoopBetweenTurns() { var operations = new FixtureSessionOperations(); using HeadlessSessionHost session = CreateSession( "system-timer", TimeProvider.System, operations); Assert.Equal( RuntimeSessionStartStatus.Connected, session.Start().Status); var scheduler = new HeadlessProcessScheduler([session]); scheduler.RebaseDeadlinesAfterSessionStart(); using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); try { await scheduler.RunAsync(cancellation.Token); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } HeadlessSchedulerSnapshot snapshot = scheduler.CaptureSnapshot(); Assert.InRange(snapshot.WaitCount, 1L, 1000L); Assert.True(snapshot.TurnCount > 0L); } [Fact] public void ReconnectQuiescenceIsAMonotonicDeadlineNotABlockingWait() { var time = new ManualTimeProvider(); var operations = new FixtureSessionOperations(); using HeadlessSessionHost session = CreateSession( "reconnect", time, operations, TimeSpan.FromMilliseconds(2500)); Assert.Equal( RuntimeSessionStartStatus.Connected, session.Start().Status); var scheduler = new HeadlessProcessScheduler( [session], time); RuntimeSessionStartResult reconnect = session.Reconnect(); Assert.Equal( RuntimeSessionStartStatus.Deferred, reconnect.Status); Assert.True(session.IsReconnectPending); Assert.Equal(1, operations.CreatedSessionCount); Assert.Equal(1, operations.DisposedSessionCount); time.Advance(TimeSpan.FromMilliseconds(2499)); Assert.False(scheduler.DispatchDue(time.GetTimestamp())); Assert.Equal(1, operations.CreatedSessionCount); time.Advance(TimeSpan.FromMilliseconds(1)); Assert.True(scheduler.DispatchDue(time.GetTimestamp())); Assert.False(session.IsReconnectPending); Assert.Equal(2, operations.CreatedSessionCount); Assert.True(session.Runtime.Session.IsInWorld); } [Fact] public void IdleDispatchBeforeDeadlineAllocatesNothingAndDoesNotTick() { var time = new ManualTimeProvider(); var operations = new FixtureSessionOperations(); using HeadlessSessionHost session = CreateSession("idle", time, operations); Assert.Equal( RuntimeSessionStartStatus.Connected, session.Start().Status); var scheduler = new HeadlessProcessScheduler([session], time); for (int index = 0; index < 32; index++) Assert.False(scheduler.DispatchDue(time.GetTimestamp())); long before = GC.GetAllocatedBytesForCurrentThread(); for (int index = 0; index < 100_000; index++) { if (scheduler.DispatchDue(time.GetTimestamp())) throw new InvalidOperationException("Idle session ticked."); } long allocated = GC.GetAllocatedBytesForCurrentThread() - before; Assert.Equal(0L, allocated); Assert.Equal(0UL, session.Runtime.Clock.FrameNumber); Assert.Equal(0L, scheduler.CaptureSnapshot().TurnCount); } [Fact] public async Task ProcessHostRunsMultipleSessionsOnOneScheduler() { var configuration = new HeadlessConfiguration { Version = 1, Sessions = [ Descriptor( "first", "first-stdin"), Descriptor( "second", "second-stdin"), ], }; var operations = new FixtureSessionOperations(); using var diagnostics = new StringWriter(); using var host = new HeadlessProcessHost( configuration, HeadlessPathSet.Resolve(new HeadlessPathOverrides()), new System.IO.StringReader( "first-password" + Environment.NewLine + "second-password" + Environment.NewLine), diagnostics, operations); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); HeadlessExitCode result = await host.RunAsync(cancellation.Token); Assert.Equal(HeadlessExitCode.Success, result); Assert.Equal(2, host.Sessions.Count); Assert.All( host.Sessions, session => Assert.True(session.Runtime.Session.IsInWorld)); Assert.Equal(2, operations.CreatedSessionCount); Assert.DoesNotContain( "password", diagnostics.ToString(), StringComparison.Ordinal); Assert.Contains( "\"kind\":\"resources\"", diagnostics.ToString(), StringComparison.Ordinal); Assert.Contains( "\"configuredCount\":2", diagnostics.ToString(), StringComparison.Ordinal); host.Dispose(); Assert.Contains( "\"state\":\"disposed\"", diagnostics.ToString(), StringComparison.Ordinal); Assert.Contains( "\"convergedRuntimeCount\":2", diagnostics.ToString(), StringComparison.Ordinal); } [Fact] public void ThrowingPolicyQuarantinesOnlyItsOwnSession() { var time = new ManualTimeProvider(); var operations = new FixtureSessionOperations(); var throwing = new ThrowingPolicy(); var healthy = new CountingPolicy(); using HeadlessSessionHost first = CreateSession( "fault", time, operations, policyOverride: throwing); using HeadlessSessionHost second = CreateSession( "healthy", time, operations, policyOverride: healthy); Assert.Equal( RuntimeSessionStartStatus.Connected, first.Start().Status); Assert.Equal( RuntimeSessionStartStatus.Connected, second.Start().Status); var scheduler = new HeadlessProcessScheduler( [first, second], time); time.Advance(TimeSpan.FromMilliseconds(15)); Assert.True(scheduler.DispatchDue(time.GetTimestamp())); Assert.True(first.IsFaulted); Assert.IsType(first.Fault); Assert.False(second.IsFaulted); Assert.Equal(1, healthy.TickCount); HeadlessSchedulerSnapshot snapshot = scheduler.CaptureSnapshot(); Assert.Equal(1, snapshot.FaultedSessionCount); Assert.Equal(1, snapshot.ActiveSessionCount); time.Advance(TimeSpan.FromMilliseconds(15)); Assert.True(scheduler.DispatchDue(time.GetTimestamp())); Assert.Equal(2, healthy.TickCount); Assert.Equal(1UL, first.Runtime.Clock.FrameNumber); Assert.Equal(2UL, second.Runtime.Clock.FrameNumber); } private static HeadlessSessionHost CreateSession( string id, TimeProvider time, ILiveSessionOperations operations, TimeSpan? reconnectQuiescence = null, IHeadlessBotPolicy? policyOverride = null) { var credential = new HeadlessCredentialSecret( $"{id}-credential", "password"); try { return new HeadlessSessionHost( Descriptor(id, $"{id}-credential"), credential, new HeadlessDiagnosticWriter(TextWriter.Null), operations, time, reconnectQuiescence, policyOverride: policyOverride); } catch { credential.Dispose(); throw; } } private static HeadlessSessionDescriptor Descriptor( string id, string credentialReference) => new() { Id = id, Endpoint = new HeadlessEndpointDescriptor { Host = "127.0.0.1", Port = 9000, }, Account = $"{id}-account", Character = new HeadlessCharacterSelector { Index = 0, }, Policy = new HeadlessBotPolicyDescriptor { Id = "idle", }, Credential = new HeadlessCredentialReference { Provider = HeadlessCredentialProviderKind.StandardInput, Reference = credentialReference, }, }; private sealed class ManualTimeProvider : TimeProvider { private long _timestamp; public override long TimestampFrequency => TimeSpan.TicksPerSecond; public override long GetTimestamp() => _timestamp; internal void Advance(TimeSpan duration) => _timestamp = checked(_timestamp + duration.Ticks); } private sealed class FixtureSessionOperations : ILiveSessionOperations { public int CreatedSessionCount { get; private set; } public int DisposedSessionCount { get; private set; } public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); public WorldSession CreateSession(IPEndPoint endpoint) { CreatedSessionCount++; return new WorldSession(endpoint); } public void Connect( WorldSession session, string user, string password) { } public CharacterList.Parsed GetCharacters( WorldSession session) => new( 0u, [ new CharacterList.Character( 0x50000001u, "Headless", 0u), ], [], 11, "account", true, true); public void EnterWorld( WorldSession session, int activeCharacterIndex) { } public void Tick(WorldSession session) { } public void DisposeSession(WorldSession session) { DisposedSessionCount++; session.Dispose(); } } private abstract class FixturePolicy : IHeadlessBotPolicy { public virtual bool IsComplete => false; public abstract void Tick( IGameRuntimeView view, IGameRuntimeCommands commands); public void OnLifecycle(in RuntimeLifecycleDelta delta) { } public void OnCommand(in RuntimeCommandDelta delta) { } public void OnEntity(in RuntimeEntityDelta delta) { } public void OnInventory(in RuntimeInventoryDelta delta) { } public void OnChat(in RuntimeChatDelta delta) { } public void OnMovement(in RuntimeMovementDelta delta) { } public void OnPortal(in RuntimePortalDelta delta) { } public void OnCombat(in RuntimeCombatDelta delta) { } public void Dispose() { } } private sealed class ThrowingPolicy : FixturePolicy { public override void Tick( IGameRuntimeView view, IGameRuntimeCommands commands) => throw new InvalidOperationException("injected policy fault"); } private sealed class CountingPolicy : FixturePolicy { public int TickCount { get; private set; } public override void Tick( IGameRuntimeView view, IGameRuntimeCommands commands) => TickCount++; } }