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