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

@ -1541,6 +1541,276 @@ public sealed class HeadlessSessionHostTests
Assert.Equal(0, ownership.CollisionAdmissionCount);
}
/// <summary>
/// #365 test 3 — the coverage gap the diagnosis doc's Q4 names
/// (<c>HeadlessSessionHostTests.cs:347-475</c>'s existing
/// <see cref="WorldProjectionHydratesCanonicalMovementAndTeleportState"/>
/// uses <see cref="FixtureCollisionNeighborhood"/>, which is trivially
/// ready and never opens a real admission — the production
/// <c>HeadlessCollisionNeighborhood</c> has never been exercised against
/// the first-entry conductor while an admission was genuinely open).
/// The player's OWN landblock is prepared the same proven way every
/// other test in this file does (<c>AddFlatLandblock</c> +
/// <c>SetPosition.BeginCollisionGeneration</c>/
/// <c>CommitCollisionGeneration</c>) so it is unconditionally resident —
/// <see cref="NeighborAdmissionHeldOpenCollisionNeighborhood"/> instead
/// holds a SEPARATE REAL <see cref="RuntimeCollisionAdmission"/> open on
/// a DIFFERENT landblock, the same shape as one of the OTHER eight
/// landblocks in production's real 3x3 publication plan — proving Step
/// 3a's gate covers "an admission is open ANYWHERE in the plan", not
/// just the center. Must FAIL on the pre-Step-3a tree — verified by
/// temporarily reverting the three <c>IsQuiescent</c> gates in
/// <c>HeadlessSessionWorldProjection.ProjectSpawn</c>/
/// <c>ProjectPosition</c>/<c>PumpFirstEntry</c> and confirming this test
/// fails (the conductor reaches <c>PublicationCommitted</c> — a non-null
/// dormant controller — while the unrelated admission is still held
/// open, since driving is unconditional pre-fix).
/// </summary>
[Fact]
public void RealAdmissionNeverDrivesTheConductorWhileOpenAndHydratesOnceReleased()
{
var operations = new FixtureSessionOperations();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(),
credential,
new HeadlessDiagnosticWriter(TextWriter.Null),
operations);
GameRuntime runtime = host.Runtime;
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
const uint player = 0x50000012u;
const uint neighborLandblockId = 0xAAB4FFFFu;
runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
0xA9B40000u, 1UL);
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
0xA9B40000u, 1UL, ready: true);
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
CreateFirstEntryDrive(runtime);
RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntityWithInitialResidence(
Spawn(player),
isLocalPlayer: true)
.Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record,
record.CreateIntegrationVersion,
record.Snapshot,
replaceGeneration: false));
var collision = new NeighborAdmissionHeldOpenCollisionNeighborhood(
runtime.EntityObjects.Physics,
neighborLandblockId);
collision.OpenHeldAdmission();
var projection = new HeadlessSessionWorldProjection(
runtime,
collision,
firstEntry);
projection.ProjectSpawn(record, isLocalPlayer: true);
Assert.False(collision.IsQuiescent);
Assert.Null(runtime.MovementOwner.Controller);
// Hold the SEPARATE admission open across many pumps — far longer
// than the conductor needs to reach mover-prep/placement/
// PublicationCommitted on the ALREADY-READY player landblock, so a
// pre-fix conductor being undriven only by luck of a short window
// cannot slip through.
for (int tick = 0; tick < 50; tick++)
{
projection.PumpFirstEntry();
Assert.False(collision.IsQuiescent);
Assert.Null(runtime.MovementOwner.Controller);
}
collision.ReleaseHeldAdmission();
Assert.True(collision.IsQuiescent);
const int boundedTicks = 200;
bool published = false;
for (int tick = 0; tick < boundedTicks; tick++)
{
projection.PumpFirstEntry();
if (runtime.MovementOwner.Controller is { IsRuntimePublished: true })
{
published = true;
break;
}
}
Assert.True(
published,
"the local player never reached RuntimePublished within the "
+ "bounded tick budget after the unrelated admission cleared.");
PlayerMovementController controller = Assert.IsType<
PlayerMovementController>(runtime.MovementOwner.Controller);
Assert.True(controller.IsRuntimePublished);
Assert.Equal(0, firstEntry.PendingCount);
}
/// <summary>
/// #365 test 4: <c>PumpFirstEntry</c> must not call <c>DriveAll</c> while
/// the collision neighborhood reports non-quiescent, and must call it on
/// the first tick after quiescence — the exact Step 3a gate, isolated
/// from the admission machinery itself via a directly-controllable fake.
/// </summary>
[Fact]
public void PumpFirstEntryWithholdsDriveAllUntilQuiescentThenDrivesImmediately()
{
var operations = new FixtureSessionOperations();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(),
credential,
new HeadlessDiagnosticWriter(TextWriter.Null),
operations);
GameRuntime runtime = host.Runtime;
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
const uint player = 0x50000013u;
runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
0xA9B40000u, 1UL);
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
0xA9B40000u, 1UL, ready: true);
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
CreateFirstEntryDrive(runtime);
RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
.Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record,
record.CreateIntegrationVersion,
record.Snapshot,
replaceGeneration: false));
var collision = new GateControllableCollisionNeighborhood
{
QuiescentOverride = false,
};
var projection = new HeadlessSessionWorldProjection(
runtime,
collision,
firstEntry);
projection.ProjectSpawn(record, isLocalPlayer: true);
Assert.Null(runtime.MovementOwner.Controller);
Assert.Equal(1, firstEntry.PendingCount);
projection.PumpFirstEntry();
Assert.Null(runtime.MovementOwner.Controller);
Assert.Equal(1, firstEntry.PendingCount);
collision.QuiescentOverride = true;
projection.PumpFirstEntry();
Assert.NotNull(runtime.MovementOwner.Controller);
}
/// <summary>
/// #365 test 5: <c>HeadlessLocalPlayerFrameHost.CanAdvancePlayer</c>
/// tracks the controller's exact publication lifecycle — false while
/// dormant (the crash bug's shape), true once published, false again
/// once retired (the #356 lifecycle-caller idiom).
/// </summary>
[Fact]
public void CanAdvancePlayerReflectsControllerPublicationLifecycle()
{
var operations = new FixtureSessionOperations();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(),
credential,
new HeadlessDiagnosticWriter(TextWriter.Null),
operations);
GameRuntime runtime = host.Runtime;
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
Assert.True(runtime.Session.IsInWorld);
var inertSession = CreateInertLiveSessionHost();
var frameHost = new HeadlessLocalPlayerFrameHost(runtime, inertSession);
PlayerMovementController candidate =
PlayerMovementController.CreatePublicationCandidate(
new PhysicsEngine(),
PlayerMovementConstructionOptions.Fallback);
candidate.SealPublicationCandidate();
candidate.CommitRuntimeOwnership(new RetailObjectQuantumClock());
runtime.MovementOwner.Controller = candidate;
Assert.True(candidate.IsRuntimeOwnedDormant);
Assert.False(frameHost.CanAdvancePlayer);
candidate.ActivateRuntimePublication();
Assert.True(candidate.IsRuntimePublished);
Assert.True(frameHost.CanAdvancePlayer);
candidate.RetireRuntimePublication();
Assert.False(frameHost.CanAdvancePlayer);
}
private static LiveSessionHost CreateInertLiveSessionHost()
{
var controller = new LiveSessionController(
new ThrowingLiveSessionOperations());
return new LiveSessionHost(
controller,
new LiveSessionHostBindings(
new LiveSessionRoutingFactories(
_ => throw new NotSupportedException(),
_ => throw new NotSupportedException()),
_ => { },
new LiveSessionSelectionBindings(
_ => { },
_ => { },
_ => { },
_ => { },
_ => { },
() => { }),
new LiveSessionEnteredWorldBindings(
_ => { },
() => { },
() => { },
_ => { },
() => { }),
(_, _, _) => { },
() => { }));
}
private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations
{
public IPEndPoint ResolveEndpoint(string host, int port) =>
throw new NotSupportedException();
public WorldSession CreateSession(IPEndPoint endpoint) =>
throw new NotSupportedException();
public void Connect(
WorldSession session,
string user,
string password) =>
throw new NotSupportedException();
public CharacterList.Parsed? GetCharacters(WorldSession session) =>
throw new NotSupportedException();
public void EnterWorld(
WorldSession session,
int activeCharacterIndex) =>
throw new NotSupportedException();
public void Tick(WorldSession session) =>
throw new NotSupportedException();
public void DisposeSession(WorldSession session) =>
throw new NotSupportedException();
}
private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment,
@ -2265,6 +2535,12 @@ public sealed class HeadlessSessionHostTests
public bool IsReady(uint fullCellId) =>
fullCellId == LastCell;
// #365 Step 3a: this fixture has no real admission/publication
// machinery to hold open — every CenterOn/IsReady call is a
// synchronous no-op, so it is quiescent by construction. A fake
// that ever wants to exercise the gate should override this.
public bool IsQuiescent => true;
// C3c-R1 review F7: the fixture window mirrors production's 3x3
// membership around the last requested center; no center yet means
// "within" (never convert before the first CenterOn).
@ -2282,6 +2558,79 @@ public sealed class HeadlessSessionHostTests
}
}
/// <summary>
/// #365 test 3 support: unlike <see cref="FixtureCollisionNeighborhood"/>
/// (trivially ready, never opens a real admission),
/// <see cref="RealAdmissionNeverDrivesTheConductorWhileOpenAndHydratesOnceReleased"/>
/// needs a fake that holds an ACTUAL <see cref="RuntimeCollisionAdmission"/>
/// open on a landblock the local player does NOT target — the same
/// per-landblock admission primitive production's
/// <c>HeadlessCollisionNeighborhood.CreatePublication</c> opens for every
/// entry in its 3x3 plan. Cancelling (never committing) sidesteps
/// <c>CommitCollisionGeneration</c>'s own multi-tick
/// <c>TryAcquireCollisionPrefixMutationPermission</c> settlement — this
/// fake only needs to prove the SEAL sees an open admission, not drive a
/// second full commit cycle to completion.
/// </summary>
private sealed class NeighborAdmissionHeldOpenCollisionNeighborhood(
RuntimePhysicsState physics,
uint heldLandblockId) : IHeadlessCollisionNeighborhood
{
private RuntimeCollisionAdmission? _admission;
private PreparedLandblockCollisionGeneration? _prepared;
internal void OpenHeldAdmission()
{
_admission = physics.BeginCollisionAdmission(heldLandblockId);
_prepared = physics.PrepareCollisionGeneration(_admission);
}
internal void ReleaseHeldAdmission()
{
if (_admission is null)
return;
bool cancelled = physics.CancelCollisionGeneration(
_admission,
_prepared);
Assert.True(
cancelled,
"the held-open neighbor admission did not cancel in one call.");
_admission = null;
_prepared = null;
}
public bool IsQuiescent => _admission is null;
public void CenterOn(uint fullCellId)
{
}
public bool IsReady(uint fullCellId) => true;
public bool IsWithinServiceWindow(uint fullCellId) => true;
}
/// <summary>
/// #365 test 4 support: a fake whose <see cref="IsQuiescent"/> the test
/// flips directly, isolating <c>PumpFirstEntry</c>'s gate from any real
/// admission machinery.
/// </summary>
private sealed class GateControllableCollisionNeighborhood
: IHeadlessCollisionNeighborhood
{
private uint _lastCell;
internal bool QuiescentOverride { get; set; } = true;
public void CenterOn(uint fullCellId) => _lastCell = fullCellId;
public bool IsReady(uint fullCellId) => fullCellId == _lastCell;
public bool IsWithinServiceWindow(uint fullCellId) => true;
public bool IsQuiescent => QuiescentOverride;
}
private sealed class FixtureEventRoute(
Action? onDispose = null) : ILiveSessionEventRouting
{

View file

@ -0,0 +1,95 @@
using AcDream.Core.Physics;
using AcDream.Headless.Configuration;
using AcDream.Headless.Hosting;
namespace AcDream.Headless.Tests;
/// <summary>
/// #365 Step 1: the audit's refusal rationale is multi-root attribution
/// ambiguity, which does not hold for a process that owns exactly one
/// session. These tests mutate <see cref="PhysicsDiagnostics"/> process-
/// global probe flags and swap <see cref="Console.Out"/>, so they run in
/// their own non-parallel collection — see
/// <see cref="HeadlessStaticStateAuditCollection"/>.
/// </summary>
[CollectionDefinition(
HeadlessStaticStateAuditCollection.Name,
DisableParallelization = true)]
public sealed class HeadlessStaticStateAuditCollection
{
public const string Name = "Headless static-state audit";
}
[Collection(HeadlessStaticStateAuditCollection.Name)]
public sealed class HeadlessStaticStateAuditTests : IDisposable
{
public HeadlessStaticStateAuditTests() => PhysicsDiagnostics.ResetForTest();
public void Dispose() => PhysicsDiagnostics.ResetForTest();
[Fact]
public void SingleSessionWithProbeEnabledIsAllowedAndLoggedLoudly()
{
PhysicsDiagnostics.ProbeParkEnabled = true;
var originalOut = Console.Out;
using var captured = new StringWriter();
Console.SetOut(captured);
try
{
HeadlessStaticStateAudit.ValidateProcessIsolation(sessionCount: 1);
}
finally
{
Console.SetOut(originalOut);
}
Assert.Contains(
nameof(PhysicsDiagnostics.ProbeParkEnabled),
captured.ToString(),
StringComparison.Ordinal);
}
[Fact]
public void SingleSessionWithNoProbesEnabledIsSilent()
{
var originalOut = Console.Out;
using var captured = new StringWriter();
Console.SetOut(captured);
try
{
HeadlessStaticStateAudit.ValidateProcessIsolation(sessionCount: 1);
}
finally
{
Console.SetOut(originalOut);
}
Assert.Equal(string.Empty, captured.ToString());
}
[Fact]
public void MultiSessionWithProbeEnabledStillThrowsNamingTheProbe()
{
PhysicsDiagnostics.ProbeParkEnabled = true;
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessStaticStateAudit.ValidateProcessIsolation(
sessionCount: 2));
Assert.Contains(
nameof(PhysicsDiagnostics.ProbeParkEnabled),
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void MultiSessionWithNoProbesEnabledIsAllowed()
{
Exception? exception = Record.Exception(
() => HeadlessStaticStateAudit.ValidateProcessIsolation(
sessionCount: 3));
Assert.Null(exception);
}
}