fix(headless): #365 — collision-admission-open window drove the first-entry conductor into a permanent seal refusal

Root cause (measured live via ACDREAM_PROBE_PARK=1): HeadlessSessionWorldProjection
drove the first-entry conductor unconditionally, including while
HeadlessCollisionNeighborhood's own 3x3 publication plan held a genuinely open
RuntimeCollisionAdmission for the local player's landblock. Every
TrySealCollisionEvaluationAuthority attempt during that window failed
(IsCollisionEvaluationPrefixAdmissible false) and retried forever without
recovering — measured verdict: "seal-refused" repeating with no preceding
[rearm] verdict= line (the operation never even reached the AwaitingCell park).
This is the diagnosis doc's "structural half" mechanism; no evidence of the
"circular HasOldPrefixPlacementDebt" hypothesis was observed, so that shape
was not needed.

Step 1 (enabler): HeadlessStaticStateAudit.ValidateProcessIsolation now takes
sessionCount and only refuses process-global physics probes for
sessionCount > 1 — its own multi-root-attribution rationale never applied to
a single session, and it was blocking the exact probe built to diagnose this
class of stall.

Step 3a (root cause): new IHeadlessCollisionNeighborhood.IsQuiescent gates
ProjectSpawn/ProjectPosition/PumpFirstEntry's conductor-drive calls — the
conductor is never driven while the neighborhood's own publication owns
collision authority for that tick.

Step 4 (defense-in-depth): HeadlessLocalPlayerFrameHost.CanAdvancePlayer now
requires Controller.CanExecuteLiveMovement instead of just a non-null
controller — the headless-only gap that turned the (now-fixed) hydration
stall into a hard crash reaching SuspendObjectUpdate on a dormant controller.
RuntimeLocalPlayerFrameController's three shared entry points gained the same
guard, contract-preserving for the graphical host.

Verified end-to-end against live ACE (jump-probe policy, three runs):
hydration succeeds cleanly (136 entities load vs. 0 before), no seal-refused
spam, no crash from the original bug, graceful logout every time. Full
airborne-transition confirmation is blocked by a separate, newly-discovered,
pre-existing defect filed as #368 (the headless scheduler's
Task.Delay(...).ConfigureAwait(false) tick loop can resume on a different
ThreadPool thread mid collision-generation, tripping
EnsureCollisionMutationThread) — explicitly out of scope here, not mentioned
anywhere in the #365 diagnosis, and unsafe to fix without graphical-host
verification this session was constrained not to perform.

New tests: the real-admission hydration test (fails on the pre-Step-3a tree,
verified by temporarily reverting the three gates and confirming failure,
then restoring), the PumpFirstEntry quiescence-gate test, the
CanAdvancePlayer publication-lifecycle test, the dormant-controller
sabotage tests for RuntimeLocalPlayerFrameController, and the audit
single/multi-session tests. RuntimeLocalPlayerPhysicsPublicationStateTests
is untouched.

