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 { /// /// 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. /// public sealed class PlayerKVLoginContext { readonly object gate = new object(); readonly string requestedId, cacheRoot; readonly long epoch; readonly Uri api; readonly Func externalCurrent; readonly Func sessionFactory; readonly Func bridgeFactory; readonly CancellationTokenSource lifetime = new CancellationTokenSource(); readonly TaskCompletionSource workSettled = NewSignal(); TaskCompletionSource preparation; TaskCompletionSource 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 isCurrent) : this(playerId, cacheRoot, epoch, isCurrent, new Uri(S101AccountSession.DefaultApiUrl), () => new S101AccountSession(), () => new PlayerKVGameSessionBridge()) { } internal PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func isCurrent, Uri api, Func sessionFactory, Func 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; // Only the coordinator may publish this original handle after preparation 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) { 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; } } /// Historical Prepared results and their data survive Close, but their authority does not. 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; } } /// /// 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. /// public Task PrepareAsync(CancellationToken lifetimeToken = default) { Task result; bool start, alreadyClosing; lock (gate) { start = preparation == null; if (start) { preparation = new TaskCompletionSource(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 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)); } } /// Synchronously revokes admission; drains preparation and bridge before disposing the session. public Task CloseAsync() { TaskCompletionSource 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 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 NewSignal() => new TaskCompletionSource(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; } } /// Controlled transport factory; formal and Editor validation modes remain separate. internal sealed class PlayerKVControlledLoadCapability { readonly Func leaseHandler; readonly bool formal; private PlayerKVControlledLoadCapability(Func leaseHandler, bool formal) { this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); this.formal = formal; } internal bool IsFormal => formal; internal static PlayerKVControlledLoadCapability CreateForGame( Func handler) => new PlayerKVControlledLoadCapability(handler, true); #if UNITY_EDITOR internal static PlayerKVControlledLoadCapability CreateForValidation( Func leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler, false); #endif internal HttpMessageHandler CreateHandler(CookieContainer cookies) { return CreateHandler(leaseHandler, cookies, formal); } internal static HttpMessageHandler CreateHandler(Func factory, CookieContainer cookies, bool formal) { var handler = factory(cookies) ?? throw new InvalidOperationException("Transport handler required."); var seen = new HashSet(); 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 (!formal && (node is HttpClientHandler || node.GetType().FullName == "System.Net.Http.SocketsHttpHandler")) { handler.Dispose(); throw new InvalidOperationException("Real or cyclic transport is not a validation handler."); } if (formal && node is HttpClientHandler real && (!real.UseCookies || !ReferenceEquals(real.CookieContainer, cookies) || real.AllowAutoRedirect)) { handler.Dispose(); throw new InvalidOperationException("Formal transport must share the session cookies and reject redirects."); } if (formal && node.GetType().FullName == "System.Net.Http.SocketsHttpHandler") { handler.Dispose(); throw new InvalidOperationException("Unverified formal transport 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; } } /// A single context-owned, synthetic-only borrower. Close drains before disposing its client. internal sealed class PlayerKVAccountLease { readonly object gate = new object(); readonly HttpClient client; readonly CookieContainer cookies; readonly bool formal; readonly Func current; readonly Func admission; readonly CancellationTokenSource lifetime = new CancellationTokenSource(); TaskCompletionSource drained; TaskCompletionSource closing; int active; internal Uri ApiBaseUri { get; } internal long Epoch { get; } internal bool IsFormal => formal; internal CookieContainer FormalCookies => formal && IsCurrent ? cookies : null; internal bool IsCurrent { get { lock (gate) if (closing != null) return false; return current(); } } internal PlayerKVAccountLease(Uri api, long epoch, CookieContainer cookies, PlayerKVControlledLoadCapability capability, Func current, Func admission) { if (capability == null) throw new ArgumentNullException(nameof(capability)); ApiBaseUri = api; Epoch = epoch; this.cookies = cookies; formal = capability.IsFormal; this.current = current; this.admission = admission; client = new HttpClient(capability.CreateHandler(cookies)) { BaseAddress = api }; if (formal) client.Timeout = TimeSpan.FromSeconds(15); } internal async Task SendAsync(HttpMethod method, string route, string body = null, string contentType = "application/json", CancellationToken token = default) { if (formal) { if (!IsCanonicalBusinessRoute(route)) throw new ArgumentException("Only canonical relative business routes are allowed.", nameof(route)); } else 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 lease 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); } } } static bool IsCanonicalBusinessRoute(string route) { if (string.IsNullOrEmpty(route) || route[0] == '/' || route[0] == '\\' || Uri.TryCreate(route, UriKind.Absolute, out _) || route.IndexOf("//", StringComparison.Ordinal) >= 0 || route.IndexOfAny(new[] { '?', '#', '%', '\\' }) >= 0) return false; var segments = route.Split('/'); foreach (var segment in segments) { if (segment.Length == 0 || segment == "." || segment == "..") return false; foreach (var character in segment) if (char.IsControl(character)) return false; } return !route.Equals("__kv_validation", StringComparison.OrdinalIgnoreCase) && !route.StartsWith("__kv_validation/", StringComparison.OrdinalIgnoreCase) && !route.Equals("account/Login", StringComparison.OrdinalIgnoreCase) && !route.Equals("playerdata", StringComparison.OrdinalIgnoreCase) && !route.StartsWith("playerdata/", StringComparison.OrdinalIgnoreCase); } internal Task CloseAsync() { TaskCompletionSource 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 Signal() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } }