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,
};
internal async Task<HeadlessExitCode> RunAsync(
internal Task<HeadlessExitCode> RunAsync(
CancellationToken cancellationToken)
{
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)
{
RuntimeSessionStartResult started = session.Start();
@ -189,8 +223,7 @@ internal sealed class HeadlessProcessHost : IDisposable
try
{
await _scheduler.RunAsync(cancellationToken)
.ConfigureAwait(false);
_scheduler.Run(cancellationToken);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)

View file

@ -147,8 +147,31 @@ internal sealed class HeadlessProcessScheduler
_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
&& HasActiveSession())
{
@ -169,11 +192,9 @@ internal sealed class HeadlessProcessScheduler
: _timeProvider.GetElapsedTime(now, deadline);
delay = NormalizeTimerDelay(delay);
Interlocked.Increment(ref _waitCount);
await Task.Delay(
delay,
_timeProvider,
cancellationToken)
.ConfigureAwait(false);
wake.Reset();
timer.Change(delay, Timeout.InfiniteTimeSpan);
WaitHandle.WaitAny(waitHandles);
}
}