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

@ -39,9 +39,18 @@ internal sealed class HeadlessLocalPlayerFrameHost
static (_, _, _, _, _, _) => { });
}
// #365 Step 4: the graphical host gates on IsRuntimePublished
// (PlayerModeAutoEntry.cs C3c-F2); this host only checked for a
// non-null controller, which is also true for a DORMANT (constructed
// but not yet activated) controller and let live-movement operations
// reach it — SuspendObjectUpdate's EnsurePublishedForRuntimeOperation
// throw. CanExecuteLiveMovement is the #356 public lifecycle-caller
// idiom for exactly this: false before publication and after
// retirement, neither of which is an error to skip.
public bool CanAdvancePlayer =>
_runtime.Session.IsInWorld
&& _runtime.MovementOwner.Controller is not null;
&& _runtime.MovementOwner.Controller
is { CanExecuteLiveMovement: true };
public PlayerMovementController? Controller =>
_runtime.MovementOwner.Controller;

View file

@ -42,7 +42,8 @@ internal sealed class HeadlessProcessHost : IDisposable
throw new HeadlessConfigurationException(
"Direct credentials require exactly one configured session.");
}
HeadlessStaticStateAudit.ValidateProcessIsolation();
HeadlessStaticStateAudit.ValidateProcessIsolation(
configuration.Sessions.Count);
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
var credentials = new HeadlessCredentialResolver(

View file

@ -26,6 +26,18 @@ internal interface IHeadlessCollisionNeighborhood
/// placement: its collision-generation wake could never fire.
/// </summary>
bool IsWithinServiceWindow(uint fullCellId);
/// <summary>
/// #365 Step 3a: true when this neighborhood holds NO open collision
/// admission and NO in-flight publication/cancellation work for any
/// landblock in its plan. Driving the first-entry conductor while this
/// is false is a guaranteed <c>TrySealCollisionEvaluationAuthority</c>
/// refusal — the neighborhood's own publication for the destination
/// prefix has an admission registered (or is quiescing) for the exact
/// window <c>RuntimePhysicsState.IsCollisionEvaluationPrefixAdmissible</c>
/// requires clear. Read-only; never mutates (unlike <see cref="IsReady"/>).
/// </summary>
bool IsQuiescent { get; }
}
internal readonly record struct HeadlessCollisionGenerationAdvance(
@ -301,6 +313,16 @@ internal sealed class HeadlessCollisionNeighborhood
return dx <= 1 && dy <= 1;
}
/// <summary>
/// #365 Step 3a: no open publication, no queued work, no in-flight
/// cancellation. See the interface doc for why this must gate driving
/// the first-entry conductor.
/// </summary>
public bool IsQuiescent =>
_pendingPublication is null
&& _publicationQueue.Count == 0
&& !_pendingPublicationCancellation;
public bool IsReady(uint fullCellId)
{
uint center = CanonicalLandblock(fullCellId);
@ -663,6 +685,13 @@ internal sealed class HeadlessSessionWorldProjection
_ = _runtime.EntityObjects
.TryConvertInitialResidenceToCellessRoute(record);
}
// #365 Step 3a: driving while the neighborhood still holds an open
// admission or in-flight quiescence for its own publication plan is
// a guaranteed seal refusal (IsCollisionEvaluationPrefixAdmissible
// false for the whole window) — the publication owns the collision
// authority this tick, not the conductor.
if (!_collision.IsQuiescent)
return;
_firstEntry?.DriveAll();
_acceptedPositionDrive?.Advance();
}
@ -687,6 +716,9 @@ internal sealed class HeadlessSessionWorldProjection
_requestedLocalPlayerCell = position.LandblockId;
_collision.CenterOn(position.LandblockId);
}
// #365 Step 3a: see the matching comment in ProjectSpawn.
if (!_collision.IsQuiescent)
return;
_firstEntry?.DriveAll();
_acceptedPositionDrive?.Advance();
}
@ -737,6 +769,14 @@ internal sealed class HeadlessSessionWorldProjection
{
if (_requestedLocalPlayerCell != 0u)
_ = _collision.IsReady(_requestedLocalPlayerCell);
// #365 Step 3a: never drive the conductor while the neighborhood's
// own publication holds the collision authority for this tick — see
// IHeadlessCollisionNeighborhood.IsQuiescent's doc. IsReady above
// still runs unconditionally: it is what ADVANCES the neighborhood's
// publication work each tick (its own mutating side effect), so
// gating it too would make the neighborhood itself never converge.
if (!_collision.IsQuiescent)
return;
_firstEntry?.DriveAll();
_acceptedPositionDrive?.Advance();
}

