Merge branch 'claude/quirky-payne-46a2e6' into claude/latest-commits-cb0c8f
This commit is contained in:
commit
629d83411d
5 changed files with 274 additions and 32 deletions
|
|
@ -1,3 +1,5 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
|
@ -198,7 +200,7 @@ public sealed class HeadlessProcessSchedulerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SystemTimerCadenceDoesNotBusyLoopBetweenTurns()
|
||||
public void SystemTimerCadenceDoesNotBusyLoopBetweenTurns()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using HeadlessSessionHost session =
|
||||
|
|
@ -214,14 +216,10 @@ public sealed class HeadlessProcessSchedulerTests
|
|||
using var cancellation =
|
||||
new CancellationTokenSource(TimeSpan.FromMilliseconds(250));
|
||||
|
||||
try
|
||||
{
|
||||
await scheduler.RunAsync(cancellation.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
when (cancellation.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
// Run executes on the calling thread and returns normally when
|
||||
// the token cancels (#368 moved thread ownership to the process
|
||||
// host; the scheduler seam itself is synchronous).
|
||||
scheduler.Run(cancellation.Token);
|
||||
|
||||
HeadlessSchedulerSnapshot snapshot =
|
||||
scheduler.CaptureSnapshot();
|
||||
|
|
@ -440,6 +438,63 @@ public sealed class HeadlessProcessSchedulerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessHostRunsStartAndEveryTickOnOneDedicatedUpdateThread()
|
||||
{
|
||||
// #368: Runtime's gameplay owners (RuntimePhysicsState's collision
|
||||
// generations foremost) require one update thread for a session's
|
||||
// whole lifetime — the graphical host provides its game-loop thread;
|
||||
// the headless host must provide an equivalent. Start() (the live
|
||||
// connect transaction) and every subsequent tick must share one
|
||||
// thread that is NOT the RunAsync caller's, across real timer waits.
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
Descriptor(
|
||||
"update-thread",
|
||||
"update-thread-stdin"),
|
||||
],
|
||||
};
|
||||
var operations = new ThreadRecordingSessionOperations();
|
||||
using var diagnostics = new StringWriter();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
|
||||
new System.IO.StringReader(
|
||||
"update-thread-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
int callerThread = Environment.CurrentManagedThreadId;
|
||||
Task<HeadlessExitCode> run = host.RunAsync(cancellation.Token);
|
||||
// Occupy the calling thread until ticks have crossed several timer
|
||||
// waits — pre-fix, resumptions migrate to other pool threads while
|
||||
// this thread is provably unavailable to them. The await happens
|
||||
// only after the measurement window closes.
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
while (operations.TickCount < 5
|
||||
&& stopwatch.Elapsed < TimeSpan.FromSeconds(10))
|
||||
{
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
cancellation.Cancel();
|
||||
HeadlessExitCode result = await run;
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
Assert.True(
|
||||
operations.TickCount >= 5,
|
||||
$"Expected at least 5 ticks, observed {operations.TickCount}.");
|
||||
int updateThread = operations.ConnectThreadId;
|
||||
Assert.NotEqual(0, updateThread);
|
||||
Assert.NotEqual(callerThread, updateThread);
|
||||
Assert.All(
|
||||
operations.TickThreadIds,
|
||||
id => Assert.Equal(updateThread, id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowingPolicyQuarantinesOnlyItsOwnSession()
|
||||
{
|
||||
|
|
@ -609,6 +664,60 @@ public sealed class HeadlessProcessSchedulerTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class ThreadRecordingSessionOperations
|
||||
: ILiveSessionOperations
|
||||
{
|
||||
private int _connectThreadId;
|
||||
private readonly ConcurrentQueue<int> _tickThreadIds = new();
|
||||
|
||||
internal int ConnectThreadId =>
|
||||
Volatile.Read(ref _connectThreadId);
|
||||
internal int TickCount => _tickThreadIds.Count;
|
||||
internal IReadOnlyCollection<int> TickThreadIds => _tickThreadIds;
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||
new(IPAddress.Loopback, port);
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint) =>
|
||||
new(endpoint);
|
||||
|
||||
public void Connect(
|
||||
WorldSession session,
|
||||
string user,
|
||||
string password) =>
|
||||
Volatile.Write(
|
||||
ref _connectThreadId,
|
||||
Environment.CurrentManagedThreadId);
|
||||
|
||||
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) =>
|
||||
_tickThreadIds.Enqueue(Environment.CurrentManagedThreadId);
|
||||
|
||||
public void DisposeSession(WorldSession session) =>
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
private abstract class FixturePolicy : IHeadlessBotPolicy
|
||||
{
|
||||
public virtual bool IsComplete => false;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue