Merge branch 'claude/quirky-payne-46a2e6' into claude/latest-commits-cb0c8f

This commit is contained in:
Erik 2026-08-10 22:20:11 +02:00
commit 629d83411d
5 changed files with 274 additions and 32 deletions

View file

@ -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;