diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index a7bd80c..5561bfe 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -1,36 +1,38 @@ -using LightlessSync.API.Data; + using LightlessSync.API.Data; -namespace LightlessSync.PlayerData.Pairs; + namespace LightlessSync.PlayerData.Pairs; -/// -/// orchestrates the lifecycle of a paired character -/// -public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject -{ - new string Ident { get; } - bool Initialized { get; } - bool IsVisible { get; } - bool ScheduledForDeletion { get; set; } - CharacterData? LastReceivedCharacterData { get; } - long LastAppliedDataBytes { get; } - new string? PlayerName { get; } - string PlayerNameHash { get; } - uint PlayerCharacterId { get; } - DateTime? LastDataReceivedAt { get; } - DateTime? LastApplyAttemptAt { get; } - DateTime? LastSuccessfulApplyAt { get; } - string? LastFailureReason { get; } - IReadOnlyList LastBlockingConditions { get; } - bool IsApplying { get; } - bool IsDownloading { get; } - int PendingDownloadCount { get; } - int ForbiddenDownloadCount { get; } + /// + /// orchestrates the lifecycle of a paired character + /// + public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject + { + new string Ident { get; } + bool Initialized { get; } + bool IsVisible { get; } + bool ScheduledForDeletion { get; set; } + CharacterData? LastReceivedCharacterData { get; } + long LastAppliedDataBytes { get; } + new string? PlayerName { get; } + string PlayerNameHash { get; } + uint PlayerCharacterId { get; } + DateTime? LastDataReceivedAt { get; } + DateTime? LastApplyAttemptAt { get; } + DateTime? LastSuccessfulApplyAt { get; } + string? LastFailureReason { get; } + IReadOnlyList LastBlockingConditions { get; } + bool IsApplying { get; } + bool IsDownloading { get; } + int PendingDownloadCount { get; } + int ForbiddenDownloadCount { get; } + DateTime? InvisibleSinceUtc { get; } + DateTime? VisibilityEvictionDueAtUtc { get; } void Initialize(); - void ApplyData(CharacterData data); - void ApplyLastReceivedData(bool forced = false); - bool FetchPerformanceMetricsFromCache(); - void LoadCachedCharacterData(CharacterData data); - void SetUploading(bool uploading); - void SetPaused(bool paused); -} + void ApplyData(CharacterData data); + void ApplyLastReceivedData(bool forced = false); + bool FetchPerformanceMetricsFromCache(); + void LoadCachedCharacterData(CharacterData data); + void SetUploading(bool uploading); + void SetPaused(bool paused); + } diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index 7d780dd..935b705 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -194,9 +194,13 @@ public class Pair { var handler = TryGetHandler(); if (handler is null) - { return PairDebugInfo.Empty; - } + + var now = DateTime.UtcNow; + var dueAt = handler.VisibilityEvictionDueAtUtc; + var remainingSeconds = dueAt.HasValue + ? Math.Max(0, (dueAt.Value - now).TotalSeconds) + : (double?)null; return new PairDebugInfo( true, @@ -206,6 +210,9 @@ public class Pair handler.LastDataReceivedAt, handler.LastApplyAttemptAt, handler.LastSuccessfulApplyAt, + handler.InvisibleSinceUtc, + handler.VisibilityEvictionDueAtUtc, + remainingSeconds, handler.LastFailureReason, handler.LastBlockingConditions, handler.IsApplying, diff --git a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs index 9074c82..31c3236 100644 --- a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs +++ b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs @@ -8,6 +8,9 @@ public sealed record PairDebugInfo( DateTime? LastDataReceivedAt, DateTime? LastApplyAttemptAt, DateTime? LastSuccessfulApplyAt, + DateTime? InvisibleSinceUtc, + DateTime? VisibilityEvictionDueAtUtc, + double? VisibilityEvictionRemainingSeconds, string? LastFailureReason, IReadOnlyList BlockingConditions, bool IsApplying, @@ -24,6 +27,9 @@ public sealed record PairDebugInfo( null, null, null, + null, + null, + null, Array.Empty(), false, false, diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 556dd84..706b0bc 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -70,7 +70,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private DateTime? _lastSuccessfulApplyAt; private string? _lastFailureReason; private IReadOnlyList _lastBlockingConditions = Array.Empty(); + private readonly object _visibilityGraceGate = new(); + private CancellationTokenSource? _visibilityGraceCts; + private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1); + private DateTime? _invisibleSinceUtc; + private DateTime? _visibilityEvictionDueAtUtc; + public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; + public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; public string Ident { get; } public bool Initialized { get; private set; } public bool ScheduledForDeletion { get; set; } @@ -80,24 +87,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa get => _isVisible; private set { - if (_isVisible != value) + if (_isVisible == value) return; + + _isVisible = value; + + if (!_isVisible) { - _isVisible = value; - if (!_isVisible) - { - DisableSync(); - ResetPenumbraCollection(reason: "VisibilityLost"); - } - else if (_charaHandler is not null && _charaHandler.Address != nint.Zero) - { - _ = EnsurePenumbraCollection(); - } - var user = GetPrimaryUserData(); - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), - EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible")))); - Mediator.Publish(new RefreshUiMessage()); - Mediator.Publish(new VisibilityChange()); + DisableSync(); + + _invisibleSinceUtc = DateTime.UtcNow; + _visibilityEvictionDueAtUtc = _invisibleSinceUtc.Value.Add(VisibilityEvictionGrace); + + StartVisibilityGraceTask(); } + else + { + CancelVisibilityGraceTask(); + + _invisibleSinceUtc = null; + _visibilityEvictionDueAtUtc = null; + + ScheduledForDeletion = false; + + if (_charaHandler is not null && _charaHandler.Address != nint.Zero) + _ = EnsurePenumbraCollection(); + } + + var user = GetPrimaryUserData(); + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), + EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible")))); + Mediator.Publish(new RefreshUiMessage()); + Mediator.Publish(new VisibilityChange()); } } @@ -918,6 +938,46 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } + private void CancelVisibilityGraceTask() + { + lock (_visibilityGraceGate) + { + _visibilityGraceCts?.CancelDispose(); + _visibilityGraceCts = null; + } + } + + private void StartVisibilityGraceTask() + { + CancellationToken token; + lock (_visibilityGraceGate) + { + _visibilityGraceCts = _visibilityGraceCts?.CancelRecreate() ?? new CancellationTokenSource(); + token = _visibilityGraceCts.Token; + } + + _visibilityGraceTask = Task.Run(async () => + { + try + { + await Task.Delay(VisibilityEvictionGrace, token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + if (IsVisible) return; + + ScheduledForDeletion = true; + ResetPenumbraCollection(reason: "VisibilityLostTimeout"); + } + catch (OperationCanceledException) + { + // operation cancelled, do nothing + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Visibility grace task failed for {handler}", GetLogIdentifier()); + } + }, CancellationToken.None); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); @@ -936,7 +996,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _downloadCancellationTokenSource = null; _downloadManager.Dispose(); _charaHandler?.Dispose(); + CancelVisibilityGraceTask(); _charaHandler = null; + _invisibleSinceUtc = null; + _visibilityEvictionDueAtUtc = null; if (!string.IsNullOrEmpty(name)) { @@ -1265,6 +1328,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } private Task? _pairDownloadTask; + private Task _visibilityGraceTask; private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index f490804..ec05ee7 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -11,7 +11,9 @@ public sealed class PairHandlerRegistry : IDisposable { private readonly object _gate = new(); private readonly object _pendingGate = new(); + private readonly object _visibilityGate = new(); private readonly Dictionary _entriesByIdent = new(StringComparer.Ordinal); + private readonly Dictionary _pendingInvisibleEvictions = new(StringComparer.Ordinal); private readonly Dictionary _entriesByHandler = new(ReferenceEqualityComparer.Instance); private readonly IPairHandlerAdapterFactory _handlerFactory; @@ -144,6 +146,37 @@ public sealed class PairHandlerRegistry : IDisposable return PairOperationResult.Ok(registration.PairIdent); } + private PairOperationResult CancelAllInvisibleEvictions() + { + List snapshot; + lock (_visibilityGate) + { + snapshot = [.. _pendingInvisibleEvictions.Values]; + _pendingInvisibleEvictions.Clear(); + } + + List? errors = null; + + foreach (var cts in snapshot) + { + try { cts.Cancel(); } + catch (Exception ex) + { + (errors ??= new List()).Add($"Cancel: {ex.Message}"); + } + + try { cts.Dispose(); } + catch (Exception ex) + { + (errors ??= new List()).Add($"Dispose: {ex.Message}"); + } + } + + return errors is null + ? PairOperationResult.Ok() + : PairOperationResult.Fail($"CancelAllInvisibleEvictions had error(s): {string.Join(" | ", errors)}"); + } + public PairOperationResult ApplyCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto) { if (registration.CharacterIdent is null) @@ -300,6 +333,7 @@ public sealed class PairHandlerRegistry : IDisposable lock (_gate) { handlers = _entriesByHandler.Keys.ToList(); + CancelAllInvisibleEvictions(); _entriesByIdent.Clear(); _entriesByHandler.Clear(); } @@ -332,6 +366,7 @@ public sealed class PairHandlerRegistry : IDisposable lock (_gate) { handlers = _entriesByHandler.Keys.ToList(); + CancelAllInvisibleEvictions(); _entriesByIdent.Clear(); _entriesByHandler.Clear(); } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 4ce64ac..c30d5fa 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1463,7 +1463,10 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawPairPropertyRow("Has Handler", FormatBool(debugInfo.HasHandler)); DrawPairPropertyRow("Handler Initialized", FormatBool(debugInfo.HandlerInitialized)); DrawPairPropertyRow("Handler Visible", FormatBool(debugInfo.HandlerVisible)); + DrawPairPropertyRow("Last Time person rendered in", FormatTimestamp(debugInfo.InvisibleSinceUtc)); + DrawPairPropertyRow("Handler Timer Temp Collection removal", FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)); DrawPairPropertyRow("Handler Scheduled For Deletion", FormatBool(debugInfo.HandlerScheduledForDeletion)); + DrawPairPropertyRow("Note", pair.GetNote() ?? "(none)"); ImGui.EndTable(); } @@ -1698,6 +1701,19 @@ public class SettingsUi : WindowMediatorSubscriberBase return value is null ? "n/a" : value.Value.ToLocalTime().ToString("G", CultureInfo.CurrentCulture); } + private static string? FormatCountdown(double? remainingSeconds) + { + if (!remainingSeconds.HasValue) + return "No"; + + var secs = Math.Max(0, remainingSeconds.Value); + var t = TimeSpan.FromSeconds(secs); + + return t.TotalHours >= 1 + ? $"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}" + : $"{(int)t.TotalMinutes:00}:{t.Seconds:00}"; + } + private static string FormatBytes(long value) => value < 0 ? "n/a" : UiSharedService.ByteToString(value); private static string FormatCharacterId(uint id) => id == uint.MaxValue ? "n/a" : $"{id} (0x{id:X8})";