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>