Files
ft/Client/Assets/Scripts/Services/PlayerData/PlayerKVLoginContext.cs

473 lines
24 KiB
C#

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);
}
}