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
25
src/AcDream.Headless/Hosting/HeadlessMonotonicTime.cs
Normal file
25
src/AcDream.Headless/Hosting/HeadlessMonotonicTime.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
|
internal static class HeadlessMonotonicTime
|
||||||
|
{
|
||||||
|
internal static long Add(
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
long timestamp,
|
||||||
|
TimeSpan duration)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||||
|
if (duration < TimeSpan.Zero)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(duration));
|
||||||
|
|
||||||
|
double ticks =
|
||||||
|
duration.TotalSeconds * timeProvider.TimestampFrequency;
|
||||||
|
if (!double.IsFinite(ticks) || ticks > long.MaxValue)
|
||||||
|
return long.MaxValue;
|
||||||
|
long delta = Math.Max(
|
||||||
|
1L,
|
||||||
|
checked((long)Math.Ceiling(ticks)));
|
||||||
|
return timestamp > long.MaxValue - delta
|
||||||
|
? long.MaxValue
|
||||||
|
: timestamp + delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Diagnostics;
|
|
||||||
using AcDream.Headless.Configuration;
|
using AcDream.Headless.Configuration;
|
||||||
using AcDream.Headless.Credentials;
|
using AcDream.Headless.Credentials;
|
||||||
using AcDream.Headless.Diagnostics;
|
using AcDream.Headless.Diagnostics;
|
||||||
|
|
@ -10,12 +9,10 @@ namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
internal sealed class HeadlessProcessHost : IDisposable
|
internal sealed class HeadlessProcessHost : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan K1TickInterval =
|
private readonly HeadlessSessionHost[] _sessions;
|
||||||
TimeSpan.FromMilliseconds(15);
|
private readonly HeadlessProcessScheduler _scheduler;
|
||||||
|
|
||||||
private readonly HeadlessSessionHost _session;
|
|
||||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||||
private readonly string _sessionId;
|
private int _disposeIndex;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
internal HeadlessProcessHost(
|
internal HeadlessProcessHost(
|
||||||
|
|
@ -30,28 +27,38 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
ArgumentNullException.ThrowIfNull(standardInput);
|
ArgumentNullException.ThrowIfNull(standardInput);
|
||||||
ArgumentNullException.ThrowIfNull(diagnostics);
|
ArgumentNullException.ThrowIfNull(diagnostics);
|
||||||
if (configuration.Sessions.Count != 1
|
if (configuration.Sessions.Count == 0)
|
||||||
|| configuration.Sessions[0] is not { } descriptor)
|
|
||||||
{
|
{
|
||||||
throw new HeadlessConfigurationException(
|
throw new HeadlessConfigurationException(
|
||||||
"Slice K1 run mode requires exactly one session.");
|
"Headless run mode requires at least one session.");
|
||||||
}
|
}
|
||||||
|
|
||||||
_sessionId = descriptor.Id;
|
|
||||||
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
|
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
|
||||||
var credentials = new HeadlessCredentialResolver(
|
var credentials = new HeadlessCredentialResolver(
|
||||||
standardInput,
|
standardInput,
|
||||||
paths.ConfigDirectory);
|
paths.ConfigDirectory);
|
||||||
HeadlessCredentialSecret secret =
|
var sessions = new List<HeadlessSessionHost>(
|
||||||
credentials.Resolve(descriptor.Id, descriptor.Credential);
|
configuration.Sessions.Count);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_session = new HeadlessSessionHost(
|
foreach (HeadlessSessionDescriptor? candidate
|
||||||
|
in configuration.Sessions)
|
||||||
|
{
|
||||||
|
HeadlessSessionDescriptor descriptor = candidate
|
||||||
|
?? throw new HeadlessConfigurationException(
|
||||||
|
"Headless run mode cannot contain a null session.");
|
||||||
|
HeadlessCredentialSecret secret =
|
||||||
|
credentials.Resolve(
|
||||||
|
descriptor.Id,
|
||||||
|
descriptor.Credential);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessions.Add(new HeadlessSessionHost(
|
||||||
descriptor,
|
descriptor,
|
||||||
secret,
|
secret,
|
||||||
_diagnostics,
|
_diagnostics,
|
||||||
sessionOperations,
|
sessionOperations,
|
||||||
timeProvider);
|
timeProvider));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
@ -60,37 +67,57 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal HeadlessSessionHost Session => _session;
|
_sessions = sessions.ToArray();
|
||||||
|
_scheduler = new HeadlessProcessScheduler(
|
||||||
|
_sessions,
|
||||||
|
timeProvider);
|
||||||
|
_disposeIndex = _sessions.Length - 1;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
for (int index = sessions.Count - 1; index >= 0; index--)
|
||||||
|
sessions[index].Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HeadlessSessionHost Session => _sessions.Length == 1
|
||||||
|
? _sessions[0]
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"The process owns more than one headless session.");
|
||||||
|
internal IReadOnlyList<HeadlessSessionHost> Sessions => _sessions;
|
||||||
|
internal HeadlessSchedulerSnapshot Scheduler =>
|
||||||
|
_scheduler.CaptureSnapshot();
|
||||||
|
|
||||||
internal async Task<HeadlessExitCode> RunAsync(
|
internal async Task<HeadlessExitCode> RunAsync(
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
RuntimeSessionStartResult started = _session.Start();
|
foreach (HeadlessSessionHost session in _sessions)
|
||||||
|
{
|
||||||
|
RuntimeSessionStartResult started = session.Start();
|
||||||
if (started.Status != RuntimeSessionStartStatus.Connected)
|
if (started.Status != RuntimeSessionStartStatus.Connected)
|
||||||
{
|
{
|
||||||
if (started.Error is { } error)
|
if (started.Error is { } error)
|
||||||
_diagnostics.Failure(_sessionId, "start", error);
|
{
|
||||||
|
_diagnostics.Failure(
|
||||||
|
session.SessionId,
|
||||||
|
"start",
|
||||||
|
error);
|
||||||
|
}
|
||||||
return HeadlessExitCode.ConnectionError;
|
return HeadlessExitCode.ConnectionError;
|
||||||
}
|
}
|
||||||
|
|
||||||
_diagnostics.Lifecycle(_sessionId, "running", _session.Runtime);
|
_diagnostics.Lifecycle(
|
||||||
long previous = Stopwatch.GetTimestamp();
|
session.SessionId,
|
||||||
|
"running",
|
||||||
|
session.Runtime);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
await _scheduler.RunAsync(cancellationToken)
|
||||||
{
|
.ConfigureAwait(false);
|
||||||
await Task.Delay(
|
|
||||||
K1TickInterval,
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
long current = Stopwatch.GetTimestamp();
|
|
||||||
double deltaSeconds =
|
|
||||||
(current - previous) / (double)Stopwatch.Frequency;
|
|
||||||
previous = current;
|
|
||||||
_session.Tick(deltaSeconds);
|
|
||||||
if (_session.IsPolicyComplete)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
when (cancellationToken.IsCancellationRequested)
|
when (cancellationToken.IsCancellationRequested)
|
||||||
|
|
@ -98,7 +125,7 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
_diagnostics.Failure(_sessionId, "tick", error);
|
_diagnostics.Failure("process", "tick", error);
|
||||||
return HeadlessExitCode.RuntimeError;
|
return HeadlessExitCode.RuntimeError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +136,11 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
return;
|
return;
|
||||||
_session.Dispose();
|
while (_disposeIndex >= 0)
|
||||||
|
{
|
||||||
|
_sessions[_disposeIndex].Dispose();
|
||||||
|
_disposeIndex--;
|
||||||
|
}
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
249
src/AcDream.Headless/Hosting/HeadlessProcessScheduler.cs
Normal file
249
src/AcDream.Headless/Hosting/HeadlessProcessScheduler.cs
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
using AcDream.Runtime;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
|
internal sealed class HeadlessProcessScheduler
|
||||||
|
{
|
||||||
|
private sealed class Slot
|
||||||
|
{
|
||||||
|
internal Slot(
|
||||||
|
HeadlessSessionHost session,
|
||||||
|
long startTimestamp,
|
||||||
|
long periodTicks)
|
||||||
|
{
|
||||||
|
Session = session;
|
||||||
|
LastTurnTimestamp = startTimestamp;
|
||||||
|
NextDeadline = AddTicks(startTimestamp, periodTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HeadlessSessionHost Session { get; }
|
||||||
|
internal long LastTurnTimestamp { get; set; }
|
||||||
|
internal long NextDeadline { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly long _periodTicks;
|
||||||
|
private readonly int _maximumCatchUpTurns;
|
||||||
|
private readonly Slot[] _slots;
|
||||||
|
private long _waitCount;
|
||||||
|
private long _turnCount;
|
||||||
|
private long _catchUpCollapseCount;
|
||||||
|
|
||||||
|
internal HeadlessProcessScheduler(
|
||||||
|
IReadOnlyList<HeadlessSessionHost> sessions,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
TimeSpan? turnPeriod = null,
|
||||||
|
int maximumCatchUpTurns = 8)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(sessions);
|
||||||
|
if (sessions.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"A headless scheduler requires at least one session.",
|
||||||
|
nameof(sessions));
|
||||||
|
}
|
||||||
|
if (maximumCatchUpTurns <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(maximumCatchUpTurns));
|
||||||
|
}
|
||||||
|
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
TimeSpan configuredTurnPeriod =
|
||||||
|
turnPeriod ?? TimeSpan.FromMilliseconds(15);
|
||||||
|
if (configuredTurnPeriod <= TimeSpan.Zero)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(turnPeriod));
|
||||||
|
_periodTicks = DurationToTimestampTicks(
|
||||||
|
_timeProvider,
|
||||||
|
configuredTurnPeriod);
|
||||||
|
_maximumCatchUpTurns = maximumCatchUpTurns;
|
||||||
|
|
||||||
|
long start = _timeProvider.GetTimestamp();
|
||||||
|
_slots = new Slot[sessions.Count];
|
||||||
|
for (int index = 0; index < sessions.Count; index++)
|
||||||
|
{
|
||||||
|
_slots[index] = new Slot(
|
||||||
|
sessions[index]
|
||||||
|
?? throw new ArgumentException(
|
||||||
|
"A headless scheduler session cannot be null.",
|
||||||
|
nameof(sessions)),
|
||||||
|
start,
|
||||||
|
_periodTicks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HeadlessSchedulerSnapshot CaptureSnapshot()
|
||||||
|
{
|
||||||
|
int activeSessionCount = 0;
|
||||||
|
for (int index = 0; index < _slots.Length; index++)
|
||||||
|
{
|
||||||
|
if (!_slots[index].Session.IsPolicyComplete)
|
||||||
|
activeSessionCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HeadlessSchedulerSnapshot(
|
||||||
|
_slots.Length,
|
||||||
|
Interlocked.Read(ref _waitCount),
|
||||||
|
Interlocked.Read(ref _turnCount),
|
||||||
|
Interlocked.Read(ref _catchUpCollapseCount),
|
||||||
|
activeSessionCount,
|
||||||
|
NextDeadline());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested
|
||||||
|
&& HasActiveSession())
|
||||||
|
{
|
||||||
|
long now = _timeProvider.GetTimestamp();
|
||||||
|
if (DispatchDue(now))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
long deadline = NextDeadline();
|
||||||
|
TimeSpan delay = deadline <= now
|
||||||
|
? TimeSpan.Zero
|
||||||
|
: _timeProvider.GetElapsedTime(now, deadline);
|
||||||
|
Interlocked.Increment(ref _waitCount);
|
||||||
|
await Task.Delay(
|
||||||
|
delay,
|
||||||
|
_timeProvider,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool DispatchDue(long nowTimestamp)
|
||||||
|
{
|
||||||
|
bool dispatched = false;
|
||||||
|
foreach (Slot slot in _slots)
|
||||||
|
{
|
||||||
|
HeadlessSessionHost session = slot.Session;
|
||||||
|
if (session.IsPolicyComplete)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (session.IsReconnectPending)
|
||||||
|
{
|
||||||
|
if (nowTimestamp < session.ReconnectDeadline)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
RuntimeSessionStartResult result =
|
||||||
|
session.CompletePendingReconnect(nowTimestamp);
|
||||||
|
if (result.Status
|
||||||
|
is not RuntimeSessionStartStatus.Connected)
|
||||||
|
{
|
||||||
|
throw result.Error
|
||||||
|
?? new InvalidOperationException(
|
||||||
|
$"Headless session reconnect completed with {result.Status}.");
|
||||||
|
}
|
||||||
|
slot.LastTurnTimestamp = nowTimestamp;
|
||||||
|
slot.NextDeadline = AddTicks(
|
||||||
|
nowTimestamp,
|
||||||
|
_periodTicks);
|
||||||
|
dispatched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowTimestamp < slot.NextDeadline)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
DispatchSessionDue(slot, nowTimestamp);
|
||||||
|
dispatched = true;
|
||||||
|
}
|
||||||
|
return dispatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchSessionDue(Slot slot, long nowTimestamp)
|
||||||
|
{
|
||||||
|
long dueCount =
|
||||||
|
1L + ((nowTimestamp - slot.NextDeadline) / _periodTicks);
|
||||||
|
int turns = (int)Math.Min(
|
||||||
|
dueCount,
|
||||||
|
_maximumCatchUpTurns);
|
||||||
|
bool collapse = dueCount > _maximumCatchUpTurns;
|
||||||
|
|
||||||
|
for (int turn = 0; turn < turns; turn++)
|
||||||
|
{
|
||||||
|
if (slot.Session.IsPolicyComplete
|
||||||
|
|| slot.Session.IsReconnectPending)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool finalCollapsedTurn =
|
||||||
|
collapse && turn == turns - 1;
|
||||||
|
long targetTimestamp = finalCollapsedTurn
|
||||||
|
? nowTimestamp
|
||||||
|
: slot.NextDeadline;
|
||||||
|
double deltaSeconds = _timeProvider
|
||||||
|
.GetElapsedTime(
|
||||||
|
slot.LastTurnTimestamp,
|
||||||
|
targetTimestamp)
|
||||||
|
.TotalSeconds;
|
||||||
|
slot.Session.Tick(deltaSeconds);
|
||||||
|
Interlocked.Increment(ref _turnCount);
|
||||||
|
slot.LastTurnTimestamp = targetTimestamp;
|
||||||
|
slot.NextDeadline = AddTicks(
|
||||||
|
targetTimestamp,
|
||||||
|
_periodTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapse)
|
||||||
|
Interlocked.Increment(ref _catchUpCollapseCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long NextDeadline()
|
||||||
|
{
|
||||||
|
long next = long.MaxValue;
|
||||||
|
foreach (Slot slot in _slots)
|
||||||
|
{
|
||||||
|
if (slot.Session.IsPolicyComplete)
|
||||||
|
continue;
|
||||||
|
long candidate = slot.Session.IsReconnectPending
|
||||||
|
? slot.Session.ReconnectDeadline
|
||||||
|
: slot.NextDeadline;
|
||||||
|
if (candidate < next)
|
||||||
|
next = candidate;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasActiveSession()
|
||||||
|
{
|
||||||
|
for (int index = 0; index < _slots.Length; index++)
|
||||||
|
{
|
||||||
|
if (!_slots[index].Session.IsPolicyComplete)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long DurationToTimestampTicks(
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
TimeSpan duration)
|
||||||
|
{
|
||||||
|
double ticks =
|
||||||
|
duration.TotalSeconds * timeProvider.TimestampFrequency;
|
||||||
|
if (!double.IsFinite(ticks)
|
||||||
|
|| ticks <= 0d
|
||||||
|
|| ticks > long.MaxValue)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(duration));
|
||||||
|
}
|
||||||
|
return Math.Max(
|
||||||
|
1L,
|
||||||
|
checked((long)Math.Ceiling(ticks)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long AddTicks(long timestamp, long ticks) =>
|
||||||
|
timestamp > long.MaxValue - ticks
|
||||||
|
? long.MaxValue
|
||||||
|
: timestamp + ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct HeadlessSchedulerSnapshot(
|
||||||
|
int SessionCount,
|
||||||
|
long WaitCount,
|
||||||
|
long TurnCount,
|
||||||
|
long CatchUpCollapseCount,
|
||||||
|
int ActiveSessionCount,
|
||||||
|
long NextDeadline);
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Diagnostics;
|
|
||||||
using AcDream.Headless.Configuration;
|
using AcDream.Headless.Configuration;
|
||||||
using AcDream.Headless.Credentials;
|
using AcDream.Headless.Credentials;
|
||||||
using AcDream.Headless.Diagnostics;
|
using AcDream.Headless.Diagnostics;
|
||||||
|
|
@ -47,12 +46,15 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
private readonly HeadlessCredentialSecret _credential;
|
private readonly HeadlessCredentialSecret _credential;
|
||||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||||
private readonly TimeSpan _reconnectQuiescence;
|
private readonly TimeSpan _reconnectQuiescence;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly HeadlessGenerationResetHost _resetHost = new();
|
private readonly HeadlessGenerationResetHost _resetHost = new();
|
||||||
private readonly IDisposable _hostLease;
|
private readonly IDisposable _hostLease;
|
||||||
private readonly IHeadlessBotPolicy _policy;
|
private readonly IHeadlessBotPolicy _policy;
|
||||||
private readonly IDisposable _policySubscription;
|
private readonly IDisposable _policySubscription;
|
||||||
private readonly LiveSessionHost _liveSession;
|
private readonly LiveSessionHost _liveSession;
|
||||||
private int _disposeStage;
|
private int _disposeStage;
|
||||||
|
private long _reconnectDeadline;
|
||||||
|
private bool _reconnectPending;
|
||||||
private ulong _stoppedGeneration;
|
private ulong _stoppedGeneration;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
|
@ -70,6 +72,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
?? throw new ArgumentNullException(nameof(credential));
|
?? throw new ArgumentNullException(nameof(credential));
|
||||||
_diagnostics = diagnostics
|
_diagnostics = diagnostics
|
||||||
?? throw new ArgumentNullException(nameof(diagnostics));
|
?? throw new ArgumentNullException(nameof(diagnostics));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
_reconnectQuiescence = reconnectQuiescence
|
_reconnectQuiescence = reconnectQuiescence
|
||||||
?? (sessionOperations is null
|
?? (sessionOperations is null
|
||||||
? TimeSpan.FromMilliseconds(2500)
|
? TimeSpan.FromMilliseconds(2500)
|
||||||
|
|
@ -92,7 +95,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
gameplay,
|
gameplay,
|
||||||
gameplay,
|
gameplay,
|
||||||
gameplay,
|
gameplay,
|
||||||
TimeProvider: timeProvider,
|
TimeProvider: _timeProvider,
|
||||||
Log: message => diagnostics.Message(
|
Log: message => diagnostics.Message(
|
||||||
descriptor.Id,
|
descriptor.Id,
|
||||||
message),
|
message),
|
||||||
|
|
@ -169,9 +172,15 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
|
|
||||||
internal GameRuntime Runtime { get; }
|
internal GameRuntime Runtime { get; }
|
||||||
internal DirectGameRuntimeCommandAdapter Commands { get; }
|
internal DirectGameRuntimeCommandAdapter Commands { get; }
|
||||||
|
internal string SessionId => _descriptor.Id;
|
||||||
internal string ActiveCharacterName { get; private set; } =
|
internal string ActiveCharacterName { get; private set; } =
|
||||||
string.Empty;
|
string.Empty;
|
||||||
internal bool IsPolicyComplete => _policy.IsComplete;
|
internal bool IsPolicyComplete => _policy.IsComplete;
|
||||||
|
internal bool IsReconnectPending => _reconnectPending;
|
||||||
|
internal long ReconnectDeadline => _reconnectPending
|
||||||
|
? _reconnectDeadline
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"The headless session has no pending reconnect.");
|
||||||
|
|
||||||
internal RuntimeSessionStartResult Start() =>
|
internal RuntimeSessionStartResult Start() =>
|
||||||
Commands.Session.Start(Runtime.Generation);
|
Commands.Session.Start(Runtime.Generation);
|
||||||
|
|
@ -182,6 +191,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
internal void Tick(double deltaSeconds)
|
internal void Tick(double deltaSeconds)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (_reconnectPending)
|
||||||
|
return;
|
||||||
_ = Runtime.Clock.Advance(deltaSeconds);
|
_ = Runtime.Clock.Advance(deltaSeconds);
|
||||||
Runtime.Session.Tick();
|
Runtime.Session.Tick();
|
||||||
Runtime.ActionOwner.CombatAttack.Tick();
|
Runtime.ActionOwner.CombatAttack.Tick();
|
||||||
|
|
@ -191,6 +202,28 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
internal RuntimeTeardownAcknowledgement Stop() =>
|
internal RuntimeTeardownAcknowledgement Stop() =>
|
||||||
Commands.Session.Stop(Runtime.Generation);
|
Commands.Session.Stop(Runtime.Generation);
|
||||||
|
|
||||||
|
internal RuntimeSessionStartResult CompletePendingReconnect(
|
||||||
|
long nowTimestamp)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (!_reconnectPending)
|
||||||
|
{
|
||||||
|
return new RuntimeSessionStartResult(
|
||||||
|
RuntimeSessionStartStatus.Inactive,
|
||||||
|
Runtime.Generation);
|
||||||
|
}
|
||||||
|
if (nowTimestamp < _reconnectDeadline)
|
||||||
|
{
|
||||||
|
return new RuntimeSessionStartResult(
|
||||||
|
RuntimeSessionStartStatus.Deferred,
|
||||||
|
Runtime.Generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
_reconnectPending = false;
|
||||||
|
_reconnectDeadline = 0L;
|
||||||
|
return StartLive(reconnect: true);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|
@ -202,6 +235,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
{
|
{
|
||||||
case 0:
|
case 0:
|
||||||
{
|
{
|
||||||
|
_reconnectPending = false;
|
||||||
|
_reconnectDeadline = 0L;
|
||||||
RuntimeTeardownAcknowledgement stopped = Stop();
|
RuntimeTeardownAcknowledgement stopped = Stop();
|
||||||
if (!stopped.IsComplete)
|
if (!stopped.IsComplete)
|
||||||
{
|
{
|
||||||
|
|
@ -266,6 +301,12 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
RuntimeSessionStartStatus.StaleGeneration,
|
RuntimeSessionStartStatus.StaleGeneration,
|
||||||
Runtime.Generation);
|
Runtime.Generation);
|
||||||
}
|
}
|
||||||
|
if (_reconnectPending)
|
||||||
|
{
|
||||||
|
return new RuntimeSessionStartResult(
|
||||||
|
RuntimeSessionStartStatus.Deferred,
|
||||||
|
Runtime.Generation);
|
||||||
|
}
|
||||||
|
|
||||||
if (reconnect)
|
if (reconnect)
|
||||||
{
|
{
|
||||||
|
|
@ -281,19 +322,32 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
"The prior headless session did not quiesce before reconnect."));
|
"The prior headless session did not quiesce before reconnect."));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACE confirms CharacterLogOff before its account/session index
|
|
||||||
// releases the retiring socket. Starting a replacement in that
|
|
||||||
// short suffix makes ACE reject both sessions as Account In Use.
|
|
||||||
// This is a one-time connection-lifecycle wait, never a steady
|
|
||||||
// scheduler sleep; K2 moves it onto the absolute-deadline host.
|
|
||||||
if (_reconnectQuiescence > TimeSpan.Zero)
|
if (_reconnectQuiescence > TimeSpan.Zero)
|
||||||
{
|
{
|
||||||
Task.Delay(_reconnectQuiescence)
|
// ACE confirms CharacterLogOff before its account/session
|
||||||
.GetAwaiter()
|
// index releases the retiring socket. The process scheduler
|
||||||
.GetResult();
|
// owns this monotonic deadline so other sessions keep moving
|
||||||
|
// and no command or worker thread blocks.
|
||||||
|
_reconnectDeadline = HeadlessMonotonicTime.Add(
|
||||||
|
_timeProvider,
|
||||||
|
_timeProvider.GetTimestamp(),
|
||||||
|
_reconnectQuiescence);
|
||||||
|
_reconnectPending = true;
|
||||||
|
_diagnostics.Lifecycle(
|
||||||
|
_descriptor.Id,
|
||||||
|
"reconnect-deferred",
|
||||||
|
Runtime);
|
||||||
|
return new RuntimeSessionStartResult(
|
||||||
|
RuntimeSessionStartStatus.Deferred,
|
||||||
|
Runtime.Generation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return StartLive(reconnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RuntimeSessionStartResult StartLive(bool reconnect)
|
||||||
|
{
|
||||||
string password = _credential.Reveal();
|
string password = _credential.Reveal();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -366,8 +420,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
OnConfirmationRequest: null,
|
OnConfirmationRequest: null,
|
||||||
OnConfirmationDone: null,
|
OnConfirmationDone: null,
|
||||||
ClientTime: () =>
|
ClientTime: () =>
|
||||||
Stopwatch.GetTimestamp()
|
Runtime.Clock.SimulationTimeSeconds),
|
||||||
/ (double)Stopwatch.Frequency),
|
|
||||||
new LiveSocialSessionBindings(
|
new LiveSocialSessionBindings(
|
||||||
Runtime.CommunicationOwner.Chat,
|
Runtime.CommunicationOwner.Chat,
|
||||||
Runtime.CommunicationOwner.TurbineChat,
|
Runtime.CommunicationOwner.TurbineChat,
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,9 @@ internal sealed class LifecycleSmokeHeadlessBotPolicy
|
||||||
RuntimeSessionStartResult reconnect =
|
RuntimeSessionStartResult reconnect =
|
||||||
commands.Session.Reconnect(view.Generation);
|
commands.Session.Reconnect(view.Generation);
|
||||||
if (reconnect.Status
|
if (reconnect.Status
|
||||||
!= RuntimeSessionStartStatus.Connected)
|
is not (
|
||||||
|
RuntimeSessionStartStatus.Connected
|
||||||
|
or RuntimeSessionStartStatus.Deferred))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Lifecycle gate reconnect was rejected with {reconnect.Status}.");
|
$"Lifecycle gate reconnect was rejected with {reconnect.Status}.");
|
||||||
|
|
|
||||||
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