feat(headless): add deterministic multi-session scheduler
This commit is contained in:
parent
b299e3738e
commit
7e8acb74dd
6 changed files with 694 additions and 54 deletions
280
tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
Normal file
280
tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
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.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 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 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 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);
|
||||
}
|
||||
|
||||
private static HeadlessSessionHost CreateSession(
|
||||
string id,
|
||||
TimeProvider time,
|
||||
ILiveSessionOperations operations,
|
||||
TimeSpan? reconnectQuiescence = null)
|
||||
{
|
||||
var credential = new HeadlessCredentialSecret(
|
||||
$"{id}-credential",
|
||||
"password");
|
||||
try
|
||||
{
|
||||
return new HeadlessSessionHost(
|
||||
Descriptor(id, $"{id}-credential"),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations,
|
||||
time,
|
||||
reconnectQuiescence);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue