acdream/tests/AcDream.Headless.Tests/HeadlessStaticStateAuditTests.cs
Erik 41b408f3e6 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>
2026-08-10 11:34:36 +02:00

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);
}
}