fix(headless): #368 — one dedicated update thread owns the session lifecycle; the scheduler loop no longer migrates across Task.Delay resumptions

Runtime's contract is ONE update thread per session for its whole
lifetime — RuntimePhysicsState.EnsureCollisionMutationThread enforces it
for collision generations (bind-first-mutator, refuse migration), and
the entity directory, physics publication, and placement channel all
document the same assumption without enforcing it. The graphical host
satisfies the contract with its game-loop thread. The headless host
violated it structurally: HeadlessProcessScheduler.RunAsync drove ticks
through await Task.Delay(...).ConfigureAwait(false), and a console app
has no SynchronizationContext, so each resumption could land on a
different ThreadPool worker. Any collision generation spanning two waits
then tripped the guard — reproduced 3/3 against live ACE at
[wake] begin gen=1 (see docs/ISSUES.md #368).

Fix shape (headless-only; zero shared Runtime changes, so the graphical
host is untouched by construction):

- HeadlessProcessScheduler.Run(CancellationToken) replaces RunAsync: the
  same deadline math, counters, and NormalizeTimerDelay clamp, but fully
  synchronous on the calling thread. Waits go through one rearmed
  TimeProvider timer signalling an event (WaitHandle.WaitAny with the
  cancellation handle), so the loop never leaves its thread and returns
  normally on cancellation.
- HeadlessProcessHost.RunAsync now spawns one named dedicated thread
  ("acdream-headless-update") that owns Start (the live connect
  transaction), every scheduler turn, and the post-loop resource
  captures, bridged to the same Task<HeadlessExitCode> via a
  TaskCompletionSource. Start had to move too: the first
  collision-mutating call can happen during connect, and binding the
  guard on the caller's thread would trip the very first dedicated tick.
  Disposal stays on the lifecycle thread, which the Runtime teardown
  path explicitly supports (ResetSessionPhysics's doc comment) and every
  prior graceful-teardown run exercised.

New test ProcessHostRunsStartAndEveryTickOnOneDedicatedUpdateThread
pins the contract: Start and every tick share one thread that is not
the RunAsync caller's, across real timer waits (RED pre-fix — Start ran
on the caller's thread). SystemTimerCadenceDoesNotBusyLoopBetweenTurns
moved to the synchronous seam and still bounds WaitCount.

Verification: Headless suite 97/97; full Release suite 12,554 passed /
4 skipped / 0 failed; three live jump-probe runs against local ACE
(ACDREAM_PROBE_PARK=1) each crossed the collision generation cleanly
(205 entities hydrated, zero faults, policy completion, ACE-confirmed
graceful logout, converged disposed sample, exit 0) — pre-fix the same
recipe quarantined 3/3. The jump-airborne timeout persists 3/3 on the
fixed tree, refuting the #365 diagnosis's "threading artifact"
hypothesis for it — filed separately as #370.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 18:21:35 +02:00
parent 8b166f3ea2
commit b7f59923ad
3 changed files with 181 additions and 18 deletions

View file

@ -156,10 +156,44 @@ internal sealed class HeadlessProcessHost : IDisposable
Credential = source.Credential, Credential = source.Credential,
}; };
internal async Task<HeadlessExitCode> RunAsync( internal Task<HeadlessExitCode> RunAsync(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
// #368: Runtime's gameplay owners require ONE update thread for a
// session's whole lifetime — collision generations bind to the
// first mutating thread and refuse migration. The graphical host
// satisfies that with its game-loop thread; this dedicated thread
// is the headless equivalent. Start (the live connect
// transaction), every scheduler turn, and the post-loop captures
// all execute here. Only disposal stays on the lifecycle thread,
// which the Runtime teardown path explicitly supports (see
// ResetSessionPhysics's own doc comment).
var completion = new TaskCompletionSource<HeadlessExitCode>(
TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
completion.SetResult(
RunOnUpdateThread(cancellationToken));
}
catch (Exception error)
{
completion.SetException(error);
}
})
{
IsBackground = true,
Name = "acdream-headless-update",
};
thread.Start();
return completion.Task;
}
private HeadlessExitCode RunOnUpdateThread(
CancellationToken cancellationToken)
{
foreach (HeadlessSessionHost session in _sessions) foreach (HeadlessSessionHost session in _sessions)
{ {
RuntimeSessionStartResult started = session.Start(); RuntimeSessionStartResult started = session.Start();
@ -189,8 +223,7 @@ internal sealed class HeadlessProcessHost : IDisposable
try try
{ {
await _scheduler.RunAsync(cancellationToken) _scheduler.Run(cancellationToken);
.ConfigureAwait(false);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested) when (cancellationToken.IsCancellationRequested)

View file

@ -147,8 +147,31 @@ internal sealed class HeadlessProcessScheduler
_observationPeriodTicks); _observationPeriodTicks);
} }
internal async Task RunAsync(CancellationToken cancellationToken) /// <summary>
/// Drives every session's deadlines on the CALLING thread until
/// cancellation or policy completion, returning normally in both
/// cases. The caller must dedicate one thread for a process's whole
/// run: Runtime's gameplay owners (collision generations foremost,
/// via <c>RuntimePhysicsState.EnsureCollisionMutationThread</c>) bind
/// to the first mutating thread and refuse migration, and an awaited
/// timer loop in a SynchronizationContext-free host resumes on
/// arbitrary ThreadPool workers — which tripped that guard whenever a
/// collision generation spanned two waits (#368). The waits below go
/// through one rearmed <see cref="TimeProvider"/> timer signalling an
/// event, so the loop never leaves its thread. A stale timer callback
/// from an abandoned wait can set the event early; that only costs
/// one extra pass over the deadline math, which re-sleeps.
/// </summary>
internal void Run(CancellationToken cancellationToken)
{ {
using var wake = new ManualResetEventSlim(false);
using ITimer timer = _timeProvider.CreateTimer(
static state => ((ManualResetEventSlim)state!).Set(),
wake,
Timeout.InfiniteTimeSpan,
Timeout.InfiniteTimeSpan);
WaitHandle[] waitHandles =
[wake.WaitHandle, cancellationToken.WaitHandle];
while (!cancellationToken.IsCancellationRequested while (!cancellationToken.IsCancellationRequested
&& HasActiveSession()) && HasActiveSession())
{ {
@ -169,11 +192,9 @@ internal sealed class HeadlessProcessScheduler
: _timeProvider.GetElapsedTime(now, deadline); : _timeProvider.GetElapsedTime(now, deadline);
delay = NormalizeTimerDelay(delay); delay = NormalizeTimerDelay(delay);
Interlocked.Increment(ref _waitCount); Interlocked.Increment(ref _waitCount);
await Task.Delay( wake.Reset();
delay, timer.Change(delay, Timeout.InfiniteTimeSpan);
_timeProvider, WaitHandle.WaitAny(waitHandles);
cancellationToken)
.ConfigureAwait(false);
} }
} }

