feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。 保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。 验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。 包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
|
||||
<DefineConstants>UNITY_EDITOR</DefineConstants><ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
|
||||
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="ServiceHost/**/*.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVInitializationIntents.cs" Link="Production/PlayerKVInitializationIntents.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadTransaction.cs" Link="Production/PlayerKVGameLoadTransaction.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadGuard.cs" Link="Production/PlayerKVGameLoadGuard.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVControlledLoadCapability.cs" Link="Production/PlayerKVControlledLoadCapability.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameLoadCoordinator.cs" Link="Production/PlayerKVGameLoadCoordinator.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs" Link="Production/PlayerKVSaveSchema.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs" Link="Production/PlayerKVDecodedSave.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVShutdownRegistry.cs" Link="Production/PlayerKVShutdownRegistry.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs" Link="Production/PlayerKVLoginContext.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginPrepareResult.cs" Link="Production/PlayerKVLoginPrepareResult.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVGameSessionBridge.cs" Link="Production/PlayerKVGameSessionBridge.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs" Link="Production/PlayerKVModels.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs" Link="Production/PlayerKVClient.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs" Link="Production/PlayerKVSyncModels.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs" Link="Production/PlayerKVCacheStore.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs" Link="Production/PlayerKVSyncState.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncCoordinator.cs" Link="Production/PlayerKVSyncCoordinator.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVSessionOwner.cs" Link="Production/PlayerKVSessionOwner.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/PlayerData/PlayerKVLocalReadResult.cs" Link="Production/PlayerKVLocalReadResult.cs" />
|
||||
<Compile Include="../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" Link="Production/S101AccountSession.cs" />
|
||||
<Reference Include="Newtonsoft.Json"><HintPath>../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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 }
|
||||
// Preserve stage evidence without retaining a successful Prepare payload on a later load failure.
|
||||
internal sealed class PlayerKVPrepareEvidence
|
||||
{
|
||||
internal PlayerKVLoginPrepareError Error { get; }
|
||||
internal PlayerKVLoginPrepareError OriginalError { get; }
|
||||
internal game.Account.AccountLoginError? AccountError { get; }
|
||||
internal PlayerKVGameOpenError? OpenError { get; }
|
||||
internal PlayerKVSessionOpenError? OwnerError { get; }
|
||||
internal PlayerKVError? KVError { get; }
|
||||
internal PlayerKVSyncStatus? SyncStatus { get; }
|
||||
internal int? HttpStatusCode { get; }
|
||||
internal string ExceptionType { get; }
|
||||
internal string CleanupExceptionType { get; }
|
||||
internal PlayerKVPrepareEvidence(PlayerKVLoginPrepareResult value)
|
||||
{ Error=value.Error; OriginalError=value.OriginalError; AccountError=value.AccountError; OpenError=value.OpenError;
|
||||
OwnerError=value.OwnerError; KVError=value.KVError; SyncStatus=value.SyncStatus; HttpStatusCode=value.HttpStatusCode;
|
||||
ExceptionType=value.ExceptionType; CleanupExceptionType=value.CleanupExceptionType; }
|
||||
}
|
||||
internal sealed class PlayerKVGameLoadResult
|
||||
{
|
||||
internal PlayerKVGameLoadState State => Error==PlayerKVGameLoadError.None ? PlayerKVGameLoadState.LoadedForValidation : PlayerKVGameLoadState.Rejected;
|
||||
internal PlayerKVGameLoadError Error { get; }
|
||||
internal PlayerKVGameLoadError OriginalError { get; }
|
||||
internal bool CleanupFailed => Error==PlayerKVGameLoadError.CloseFailed;
|
||||
internal PlayerKVSchemaVerdict? SchemaVerdict { get; }
|
||||
internal PlayerKVPrepareEvidence Prepare { get; }
|
||||
internal long Epoch { get; }
|
||||
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; }
|
||||
}
|
||||
internal sealed class PlayerKVGameLoadCoordinator
|
||||
{
|
||||
readonly SemaphoreSlim loginGate=new SemaphoreSlim(1,1);
|
||||
readonly object closeGate=new object();
|
||||
readonly PlayerKVGameLoadTransaction transaction;
|
||||
readonly Func<bool> unstarted;
|
||||
readonly Action notify;
|
||||
readonly Func<int> intentCount;
|
||||
readonly Action<PlayerKVAccountLease> bind;
|
||||
readonly Func<Task> unbind;
|
||||
readonly PlayerKVShutdownRegistry registry;
|
||||
readonly int mainThread;
|
||||
readonly SynchronizationContext mainContext;
|
||||
PlayerKVLoginContext context;
|
||||
PlayerKVGameLoadGuard guard;
|
||||
PlayerKVGameLoadCapability capability;
|
||||
Task closing;
|
||||
CancellationTokenRegistration lifetimeRegistration;
|
||||
Action clearAfterApply;
|
||||
long epoch;
|
||||
bool loaded;
|
||||
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<bool> unstarted,Action notify,Func<int> intentCount,
|
||||
Action<PlayerKVAccountLease> bind,Func<Task> unbind,PlayerKVShutdownRegistry registry=null)
|
||||
{
|
||||
this.transaction=transaction ?? throw new ArgumentNullException(nameof(transaction));
|
||||
this.unstarted=unstarted ?? throw new ArgumentNullException(nameof(unstarted)); this.notify=notify ?? throw new ArgumentNullException(nameof(notify));
|
||||
this.intentCount=intentCount ?? throw new ArgumentNullException(nameof(intentCount)); this.bind=bind ?? throw new ArgumentNullException(nameof(bind));
|
||||
this.unbind=unbind ?? throw new ArgumentNullException(nameof(unbind)); this.registry=registry ?? PlayerKVShutdownRegistry.Process;
|
||||
mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current;
|
||||
}
|
||||
internal async Task<PlayerKVGameLoadResult> LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
|
||||
{
|
||||
if(next==null || Thread.CurrentThread.ManagedThreadId!=mainThread || string.IsNullOrEmpty(id))
|
||||
return Result(PlayerKVGameLoadError.Input);
|
||||
if(!await EnterGate(next,token)) return Result(token.IsCancellationRequested ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.GateBusy);
|
||||
PlayerKVLoginPrepareResult prepared=null; PlayerKVSchemaVerdict? verdict=null;
|
||||
long attempt=0;
|
||||
try
|
||||
{
|
||||
// An earlier successful validation must also be explicitly revoked before replacing its account.
|
||||
if(context!=null) _=Revoke();
|
||||
var previous=await registry.ObserveAsync(next.LoginObserveMs,token,next.Delay);
|
||||
if(previous!=PlayerKVShutdownStatus.Ready) return Result(previous==PlayerKVShutdownStatus.Failed ? PlayerKVGameLoadError.PreviousCloseFailed :
|
||||
previous==PlayerKVShutdownStatus.Cancelled ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.PreviousClosePending);
|
||||
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()));
|
||||
lifetimeRegistration=token.Register(()=> { _=Revoke(attempt); });
|
||||
var work=context.PrepareAsync(token);
|
||||
var cancelled=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using(token.Register(()=>cancelled.TrySetResult(true)))
|
||||
{
|
||||
if(await Task.WhenAny(work,cancelled.Task)!=work)
|
||||
return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
prepared=await work;
|
||||
}
|
||||
if(!prepared.Prepared) return await Reject(prepared.CleanupFailed ? PlayerKVGameLoadError.CloseFailed : PlayerKVGameLoadError.Prepare,attempt,next,prepared,verdict);
|
||||
if(!context.IsCurrent || epoch!=attempt) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
var decoded=PlayerKVSaveSchema.Decode(prepared,context.AccountDescriptor,id,attempt,context.IsCurrent,next.Snapshot,next.Snapshot.Version,next.Identity);
|
||||
verdict=decoded.Verdict;
|
||||
if(decoded.Data==null) return await Reject(PlayerKVGameLoadError.Schema,attempt,next,prepared,verdict);
|
||||
if(!context.TryAcquireAccountLease(next.LeaseCapability,out var lease)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
bind(lease);
|
||||
PlayerKVApplyError applied;
|
||||
try { applied=transaction.Apply(decoded.Data,()=>context.IsCurrent && epoch==attempt && unstarted(),()=>guard.IsCurrent && guard.Violations==0,notify); }
|
||||
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());
|
||||
}
|
||||
catch { return await Reject(PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); }
|
||||
finally { loginGate.Release(); }
|
||||
}
|
||||
async Task<bool> EnterGate(PlayerKVGameLoadCapability next,CancellationToken token)
|
||||
{
|
||||
using(var cancellation=CancellationTokenSource.CreateLinkedTokenSource(token))
|
||||
{
|
||||
var wait=loginGate.WaitAsync(cancellation.Token); var deadline=next.Delay(next.LoginObserveMs,cancellation.Token);
|
||||
bool acquired=false,accepted=false;
|
||||
try
|
||||
{
|
||||
if(await Task.WhenAny(wait,deadline)==wait) { await wait; acquired=true; accepted=!token.IsCancellationRequested && !deadline.IsCompleted; return accepted; }
|
||||
cancellation.Cancel();
|
||||
try { await wait; acquired=true; } catch(OperationCanceledException) { }
|
||||
return false;
|
||||
}
|
||||
catch(OperationCanceledException) { return false; }
|
||||
finally
|
||||
{
|
||||
cancellation.Cancel();
|
||||
// A lock won at the timeout/cancel boundary belongs to this attempt and must be returned.
|
||||
if(acquired && !accepted) loginGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
internal Task Revoke(long? expectedEpoch=null)
|
||||
{
|
||||
lock(closeGate)
|
||||
{
|
||||
if(expectedEpoch.HasValue && expectedEpoch.Value!=Interlocked.Read(ref epoch)) return Task.CompletedTask;
|
||||
if(closing!=null) return closing;
|
||||
loaded=false; Interlocked.Increment(ref epoch);
|
||||
if(context==null && guard==null) return Task.CompletedTask;
|
||||
var completion=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
closing=completion.Task;
|
||||
var owner=context; var leaseGuard=guard; var source=capability;
|
||||
// Context cancellation is synchronous and occurs even if the main-thread reset is queued.
|
||||
var drain=owner?.CloseAsync() ?? Task.CompletedTask;
|
||||
var cleared=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
void Clear()
|
||||
{
|
||||
try
|
||||
{
|
||||
var detached=unbind();
|
||||
if(!transaction.Clear()) throw new InvalidOperationException("Controlled container is poisoned.");
|
||||
CompleteClear(detached,cleared);
|
||||
}
|
||||
catch(Exception ex) { cleared.TrySetException(ex); }
|
||||
}
|
||||
if(Thread.CurrentThread.ManagedThreadId==mainThread && transaction.IsApplying) clearAfterApply=Clear;
|
||||
else if(Thread.CurrentThread.ManagedThreadId==mainThread) Clear();
|
||||
else if(mainContext!=null) mainContext.Post(_=>Clear(),null);
|
||||
else cleared.TrySetException(new InvalidOperationException("Main-thread cleanup unavailable."));
|
||||
_=FinishClose(drain,cleared.Task,leaseGuard,source,completion);
|
||||
registry.Register(drain,owner);
|
||||
registry.Register(cleared.Task,owner);
|
||||
registry.Register(closing,owner);
|
||||
return closing;
|
||||
}
|
||||
}
|
||||
static async void CompleteClear(Task detached,TaskCompletionSource<bool> cleared)
|
||||
{ try { await detached; cleared.TrySetResult(true); } catch(Exception ex) { cleared.TrySetException(ex); } }
|
||||
static async Task FinishClose(Task drain,Task clear,PlayerKVGameLoadGuard leaseGuard,PlayerKVGameLoadCapability source,TaskCompletionSource<bool> completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(drain,clear).ConfigureAwait(false); source?.CloseUnused(); leaseGuard?.Dispose(); completion.TrySetResult(true);
|
||||
}
|
||||
catch(Exception ex) { completion.TrySetException(ex); }
|
||||
}
|
||||
async Task<PlayerKVGameLoadResult> Reject(PlayerKVGameLoadError original,long attempt,PlayerKVGameLoadCapability source,
|
||||
PlayerKVLoginPrepareResult prepare,PlayerKVSchemaVerdict? verdict)
|
||||
{
|
||||
var close=Revoke();
|
||||
var status=await registry.ObserveAsync(source.LoginObserveMs,CancellationToken.None,source.Delay);
|
||||
var error=status==PlayerKVShutdownStatus.Failed || close.IsFaulted ? PlayerKVGameLoadError.CloseFailed :
|
||||
!close.IsCompleted ? PlayerKVGameLoadError.ClosePending : original;
|
||||
return new PlayerKVGameLoadResult(error,attempt,original:prepare?.CleanupFailed==true ? PlayerKVGameLoadError.Prepare : original,verdict:verdict,prepare:prepare);
|
||||
}
|
||||
PlayerKVGameLoadResult Result(PlayerKVGameLoadError error) => new PlayerKVGameLoadResult(error,Interlocked.Read(ref epoch));
|
||||
internal Task<PlayerKVShutdownStatus> ObserveQuitAsync() => registry.ObserveAsync(QuitObserveMs,delay:capability?.Delay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns exactly one authenticated session and its KV bridge. Does not initialize gameplay or write.
|
||||
/// The caller must await the previous context's CloseAsync before creating the next context.
|
||||
/// A failed Close must never be bypassed by constructing another instance.
|
||||
/// </summary>
|
||||
public sealed class PlayerKVLoginContext
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly string requestedId, cacheRoot;
|
||||
readonly long epoch;
|
||||
readonly Uri api;
|
||||
readonly Func<long, bool> externalCurrent;
|
||||
readonly Func<S101AccountSession> sessionFactory;
|
||||
readonly Func<PlayerKVGameSessionBridge> bridgeFactory;
|
||||
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
readonly TaskCompletionSource<bool> workSettled = NewSignal();
|
||||
TaskCompletionSource<PlayerKVLoginPrepareResult> preparation;
|
||||
TaskCompletionSource<bool> closing;
|
||||
CancellationToken firstToken;
|
||||
CancellationTokenRegistration registration;
|
||||
S101AccountSession session;
|
||||
CookieContainer cookies;
|
||||
PlayerKVGameSessionBridge bridge;
|
||||
PlayerKVGameSessionHandle handle;
|
||||
PlayerKVAccountDescriptor accountDescriptor;
|
||||
PlayerKVAccountLease accountLease;
|
||||
bool leaseIssued;
|
||||
string playerId, predicateExceptionType;
|
||||
int revoked, prepared, differentTokenCount;
|
||||
|
||||
// Internal factories are only for synthetic transport/cleanup faults. No public session or handle escapes.
|
||||
public PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func<long, bool> isCurrent)
|
||||
: this(playerId, cacheRoot, epoch, isCurrent, new Uri(S101AccountSession.DefaultApiUrl),
|
||||
() => new S101AccountSession(), () => new PlayerKVGameSessionBridge()) { }
|
||||
|
||||
internal PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func<long, bool> isCurrent,
|
||||
Uri api, Func<S101AccountSession> sessionFactory, Func<PlayerKVGameSessionBridge> bridgeFactory)
|
||||
{
|
||||
requestedId = playerId; this.cacheRoot = cacheRoot; this.epoch = epoch; externalCurrent = isCurrent;
|
||||
this.api = api; this.sessionFactory = sessionFactory; this.bridgeFactory = bridgeFactory;
|
||||
}
|
||||
|
||||
internal int DifferentTokenCount => Volatile.Read(ref differentTokenCount);
|
||||
|
||||
internal PlayerKVAccountDescriptor AccountDescriptor =>
|
||||
Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null;
|
||||
|
||||
// All issuance and Close admission share gate. The editor factory may not block on Close.
|
||||
internal bool TryAcquireAccountLease(PlayerKVControlledLoadCapability capability,
|
||||
out PlayerKVAccountLease lease)
|
||||
{
|
||||
lease = null;
|
||||
if (capability == null || !IsCurrent) return false;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null || revoked != 0 || prepared == 0 || leaseIssued) return false;
|
||||
leaseIssued = true;
|
||||
accountLease = new PlayerKVAccountLease(api, epoch, cookies, capability, () => IsCurrent,
|
||||
() => Volatile.Read(ref revoked) == 0);
|
||||
// A synthetic factory can synchronously revoke this context while being invoked.
|
||||
if (revoked != 0 || closing != null) return false;
|
||||
lease = accountLease;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Historical Prepared results and their data survive Close, but their authority does not.</summary>
|
||||
public bool IsCurrent
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Volatile.Read(ref prepared) == 0 || !EpochCurrent(epoch)) return false;
|
||||
var observed = session;
|
||||
return observed != null && observed.IsAuthenticated && observed.PlayerId == playerId &&
|
||||
ReferenceEquals(observed.Cookies, cookies) && Volatile.Read(ref revoked) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One task per instance. Only the FIRST token owns the lifetime; a different token on later calls
|
||||
/// is diagnosed but never replaces it. Even after Close this returns the original historical task.
|
||||
/// Cancellation/failure does not complete until transport and Close have really drained. A transport
|
||||
/// ignoring cancellation can hold this task indefinitely; observe CloseAsync, never bypass its barrier.
|
||||
/// </summary>
|
||||
public Task<PlayerKVLoginPrepareResult> PrepareAsync(CancellationToken lifetimeToken = default)
|
||||
{
|
||||
Task<PlayerKVLoginPrepareResult> result;
|
||||
bool start, alreadyClosing;
|
||||
lock (gate)
|
||||
{
|
||||
start = preparation == null;
|
||||
if (start)
|
||||
{
|
||||
preparation = new TaskCompletionSource<PlayerKVLoginPrepareResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
firstToken = lifetimeToken;
|
||||
}
|
||||
else if (firstToken != lifetimeToken) Interlocked.Increment(ref differentTokenCount);
|
||||
result = preparation.Task;
|
||||
alreadyClosing = closing != null;
|
||||
}
|
||||
if (!start)
|
||||
{
|
||||
if (firstToken != lifetimeToken) Debug.WriteLine("KV PrepareAsync ignored a different lifetime token; the first token still owns this context.");
|
||||
return result;
|
||||
}
|
||||
if (alreadyClosing) _ = CompleteFailureAsync(new Attempt(), PlayerKVLoginPrepareError.Cancelled);
|
||||
else _ = PrepareCoreAsync();
|
||||
return result;
|
||||
}
|
||||
|
||||
sealed class Attempt
|
||||
{
|
||||
internal AccountLoginError? AccountError;
|
||||
internal int? HttpStatus;
|
||||
internal PlayerKVGameOpenError? OpenError;
|
||||
internal PlayerKVSessionOpenError? OwnerError;
|
||||
internal PlayerKVError? KVError;
|
||||
internal PlayerKVSyncStatus? SyncStatus;
|
||||
internal string OwnerId, ExceptionType;
|
||||
}
|
||||
|
||||
async Task PrepareCoreAsync()
|
||||
{
|
||||
var evidence = new Attempt();
|
||||
var error = PlayerKVLoginPrepareError.Unexpected;
|
||||
try
|
||||
{
|
||||
registration = firstToken.Register(() => { _ = CloseAsync(); });
|
||||
if (!Validate()) error = PlayerKVLoginPrepareError.Validation;
|
||||
else if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else
|
||||
{
|
||||
session = sessionFactory() ?? throw new InvalidOperationException("Session factory returned null.");
|
||||
if (session.ApiBaseUri != api) error = PlayerKVLoginPrepareError.Validation;
|
||||
else
|
||||
{
|
||||
var auth = await session.LoginAsync(playerId, lifetime.Token).ConfigureAwait(false);
|
||||
evidence.AccountError = auth.Error; evidence.HttpStatus = auth.StatusCode;
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else if (!auth.Success) error = PlayerKVLoginPrepareError.AuthenticationFailed;
|
||||
else
|
||||
{
|
||||
cookies = session.Cookies;
|
||||
bridge = bridgeFactory() ?? throw new InvalidOperationException("Bridge factory returned null.");
|
||||
var opened = await bridge.OpenAsync(session, cacheRoot, playerId, api, epoch,
|
||||
EpochCurrent, lifetime.Token).ConfigureAwait(false);
|
||||
evidence.OpenError = opened.Error;
|
||||
if (opened.Error == PlayerKVGameOpenError.OwnerRejected)
|
||||
{ evidence.OwnerError = opened.OwnerError; evidence.KVError = opened.TransportError; }
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else if (!opened.Success) error = PlayerKVLoginPrepareError.OwnerRejected;
|
||||
else
|
||||
{
|
||||
handle = opened.Handle;
|
||||
evidence.OwnerId = handle.OwnerInstanceId;
|
||||
var read = handle.ReadLocalSnapshot();
|
||||
evidence.SyncStatus = handle.Status;
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else if (!read.Success) error = read.Error == PlayerKVLocalReadError.StaleOrClosing
|
||||
? PlayerKVLoginPrepareError.Stale : PlayerKVLoginPrepareError.NotReady;
|
||||
else
|
||||
{
|
||||
var success = Result(evidence, PlayerKVLoginPrepareError.None, read.Data);
|
||||
// External callback is never invoked under our monitor. Close/reentry is rechecked at publication.
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else lock (gate)
|
||||
{
|
||||
if (revoked == 0)
|
||||
{
|
||||
accountDescriptor = new PlayerKVAccountDescriptor(session.PlayerId,
|
||||
epoch, session.InternalId, session.NewlyCreated,
|
||||
session.CreateTimeUtc, session.ServerTimeUtc);
|
||||
Volatile.Write(ref prepared, 1);
|
||||
preparation.TrySetResult(success);
|
||||
return;
|
||||
}
|
||||
error = PlayerKVLoginPrepareError.Cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
evidence.ExceptionType = ex.GetType().Name;
|
||||
error = Volatile.Read(ref revoked) != 0 ? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Unexpected;
|
||||
}
|
||||
finally { workSettled.TrySetResult(true); }
|
||||
evidence.ExceptionType = evidence.ExceptionType ?? Volatile.Read(ref predicateExceptionType);
|
||||
await CompleteFailureAsync(evidence, error).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
bool Validate()
|
||||
{
|
||||
if (epoch < 0 || externalCurrent == null || sessionFactory == null || bridgeFactory == null ||
|
||||
string.IsNullOrWhiteSpace(cacheRoot) || !Path.IsPathFullyQualified(cacheRoot) ||
|
||||
!long.TryParse(requestedId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0 ||
|
||||
api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") ||
|
||||
api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0) return false;
|
||||
try { Path.GetFullPath(cacheRoot); }
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) { return false; }
|
||||
playerId = id.ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EpochCurrent(long observedEpoch)
|
||||
{
|
||||
if (Volatile.Read(ref revoked) != 0 || observedEpoch != epoch) return false;
|
||||
try { return externalCurrent != null && externalCurrent(epoch) && Volatile.Read(ref revoked) == 0; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Interlocked.CompareExchange(ref predicateExceptionType, ex.GetType().Name, null);
|
||||
_ = CloseAsync();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
PlayerKVLoginPrepareError InactiveError() => Volatile.Read(ref predicateExceptionType) != null
|
||||
? PlayerKVLoginPrepareError.Unexpected : Volatile.Read(ref revoked) != 0
|
||||
? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Stale;
|
||||
|
||||
PlayerKVLoginPrepareResult Result(Attempt e, PlayerKVLoginPrepareError error,
|
||||
System.Collections.Generic.IReadOnlyDictionary<string, string> data = null,
|
||||
string cleanupException = null, PlayerKVLoginPrepareError? original = null) =>
|
||||
new PlayerKVLoginPrepareResult(error, playerId, epoch, e.AccountError, e.HttpStatus,
|
||||
e.OpenError, e.OwnerError, e.KVError, e.SyncStatus, e.OwnerId, data,
|
||||
e.ExceptionType, cleanupException, original);
|
||||
|
||||
async Task CompleteFailureAsync(Attempt evidence, PlayerKVLoginPrepareError error)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CloseAsync().ConfigureAwait(false);
|
||||
preparation.TrySetResult(Result(evidence, error));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
preparation.TrySetResult(Result(evidence, PlayerKVLoginPrepareError.CloseFailed,
|
||||
cleanupException: ex.GetType().Name, original: error));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Synchronously revokes admission; drains preparation and bridge before disposing the session.</summary>
|
||||
public Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null) return closing.Task;
|
||||
Volatile.Write(ref revoked, 1);
|
||||
completion = closing = NewSignal();
|
||||
if (preparation == null) workSettled.TrySetResult(true);
|
||||
}
|
||||
// A cancellation registration must not execute disposal of itself or user cancellation callbacks inline.
|
||||
// Observe faults without changing the shared task, even when cancellation initiates a background Close.
|
||||
_ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
_ = Task.Run(() => CloseCoreAsync(completion));
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
async Task CloseCoreAsync(TaskCompletionSource<bool> completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
Exception cancellationFailure = null;
|
||||
try { lifetime.Cancel(); }
|
||||
catch (Exception ex) { cancellationFailure = ex; }
|
||||
await workSettled.Task.ConfigureAwait(false);
|
||||
PlayerKVAccountLease borrowed;
|
||||
lock (gate) borrowed = accountLease;
|
||||
if (borrowed != null) await borrowed.CloseAsync().ConfigureAwait(false);
|
||||
if (bridge != null) await bridge.CloseAsync().ConfigureAwait(false);
|
||||
// Never dispose the session on a failed bridge close. References and the faulted task stay retained.
|
||||
session?.Dispose();
|
||||
registration.Dispose();
|
||||
lifetime.Dispose();
|
||||
if (cancellationFailure != null) throw cancellationFailure;
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> NewSignal() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
// Kept with the context so its existing explicitly linked regression host retains all dependencies.
|
||||
internal sealed class PlayerKVAccountDescriptor
|
||||
{
|
||||
internal string PlayerId { get; }
|
||||
internal long Epoch { get; }
|
||||
internal uint InternalId { get; }
|
||||
internal bool NewlyCreated { get; }
|
||||
internal DateTime CreateTimeUtc { get; }
|
||||
internal DateTime ServerTimeUtc { get; }
|
||||
internal PlayerKVAccountDescriptor(string playerId, long epoch, uint internalId,
|
||||
bool newlyCreated, DateTime createTimeUtc, DateTime serverTimeUtc)
|
||||
{
|
||||
PlayerId = playerId; Epoch = epoch; InternalId = internalId; NewlyCreated = newlyCreated;
|
||||
CreateTimeUtc = createTimeUtc; ServerTimeUtc = serverTimeUtc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Construction is absent from player builds; no default network handler is supplied.</summary>
|
||||
internal sealed class PlayerKVControlledLoadCapability
|
||||
{
|
||||
readonly Func<CookieContainer, HttpMessageHandler> leaseHandler;
|
||||
private PlayerKVControlledLoadCapability(Func<CookieContainer, HttpMessageHandler> leaseHandler)
|
||||
{ this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); }
|
||||
|
||||
#if UNITY_EDITOR
|
||||
internal static PlayerKVControlledLoadCapability CreateForValidation(
|
||||
Func<CookieContainer, HttpMessageHandler> leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler);
|
||||
#endif
|
||||
|
||||
internal HttpMessageHandler CreateHandler(CookieContainer cookies)
|
||||
{
|
||||
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Synthetic handler required.");
|
||||
var seen = new HashSet<HttpMessageHandler>();
|
||||
var node = handler;
|
||||
while (node != null)
|
||||
{
|
||||
// Disposing a cyclic DelegatingHandler graph recursively would overflow the stack.
|
||||
// It never becomes a lease; the rejecting factory retains responsibility for that graph.
|
||||
if (!seen.Add(node))
|
||||
throw new InvalidOperationException("Cyclic transport is not a validation handler.");
|
||||
if (node is HttpClientHandler ||
|
||||
node.GetType().FullName == "System.Net.Http.SocketsHttpHandler")
|
||||
{
|
||||
handler.Dispose();
|
||||
throw new InvalidOperationException("Real or cyclic transport is not a validation handler.");
|
||||
}
|
||||
node = (node as DelegatingHandler)?.InnerHandler;
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVAccountResponse
|
||||
{
|
||||
internal bool IsCurrent { get; }
|
||||
internal int? StatusCode { get; }
|
||||
internal string Body { get; }
|
||||
internal PlayerKVAccountResponse(bool isCurrent, int? statusCode = null, string body = null)
|
||||
{ IsCurrent = isCurrent; StatusCode = isCurrent ? statusCode : null; Body = isCurrent ? body : null; }
|
||||
}
|
||||
|
||||
/// <summary>A single context-owned, synthetic-only borrower. Close drains before disposing its client.</summary>
|
||||
internal sealed class PlayerKVAccountLease
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly HttpClient client;
|
||||
readonly Func<bool> current;
|
||||
readonly Func<bool> admission;
|
||||
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
TaskCompletionSource<bool> drained;
|
||||
TaskCompletionSource<bool> closing;
|
||||
int active;
|
||||
internal Uri ApiBaseUri { get; }
|
||||
internal long Epoch { get; }
|
||||
internal bool IsCurrent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate) if (closing != null) return false;
|
||||
return current();
|
||||
}
|
||||
}
|
||||
|
||||
internal PlayerKVAccountLease(Uri api, long epoch, CookieContainer cookies,
|
||||
PlayerKVControlledLoadCapability capability, Func<bool> current, Func<bool> admission)
|
||||
{
|
||||
if (capability == null) throw new ArgumentNullException(nameof(capability));
|
||||
ApiBaseUri = api; Epoch = epoch; this.current = current; this.admission = admission;
|
||||
client = new HttpClient(capability.CreateHandler(cookies)) { BaseAddress = api };
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVAccountResponse> SendAsync(HttpMethod method, string route,
|
||||
string body = null, string contentType = "application/json", CancellationToken token = default)
|
||||
{
|
||||
// Exact canonical relative paths: no query, fragment, encoding, traversal or absolute URL.
|
||||
if (route != "__kv_validation/ok" && route != "__kv_validation/unauthorized")
|
||||
throw new ArgumentException("Only canonical validation routes are allowed.", nameof(route));
|
||||
if (method != HttpMethod.Get && method != HttpMethod.Post)
|
||||
throw new ArgumentException("Unsupported validation method.", nameof(method));
|
||||
if (!IsCurrent) return new PlayerKVAccountResponse(false);
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null || !admission()) return new PlayerKVAccountResponse(false);
|
||||
active++;
|
||||
}
|
||||
try
|
||||
{
|
||||
using (var linked = CancellationTokenSource.CreateLinkedTokenSource(token, lifetime.Token))
|
||||
using (var request = new HttpRequestMessage(method, route))
|
||||
{
|
||||
if (body != null) request.Content = new StringContent(body, System.Text.Encoding.UTF8, contentType);
|
||||
using (var response = await client.SendAsync(request, linked.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (!IsCurrent || linked.IsCancellationRequested) return new PlayerKVAccountResponse(false);
|
||||
var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
// No redirect following, even if a synthetic handler returns a Location header.
|
||||
if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400)
|
||||
throw new InvalidOperationException("Redirects are not accepted.");
|
||||
return IsCurrent && !linked.IsCancellationRequested
|
||||
? new PlayerKVAccountResponse(true, (int)response.StatusCode, text)
|
||||
: new PlayerKVAccountResponse(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!IsCurrent || token.IsCancellationRequested || lifetime.IsCancellationRequested)
|
||||
{ return new PlayerKVAccountResponse(false); }
|
||||
finally
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
active--;
|
||||
if (active == 0) drained?.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null) return closing.Task;
|
||||
completion = closing = Signal();
|
||||
drained = Signal();
|
||||
if (active == 0) drained.TrySetResult(true);
|
||||
}
|
||||
_ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Exception cancellationFailure = null;
|
||||
try { lifetime.Cancel(); }
|
||||
catch (Exception ex) { cancellationFailure = ex; }
|
||||
await drained.Task.ConfigureAwait(false);
|
||||
client.Dispose();
|
||||
lifetime.Dispose();
|
||||
if (cancellationFailure != null) throw cancellationFailure;
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
});
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> Signal() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user