using System; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using game.Account; namespace game.PlayerDataStorage { public enum PlayerKVGameOpenError { None, OwnerRejected, Cancelled, Stale, CloseFailed } public sealed class PlayerKVGameOpenResult { public bool Success => Error == PlayerKVGameOpenError.None; public PlayerKVGameOpenError Error { get; } public PlayerKVSessionOpenError OwnerError { get; } public PlayerKVError TransportError { get; } public PlayerKVGameSessionHandle Handle { get; } internal PlayerKVGameOpenResult(PlayerKVGameOpenError error, PlayerKVSessionOpenError ownerError = PlayerKVSessionOpenError.None, PlayerKVError transportError = PlayerKVError.None, PlayerKVGameSessionHandle handle = null) { Error = error; OwnerError = ownerError; TransportError = transportError; Handle = handle; } } /// Owns the KV owner, but never disposes the supplied login session. public sealed class PlayerKVGameSessionBridge { // A fixed slot is also the handle's identity. It can never be rebound to another owner. internal sealed class Slot { internal readonly CancellationTokenSource Lifetime; internal readonly Func ExternalCurrent; internal readonly long Epoch; readonly S101AccountSession session; readonly CookieContainer cookies; internal PlayerKVSessionOwner Owner; internal Task Creation; internal Task Closing; readonly object cancellationGate = new object(); bool lifetimeDisposed; Exception cancellationFailure; internal Exception CancellationFailure { get { lock (cancellationGate) return cancellationFailure; } } int revoked; internal Slot(S101AccountSession session, long epoch, Func current, CancellationToken token) { this.session = session; cookies = session?.Cookies; Epoch = epoch; ExternalCurrent = current; Lifetime = CancellationTokenSource.CreateLinkedTokenSource(token); } internal void DisposeLifetime() { lock (cancellationGate) { if (lifetimeDisposed) return; Lifetime.Dispose(); lifetimeDisposed = true; } } internal bool Revoked => Volatile.Read(ref revoked) != 0; internal void Revoke() => Interlocked.Exchange(ref revoked, 1); internal bool IsCurrent(long epoch) => !Revoked && !Lifetime.IsCancellationRequested && session != null && session.IsAuthenticated && ReferenceEquals(session.Cookies, cookies) && epoch == Epoch && ExternalCurrent(epoch); internal void Cancel() { Revoke(); // Close can revoke an opening slot while Create is completing its cleanup. // Serialize CTS disposal with cancellation, without holding the bridge monitor. lock (cancellationGate) { if (lifetimeDisposed) return; try { Lifetime.Cancel(); } catch (Exception ex) { cancellationFailure = ex; } } } } sealed class OpenRequest { internal bool Entered; internal CancellationTokenRegistration Registration; internal readonly TaskCompletionSource Result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } readonly object gate = new object(); readonly Func handlerFactory; Task tail = Task.CompletedTask; Task lastClose; Slot active; long closeGeneration; bool closeFailed; public PlayerKVGameSessionBridge() : this(jar => new HttpClientHandler { CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { } internal PlayerKVGameSessionBridge(Func handlerFactory) { this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory)); } public Task OpenAsync(S101AccountSession session, string cacheRoot, string playerId, Uri api, long epoch, Func isCurrent, CancellationToken lifetimeToken = default) { if (isCurrent == null) return Task.FromResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.OwnerRejected, PlayerKVSessionOpenError.Validation)); var request = new OpenRequest(); var done = NewCompletion(); Task predecessor; long generation; lock (gate) { predecessor = tail; tail = done.Task; generation = closeGeneration; lastClose = null; } request.Registration = lifetimeToken.Register(() => { lock (gate) if (!request.Entered) request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.Cancelled)); }); // The reserved tail stays pending even if the caller's queued wait is cancelled. _ = OpenCoreAsync(predecessor, done, request, generation, session, cacheRoot, playerId, api, epoch, isCurrent, lifetimeToken); return request.Result.Task; } async Task OpenCoreAsync(Task predecessor, TaskCompletionSource done, OpenRequest request, long generation, S101AccountSession session, string cacheRoot, string playerId, Uri api, long epoch, Func isCurrent, CancellationToken token) { try { await predecessor.ConfigureAwait(false); Slot previous; lock (gate) { request.Entered = true; if (closeFailed) { Complete(request, PlayerKVGameOpenError.CloseFailed); return; } if (token.IsCancellationRequested) { Complete(request, PlayerKVGameOpenError.Cancelled); return; } if (generation != closeGeneration) { Complete(request, PlayerKVGameOpenError.Stale); return; } previous = active; previous?.Revoke(); } if (previous != null) await CloseSlotAsync(previous).ConfigureAwait(false); Slot slot; lock (gate) { if (token.IsCancellationRequested) { Complete(request, PlayerKVGameOpenError.Cancelled); return; } if (generation != closeGeneration) { Complete(request, PlayerKVGameOpenError.Stale); return; } slot = new Slot(session, epoch, isCurrent, token); active = slot; } slot.Creation = PlayerKVSessionOwner.CreateAsync(session, cacheRoot, playerId, api, epoch, slot.IsCurrent, slot.Lifetime.Token, handlerFactory); var opened = await slot.Creation.ConfigureAwait(false); if (!opened.Success) { // CreateAsync returns failure only after its failed owner has drained. if (slot.CancellationFailure != null) throw slot.CancellationFailure; lock (gate) if (ReferenceEquals(active, slot)) active = null; slot.Revoke(); slot.DisposeLifetime(); request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.OwnerRejected, opened.Error, opened.TransportError)); return; } slot.Owner = opened.Owner; var valid = slot.IsCurrent(epoch); lock (gate) { if (valid && generation == closeGeneration && !slot.Revoked && !token.IsCancellationRequested) { request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.None, handle: new PlayerKVGameSessionHandle(slot))); return; } slot.Revoke(); } await CloseSlotAsync(slot).ConfigureAwait(false); Complete(request, token.IsCancellationRequested ? PlayerKVGameOpenError.Cancelled : PlayerKVGameOpenError.Stale); } catch (Exception) { // Includes failed Create cleanup. Retain the slot, Creation/Closing task and owner; // no later Open is allowed to pretend the cache is reusable. lock (gate) { closeFailed = true; active?.Revoke(); } Complete(request, PlayerKVGameOpenError.CloseFailed); } finally { request.Registration.Dispose(); done.TrySetResult(true); } } /// Immediately revokes admission, then drains every preceding lifecycle operation. public Task CloseAsync() { Task predecessor; Slot observed; var done = NewCompletion(); var result = NewCompletion(); lock (gate) { if (lastClose != null) return lastClose; closeGeneration++; observed = active; observed?.Revoke(); predecessor = tail; tail = done.Task; lastClose = result.Task; } // Cancellation callbacks must never run while the bridge monitor is held. observed?.Cancel(); _ = CloseCoreAsync(predecessor, done, result); return result.Task; } async Task CloseCoreAsync(Task predecessor, TaskCompletionSource done, TaskCompletionSource result) { try { await predecessor.ConfigureAwait(false); Slot slot; lock (gate) { if (closeFailed) throw new InvalidOperationException("KV owner cleanup previously failed."); slot = active; } if (slot != null) await CloseSlotAsync(slot).ConfigureAwait(false); result.TrySetResult(true); } catch (Exception ex) { lock (gate) { closeFailed = true; active?.Revoke(); } result.TrySetException(ex); } finally { done.TrySetResult(true); } } async Task CloseSlotAsync(Slot slot) { slot.Cancel(); if (slot.Owner != null) { slot.Closing = slot.Closing ?? slot.Owner.CloseAsync(); await slot.Closing.ConfigureAwait(false); } if (slot.CancellationFailure != null) throw slot.CancellationFailure; lock (gate) if (ReferenceEquals(active, slot)) active = null; slot.DisposeLifetime(); } static TaskCompletionSource NewCompletion() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); static void Complete(OpenRequest request, PlayerKVGameOpenError error) => request.Result.TrySetResult(new PlayerKVGameOpenResult(error)); } /// Captured by delayed callbacks; all operations target this original owner only. public sealed class PlayerKVGameSessionHandle { readonly PlayerKVGameSessionBridge.Slot slot; internal PlayerKVGameSessionHandle(PlayerKVGameSessionBridge.Slot slot) { this.slot = slot; } PlayerKVSessionOwner Owner => slot.Owner; public string OwnerInstanceId => Owner.OwnerInstanceId; public string PlayerId => Owner.PlayerId; public long Epoch => Owner.Epoch; public PlayerKVSyncStatus Status => Owner.Status; public PlayerKVLocalReadResult ReadLocalSnapshot() => slot.Revoked ? new PlayerKVLocalReadResult(PlayerKVLocalReadError.StaleOrClosing, OwnerInstanceId, PlayerId, Epoch) : Owner.ReadLocalSnapshot(); public PlayerKVEditResult Update(string key, string value, bool remoteStore = true) => slot.Revoked ? BlockedEdit() : Owner.Update(key, value, remoteStore); public PlayerKVEditResult Delete(string key) => slot.Revoked ? BlockedEdit() : Owner.Delete(key); public Task FlushAsync(CancellationToken waitToken = default) => slot.Revoked ? Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, Owner.Status)) : Owner.FlushAsync(waitToken); public Task WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token = default) => !slot.IsCurrent(Epoch) ? Task.FromResult(new PlayerKVTicketResult( PlayerKVTicketStatus.Blocked, PlayerKVError.Cancelled, Owner.Status)) : Owner.WaitForTicketAsync(ticket, token); static PlayerKVEditResult BlockedEdit() => new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked)); } }