Files
ft/CodexOutput/upserver/KVGameLoadTests/Program.cs
Liubing\LB 6813da149a feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
2026-09-30 19:19:05 +08:00

350 lines
23 KiB
C#

using System.Net;
using System.Reflection;
using System.Text;
using game.Account;
using game.PlayerDataStorage;
using Newtonsoft.Json;
static partial class Tests
{
static readonly Uri Api = new("http://synthetic.invalid/");
static readonly string RunId = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "_" + Guid.NewGuid().ToString("N");
static readonly string Root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../runs", RunId));
static readonly List<object> Results = new();
static int assertions, failures;
static async Task<int> Main(string[] args)
{
if(args.Length==1 && args[0]=="--audit-precompiled") return PrecompiledAudit.Run();
if(args.Length==2 && args[0]=="--cleanup-failure") return await IsolatedCleanupFailure(args[1]);
Directory.CreateDirectory(Root);
await Run("B2-C1-metadata-one-auth-shared-cookie", SingleAuthentication);
await Run("B2-C1-lease-once-before-after-close", Admission);
await Run("B2-C1-denied-routes-zero-send", Routes);
await Run("CP2B-T-formal-lease-route-and-cookie-isolation", FormalBusinessLease);
await Run("B2-C1-real-handler-rejected", RealHandlerRejected);
await Run("B2-C1-cyclic-handler-rejected-without-recursive-dispose", CyclicHandlerRejected);
await Run("B2-C1-active-send-drains-before-session", Drain);
await Run("B2-C1-reentrant-cancel-same-task", Reentry);
await Run("B2-C1-dispose-failure-retains-session", DisposeFailure);
await Run("B2-C1-issuance-close-race", IssuanceRace);
await Run("B2-C1-factory-revokes-context", FactoryRevocation);
await Run("B2-C1-stale-success-body-discarded", StaleResponse);
await Run("B2-C1-401-and-redirect-evidence", Responses);
await Run("B2-C1-registry-timeout-then-real-drain", RegistryTimeout);
await Run("B2-C1-registry-failure-retained", RegistryFailure);
await Run("B2-C1-registry-fails-before-other-drains", RegistryEarlyFailure);
await Run("B2-C1-new-failure-wakes-existing-observer", RegistryNewFailure);
await Run("B2-C1-registry-registration-during-await", RegistryRegistration);
await Run("B2-C1-registry-invalid-cancel", RegistryCancellation);
await Run("B2-C1-process-registry-cross-caller", ProcessBarrier);
await RunSchemaTests();
await RunD3Tests();
await RunCollectionSchemaTests();
await RunClubPackSchemaTests();
await RunCampEventSchemaTests();
await RunOfferSchemaTests();
await RunBuffTriggerSchemaTests();
await RunActivitySchemaTests();
await RunPinataSchemaTests();
await RunCubeSchemaTests();
await RunBattlePassSchemaTests();
await RunPotionSchemaTests();
await RunPlayerSchemaTests();
await RunShopSchemaTests();
await RunEventBaseSchemaTests();
await RunFishSchemaTests();
await RunPartnerSchemaTests();
await RunLoginSchemaTests();
await RunAggregateTests();
await RunCoordinatorTests();
await RunCP2ARuntimeSelectionTests();
await RunCP2AExistingArchiveTests();
var report = new { runId = RunId, checkpoint = "4-validation", failures, assertions, accessedFormalServer = false, tests = Results };
await File.WriteAllTextAsync(Path.Combine(Root, "result.json"), JsonConvert.SerializeObject(report, Formatting.Indented));
Console.WriteLine($"RESULT tests={Results.Count} failed={failures} assertions={assertions} run={RunId}");
return failures == 0 ? 0 : 1;
}
static async Task Run(string name, Func<Task> test)
{
var before = assertions;
try { await test().WaitAsync(TimeSpan.FromSeconds(30)); Results.Add(new { name, passed = true, assertions = assertions - before }); Console.WriteLine("PASS " + name); }
catch (Exception ex) { failures++; Results.Add(new { name, passed = false, error = ex.GetType().Name }); Console.WriteLine("FAIL " + name + " " + ex); }
}
static void Check(bool value, string message) { Interlocked.Increment(ref assertions); if (!value) throw new InvalidOperationException(message); }
sealed class CyclicHandler : DelegatingHandler { }
static async Task CyclicHandlerRejected()
{
var first = new CyclicHandler();
var second = new CyclicHandler();
first.InnerHandler = second;
second.InnerHandler = first;
var capability = PlayerKVControlledLoadCapability.CreateForValidation(_ => first);
await Throws<InvalidOperationException>(() =>
{
capability.CreateHandler(new CookieContainer());
return Task.CompletedTask;
});
// No send occurred. Break the rejected graph before its owner disposes it.
second.InnerHandler = new Handler((_, _) => throw new InvalidOperationException("Must not send."));
first.Dispose();
}
static TaskCompletionSource<bool> Signal() => new(TaskCreationOptions.RunContinuationsAsynchronously);
static HttpResponseMessage Json(string text, HttpStatusCode code = HttpStatusCode.OK) => new(code) { Content = new StringContent(text, Encoding.UTF8, "application/json") };
static async Task Throws<T>(Func<Task> action) where T : Exception
{ try { await action(); } catch (T) { Check(true, "expected exception"); return; } throw new InvalidOperationException("Expected " + typeof(T).Name); }
sealed class Handler : HttpMessageHandler
{
readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send;
readonly Action dispose;
internal Handler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send, Action dispose = null)
{ this.send = send; this.dispose = dispose; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken t) => send(r, t);
protected override void Dispose(bool disposing) { if (disposing) dispose?.Invoke(); base.Dispose(disposing); }
}
sealed class Fixture
{
internal readonly List<string> Order = new();
internal int Auth, Gets, BorrowedSends, SessionDisposes, LeaseDisposes;
internal bool Current = true, FailLeaseDispose;
internal Action DuringFactory;
internal CookieContainer Jar;
internal Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> Borrowed = (_, _) => Task.FromResult(Json("{}"));
internal PlayerKVLoginContext Context;
internal void Record(string x) { lock (Order) Order.Add(x); }
internal Fixture()
{
Context = new PlayerKVLoginContext("101", Path.Combine(Root, "cache", Guid.NewGuid().ToString("N")), 7,
_ => Current, Api,
() => new S101AccountSession(Api, jar =>
{
Jar = jar;
return new Handler((r, t) =>
{
Check(r.RequestUri.AbsolutePath == "/account/Login", "one synthetic auth route");
Auth++;
jar.Add(Api, new Cookie("sid", "synthetic"));
return Task.FromResult(Json("{\"state\":0,\"internalId\":17,\"newCreate\":true,\"createTime\":\"2026-01-01T00:00:00Z\",\"svrTime\":\"2026-01-02T00:00:00Z\"}"));
}, () => { SessionDisposes++; Record("session"); });
}),
() => new PlayerKVGameSessionBridge(jar =>
{
Check(ReferenceEquals(Jar, jar), "bridge shares cookie identity");
return new Handler((r, t) =>
{
Check(r.RequestUri.AbsolutePath == "/playerdata/Get", "get only, zero KV mutation");
Gets++; return Task.FromResult(Json("{\"data\":{}}"));
}, () => Record("bridge"));
}));
}
internal PlayerKVControlledLoadCapability Capability() => PlayerKVControlledLoadCapability.CreateForValidation(jar =>
{
Check(ReferenceEquals(Jar, jar), "borrower shares exact cookie identity");
DuringFactory?.Invoke();
return new Handler((r, t) => { BorrowedSends++; return Borrowed(r, t); }, () =>
{ LeaseDisposes++; Record("lease"); if (FailLeaseDispose) throw new IOException("synthetic cleanup fault"); });
});
internal PlayerKVControlledLoadCapability FormalCapability() => PlayerKVControlledLoadCapability.CreateForGame(jar =>
{
Check(ReferenceEquals(Jar, jar), "formal lease shares exact cookie identity");
return new Handler((r, t) => { BorrowedSends++; return Borrowed(r, t); }, () =>
{ LeaseDisposes++; Record("lease"); if (FailLeaseDispose) throw new IOException("synthetic cleanup fault"); });
});
internal async Task<PlayerKVAccountLease> PreparedLease()
{
Check((await Context.PrepareAsync()).Prepared, "prepared");
Check(Context.TryAcquireAccountLease(Capability(), out var lease), "lease admitted");
return lease;
}
}
static async Task SingleAuthentication()
{
var f = new Fixture(); var lease = await f.PreparedLease();
var d = f.Context.AccountDescriptor;
Check(d.PlayerId == "101" && d.Epoch == 7 && d.InternalId == 17 && d.NewlyCreated, "metadata belongs to authentication");
Check(d.CreateTimeUtc.Year == 2026 && d.ServerTimeUtc.Day == 2, "metadata time");
Check((await lease.SendAsync(HttpMethod.Get, "__kv_validation/ok")).IsCurrent, "borrowed send");
Check(f.Auth == 1 && f.Gets == 1 && f.BorrowedSends == 1, "no second login");
await f.Context.CloseAsync();
Check(string.Join(",", f.Order) == "lease,bridge,session", "borrower drains before bridge and session");
Check(f.Context.AccountDescriptor == null && !lease.IsCurrent, "closed admission revoked");
}
static async Task Admission()
{
var f = new Fixture(); Check(!f.Context.TryAcquireAccountLease(f.Capability(), out _), "before prepared rejected");
var lease = await f.PreparedLease();
Check(!f.Context.TryAcquireAccountLease(f.Capability(), out _), "one lease only");
Check(!f.Context.TryAcquireAccountLease(null, out _), "capability required");
var close = f.Context.CloseAsync(); Check(!f.Context.TryAcquireAccountLease(f.Capability(), out _), "close synchronous");
await close;
Check(!(await lease.SendAsync(HttpMethod.Get, "__kv_validation/ok")).IsCurrent && f.BorrowedSends == 0, "after close no transport");
}
static async Task Routes()
{
var f = new Fixture(); var l = await f.PreparedLease();
foreach (var route in new[] { "account/Login", "playerdata/Get", "playerdata/Update", "http://synthetic.invalid/__kv_validation/ok", "//synthetic.invalid/x", "__kv_validation/../ok", "__kv_validation/ok?x=1", "%5f%5fkv_validation/ok", "__kv_validation/ok#f" })
await Throws<ArgumentException>(() => l.SendAsync(HttpMethod.Get, route));
Check(f.BorrowedSends == 0, "rejected routes never sent"); await f.Context.CloseAsync();
}
static async Task FormalBusinessLease()
{
var f = new Fixture(); Check((await f.Context.PrepareAsync()).Prepared, "formal prepared");
Check(f.Context.TryAcquireAccountLease(f.FormalCapability(), out var lease), "formal lease admitted");
Check(lease.IsFormal && ReferenceEquals(lease.FormalCookies, f.Jar), "immutable formal mode and shared jar");
var formalClient=(HttpClient)typeof(PlayerKVAccountLease).GetField("client", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(lease)!;
Check(formalClient.Timeout == TimeSpan.FromSeconds(15), "formal transport retains old 15 second timeout");
f.Borrowed = async (request, _) =>
{
Check(request.RequestUri.AbsolutePath == "/mail/fetch", "canonical business path");
Check(request.Method == HttpMethod.Post, "POST method retained");
Check(request.Content.Headers.ContentType.MediaType == "application/json", "JSON content type retained");
Check(await request.Content.ReadAsStringAsync() == "{}", "body retained");
return Json("business");
};
var response = await lease.SendAsync(HttpMethod.Post, "mail/fetch", "{}");
Check(response.IsCurrent && response.StatusCode == 200 && response.Body == "business", "formal response retained");
foreach (var route in new[] { "__kv_validation/ok", "__kv_validation/unauthorized", "account/Login", "playerdata/Get", "/mail/fetch", "//other/x", "http://other/x", "mail/fetch?x=1", "mail/%66etch", "mail/../fetch", "mail\\fetch", "mail//fetch" })
await Throws<ArgumentException>(() => lease.SendAsync(HttpMethod.Get, route));
Check(f.BorrowedSends == 1, "invalid formal routes zero send");
await f.Context.CloseAsync();
var v = new Fixture(); var validation = await v.PreparedLease();
Check(!validation.IsFormal && validation.FormalCookies == null, "validation never exposes formal cookie");
var validationClient=(HttpClient)typeof(PlayerKVAccountLease).GetField("client", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(validation)!;
Check(validationClient.Timeout == TimeSpan.FromSeconds(100), "validation timeout unchanged");
await Throws<ArgumentException>(() => validation.SendAsync(HttpMethod.Get, "mail/fetch"));
Check(v.BorrowedSends == 0, "validation ordinary path zero send");
await v.Context.CloseAsync();
var broken = new Fixture { FailLeaseDispose = true };
Check((await broken.Context.PrepareAsync()).Prepared, "failure case prepared");
Check(broken.Context.TryAcquireAccountLease(broken.FormalCapability(), out var failedLease), "failure case formal lease");
var failedClose = broken.Context.CloseAsync();
await Throws<IOException>(() => failedClose);
Check(!failedLease.IsCurrent && failedLease.FormalCookies == null &&
ReferenceEquals(failedClose, broken.Context.CloseAsync()), "formal close failure remains closed");
}
static async Task RealHandlerRejected()
{
var f = new Fixture(); await f.Context.PrepareAsync();
await Throws<InvalidOperationException>(() =>
{ f.Context.TryAcquireAccountLease(PlayerKVControlledLoadCapability.CreateForValidation(_ => new HttpClientHandler()), out _); return Task.CompletedTask; });
Check(f.BorrowedSends == 0, "real handler never sends"); await f.Context.CloseAsync();
}
static async Task Drain()
{
var f = new Fixture(); var entered = Signal(); var release = Signal(); var cancelled = Signal();
f.Borrowed = async (_, token) => { token.Register(() => cancelled.TrySetResult(true)); entered.TrySetResult(true); await release.Task; f.Record("send-ended"); return Json("late"); };
var l = await f.PreparedLease(); var send = l.SendAsync(HttpMethod.Get, "__kv_validation/ok"); await entered.Task;
var close = f.Context.CloseAsync(); await cancelled.Task;
Check(!close.IsCompleted && f.SessionDisposes == 0 && f.LeaseDisposes == 0, "no premature dispose despite cancellation");
release.TrySetResult(true); var response = await send; await close;
Check(!response.IsCurrent && response.Body == null, "late response withheld");
Check(string.Join(",", f.Order) == "send-ended,lease,bridge,session", "full resource order");
}
static async Task Reentry()
{
var f = new Fixture(); var entered = Signal(); var release = Signal(); var callback = Signal(); Task reentered = null;
f.Borrowed = async (_, token) => { token.Register(() => { reentered = f.Context.CloseAsync(); callback.TrySetResult(true); }); entered.TrySetResult(true); await release.Task; return Json("{}"); };
var l = await f.PreparedLease(); var send = l.SendAsync(HttpMethod.Get, "__kv_validation/ok"); await entered.Task;
var close = f.Context.CloseAsync(); await callback.Task;
Check(ReferenceEquals(close, reentered), "cancellation reentry same task, no cycle");
release.TrySetResult(true); await send; await close;
}
static async Task DisposeFailure()
{
var f = new Fixture { FailLeaseDispose = true }; var l = await f.PreparedLease();
var close = f.Context.CloseAsync(); await Throws<IOException>(() => close);
Check(f.SessionDisposes == 0 && !f.Order.Contains("bridge"), "failed borrower cleanup prevents owner disposal");
Check(ReferenceEquals(close, f.Context.CloseAsync()) && !f.Context.TryAcquireAccountLease(f.Capability(), out _), "failure cannot reopen");
var registry = new PlayerKVShutdownRegistry(); registry.Register(close, f.Context);
Check(registry.Status == PlayerKVShutdownStatus.Failed, "fault retained");
var retained = (Dictionary<Task, PlayerKVLoginContext>)typeof(PlayerKVShutdownRegistry)
.GetField("pending", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(registry)!;
Check(ReferenceEquals(retained[close], f.Context), "failed owner retained beyond caller lifetime");
}
static async Task IssuanceRace()
{
for (int i = 0; i < 24; i++)
{
var f = new Fixture(); await f.Context.PrepareAsync(); var go = Signal();
var issue = Task.Run(async () => { await go.Task; return f.Context.TryAcquireAccountLease(f.Capability(), out _); });
var close = Task.Run(async () => { await go.Task; await f.Context.CloseAsync(); });
go.TrySetResult(true); await Task.WhenAll(issue, close);
Check(f.SessionDisposes == 1 && !f.Context.IsCurrent, "race closes session once");
if (issue.Result) Check(f.LeaseDisposes == 1, "published borrower observed by close");
}
}
static async Task FactoryRevocation()
{
var f = new Fixture(); await f.Context.PrepareAsync(); f.DuringFactory = () => { _ = f.Context.CloseAsync(); };
Check(!f.Context.TryAcquireAccountLease(f.Capability(), out var l) && l == null, "reentrant factory cannot publish");
await f.Context.CloseAsync(); Check(f.LeaseDisposes == 1 && f.SessionDisposes == 1, "candidate still cleaned");
}
static async Task StaleResponse()
{
var f = new Fixture(); f.Borrowed = (_, _) => { f.Current = false; return Task.FromResult(Json("should-not-escape")); };
var l = await f.PreparedLease(); var r = await l.SendAsync(HttpMethod.Get, "__kv_validation/ok");
Check(!r.IsCurrent && r.Body == null && r.StatusCode == null, "200 does not restore old epoch"); await f.Context.CloseAsync();
}
static async Task Responses()
{
var f = new Fixture(); var l = await f.PreparedLease();
f.Borrowed = (_, _) => Task.FromResult(Json("{}", HttpStatusCode.Unauthorized));
Check((await l.SendAsync(HttpMethod.Get, "__kv_validation/unauthorized")).StatusCode == 401, "401 evidence for version-aware caller");
f.Borrowed = (_, _) => Task.FromResult(Json("{}", HttpStatusCode.Redirect));
await Throws<InvalidOperationException>(() => l.SendAsync(HttpMethod.Get, "__kv_validation/ok")); await f.Context.CloseAsync();
}
static async Task RegistryTimeout()
{
var registry = new PlayerKVShutdownRegistry(); var closing = Signal(); var deadline = Signal(); registry.Register(closing.Task);
var observe = registry.ObserveAsync(10000, delay: (_, _) => deadline.Task); deadline.TrySetResult(true);
Check(await observe == PlayerKVShutdownStatus.Pending && !closing.Task.IsCompleted, "timeout not force-close");
closing.TrySetResult(true); await closing.Task;
Check(registry.Status == PlayerKVShutdownStatus.Ready, "real completion opens barrier");
}
static async Task RegistryFailure()
{
var registry = new PlayerKVShutdownRegistry(); var fail = Signal(); registry.Register(fail.Task); fail.TrySetException(new IOException("synthetic"));
Check(await registry.ObserveAsync(10000) == PlayerKVShutdownStatus.Failed, "fault classified");
registry.Register(Task.CompletedTask); Check(registry.Status == PlayerKVShutdownStatus.Failed, "new task cannot erase failure");
Check(!typeof(PlayerKVShutdownRegistry).GetMethods(BindingFlags.Instance | BindingFlags.NonPublic).Any(m => m.Name == "Clear" || m.Name == "Reset"), "no reset API");
}
static async Task RegistryRegistration()
{
var registry = new PlayerKVShutdownRegistry(); var first = Signal(); var second = Signal(); var deadline = Signal();
registry.Register(first.Task); var observe = registry.ObserveAsync(10000, delay: (_, _) => deadline.Task);
registry.Register(second.Task); first.TrySetResult(true); deadline.TrySetResult(true);
Check(await observe == PlayerKVShutdownStatus.Pending, "newly registered work not missed"); second.TrySetResult(true);
}
static async Task RegistryEarlyFailure()
{
var registry = new PlayerKVShutdownRegistry(); var fail = Signal(); var slow = Signal(); var deadline = Signal();
registry.Register(fail.Task); registry.Register(slow.Task);
var observe = registry.ObserveAsync(10000, delay: (_, _) => deadline.Task);
fail.TrySetException(new IOException("synthetic"));
Check(await observe == PlayerKVShutdownStatus.Failed && !slow.Task.IsCompleted && !deadline.Task.IsCompleted,
"failure reported without waiting for unrelated cleanup or observation deadline");
slow.TrySetResult(true);
}
static async Task RegistryNewFailure()
{
var registry = new PlayerKVShutdownRegistry(); var slow = Signal(); var deadline = Signal();
registry.Register(slow.Task); var observe = registry.ObserveAsync(10000, delay: (_, _) => deadline.Task);
registry.Register(Task.FromException(new IOException("synthetic")));
Check(await observe == PlayerKVShutdownStatus.Failed && !slow.Task.IsCompleted && !deadline.Task.IsCompleted,
"new root failure wakes existing observer"); slow.TrySetResult(true);
}
static async Task RegistryCancellation()
{
var registry = new PlayerKVShutdownRegistry(); var close = Signal(); registry.Register(close.Task);
Check(await registry.ObserveAsync(0) == PlayerKVShutdownStatus.Input, "invalid timeout Input");
using var cts = new CancellationTokenSource(); var observed = registry.ObserveAsync(10000, cts.Token); cts.Cancel();
Check(await observed == PlayerKVShutdownStatus.Cancelled && !close.Task.IsCompleted, "observer cancellation does not cancel cleanup"); close.TrySetResult(true);
}
static async Task ProcessBarrier()
{
var firstRoot = PlayerKVShutdownRegistry.Process; var close = Signal(); firstRoot.Register(close.Task);
var secondRoot = PlayerKVShutdownRegistry.Process;
Check(ReferenceEquals(firstRoot, secondRoot) && secondRoot.Status == PlayerKVShutdownStatus.Pending, "new caller same process barrier");
close.TrySetResult(true); Check(await secondRoot.ObserveAsync(1000) == PlayerKVShutdownStatus.Ready, "drained across caller replacement");
}
}