From 11ad5db127277b7ddefcc2c4c20a4b58cc9de3f1 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 12 May 2026 09:06:20 +0200 Subject: [PATCH] perf(autotell): replace lock-protected count with Interlocked counter F2.1: ActiveTempTabCount was doing a LINQ Count under _tempTabsLock on every read, including the hot-path HandleTell guard. Replace with an Interlocked counter kept in sync with Config.Tabs from inside the existing mutation paths (SpawnTempTab, DropOldestTempTab, OnLogout). Initialize from the persisted Tabs list on Initialize() to handle configs that already contain TempTabs from a prior session. Plugin.cs SaveConfig snapshot-restore mutates Config.Tabs outside of AutoTellTabsService; expose ResyncTempTabCounter() and call it after AddRange so the counter stays consistent. Plugin.cs:168 crash-recovery RemoveAll runs before Initialize() and is covered by the init snapshot. --- HellionChat/AutoTellTabsService.cs | 45 ++++++++++++++++++++++-------- HellionChat/Plugin.cs | 5 ++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 8bc3436..64eb8f5 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using HellionChat.Code; @@ -19,6 +20,14 @@ internal sealed class AutoTellTabsService : IDisposable private readonly MessageStore _store; private readonly object _tempTabsLock = new(); + // F2.1: lock-free counter mirrors Config.Tabs.Count(IsTempTab) so the + // hot-path getter doesn't contend with HandleTell on every render frame. + // Bumped from inside the existing mutation paths so it stays consistent + // with the underlying list — see SpawnTempTab, DropOldestTempTab, OnLogout + // and ResyncTempTabCounter (used by Plugin.cs snapshot-restore). + // TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs + private int _activeTempTabCount; + private bool _initialized; internal AutoTellTabsService(Plugin plugin, MessageManager messageManager, MessageStore store) @@ -28,16 +37,7 @@ internal sealed class AutoTellTabsService : IDisposable _store = store; } - internal int ActiveTempTabCount - { - get - { - lock (_tempTabsLock) - { - return Plugin.Config.Tabs.Count(t => t.IsTempTab); - } - } - } + internal int ActiveTempTabCount => Volatile.Read(ref _activeTempTabCount); internal void Initialize() { @@ -46,11 +46,31 @@ internal sealed class AutoTellTabsService : IDisposable return; } + // Seed the counter from the persisted Tabs list so a config that already + // contains TempTabs from a prior session starts in sync. Plugin.cs:168 + // crash-recovery has already dropped TempTabs by the time we get here, + // so the snapshot reflects post-recovery reality. + Interlocked.Exchange( + ref _activeTempTabCount, + Plugin.Config.Tabs.Count(t => t.IsTempTab) + ); + _messageManager.MessageProcessed += HandleTell; Plugin.ClientState.Logout += OnLogout; _initialized = true; } + // F2.1: callable from outside paths that mutate Config.Tabs directly + // (Plugin.cs snapshot-restore). Atomically re-pegs the counter to the + // live IsTempTab count. + internal void ResyncTempTabCounter() + { + Interlocked.Exchange( + ref _activeTempTabCount, + Plugin.Config.Tabs.Count(t => t.IsTempTab) + ); + } + public void Dispose() { if (!_initialized) @@ -184,6 +204,7 @@ internal sealed class AutoTellTabsService : IDisposable } Plugin.Config.Tabs.RemoveAt(victim.Index); + Interlocked.Decrement(ref _activeTempTabCount); // Re-anchor active tab to avoid silent switch when tab is dropped if (victim.Index <= _plugin.LastTab) @@ -208,6 +229,7 @@ internal sealed class AutoTellTabsService : IDisposable } Plugin.Config.Tabs.Add(tab); + Interlocked.Increment(ref _activeTempTabCount); } private static Tab BuildTempTab(string playerName, uint worldRowId) @@ -361,7 +383,8 @@ internal sealed class AutoTellTabsService : IDisposable } } - Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab); + var removed = Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab); + Interlocked.Add(ref _activeTempTabCount, -removed); // Force switch to tab 0 if active tab was temp or index is now out of range var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index a8411f5..0a33a1c 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -641,6 +641,11 @@ public sealed class Plugin : IAsyncDalamudPlugin Config.Tabs.Clear(); Config.Tabs.AddRange(snapshot); + + // F2.1: snapshot-restore preserves IsTempTab tabs but the mid-step + // RemoveAll bypasses AutoTellTabsService, so re-peg the counter. + // Null-conditional because SaveConfig can fire before Phase-2 init. + AutoTellTabsService?.ResyncTempTabCounter(); } internal void LanguageChanged(string langCode)