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:
parent
6150327ea3
commit
41b408f3e6
10 changed files with 937 additions and 35 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue