refactor(runtime): unify generation reset for direct hosts

Move canonical per-session teardown into one retryable Runtime transaction, reduce App reset to projection acknowledgements, and prove the same GameRuntime graph through deterministic no-window lifecycle, gameplay, portal, fault, reconnect, and isolation gates.\n\nCo-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-27 00:43:26 +02:00
parent 7818494116
commit a9a822f206
28 changed files with 2707 additions and 345 deletions

View file

@ -968,15 +968,55 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
_sessionClearInProgress = true;
Entities.BeginSessionClear();
RuntimeEntityRecord[] retired = Entities.ActiveRecords.ToArray();
foreach (RuntimeEntityRecord canonical in retired)
RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray();
foreach (RuntimeEntityRecord canonical in active)
{
if (!Entities.RemoveActive(canonical))
continue;
Entities.RetainTeardown(canonical);
PublishEntity(RuntimeEntityChange.Deleted, canonical);
}
return retired;
return active;
}
public IReadOnlyList<RuntimeEntityRecord>
CaptureSessionClearRetirements()
{
EnsureNotDisposed();
if (!_sessionClearInProgress)
{
throw new InvalidOperationException(
"Session-clear retirements are only available while the "
+ "canonical clear transaction is active.");
}
return Entities.TeardownRecords.ToArray();
}
/// <summary>
/// Completes one exact incarnation retained by
/// <see cref="BeginSessionClear"/> after the borrowed host has retired its
/// presentation projection. Failure keeps the tombstone and local
/// identity intact so the same transaction cursor can retry this exact
/// record without reconstructing the retirement set.
/// </summary>
public void CompleteSessionEntityRetirement(
RuntimeEntityRecord canonical)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical);
if (!_sessionClearInProgress
|| !Entities.TryGetTeardown(
canonical.ServerGuid,
canonical.Incarnation,
out RuntimeEntityRecord retained)
|| !ReferenceEquals(retained, canonical))
{
throw new InvalidOperationException(
$"Live entity 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} is not retained by the active session-clear transaction.");
}
CompleteProjectionRetirement(canonical);
Entities.ReleaseTeardown(canonical);
}
public bool CompleteSessionClearIfConverged()

View file

@ -58,6 +58,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot(
RuntimeSimulationOwnershipSnapshot Simulation,
RuntimeWorldEnvironmentOwnershipSnapshot Environment,
RuntimeWorldTransitOwnershipSnapshot Transit,
RuntimeGenerationResetSnapshot GenerationReset,
GameRuntimeEventOwnershipSnapshot Events)
{
public bool IsConverged =>
@ -70,6 +71,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot(
&& PlayerIdentity.IsConverged
&& Simulation.IsConverged
&& Transit.IsSessionIdle
&& GenerationReset.IsConverged
&& Events.IsConverged;
}
@ -241,8 +243,19 @@ public sealed class GameRuntime
context,
faultInjection);
var generationReset = new RuntimeGenerationReset(
transit,
context.Communication,
context.Inventory,
context.Actions,
context.Movement,
context.EntityObjects,
context.Character,
context.PlayerIdentity);
context.EntityObjects.BindEventContext(
() => context.Session.Generation,
() => generationReset.ActiveRetiringGeneration
?? context.Session.Generation,
() => clock.FrameNumber);
context.Events = new GameRuntimeEventHub(
context.EntityObjects,
@ -264,6 +277,7 @@ public sealed class GameRuntime
ActionOwner = context.Actions;
EnvironmentOwner = environment;
TransitOwner = transit;
GenerationReset = generationReset;
_events = context.Events;
construction.Complete();
}
@ -285,6 +299,7 @@ public sealed class GameRuntime
public RuntimeLocalPlayerMovementState MovementOwner { get; }
public RuntimeWorldEnvironmentState EnvironmentOwner { get; }
public RuntimeWorldTransitState TransitOwner { get; }
public RuntimeGenerationReset GenerationReset { get; }
public RuntimeGenerationToken Generation => Session.Generation;
@ -347,6 +362,11 @@ public sealed class GameRuntime
Portal.Snapshot,
Portal.Ownership);
public void ResetGeneration(
RuntimeGenerationToken retiringGeneration,
IRuntimeGenerationResetHost host) =>
GenerationReset.Reset(retiringGeneration, host);
public IDisposable Subscribe(IRuntimeEventObserver observer)
{
lock (_lifetimeGate)
@ -393,6 +413,7 @@ public sealed class GameRuntime
MovementOwner),
EnvironmentOwner.CaptureOwnership(),
TransitOwner.CaptureOwnership(),
GenerationReset.CaptureSnapshot(),
_events.CaptureOwnership());
}
}
@ -534,6 +555,7 @@ public sealed class GameRuntime
StopSession();
return Session.CaptureOwnership().IsConverged;
case 3:
GenerationReset.DrainPending();
TransitOwner.ResetSession();
return TransitOwner.CaptureOwnership().IsSessionIdle;
case 4:

View file

@ -134,6 +134,26 @@ public sealed class RuntimeLocalPlayerMovementState
Interlocked.Increment(ref _revision);
}
/// <summary>
/// Retires every character-generation movement owner while keeping the
/// process-scoped Runtime root reusable for the next session.
/// Graphical hosts release their camera/physics projections first; direct
/// hosts have no parallel projection to coordinate.
/// </summary>
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
bool changed =
_autoRunActive
|| _controller is not null
|| _preparingMotionOwner is not null;
_autoRunActive = false;
_controller = null;
_preparingMotionOwner = null;
if (changed)
Interlocked.Increment(ref _revision);
}
public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
new(
_disposed,

View file

@ -0,0 +1,387 @@
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.World;
namespace AcDream.Runtime;
/// <summary>
/// The only host-specific edge in a character-generation reset. The host
/// retires presentation borrowed from an exact Runtime incarnation; Runtime
/// retains the retirement set, cursor, canonical cleanup, and generation.
/// </summary>
public interface IRuntimeGenerationResetHost
{
void RetireEntityProjection(RuntimeEntityRecord entity);
void DrainEntityProjectionBoundary();
void CompleteEntityProjectionRetirement();
}
public enum RuntimeGenerationResetStage
{
None = 0,
Transit = 1,
CommandTargets = 2,
ExternalContainer = 3,
Actions = 4,
Movement = 5,
ObjectTable = 6,
Character = 7,
ItemMana = 8,
Friends = 9,
Squelch = 10,
NegotiatedChannels = 11,
BeginEntityRetirement = 12,
RetireEntities = 13,
DrainHostProjection = 14,
CompleteCanonicalEntities = 15,
CompleteHostProjection = 16,
ChatIdentity = 17,
PlayerSnapshots = 18,
PlayerIdentity = 19,
Complete = 20,
}
public readonly record struct RuntimeGenerationResetSnapshot(
bool IsActive,
bool IsExecuting,
RuntimeGenerationToken RetiringGeneration,
RuntimeGenerationToken LastCompletedGeneration,
RuntimeGenerationResetStage Stage,
int RetirementCount,
int RetirementCursor,
bool CurrentProjectionAcknowledged,
long TransactionId)
{
public bool IsConverged =>
!IsActive
&& !IsExecuting;
}
public sealed class RuntimeGenerationResetStageException(
RuntimeGenerationToken retiringGeneration,
RuntimeGenerationResetStage stage,
Exception innerException) : Exception(
$"Runtime generation {retiringGeneration.Value} reset stage "
+ $"'{stage}' did not converge.",
innerException)
{
public RuntimeGenerationToken RetiringGeneration { get; } =
retiringGeneration;
public RuntimeGenerationResetStage Stage { get; } = stage;
}
/// <summary>
/// One persisted, retryable reset transaction for every canonical owner in a
/// reusable <see cref="GameRuntime"/>. A failed call retains the exact suffix:
/// completed stages and exact-incarnation retirements never replay.
/// </summary>
public sealed class RuntimeGenerationReset
{
private readonly RuntimeWorldTransitState _transit;
private readonly RuntimeCommunicationState _communication;
private readonly RuntimeInventoryState _inventory;
private readonly RuntimeActionState _actions;
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeCharacterState _character;
private readonly RuntimeLocalPlayerIdentityState _identity;
private ResetState? _state;
private RuntimeGenerationToken _lastCompletedGeneration;
private bool _hasCompletedGeneration;
private bool _executing;
private long _nextTransactionId;
internal RuntimeGenerationReset(
RuntimeWorldTransitState transit,
RuntimeCommunicationState communication,
RuntimeInventoryState inventory,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeEntityObjectLifetime entityObjects,
RuntimeCharacterState character,
RuntimeLocalPlayerIdentityState identity)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
?? throw new ArgumentNullException(nameof(communication));
_inventory = inventory
?? throw new ArgumentNullException(nameof(inventory));
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
_movement = movement
?? throw new ArgumentNullException(nameof(movement));
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_character = character
?? throw new ArgumentNullException(nameof(character));
_identity = identity
?? throw new ArgumentNullException(nameof(identity));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
_state?.Generation;
public RuntimeGenerationResetSnapshot CaptureSnapshot()
{
ResetState? state = _state;
return state is null
? new RuntimeGenerationResetSnapshot(
false,
_executing,
default,
_lastCompletedGeneration,
_hasCompletedGeneration
? RuntimeGenerationResetStage.Complete
: RuntimeGenerationResetStage.None,
0,
0,
false,
0)
: new RuntimeGenerationResetSnapshot(
true,
_executing,
state.Generation,
_lastCompletedGeneration,
state.Stage,
state.Retirements?.Length ?? 0,
state.RetirementCursor,
state.CurrentProjectionAcknowledged,
state.TransactionId);
}
public void Reset(
RuntimeGenerationToken retiringGeneration,
IRuntimeGenerationResetHost host)
{
ArgumentNullException.ThrowIfNull(host);
if (_executing)
{
throw new InvalidOperationException(
"Runtime generation reset cannot run concurrently or reentrantly.");
}
ResetState state = AcquireState(retiringGeneration, host);
if (state.Stage is RuntimeGenerationResetStage.Complete)
return;
_executing = true;
try
{
Drain(state);
}
catch (Exception error)
{
throw new RuntimeGenerationResetStageException(
state.Generation,
state.Stage,
error);
}
finally
{
_executing = false;
}
}
internal void DrainPending()
{
ResetState? state = _state;
if (state is null)
return;
Reset(state.Generation, state.Host);
}
private ResetState AcquireState(
RuntimeGenerationToken generation,
IRuntimeGenerationResetHost host)
{
if (_state is { } pending)
{
if (pending.Generation != generation)
{
throw new InvalidOperationException(
$"Runtime generation {pending.Generation.Value} reset "
+ $"must converge before generation {generation.Value} can reset.");
}
if (!ReferenceEquals(pending.Host, host))
{
throw new InvalidOperationException(
"An in-progress Runtime generation reset cannot replace "
+ "its borrowed projection host.");
}
return pending;
}
if (_hasCompletedGeneration)
{
if (generation == _lastCompletedGeneration)
return ResetState.Completed(generation, host);
if (generation.Value < _lastCompletedGeneration.Value)
{
throw new InvalidOperationException(
$"Runtime generation {generation.Value} reset is stale; "
+ $"generation {_lastCompletedGeneration.Value} already converged.");
}
}
var created = new ResetState(
generation,
host,
checked(++_nextTransactionId));
_state = created;
return created;
}
private void Drain(ResetState state)
{
while (state.Stage is not RuntimeGenerationResetStage.Complete)
{
switch (state.Stage)
{
case RuntimeGenerationResetStage.Transit:
Advance(state, _transit.ResetSession);
break;
case RuntimeGenerationResetStage.CommandTargets:
Advance(state, _communication.ResetCommandTargets);
break;
case RuntimeGenerationResetStage.ExternalContainer:
Advance(state, _inventory.ResetExternalContainer);
break;
case RuntimeGenerationResetStage.Actions:
Advance(state, _actions.ResetSession);
break;
case RuntimeGenerationResetStage.Movement:
Advance(state, _movement.ResetSession);
break;
case RuntimeGenerationResetStage.ObjectTable:
Advance(state, _entityObjects.ClearObjects);
break;
case RuntimeGenerationResetStage.Character:
Advance(state, _character.ResetSession);
break;
case RuntimeGenerationResetStage.ItemMana:
Advance(state, _inventory.ResetItemMana);
break;
case RuntimeGenerationResetStage.Friends:
Advance(state, _communication.ResetFriends);
break;
case RuntimeGenerationResetStage.Squelch:
Advance(state, _communication.ResetSquelch);
break;
case RuntimeGenerationResetStage.NegotiatedChannels:
Advance(
state,
_communication.ResetNegotiatedChannels);
break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects
.CaptureSessionClearRetirements()
.ToArray();
state.Stage = RuntimeGenerationResetStage.RetireEntities;
break;
case RuntimeGenerationResetStage.RetireEntities:
RetireCurrentEntity(state);
break;
case RuntimeGenerationResetStage.DrainHostProjection:
state.Host.DrainEntityProjectionBoundary();
state.Stage =
RuntimeGenerationResetStage.CompleteCanonicalEntities;
break;
case RuntimeGenerationResetStage.CompleteCanonicalEntities:
if (!_entityObjects.CompleteSessionClearIfConverged())
{
throw new InvalidOperationException(
"Canonical entity/object lifetime still owns an "
+ "unacknowledged session retirement.");
}
state.Stage =
RuntimeGenerationResetStage.CompleteHostProjection;
break;
case RuntimeGenerationResetStage.CompleteHostProjection:
state.Host.CompleteEntityProjectionRetirement();
state.Stage = RuntimeGenerationResetStage.ChatIdentity;
break;
case RuntimeGenerationResetStage.ChatIdentity:
Advance(state, _communication.ResetChatIdentity);
break;
case RuntimeGenerationResetStage.PlayerSnapshots:
Advance(state, _inventory.ResetPlayerSnapshots);
break;
case RuntimeGenerationResetStage.PlayerIdentity:
_identity.ResetSession();
Complete(state);
break;
default:
throw new InvalidOperationException(
$"Unsupported Runtime generation reset stage {state.Stage}.");
}
}
}
private void RetireCurrentEntity(ResetState state)
{
RuntimeEntityRecord[] retirements = state.Retirements ?? [];
if (state.RetirementCursor >= retirements.Length)
{
state.Stage = RuntimeGenerationResetStage.DrainHostProjection;
return;
}
RuntimeEntityRecord current = retirements[state.RetirementCursor];
if (!state.CurrentProjectionAcknowledged)
{
state.Host.RetireEntityProjection(current);
state.CurrentProjectionAcknowledged = true;
}
_entityObjects.CompleteSessionEntityRetirement(current);
state.CurrentProjectionAcknowledged = false;
state.RetirementCursor++;
}
private static void Advance(ResetState state, Action action)
{
action();
state.Stage++;
}
private void Complete(ResetState state)
{
state.Stage = RuntimeGenerationResetStage.Complete;
_lastCompletedGeneration = state.Generation;
_hasCompletedGeneration = true;
_state = null;
}
private sealed class ResetState
{
public ResetState(
RuntimeGenerationToken generation,
IRuntimeGenerationResetHost host,
long transactionId)
{
Generation = generation;
Host = host;
TransactionId = transactionId;
Stage = RuntimeGenerationResetStage.Transit;
}
public RuntimeGenerationToken Generation { get; }
public IRuntimeGenerationResetHost Host { get; }
public long TransactionId { get; }
public RuntimeGenerationResetStage Stage { get; set; }
public RuntimeEntityRecord[]? Retirements { get; set; }
public int RetirementCursor { get; set; }
public bool CurrentProjectionAcknowledged { get; set; }
public static ResetState Completed(
RuntimeGenerationToken generation,
IRuntimeGenerationResetHost host) =>
new(generation, host, 0)
{
Stage = RuntimeGenerationResetStage.Complete,
};
}
}

View file

@ -58,7 +58,7 @@ public sealed record LiveSessionStartResult(
public interface ILiveSessionLifecycleHost
{
LiveSessionBinding BindSession(WorldSession session);
void ResetSessionState();
void ResetSessionState(RuntimeGenerationToken retiringGeneration);
void ReportConnecting(string host, int port, string user);
void ReportConnected();
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
@ -185,12 +185,14 @@ public sealed class LiveSessionController
{
private sealed class SessionScope(
WorldSession session,
ILiveSessionLifecycleHost host)
ILiveSessionLifecycleHost host,
RuntimeGenerationToken generation)
{
private int _teardownStage;
public WorldSession Session { get; } = session;
public ILiveSessionLifecycleHost Host { get; } = host;
public RuntimeGenerationToken Generation { get; } = generation;
public LiveSessionBinding? Binding { get; set; }
public bool HostAttached { get; set; }
public RuntimeTeardownStage CompletedStages { get; private set; }
@ -226,7 +228,7 @@ public sealed class LiveSessionController
}
if (_teardownStage == 3)
{
Host.ResetSessionState();
Host.ResetSessionState(Generation);
CompletedStages |= RuntimeTeardownStage.HostReset;
_teardownStage = 4;
}
@ -247,11 +249,15 @@ public sealed class LiveSessionController
LiveSessionConnectOptions? Options = null,
ILiveSessionLifecycleHost? Host = null);
private sealed record PendingHostReset(
ILiveSessionLifecycleHost Host,
RuntimeGenerationToken Generation);
private readonly object _gate = new();
private readonly ILiveSessionOperations _operations;
private SessionScope? _scope;
private SessionScope? _retiredScope;
private ILiveSessionLifecycleHost? _pendingInitialResetHost;
private PendingHostReset? _pendingInitialReset;
private PendingOperation? _pendingOperation;
private int _operationDepth;
private bool _inWorld;
@ -306,7 +312,7 @@ public sealed class LiveSessionController
_inWorld,
_scope is not null,
_retiredScope is not null,
_pendingInitialResetHost is not null,
_pendingInitialReset is not null,
_pendingOperation is not null,
_operationDepth,
_generation,
@ -460,7 +466,9 @@ public sealed class LiveSessionController
ILiveSessionLifecycleHost host)
{
ILiveSessionLifecycleHost? oldHost =
_scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost;
_scope?.Host
?? _retiredScope?.Host
?? _pendingInitialReset?.Host;
ulong generationBeforeStop = _generation;
try
{
@ -480,6 +488,7 @@ public sealed class LiveSessionController
ILiveSessionLifecycleHost host,
bool resetHost)
{
RuntimeGenerationToken resetGeneration = new(_generation);
ulong generation = ++_generation;
try
{
@ -487,7 +496,12 @@ public sealed class LiveSessionController
if (_generation != generation)
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
if (resetHost)
ResetHostBeforeStart(host, generation);
{
ResetHostBeforeStart(
host,
generation,
resetGeneration);
}
if (_generation != generation)
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
}
@ -509,7 +523,10 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
Console.WriteLine($"live: connecting to {endpoint} as {options.User}");
WorldSession session = _operations.CreateSession(endpoint);
scope = new SessionScope(session, host);
scope = new SessionScope(
session,
host,
new RuntimeGenerationToken(generation));
_scope = scope;
_inWorld = false;
if (!IsCurrent(scope, generation))
@ -614,7 +631,7 @@ public sealed class LiveSessionController
}
DrainRetiredScope();
DrainPendingInitialReset();
if (_retiredScope is null && _pendingInitialResetHost is null)
if (_retiredScope is null && _pendingInitialReset is null)
_lastTeardownStages = RuntimeTeardownStage.Complete;
}
@ -632,35 +649,38 @@ public sealed class LiveSessionController
private void ResetHostBeforeStart(
ILiveSessionLifecycleHost host,
ulong generation)
ulong generation,
RuntimeGenerationToken resetGeneration)
{
bool requestedHostAlreadyReset = false;
if (_pendingInitialResetHost is { } pending)
if (_pendingInitialReset is { } pending)
{
pending.ResetSessionState();
_pendingInitialResetHost = null;
requestedHostAlreadyReset = ReferenceEquals(pending, host);
pending.Host.ResetSessionState(pending.Generation);
_pendingInitialReset = null;
requestedHostAlreadyReset =
ReferenceEquals(pending.Host, host);
}
if (requestedHostAlreadyReset || _generation != generation)
return;
try
{
host.ResetSessionState();
host.ResetSessionState(resetGeneration);
}
catch
{
_pendingInitialResetHost = host;
_pendingInitialReset =
new PendingHostReset(host, resetGeneration);
throw;
}
}
private void DrainPendingInitialReset()
{
if (_pendingInitialResetHost is not { } host)
if (_pendingInitialReset is not { } pending)
return;
host.ResetSessionState();
_pendingInitialResetHost = null;
pending.Host.ResetSessionState(pending.Generation);
_pendingInitialReset = null;
}
private void Schedule(PendingOperation operation)

View file

@ -24,7 +24,7 @@ public sealed record LiveSessionEnteredWorldBindings(
public sealed record LiveSessionHostBindings(
LiveSessionRoutingFactories Routing,
Action Reset,
Action<RuntimeGenerationToken> Reset,
LiveSessionSelectionBindings Selection,
LiveSessionEnteredWorldBindings EnteredWorld,
Action<string, int, string> Connecting,
@ -84,7 +84,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
private readonly LiveSessionRoutingFactories _routing;
private readonly LiveSessionSelectionBindings _selection;
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
private readonly Action _reset;
private readonly Action<RuntimeGenerationToken> _reset;
private readonly LiveSessionLifecycleHost _lifecycle;
private PendingRouteRollback? _pendingRouteRollback;
@ -188,13 +188,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
}
}
private void ResetSessionState()
private void ResetSessionState(
RuntimeGenerationToken retiringGeneration)
{
// An incompletely detached route can still deliver callbacks into the
// state below. Treat physical route convergence as the same hard
// barrier used by normal LiveSessionBinding teardown.
DrainPendingRouteRollback();
_reset();
_reset(retiringGeneration);
}
private void ApplySelection(LiveSessionCharacterSelection selection)

View file

@ -4,7 +4,7 @@ namespace AcDream.Runtime.Session;
public sealed record LiveSessionLifecycleBindings(
Func<WorldSession, LiveSessionBinding> Bind,
Action Reset,
Action<RuntimeGenerationToken> Reset,
Action<string, int, string> Connecting,
Action Connected,
Action<LiveSessionCharacterSelection> Selected,
@ -42,7 +42,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
return binding;
}
public void ResetSessionState() => _bindings.Reset();
public void ResetSessionState(
RuntimeGenerationToken retiringGeneration) =>
_bindings.Reset(retiringGeneration);
public void ReportConnecting(string host, int port, string user) =>
_bindings.Connecting(host, port, user);