Full Release suite: 12,343 passed / 4 skipped / 0 failed (baseline ~12,330/4
plus 11 new tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 11:34:36 +02:00
parent 6150327ea3
commit 41b408f3e6
10 changed files with 937 additions and 35 deletions

View file

@ -353,3 +353,97 @@ that +Acdream walks — the K3/K4 gate unrun since 2026-08-02.
| 529e0e9d root cause; 78b981cc changed the symptom | High | git show 529e0e9d -- src/AcDream.Headless/ |
| Probes unrunnable headless | Certain | HeadlessProcessHost.cs:45 |
| Existing hydration test bypasses admission/seal | Certain | HeadlessSessionHostTests.cs:372-375 |
## 8. OUTCOME (2026-08-10, fix session)
Steps 1, 3a, and 4 landed exactly as specified; **3b was not needed.**
**Step 2 measurement (mandatory, run before any Step-3 code):** the
`ACDREAM_PROBE_PARK=1` jump-probe repro against local ACE produced
```
[pump] DriveAll #1 pending=0
...
[wake] begin lb=0x0904FFFF gen=1 unboundCells=0 buckets=0
[rearm] guid=0x5000000A seal-refused (transient; lease retained)
[rearm] guid=0x5000000A seal-refused (transient; lease retained)
[jump-probe] local player present; charging jump
```
`seal-refused` repeating with **no preceding `[rearm] verdict=` line**.
Per this doc's own Step-2 table, that shape means the operation never even
reached the `AwaitingCell` park: `IsExactDormantLocalActivationCurrent`
already reports "current" on every attempt (the op sits in
`AwaitingPreparation` the whole time), so `TryRearmDeferredDormantLocalActivation`
is never called, and every attempt fails at
`TrySealCollisionEvaluationAuthority` on the SAME still-open admission. This
is the doc's §3 "structural half — CONFIRMED" mechanism. **No
`prefix-inadmissible` rearm verdict was ever observed**, so there is no
direct evidence the §3 "why it may never open" `HasOldPrefixPlacementDebt`
circular-wait hypothesis is in play for this repro — 3a alone was measured
sufficient.
**Fix shape: 3a only.** `IHeadlessCollisionNeighborhood.IsQuiescent`
(`_pendingPublication is null && _publicationQueue.Count == 0 &&
!_pendingPublicationCancellation`) gates `ProjectSpawn`/`ProjectPosition`/
`PumpFirstEntry`'s trailing `_firstEntry?.DriveAll()` / `_acceptedPositionDrive?.Advance()`
calls exactly as specified. Step 4 landed as specified
(`CanAdvancePlayer` requires `CanExecuteLiveMovement`, plus the three shared
`RuntimeLocalPlayerFrameController` entry points hardened the same way).
Step 1 landed as specified (`ValidateProcessIsolation(sessionCount)`, single
session + probe logs and proceeds, multi-session + probe still refuses,
naming the probe).
**Verification that the new test (item 3) actually discriminates:**
temporarily reverting the three `IsQuiescent` gates (commented out, never
committed) made both
`RealAdmissionNeverDrivesTheConductorWhileOpenAndHydratesOnceReleased` and
`PumpFirstEntryWithholdsDriveAllUntilQuiescentThenDrivesImmediately` fail —
`Assert.Null(runtime.MovementOwner.Controller)` failed because the controller
was ALREADY built and published (`CanExecuteLiveMovement = True`) while the
neighborhood's admission was still held open, exactly the pre-fix race. Both
pass cleanly on the real, fixed tree. The gate was then restored and
`git diff` confirmed the file matches the shipped Step-3a diff exactly (no
residual simulation code).
**A design note for the test:** the first attempt at test item 3 opened the
held admission on the PLAYER'S OWN landblock via the full
`HeadlessCollisionGenerationTransaction` commit cycle. That hit a genuine,
separate settlement question in `CommitCollisionGeneration`
`TryAcquireCollisionPrefixMutationPermission` (never resolved within 200
ticks in that configuration) — worth a future look if it turns out to matter
in production, but not needed to prove Step 3a. The shipped test instead
holds a real admission open on a DIFFERENT (neighbor) landblock the player
does not target, cancelling rather than committing it — a faithful, simpler
proof of "an open admission anywhere in the plan blocks driving" without
touching that separate question.
**End-to-end (live ACE, `jump-probe` policy, `ACDREAM_PROBE_PARK=1`), three
runs, all consistent:** hydration now succeeds — `entityCount` reaches 136 at
the `running-stop` resource sample (previously 0, permanently), `[jump-probe]
local player present; charging jump` fires promptly, `seal-refused` spam is
gone, and the original fork-1 crash (`SuspendObjectUpdate` on a dormant
controller) never recurs. **Full pass criterion 4
(`[jump-probe] airborne-transition False -> True`) was NOT independently
observed on the unmodified tree** — every real run hit a SEPARATE,
newly-discovered, pre-existing defect first (filed as issue #368: the
headless scheduler's `await Task.Delay(...).ConfigureAwait(false)` loop can
resume ticks on a different ThreadPool thread than the one that opened a
collision generation, tripping `RuntimePhysicsState.EnsureCollisionMutationThread`).
#368 is explicitly out of scope for this fix — orthogonal mechanism, no
mention anywhere in this diagnosis, and a proper fix needs verification
against the graphical host, which this session was constrained not to
launch. A throwaway, never-committed diagnostic run with #368's guard
neutralized (verified via `git diff` to have zero residual footprint) DID
reach `[jump-probe] releasing jump (fire)` with a clean exit (code 0,
graceful logout, `entityCount=136`), confirming the #365 mechanism itself is
sound; it timed out waiting for `airborne-transition True` in THAT run,
plausibly a downstream artifact of the same unsynchronized-thread condition
the neutralized guard exists to catch (racing collision/physics state across
threads) rather than a second #365-scope defect — flagged as an open
question in #368, not claimed as resolved.
Every real (unmodified) run's session tore down gracefully
(`[session] graceful logout confirmed`, zero entities/leases at the final
`disposed` sample) regardless of which way it exited — `testaccount` was
never left in a stuck state by this work.