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
|
|
@ -0,0 +1,154 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// #365 Step 4: <see cref="RuntimeLocalPlayerFrameController"/> must never
|
||||
/// advance a DORMANT (constructed but not yet <c>ActivateRuntimePublication</c>
|
||||
/// -ed) controller's live-movement operations, even if a host's own
|
||||
/// <c>CanAdvancePlayer</c> is wrong (the exact headless defect — it only
|
||||
/// checked <c>Controller is not null</c>, true for a dormant controller too).
|
||||
/// These tests deliberately SABOTAGE the host's <c>CanAdvancePlayer</c> to
|
||||
/// <c>true</c> to prove the controller's own
|
||||
/// <see cref="PlayerMovementController.CanExecuteLiveMovement"/> gate is the
|
||||
/// one holding the line, not the host's contract.
|
||||
/// </summary>
|
||||
public sealed class RuntimeLocalPlayerFrameControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void AdvanceBeforeNetworkDoesNotThrowOnDormantControllerInSuspendDisposition()
|
||||
{
|
||||
PlayerMovementController dormant = CreateDormantController();
|
||||
var host = new SabotagingHost(
|
||||
dormant,
|
||||
RetailObjectClockDisposition.Suspend);
|
||||
var controller = new RuntimeLocalPlayerFrameController(
|
||||
host,
|
||||
new FixedMovementInputSource());
|
||||
|
||||
Exception? exception = Record.Exception(
|
||||
() => controller.AdvanceBeforeNetwork(0.015f));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Equal(0, host.ProjectCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvanceBeforeNetworkDoesNotThrowOnDormantControllerInAdvanceDisposition()
|
||||
{
|
||||
PlayerMovementController dormant = CreateDormantController();
|
||||
var host = new SabotagingHost(
|
||||
dormant,
|
||||
RetailObjectClockDisposition.Advance);
|
||||
var controller = new RuntimeLocalPlayerFrameController(
|
||||
host,
|
||||
new FixedMovementInputSource());
|
||||
|
||||
Exception? exception = Record.Exception(
|
||||
() => controller.AdvanceBeforeNetwork(0.015f));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Equal(0, host.ProjectCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunPostNetworkCommandPhaseDoesNotThrowOnDormantController()
|
||||
{
|
||||
PlayerMovementController dormant = CreateDormantController();
|
||||
var host = new SabotagingHost(
|
||||
dormant,
|
||||
RetailObjectClockDisposition.Advance);
|
||||
var controller = new RuntimeLocalPlayerFrameController(
|
||||
host,
|
||||
new FixedMovementInputSource());
|
||||
|
||||
Exception? exception = Record.Exception(
|
||||
controller.RunPostNetworkCommandPhase);
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Equal(0, host.SendPostNetworkCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetPresentationAfterNetworkReturnsFalseOnDormantControllerWithoutThrowing()
|
||||
{
|
||||
PlayerMovementController dormant = CreateDormantController();
|
||||
var host = new SabotagingHost(
|
||||
dormant,
|
||||
RetailObjectClockDisposition.Advance);
|
||||
var controller = new RuntimeLocalPlayerFrameController(
|
||||
host,
|
||||
new FixedMovementInputSource());
|
||||
|
||||
bool result = false;
|
||||
Exception? exception = Record.Exception(() =>
|
||||
result = controller.TryGetPresentationAfterNetwork(
|
||||
out RuntimeLocalPlayerPresentationFrame _));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
private static PlayerMovementController CreateDormantController()
|
||||
{
|
||||
PlayerMovementController candidate =
|
||||
PlayerMovementController.CreatePublicationCandidate(
|
||||
new PhysicsEngine(),
|
||||
PlayerMovementConstructionOptions.Fallback);
|
||||
candidate.SealPublicationCandidate();
|
||||
candidate.CommitRuntimeOwnership(new RetailObjectQuantumClock());
|
||||
Assert.True(candidate.IsRuntimeOwnedDormant);
|
||||
Assert.False(candidate.CanExecuteLiveMovement);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private sealed class FixedMovementInputSource : IRuntimeMovementInputSource
|
||||
{
|
||||
public MovementInput Capture() => default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A host that reports <c>CanAdvancePlayer: true</c> unconditionally —
|
||||
/// the exact pre-fix headless bug shape — regardless of the wired
|
||||
/// controller's real publication state.
|
||||
/// </summary>
|
||||
private sealed class SabotagingHost(
|
||||
PlayerMovementController? controller,
|
||||
RetailObjectClockDisposition disposition)
|
||||
: IRuntimeLocalPlayerFrameHost
|
||||
{
|
||||
internal int ProjectCallCount { get; private set; }
|
||||
internal int SendPostNetworkCallCount { get; private set; }
|
||||
|
||||
public bool CanAdvancePlayer => true;
|
||||
public PlayerMovementController? Controller => controller;
|
||||
|
||||
public uint ResolveLocalEntityId() => 1u;
|
||||
public void HandleTargeting()
|
||||
{
|
||||
}
|
||||
|
||||
public bool IsHidden => false;
|
||||
public RetailObjectClockDisposition ObjectClockDisposition =>
|
||||
disposition;
|
||||
|
||||
public void Project(
|
||||
PlayerMovementController controller,
|
||||
MovementResult movement,
|
||||
bool hidden) =>
|
||||
ProjectCallCount++;
|
||||
|
||||
public void SendPreNetwork(
|
||||
PlayerMovementController controller,
|
||||
MovementResult movement,
|
||||
bool hidden)
|
||||
{
|
||||
}
|
||||
|
||||
public void SendPostNetwork(
|
||||
PlayerMovementController controller,
|
||||
bool hidden) =>
|
||||
SendPostNetworkCallCount++;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue