diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs index 2ce900a10..dc6fdc43b 100644 --- a/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs @@ -6,7 +6,7 @@ namespace game.PlayerDataStorage { internal enum PlayerKVGameLoadError { None, BackendNotEnabled, BackendUnsupported, Input, Prepare, Schema, Apply, Stale, SideEffect, GateBusy, PreviousClosePending, PreviousCloseFailed, ClosePending, CloseFailed } - internal enum PlayerKVGameLoadState { Rejected, LoadedForValidation } + internal enum PlayerKVGameLoadState { Rejected, LoadedForValidation, GameLoaded } // Preserve stage evidence without retaining a successful Prepare payload on a later load failure. internal sealed class PlayerKVPrepareEvidence { @@ -27,7 +27,7 @@ namespace game.PlayerDataStorage } internal sealed class PlayerKVGameLoadResult { - internal PlayerKVGameLoadState State => Error==PlayerKVGameLoadError.None ? PlayerKVGameLoadState.LoadedForValidation : PlayerKVGameLoadState.Rejected; + internal PlayerKVGameLoadState State { get; } internal PlayerKVGameLoadError Error { get; } internal PlayerKVGameLoadError OriginalError { get; } internal bool CleanupFailed => Error==PlayerKVGameLoadError.CloseFailed; @@ -37,8 +37,9 @@ namespace game.PlayerDataStorage internal int ModuleCount { get; } internal int IntentCount { get; } internal PlayerKVGameLoadResult(PlayerKVGameLoadError error,long epoch,PlayerKVGameLoadError? original=null, - PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0) - { Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents; } + PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0,bool forGame=false) + { Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents; + State=error!=PlayerKVGameLoadError.None ? PlayerKVGameLoadState.Rejected : forGame ? PlayerKVGameLoadState.GameLoaded : PlayerKVGameLoadState.LoadedForValidation; } } internal sealed class PlayerKVGameLoadCoordinator { @@ -61,6 +62,7 @@ namespace game.PlayerDataStorage Action clearAfterApply; long epoch; bool loaded; + PlayerKVGameSessionHandle gameHandle; internal bool LoadedForValidation => loaded && context!=null && context.IsCurrent && guard!=null && guard.IsCurrent && guard.Violations==0; internal int QuitObserveMs => capability?.QuitObserveMs ?? 5000; internal PlayerKVGameLoadCoordinator(PlayerKVGameLoadTransaction transaction,Func unstarted,Action notify,Func intentCount, @@ -72,7 +74,29 @@ namespace game.PlayerDataStorage this.unbind=unbind ?? throw new ArgumentNullException(nameof(unbind)); this.registry=registry ?? PlayerKVShutdownRegistry.Process; mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current; } - internal async Task LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func identityCurrent=null) + internal Task LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func identityCurrent=null) + => LoadCoreAsync(id,next,token,identityCurrent,false); + internal Task LoadForGameAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func identityCurrent=null) + => LoadCoreAsync(id,next,token,identityCurrent,true); + internal bool TryGetCurrentGameHandle(long expectedEpoch,out PlayerKVGameSessionHandle handle) + { + handle=null; + PlayerKVLoginContext owner; PlayerKVGameSessionHandle published; + lock(closeGate) + { + if(closing!=null || epoch!=expectedEpoch || gameHandle==null) return false; + owner=context; published=gameHandle; + } + // Never call the external identity predicate while holding a publication monitor. + if(!owner.TryAcquireGameHandle(expectedEpoch,out var original) || !ReferenceEquals(original,published)) return false; + lock(closeGate) + { + if(closing!=null || epoch!=expectedEpoch || !ReferenceEquals(context,owner) || !ReferenceEquals(gameHandle,published)) return false; + handle=published; + return true; + } + } + async Task LoadCoreAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token,Func identityCurrent,bool forGame) { if(next==null || Thread.CurrentThread.ManagedThreadId!=mainThread || string.IsNullOrEmpty(id)) return Result(PlayerKVGameLoadError.Input); @@ -89,9 +113,13 @@ namespace game.PlayerDataStorage if(token.IsCancellationRequested || !unstarted() || transaction.Poisoned) return Result(PlayerKVGameLoadError.Input); if(!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard)) return Result(PlayerKVGameLoadError.GateBusy); lifetimeRegistration.Dispose(); - guard=enteredGuard; capability=next; closing=null; loaded=false; - attempt=Interlocked.Increment(ref epoch); - context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent())); + lock(closeGate) + { + guard=enteredGuard; capability=next; closing=null; loaded=false; gameHandle=null; + attempt=Interlocked.Increment(ref epoch); + context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent())); + } + var owner=context; lifetimeRegistration=token.Register(()=> { _=Revoke(attempt); }); var work=context.PrepareAsync(token); var cancelled=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -113,9 +141,26 @@ namespace game.PlayerDataStorage finally { var pending=clearAfterApply; clearAfterApply=null; pending?.Invoke(); } if(applied!=PlayerKVApplyError.None) return await Reject(applied==PlayerKVApplyError.Stale ? PlayerKVGameLoadError.Stale : applied==PlayerKVApplyError.SideEffect ? PlayerKVGameLoadError.SideEffect : PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); - if(token.IsCancellationRequested || !context.IsCurrent || guard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict); - loaded=true; - return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intentCount()); + if(token.IsCancellationRequested || !owner.IsCurrent || enteredGuard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict); + var intents=intentCount(); + PlayerKVGameSessionHandle original=null; + if(forGame && !owner.TryAcquireGameHandle(attempt,out original)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict); + bool published=false; + lock(closeGate) + { + if(closing==null && epoch==attempt && !token.IsCancellationRequested && ReferenceEquals(context,owner) && enteredGuard.IsCurrent && enteredGuard.Violations==0) + { + if(forGame) + { + enteredGuard.Dispose(); + // Dispose serializes with Reject; its final count cannot change afterwards. + if(enteredGuard.Violations==0) { gameHandle=original; published=true; } + } + else { loaded=true; published=true; } + } + } + if(!published) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict); + return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intents,forGame:forGame); } catch { return await Reject(PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); } finally { loginGate.Release(); } @@ -148,7 +193,7 @@ namespace game.PlayerDataStorage { if(expectedEpoch.HasValue && expectedEpoch.Value!=Interlocked.Read(ref epoch)) return Task.CompletedTask; if(closing!=null) return closing; - loaded=false; Interlocked.Increment(ref epoch); + loaded=false; gameHandle=null; Interlocked.Increment(ref epoch); if(context==null && guard==null) return Task.CompletedTask; var completion=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); closing=completion.Task; diff --git a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs index a1bb78c83..8b779f45c 100644 --- a/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs +++ b/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs @@ -58,6 +58,21 @@ namespace game.PlayerDataStorage internal PlayerKVAccountDescriptor AccountDescriptor => Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null; + // Only the coordinator may publish this original handle after Apply and Guard release. + internal bool TryAcquireGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle gameHandle) + { + gameHandle = null; + // IsCurrent can invoke external code and synchronously reenter CloseAsync. + if (expectedEpoch != epoch || !IsCurrent) return false; + lock (gate) + { + if (closing != null || revoked != 0 || prepared != 1 || handle == null || handle.Epoch != expectedEpoch) + return false; + gameHandle = handle; + return true; + } + } + // All issuance and Close admission share gate. The editor factory may not block on Close. internal bool TryAcquireAccountLease(PlayerKVControlledLoadCapability capability, out PlayerKVAccountLease lease) diff --git a/CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs b/CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs index 46efb0c55..d53aa274c 100644 --- a/CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs +++ b/CodexOutput/upserver/KVGameLoadTests/CoordinatorTests.cs @@ -59,6 +59,19 @@ static partial class Tests } static async Task RunCoordinatorTests() { + await Run("M2-CP1-game-load-releases-guard-after-notification",()=>OnMain(async()=> + { + var f=new LoadFixture(); + try + { + var result=await f.Coordinator.LoadForGameAsync("101",f.Capability()); + Check(result.Error==PlayerKVGameLoadError.None && f.Notify==1,"complete apply and notification"); + Check(!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.ReadLocal),"game success must release its load guard"); + Check(result.State.ToString()=="GameLoaded" && !f.Coordinator.LoadedForValidation,"game success differs from validation"); + } + finally { await f.Coordinator.Revoke(); } + })); + await RunGameHandoffTests(); await Run("coordinator-success-reload-revoke-and-one-auth",()=>OnMain(async()=> { var f=new LoadFixture(); diff --git a/CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj b/CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj index 423e444ba..eaf11bc49 100644 --- a/CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj +++ b/CodexOutput/upserver/KVGameLoadTests/KVGameLoadTests.csproj @@ -5,6 +5,8 @@ $(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config + +