View file

@ -10,9 +10,19 @@ namespace AcDream.Headless.Hosting;
/// share one process. Ordinary diagnostics remain session-labelled through
/// <see cref="Diagnostics.HeadlessDiagnosticWriter"/>.
/// </summary>
/// <remarks>
/// #365 Step 1: the refusal's rationale is multi-root attribution ambiguity
/// — it does not hold for a process that owns exactly ONE session, since
/// there is no second root to confuse a probe's cursor/last-hit fields
/// with. Refusing anyway made the exact probe built to diagnose the #365
/// hydration stall (<c>ACDREAM_PROBE_PARK=1</c>) impossible to run against
/// the single-session repro that needed it. A <paramref name="sessionCount"/>
/// of 1 now logs the enabled probes loudly and proceeds; anything else
/// keeps the original hard refusal, naming every enabled probe.
/// </remarks>
internal static class HeadlessStaticStateAudit
{
internal static void ValidateProcessIsolation()
internal static void ValidateProcessIsolation(int sessionCount)
{
var enabled = new List<string>();
foreach (PropertyInfo property in typeof(PhysicsDiagnostics)
@ -38,12 +48,19 @@ internal static class HeadlessStaticStateAudit
if (PhysicsResolveCapture.IsEnabled)
enabled.Add(nameof(PhysicsResolveCapture));
if (enabled.Count != 0)
if (enabled.Count == 0)
return;
if (sessionCount == 1)
{
throw new HeadlessConfigurationException(
"Multi-session headless mode cannot use process-global "
+ "physics probes. Disable: "
+ string.Join(", ", enabled));
Console.WriteLine(FormattableString.Invariant(
$"[headless-audit] single-session process — process-global physics probes enabled: {string.Join(", ", enabled)}"));
return;
}
throw new HeadlessConfigurationException(
"Multi-session headless mode cannot use process-global "
+ "physics probes. Disable: "
+ string.Join(", ", enabled));
}
}

View file

@ -72,8 +72,18 @@ public sealed class RuntimeLocalPlayerFrameController
{
_advancedFrame = null;
PlayerMovementController? controller = _host.Controller;
if (!_host.CanAdvancePlayer || controller is null)
// #365 Step 4 hardening: a host whose CanAdvancePlayer check is
// looser than "published" (the exact defect this issue fixed for
// the headless host) still cannot reach a dormant controller here —
// contract-preserving for hosts (the graphical one) that already
// gate correctly, since a published controller's
// CanExecuteLiveMovement is always true.
if (!_host.CanAdvancePlayer
|| controller is null
|| !controller.CanExecuteLiveMovement)
{
return;
}
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
{
@ -132,8 +142,18 @@ public sealed class RuntimeLocalPlayerFrameController
public void RunPostNetworkCommandPhase()
{
PlayerMovementController? controller = _host.Controller;
if (!_host.CanAdvancePlayer || controller is null)
// #365 Step 4 hardening: a host whose CanAdvancePlayer check is
// looser than "published" (the exact defect this issue fixed for
// the headless host) still cannot reach a dormant controller here —
// contract-preserving for hosts (the graphical one) that already
// gate correctly, since a published controller's
// CanExecuteLiveMovement is always true.
if (!_host.CanAdvancePlayer
|| controller is null
|| !controller.CanExecuteLiveMovement)
{
return;
}
bool hidden = _host.IsHidden;
if (_advancedFrame is { } advanced
@ -163,8 +183,13 @@ public sealed class RuntimeLocalPlayerFrameController
{
frame = default;
PlayerMovementController? controller = _host.Controller;
if (!_host.CanAdvancePlayer || controller is null)
// #365 Step 4 hardening: see AdvanceBeforeNetwork's matching comment.
if (!_host.CanAdvancePlayer
|| controller is null
|| !controller.CanExecuteLiveMovement)
{
return false;
}
bool hidden = _host.IsHidden;
if (_advancedFrame is { } advanced