View file

@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net; using System.Net;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
@ -198,7 +200,7 @@ public sealed class HeadlessProcessSchedulerTests
} }
[Fact] [Fact]
public async Task SystemTimerCadenceDoesNotBusyLoopBetweenTurns() public void SystemTimerCadenceDoesNotBusyLoopBetweenTurns()
{ {
var operations = new FixtureSessionOperations(); var operations = new FixtureSessionOperations();
using HeadlessSessionHost session = using HeadlessSessionHost session =
@ -214,14 +216,10 @@ public sealed class HeadlessProcessSchedulerTests
using var cancellation = using var cancellation =
new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); new CancellationTokenSource(TimeSpan.FromMilliseconds(250));
try // Run executes on the calling thread and returns normally when
{ // the token cancels (#368 moved thread ownership to the process
await scheduler.RunAsync(cancellation.Token); // host; the scheduler seam itself is synchronous).
} scheduler.Run(cancellation.Token);
catch (OperationCanceledException)
when (cancellation.IsCancellationRequested)
{
}
HeadlessSchedulerSnapshot snapshot = HeadlessSchedulerSnapshot snapshot =
scheduler.CaptureSnapshot(); 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] [Fact]
public void ThrowingPolicyQuarantinesOnlyItsOwnSession() 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 private abstract class FixturePolicy : IHeadlessBotPolicy
{ {
public virtual bool IsComplete => false; public virtual bool IsComplete => false;