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
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue