fix(runtime): name why a placement is parked and fail closed when it cannot resolve
Fixes #284 (plan S1).
A first-entry placement that could not be prepared returned
RetrySetupUnavailable and was re-Advanced every pump forever. Nothing counted
it, nothing named its cause, and nothing distinguished "waiting for something
that will arrive" from "waiting for something that never can". That is why
#281's 43 test failures presented as four unrelated symptoms across App and
Runtime instead of one cause, and why a stuck entity in the live client simply
never appears with no log line to follow.
Worse, the two causes were conflated: 670f307c's missing-world-frame park
reported itself as RetrySetupUnavailable, sending anyone diagnosing it to the
prepared-asset pipeline rather than to the absent local-player Create that
actually publishes the frame.
- RetryWorldFrameUnavailable splits the two causes. Call sites now ask
IsRetryable() instead of comparing against one reason, so a future retry
reason cannot be silently reclassified as a hard rejection - the exact way
this class of bug hides.
- The operation retains its RuntimeSetPositionParkReason, and
RuntimeSetPositionOwnershipSnapshot reports parked work by cause
(ParkedAwaitingSetupCollisionCount / ParkedAwaitingWorldFrameCount /
ParkedPlacementCount), so parked placements appear wherever ledgers are
already asserted.
- ObserveLocalPlayerCreate records the accepted local-player Create even when
it carries no landblock - precisely the case where no frame is ever
published - and ThrowIfWorldFrameUnreachable makes that contradiction
terminal. Waiting is legitimate only while that Create is outstanding; after
it, no later pump can supply the frame. Same shape as 01f4791e, which made a
violated receipt-ledger invariant terminal rather than resumable.
This is observability plus fail-fast. There is no timeout, no retry cap, and
no grace period anywhere in it; retryable work still retries exactly as before
and no placement behaviour changed.
The parked counts are deliberately NOT folded into IsConverged: #277 documents
a far Create legitimately parking for a whole session, so a parked entry at
teardown is not automatically a defect. Wiring them into the connected gates
is carried with #277's service-window conversion, where "legitimately parked"
becomes definable.
Runtime 1,012/1,012. Complete Release solution: 10,834 passed / 4 skipped /
0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
95ebc03af4
commit
97d11e6c7f
9 changed files with 273 additions and 19 deletions
|
|
@ -556,17 +556,17 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
Func<RuntimeEntityRecord, Exception?>? retirePriorProjection)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
if (beginInitialResidence
|
||||
&& isLocalPlayer
|
||||
&& (incoming.Physics?.Position ?? incoming.Position)
|
||||
is { LandblockId: not 0u } initialPlayerPosition)
|
||||
if (beginInitialResidence && isLocalPlayer)
|
||||
{
|
||||
// The accepted local Create establishes the shared world frame
|
||||
// before any remote first-entry conductor converts its authored
|
||||
// landblock-local coordinates.
|
||||
Physics.ObserveLocalWorldFrame(
|
||||
initialPlayerPosition.LandblockId,
|
||||
teleportAdvanced: false);
|
||||
// landblock-local coordinates. #284: observe the Create itself
|
||||
// even when it carries no usable landblock - that is precisely
|
||||
// the case in which no frame is ever published, and every remote
|
||||
// placement would otherwise park forever without a diagnostic.
|
||||
Physics.ObserveLocalPlayerCreate(
|
||||
(incoming.Physics?.Position ?? incoming.Position)
|
||||
?.LandblockId ?? 0u);
|
||||
}
|
||||
if (_sessionClearInProgress)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -348,8 +348,10 @@ internal sealed class RuntimeRemoteFirstEntryState
|
|||
gameTime,
|
||||
out RuntimeSetPositionCommand command,
|
||||
resolveWorldOffsetFromRuntimeFrame: true);
|
||||
if (moverStatus
|
||||
== RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable)
|
||||
// #284: every retryable reason resumes on a later pump. Comparing
|
||||
// against one reason would silently reclassify a new one as a
|
||||
// hard rejection.
|
||||
if (moverStatus.IsRetryable())
|
||||
{
|
||||
return RuntimeRemoteFirstEntryStatus.AwaitingCollisionSource;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -364,8 +364,10 @@ internal sealed class RuntimeLocalPlayerFirstEntryState
|
|||
collisionSource,
|
||||
gameTime,
|
||||
out RuntimeSetPositionCommand command);
|
||||
if (moverStatus
|
||||
== RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable)
|
||||
// #284: every retryable reason resumes on a later pump. Comparing
|
||||
// against one reason would silently reclassify a new one as a
|
||||
// hard rejection.
|
||||
if (moverStatus.IsRetryable())
|
||||
{
|
||||
return RuntimeLocalPlayerFirstEntryStatus
|
||||
.AwaitingCollisionSource;
|
||||
|
|
|
|||
|
|
@ -449,6 +449,7 @@ public sealed class RuntimePhysicsState : IDisposable
|
|||
private long _nextCollisionPreparationSequence;
|
||||
private ulong _collisionWorldAuthority = 1UL;
|
||||
private uint _worldFrameCenterLandblockId;
|
||||
private bool _localPlayerCreateObserved;
|
||||
private readonly List<Action<RuntimeCollisionGenerationCommitted>>
|
||||
_collisionGenerationCommittedObservers = new();
|
||||
private bool _disposed;
|
||||
|
|
@ -545,6 +546,40 @@ public sealed class RuntimePhysicsState : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #284: records that this session's local-player Create was accepted,
|
||||
/// independently of whether it carried a usable landblock. The frame is
|
||||
/// published exactly once per session from that Create, so after it has
|
||||
/// been observed a still-absent frame can never be supplied by a later
|
||||
/// pump - see <see cref="ThrowIfWorldFrameUnreachable"/>.
|
||||
/// </summary>
|
||||
internal void ObserveLocalPlayerCreate(uint fullCellId)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
_localPlayerCreateObserved = true;
|
||||
ObserveLocalWorldFrame(fullCellId, teleportAdvanced: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #284: converts an unresolvable world-frame wait into a loud failure.
|
||||
/// Parking is legitimate only while the local-player Create is still
|
||||
/// outstanding; once it has been accepted without publishing a frame,
|
||||
/// every remote placement would retry forever in silence and the entities
|
||||
/// would simply never appear. Terminal by design - this is a violated
|
||||
/// invariant, not resumable work.
|
||||
/// </summary>
|
||||
internal void ThrowIfWorldFrameUnreachable(uint fullCellId)
|
||||
{
|
||||
if (!_localPlayerCreateObserved || _worldFrameCenterLandblockId != 0u)
|
||||
return;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Runtime's world frame is unreachable: the local-player Create "
|
||||
+ "was accepted without publishing a frame, so the placement for "
|
||||
+ $"landblock 0x{fullCellId & 0xFFFF0000u:X8} can never resolve. "
|
||||
+ "A local-player CreateObject must carry a non-zero landblock.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a retail landblock-local network frame into the Runtime's
|
||||
/// current world frame without consulting presentation or waiting for
|
||||
|
|
@ -1363,6 +1398,7 @@ public sealed class RuntimePhysicsState : IDisposable
|
|||
SetPosition.ResetSession();
|
||||
CollisionReports.ResetSession();
|
||||
_worldFrameCenterLandblockId = 0u;
|
||||
_localPlayerCreateObserved = false;
|
||||
AdvanceCollisionWorldAuthority();
|
||||
Volatile.Write(ref _collisionMutationThreadId, 0);
|
||||
}
|
||||
|
|
@ -2056,6 +2092,7 @@ public sealed class RuntimePhysicsState : IDisposable
|
|||
_collisionAdmissions.Clear();
|
||||
_collisionGenerations.Clear();
|
||||
_worldFrameCenterLandblockId = 0u;
|
||||
_localPlayerCreateObserved = false;
|
||||
CellCommitted = null;
|
||||
_collisionGenerationCommittedObservers.Clear();
|
||||
_disposed = true;
|
||||
|
|
|
|||
|
|
@ -9,11 +9,72 @@ namespace AcDream.Runtime.Physics;
|
|||
internal enum RuntimeSetPositionMoverPreparationStatus
|
||||
{
|
||||
Prepared,
|
||||
|
||||
/// <summary>
|
||||
/// The authored Setup collision payload has not been read yet. Resolves
|
||||
/// when the prepared-asset read completes.
|
||||
/// </summary>
|
||||
RetrySetupUnavailable,
|
||||
|
||||
/// <summary>
|
||||
/// #284: Runtime's world frame is not published yet, so a landblock-local
|
||||
/// Create origin cannot be converted
|
||||
/// (<see cref="RuntimePhysicsState.TryGetWorldFrameOffset"/>). Previously
|
||||
/// this reported itself as <see cref="RetrySetupUnavailable"/>, which sent
|
||||
/// anyone diagnosing a parked placement to the asset pipeline instead of
|
||||
/// the missing local-player Create that actually publishes the frame.
|
||||
/// Retryable exactly like Setup - only the reason differs.
|
||||
/// </summary>
|
||||
RetryWorldFrameUnavailable,
|
||||
|
||||
RejectedAuthority,
|
||||
InvalidData,
|
||||
}
|
||||
|
||||
internal static class RuntimeSetPositionMoverPreparationStatusExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// True for the statuses whose work can still succeed on a later pump.
|
||||
/// Call sites must use this rather than comparing against one reason, so
|
||||
/// a newly added retry reason cannot be silently treated as a rejection.
|
||||
/// </summary>
|
||||
internal static bool IsRetryable(
|
||||
this RuntimeSetPositionMoverPreparationStatus status) =>
|
||||
status is RuntimeSetPositionMoverPreparationStatus
|
||||
.RetrySetupUnavailable
|
||||
or RuntimeSetPositionMoverPreparationStatus
|
||||
.RetryWorldFrameUnavailable;
|
||||
|
||||
/// <summary>
|
||||
/// The parked-placement reason a retryable status contributes to the
|
||||
/// ownership ledger, or <see cref="RuntimeSetPositionParkReason.None"/>
|
||||
/// when the status is not a park.
|
||||
/// </summary>
|
||||
internal static RuntimeSetPositionParkReason ParkReason(
|
||||
this RuntimeSetPositionMoverPreparationStatus status) =>
|
||||
status switch
|
||||
{
|
||||
RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable =>
|
||||
RuntimeSetPositionParkReason.AwaitingSetupCollision,
|
||||
RuntimeSetPositionMoverPreparationStatus
|
||||
.RetryWorldFrameUnavailable =>
|
||||
RuntimeSetPositionParkReason.AwaitingWorldFrame,
|
||||
_ => RuntimeSetPositionParkReason.None,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #284: why a first-entry placement is currently parked. Retained on the
|
||||
/// operation so <c>CaptureOwnership</c> can report parked work by cause
|
||||
/// instead of leaving it invisible until a downstream symptom appears.
|
||||
/// </summary>
|
||||
internal enum RuntimeSetPositionParkReason
|
||||
{
|
||||
None,
|
||||
AwaitingSetupCollision,
|
||||
AwaitingWorldFrame,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinguishes a Setup payload which has not arrived yet from a completed
|
||||
/// lookup whose result is absent. Retail supplies its dummy placement sphere
|
||||
|
|
|
|||
|
|
@ -273,8 +273,18 @@ internal readonly record struct RuntimeSetPositionOwnershipSnapshot(
|
|||
int AcknowledgedPlacementCompletionCount,
|
||||
int CollisionPrefixQuiescenceCount,
|
||||
int PendingQuiescenceProjectionCount,
|
||||
int PooledOperationCount)
|
||||
int PooledOperationCount,
|
||||
int ParkedAwaitingSetupCollisionCount = 0,
|
||||
int ParkedAwaitingWorldFrameCount = 0)
|
||||
{
|
||||
/// <summary>
|
||||
/// #284: placements that could not be prepared on their last attempt and
|
||||
/// are waiting for a later pump, by cause. Non-zero at a stable
|
||||
/// checkpoint means work is stuck; the reason names where to look.
|
||||
/// </summary>
|
||||
internal int ParkedPlacementCount =>
|
||||
ParkedAwaitingSetupCollisionCount + ParkedAwaitingWorldFrameCount;
|
||||
|
||||
internal bool IndexesConsistent =>
|
||||
LostDeadlineCount == LostDeadlineNodeCount
|
||||
&& LostDeadlineCount == LostDeadlineIndexCount
|
||||
|
|
@ -399,6 +409,13 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
internal bool InheritedLostDeadline { get; set; }
|
||||
internal bool EnteringWorldFromCelllessResidence { get; set; }
|
||||
internal bool DormantLocalActivation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// #284: why this operation's mover preparation is currently parked,
|
||||
/// or <see cref="RuntimeSetPositionParkReason.None"/> when it is not.
|
||||
/// Observability only - it never gates work.
|
||||
/// </summary>
|
||||
internal RuntimeSetPositionParkReason ParkReason { get; set; }
|
||||
internal RuntimeSetPositionCommand? PreparedCommandAwaitingWithdrawalAck
|
||||
{
|
||||
get;
|
||||
|
|
@ -464,6 +481,7 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
InheritedLostDeadline = false;
|
||||
EnteringWorldFromCelllessResidence = false;
|
||||
DormantLocalActivation = false;
|
||||
ParkReason = RuntimeSetPositionParkReason.None;
|
||||
PreparedCommandAwaitingWithdrawalAck = null;
|
||||
InPool = false;
|
||||
}
|
||||
|
|
@ -726,6 +744,8 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
{
|
||||
int deferred = 0;
|
||||
int awaitingPreparation = 0;
|
||||
int parkedAwaitingSetup = 0;
|
||||
int parkedAwaitingWorldFrame = 0;
|
||||
foreach (Operation operation in _operations.Values)
|
||||
{
|
||||
if (operation.Stage
|
||||
|
|
@ -735,6 +755,15 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
}
|
||||
if (operation.WakeableLostCell)
|
||||
deferred++;
|
||||
switch (operation.ParkReason)
|
||||
{
|
||||
case RuntimeSetPositionParkReason.AwaitingSetupCollision:
|
||||
parkedAwaitingSetup++;
|
||||
break;
|
||||
case RuntimeSetPositionParkReason.AwaitingWorldFrame:
|
||||
parkedAwaitingWorldFrame++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
int pendingQuiescenceProjections = 0;
|
||||
foreach (CollisionPrefixQuiescence quiescence
|
||||
|
|
@ -764,7 +793,9 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
_acknowledgedPlacementCompletions.Count,
|
||||
_collisionPrefixQuiescence.Count,
|
||||
pendingQuiescenceProjections,
|
||||
_operationPool.Count);
|
||||
_operationPool.Count,
|
||||
parkedAwaitingSetup,
|
||||
parkedAwaitingWorldFrame);
|
||||
}
|
||||
|
||||
internal int PendingProjectionCount => _pendingProjection.Count;
|
||||
|
|
@ -1518,8 +1549,10 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
|
||||
if (!preparation.Setup.IsResolved)
|
||||
{
|
||||
return RuntimeSetPositionMoverPreparationStatus
|
||||
.RetrySetupUnavailable;
|
||||
return Park(
|
||||
operation,
|
||||
RuntimeSetPositionMoverPreparationStatus
|
||||
.RetrySetupUnavailable);
|
||||
}
|
||||
|
||||
RuntimeSetPositionMoverPreparation effectivePreparation = preparation;
|
||||
|
|
@ -1537,8 +1570,19 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
out float worldOffsetX,
|
||||
out float worldOffsetY))
|
||||
{
|
||||
return RuntimeSetPositionMoverPreparationStatus
|
||||
.RetrySetupUnavailable;
|
||||
// #284: the frame is published once, by the accepted
|
||||
// local-player Create, and is never withdrawn inside a
|
||||
// session. If that Create has already been observed, waiting
|
||||
// is not a wait - no later pump can ever supply the frame, so
|
||||
// parking here would retry forever in silence. Surface the
|
||||
// contradiction instead, exactly as 01f4791e made a violated
|
||||
// receipt-ledger invariant terminal rather than resumable.
|
||||
_physics.ThrowIfWorldFrameUnreachable(
|
||||
authority.AcceptedPosition.LandblockId);
|
||||
return Park(
|
||||
operation,
|
||||
RuntimeSetPositionMoverPreparationStatus
|
||||
.RetryWorldFrameUnavailable);
|
||||
}
|
||||
|
||||
effectivePreparation = preparation with
|
||||
|
|
@ -1569,9 +1613,24 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
PreparedCommand = command,
|
||||
};
|
||||
|
||||
operation.ParkReason = RuntimeSetPositionParkReason.None;
|
||||
return RuntimeSetPositionMoverPreparationStatus.Prepared;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #284: records why this operation could not be prepared, so parked work
|
||||
/// is visible in <see cref="CaptureOwnership"/> by cause rather than only
|
||||
/// as a downstream symptom. Retains the reason on the operation until the
|
||||
/// preparation succeeds or the operation is retired.
|
||||
/// </summary>
|
||||
private static RuntimeSetPositionMoverPreparationStatus Park(
|
||||
Operation operation,
|
||||
RuntimeSetPositionMoverPreparationStatus status)
|
||||
{
|
||||
operation.ParkReason = status.ParkReason();
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C0-3: chains the exact-Setup mover pipeline end-to-end for an
|
||||
/// authored placement (initial-Create or any other authored-mover
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue