191 lines
15 KiB
C#
191 lines
15 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
using game.PlayerDataStorage;
|
|
using Newtonsoft.Json;
|
|
|
|
static partial class Tests
|
|
{
|
|
sealed class MainLoop : SynchronizationContext
|
|
{
|
|
internal readonly BlockingCollection<(SendOrPostCallback,object)> Queue=new();
|
|
public override void Post(SendOrPostCallback callback,object state) => Queue.Add((callback,state));
|
|
}
|
|
static Task OnMain(Func<Task> body) => Task.Run(()=>
|
|
{
|
|
var old=SynchronizationContext.Current; var loop=new MainLoop(); SynchronizationContext.SetSynchronizationContext(loop);
|
|
try
|
|
{
|
|
var task=body();
|
|
_=task.ContinueWith(_=>loop.Post(_=>{},null),TaskScheduler.Default);
|
|
while(!task.IsCompleted) { var (callback,state)=loop.Queue.Take(); callback(state); }
|
|
task.GetAwaiter().GetResult();
|
|
}
|
|
finally { SynchronizationContext.SetSynchronizationContext(old); }
|
|
});
|
|
sealed class LoadFixture
|
|
{
|
|
internal readonly PlayerKVShutdownRegistry Registry;
|
|
internal readonly int[] Values=new int[31];
|
|
internal int Auth,Gets,Sends,Notify,Discard,AuthDisposed,LeaseDisposed;
|
|
internal bool Fresh=true,Unstarted=true,FailApply,NotifyViolation,AuthDisposeFails,LeaseDisposeFails;
|
|
internal Action OnNotify;
|
|
internal Func<Task<HttpResponseMessage>> GetOverride;
|
|
internal Func<int,CancellationToken,Task> Clock;
|
|
internal PlayerKVAccountLease Lease;
|
|
internal PlayerKVGameLoadCoordinator Coordinator;
|
|
internal LoadFixture(PlayerKVShutdownRegistry registry=null)
|
|
{
|
|
Registry=registry ?? new();
|
|
var modules=Enumerable.Range(1,31).Select(id=>(IPlayerKVLoadModule)new PlayerKVLoadModule(id,
|
|
()=>Values[id-1]=0,_=>{Values[id-1]=id;if(FailApply && id==18) throw new InvalidOperationException();})).ToArray();
|
|
var transaction=new PlayerKVGameLoadTransaction(modules,Environment.CurrentManagedThreadId,()=>Discard++);
|
|
Coordinator=new(transaction,()=>Unstarted,()=>{Notify++;OnNotify?.Invoke();if(NotifyViolation) PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Update);},()=>1,
|
|
lease=>Lease=lease,()=> {var lease=Lease;Lease=null;return lease?.CloseAsync() ?? Task.CompletedTask;},Registry);
|
|
}
|
|
internal PlayerKVGameLoadCapability Capability(Dictionary<string,string> raw=null,bool realKV=false)
|
|
{
|
|
CookieContainer jar=null;
|
|
return PlayerKVGameLoadCapability.CreateForValidation(Identity(),SchemaSnapshot(),Api,Path.Combine(Root,"load",Guid.NewGuid().ToString("N")),
|
|
cookies=>{ jar=cookies;return new Handler((r,t)=>
|
|
{
|
|
Auth++; Check(r.RequestUri.AbsolutePath=="/account/Login","one auth route"); jar.Add(Api,new Cookie("sid","synthetic"));
|
|
return Task.FromResult(Json(JsonConvert.SerializeObject(new{state=0,internalId=17,newCreate=Fresh,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-02T00:00:00Z"})));
|
|
},()=>{AuthDisposed++;if(AuthDisposeFails)throw new InvalidOperationException("synthetic dispose");});},
|
|
cookies=>{Check(ReferenceEquals(cookies,jar),"KV cookie identity");if(realKV) return new HttpClientHandler();return new Handler((r,t)=>
|
|
{Gets++;Check(r.RequestUri.AbsolutePath=="/playerdata/Get","no mutation route");return GetOverride?.Invoke() ?? Task.FromResult(Json(JsonConvert.SerializeObject(new{data=raw ?? ModernRaw()})));});},
|
|
cookies=>{Check(ReferenceEquals(cookies,jar),"lease cookie identity");return new Handler((r,t)=>{Sends++;return Task.FromResult(Json("{}"));},()=>{LeaseDisposed++;if(LeaseDisposeFails)throw new InvalidOperationException("synthetic dispose");});},
|
|
delay:Clock);
|
|
}
|
|
}
|
|
static async Task RunCoordinatorTests()
|
|
{
|
|
await Run("coordinator-success-reload-revoke-and-one-auth",()=>OnMain(async()=>
|
|
{
|
|
var f=new LoadFixture();
|
|
var first=await f.Coordinator.LoadAsync("101",f.Capability());
|
|
Check(first.State==PlayerKVGameLoadState.LoadedForValidation && first.ModuleCount==31 && f.Coordinator.LoadedForValidation,"controlled success only");
|
|
Check(f.Auth==1 && f.Gets==1 && f.Notify==1 && f.Values.All(v=>v>0),"one auth/get and actual transaction");
|
|
var oldLease=f.Lease;
|
|
var second=await f.Coordinator.LoadAsync("102",f.Capability());
|
|
Check(second.State==PlayerKVGameLoadState.LoadedForValidation && second.Epoch>first.Epoch && f.Auth==2 && f.AuthDisposed==1 && !oldLease.IsCurrent,"previous account drained before reload");
|
|
var close=f.Coordinator.Revoke(); Check(!f.Coordinator.LoadedForValidation && f.Values.All(v=>v==0),"synchronous invalidation and module clearing");
|
|
Check(ReferenceEquals(close,f.Coordinator.Revoke()),"same close task"); await close;
|
|
Check(f.Registry.Status==PlayerKVShutdownStatus.Ready && f.AuthDisposed==2 && f.LeaseDisposed==2,"all ownership drained");
|
|
}));
|
|
await Run("coordinator-schema-apply-and-sideeffect-failures",()=>OnMain(async()=>
|
|
{
|
|
foreach(var mode in new[]{0,1,2})
|
|
{
|
|
var f=new LoadFixture{FailApply=mode==1,NotifyViolation=mode==2};
|
|
var result=await f.Coordinator.LoadAsync("101",f.Capability(mode==0 ? new Dictionary<string,string>() : null));
|
|
Check(result.Error==(mode==0 ? PlayerKVGameLoadError.Schema : mode==1 ? PlayerKVGameLoadError.Apply : PlayerKVGameLoadError.SideEffect),"stage error preserved");
|
|
Check(f.Values.All(v=>v==0) && !f.Coordinator.LoadedForValidation && f.AuthDisposed==1,"failure clears and closes");
|
|
Check(f.Notify==(mode==2 ? 1 : 0),"notification cannot precede complete apply");
|
|
}
|
|
var rejected=new LoadFixture(); var real=await rejected.Coordinator.LoadAsync("101",rejected.Capability(realKV:true));
|
|
Check(real.State==PlayerKVGameLoadState.Rejected && rejected.Auth==0 && rejected.Gets==0,"all handlers preflight before authentication");
|
|
}));
|
|
await Run("coordinator-cancel-during-notification-and-after-success",()=>OnMain(async()=>
|
|
{
|
|
using var cancel=new CancellationTokenSource(); var f=new LoadFixture(); f.OnNotify=()=>cancel.Cancel();
|
|
var result=await f.Coordinator.LoadAsync("101",f.Capability(),cancel.Token);
|
|
Check(result.Error==PlayerKVGameLoadError.Stale && !f.Coordinator.LoadedForValidation && f.Values.All(v=>v==0),"callback revocation clears after transaction unwinds");
|
|
using var later=new CancellationTokenSource(); var g=new LoadFixture();
|
|
Check((await g.Coordinator.LoadAsync("101",g.Capability(),later.Token)).State==PlayerKVGameLoadState.LoadedForValidation,"ready before later cancel");
|
|
later.Cancel(); await g.Coordinator.Revoke();
|
|
Check(!g.Coordinator.LoadedForValidation && g.Values.All(v=>v==0),"lifetime remains effective after result");
|
|
}));
|
|
await Run("coordinator-pending-close-cross-instance-and-late-prepare",()=>OnMain(async()=>
|
|
{
|
|
var response=new TaskCompletionSource<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var started=Signal(); var f=new LoadFixture(); f.GetOverride=()=>{started.TrySetResult(true);return response.Task;};
|
|
var deadlines=new List<TaskCompletionSource<bool>>();
|
|
var observedDurations=new List<int>();
|
|
f.Clock=(ms,token)=>{var s=Signal();lock(deadlines){deadlines.Add(s);observedDurations.Add(ms);}token.Register(()=>s.TrySetCanceled(token));return s.Task;};
|
|
using var cancel=new CancellationTokenSource(); var load=f.Coordinator.LoadAsync("101",f.Capability(),cancel.Token);
|
|
await started.Task; cancel.Cancel();
|
|
// Advance only the live observation clock; transport deliberately ignores cancellation.
|
|
while(true)
|
|
{
|
|
await Task.Yield(); TaskCompletionSource<bool> deadline; lock(deadlines) deadline=deadlines.Last();
|
|
if(!deadline.Task.IsCompleted) {deadline.TrySetResult(true);break;}
|
|
}
|
|
var result=await load;
|
|
Check(result.Error==PlayerKVGameLoadError.ClosePending && f.Registry.Status==PlayerKVShutdownStatus.Pending && f.AuthDisposed==0,"deadline is not drainage");
|
|
var g=new LoadFixture(f.Registry);int ticks=0;g.Clock=(ms,t)=>++ticks==1 ? Task.Delay(Timeout.Infinite,t) : Task.CompletedTask;
|
|
var blocked=await g.Coordinator.LoadAsync("102",g.Capability());
|
|
Check(blocked.Error==PlayerKVGameLoadError.PreviousClosePending && g.Auth==0,"new coordinator cannot bypass pending close");
|
|
int observed=0;f.Clock=null;
|
|
// Existing captured capability owns the quit clock, not a later mutable option.
|
|
var quit=f.Coordinator.ObserveQuitAsync();
|
|
while(!quit.IsCompleted){await Task.Yield();lock(deadlines) deadlines.Last().TrySetResult(true);observed++;}
|
|
Check(await quit==PlayerKVShutdownStatus.Pending && f.AuthDisposed==0 && observed>=0,"quit observation is not force disposal");
|
|
Check(observedDurations.Contains(10000) && observedDurations.Last()==5000,"captured login/quit observations are 10s/5s");
|
|
response.TrySetResult(Json(JsonConvert.SerializeObject(new{data=ModernRaw()})));
|
|
await f.Coordinator.Revoke();
|
|
Check(f.AuthDisposed==1 && f.Notify==0 && f.Values.All(v=>v==0) && !f.Coordinator.LoadedForValidation,"late success cannot apply");
|
|
}));
|
|
await Run("guard-exclusive-across-await-and-all-points",async()=>
|
|
{
|
|
Check(PlayerKVGameLoadGuard.TryEnter(out var guard),"first guard");
|
|
using(guard)
|
|
{
|
|
await Task.Yield(); Check(!PlayerKVGameLoadGuard.TryEnter(out _),"process lease crosses await");
|
|
foreach(var point in Enum.GetValues<PlayerKVGuardPoint>()) Check(PlayerKVGameLoadGuard.Reject(point) && guard.Count(point)==1,"all points counted");
|
|
await Throws<PlayerKVLoadSideEffectException>(()=>{PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal);return Task.CompletedTask;});
|
|
Check(guard.Violations==8,"total violations");
|
|
}
|
|
Check(!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Update),"PF passthrough outside scope");
|
|
});
|
|
await Run("coordinator-gate-timeout-cancel-boundary-no-leak",()=>OnMain(async()=>
|
|
{
|
|
var f=new LoadFixture{Clock=(ms,t)=>Task.CompletedTask};
|
|
Check((await f.Coordinator.LoadAsync("101",f.Capability())).Error==PlayerKVGameLoadError.GateBusy && f.Auth==0,"simultaneous free lock and elapsed deadline rejects");
|
|
f.Clock=null;using var cancel=new CancellationTokenSource();cancel.Cancel();
|
|
Check((await f.Coordinator.LoadAsync("101",f.Capability(),cancel.Token)).Error==PlayerKVGameLoadError.Stale,"cancelled gate");
|
|
Check((await f.Coordinator.LoadAsync("101",f.Capability())).State==PlayerKVGameLoadState.LoadedForValidation,"rejected gate returned permit");await f.Coordinator.Revoke();
|
|
var g=new LoadFixture();bool current=false;
|
|
Check((await g.Coordinator.LoadAsync("102",g.Capability(),identityCurrent:()=>current)).State==PlayerKVGameLoadState.Rejected && g.Notify==0,"changed identity never applies");
|
|
}));
|
|
await Run("coordinator-occupied-gate-bounded-and-reusable",()=>OnMain(async()=>
|
|
{
|
|
var started=Signal();var response=new TaskCompletionSource<HttpResponseMessage>();var f=new LoadFixture();
|
|
f.GetOverride=()=>{started.TrySetResult(true);return response.Task;};var first=f.Coordinator.LoadAsync("101",f.Capability());await started.Task;
|
|
f.Clock=(ms,t)=>Task.CompletedTask;
|
|
Check((await f.Coordinator.LoadAsync("102",f.Capability())).Error==PlayerKVGameLoadError.GateBusy && f.Auth==1,"busy gate never authenticates next account");
|
|
response.SetResult(Json(Newtonsoft.Json.JsonConvert.SerializeObject(new{data=ModernRaw()})));Check((await first).State==PlayerKVGameLoadState.LoadedForValidation,"first untouched");await f.Coordinator.Revoke();
|
|
f.Clock=null;f.GetOverride=null;Check((await f.Coordinator.LoadAsync("102",f.Capability())).State==PlayerKVGameLoadState.LoadedForValidation,"permit reusable");await f.Coordinator.Revoke();
|
|
}));
|
|
await Run("coordinator-all-prepare-evidence-under-cleanup-failure",()=>OnMain(async()=>
|
|
{
|
|
// Layered mapping probe: 4B1 independently exercises the preparation causes; this invokes the real rejection path for every historical error.
|
|
var registry=new PlayerKVShutdownRegistry();registry.Register(Task.FromException(new InvalidOperationException("synthetic close")));
|
|
var fixture=new LoadFixture(registry);
|
|
var reject=typeof(PlayerKVGameLoadCoordinator).GetMethod("Reject",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic);
|
|
foreach(var error in Enum.GetValues<PlayerKVLoginPrepareError>().Where(e=>e!=PlayerKVLoginPrepareError.None))
|
|
{
|
|
var prepared=new PlayerKVLoginPrepareResult(error,"101",7,httpStatus:401,exceptionType:"SyntheticPrepare",cleanupExceptionType:"SyntheticClose",
|
|
originalError:error==PlayerKVLoginPrepareError.CloseFailed ? PlayerKVLoginPrepareError.AuthenticationFailed : error);
|
|
var task=(Task<PlayerKVGameLoadResult>)reject.Invoke(fixture.Coordinator,new object[]{PlayerKVGameLoadError.Prepare,7L,fixture.Capability(),prepared,null});
|
|
var result=await task;
|
|
Check(result.CleanupFailed && result.OriginalError==PlayerKVGameLoadError.Prepare && result.Prepare.Error==error && result.Prepare.OriginalError==prepared.OriginalError,"all historical errors preserved under CloseFailed");
|
|
Check(result.Prepare.HttpStatusCode==401 && result.Prepare.ExceptionType=="SyntheticPrepare" && result.Prepare.CleanupExceptionType=="SyntheticClose" && fixture.Auth==0,"stage metadata retained with zero new authentication");
|
|
}
|
|
}));
|
|
}
|
|
static async Task<int> IsolatedCleanupFailure(string mode)
|
|
{
|
|
await Run("coordinator-cleanup-failure-"+mode,()=>OnMain(async()=>
|
|
{
|
|
var f=new LoadFixture{FailApply=mode=="apply",LeaseDisposeFails=mode!="prepare",AuthDisposeFails=mode=="prepare"};
|
|
var result=await f.Coordinator.LoadAsync("101",f.Capability(mode=="schema" ? new Dictionary<string,string>() : null,mode=="prepare"));
|
|
var expected=mode=="prepare" ? PlayerKVGameLoadError.Prepare : mode=="schema" ? PlayerKVGameLoadError.Schema : PlayerKVGameLoadError.Apply;
|
|
Check(result.Error==PlayerKVGameLoadError.CloseFailed && result.OriginalError==expected && result.CleanupFailed,"cleanup failure preserves original stage");
|
|
Check(result.Prepare!=null && result.ModuleCount==0 && result.IntentCount==0 && !f.Coordinator.LoadedForValidation,"failure carries evidence only");
|
|
var next=new LoadFixture(f.Registry);Check((await next.Coordinator.LoadAsync("102",next.Capability())).Error==PlayerKVGameLoadError.PreviousCloseFailed && next.Auth==0,"failure persists across coordinators");
|
|
Check(!PlayerKVGameLoadGuard.TryEnter(out _),"failed cleanup cannot release guard");
|
|
}));
|
|
Console.WriteLine($"RESULT isolated mode={mode} failed={failures} assertions={assertions}");return failures==0?0:1;
|
|
}
|
|
}
|