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:
parent
8b166f3ea2
commit
b7f59923ad
3 changed files with 181 additions and 18 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue