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>
95 lines
2.8 KiB
C#
95 lines
2.8 KiB
C#
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);
|
|
}
|
|
}
|