perf(tabs): share Plugin.TabsListLock across AutoTellTabsService + MessageManager refilter + SaveConfig (B3)

This commit is contained in:
2026-06-16 20:39:56 +02:00
parent 93f4fbba72
commit 618e029ff4
3 changed files with 103 additions and 54 deletions
+22 -9
View File
@@ -189,6 +189,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal readonly object RetentionSweepLock = new();
internal volatile bool RetentionSweepRunning;
// B3: neutral owner of the Config.Tabs LIST-structure lock so both the
// worker-thread mutator (AutoTellTabsService) and the framework-thread
// refilter (MessageManager) share ONE monitor. Lock order: this outer,
// MessageList's SemaphoreSlim inner — never the reverse.
internal readonly object TabsListLock = new();
internal DateTime GameStarted { get; }
// Couples "current tab" to the real UI selection. The chat hooks are
@@ -976,11 +982,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
{
Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Schedule on the next framework tick to avoid the ~194ms
// hitch from blocking with .Wait() while the framework
// finishes the current frame. Tabs-list mutation must
// stay on the framework thread because Plugin.Config.Tabs
// (Configuration.cs:222) is not lock-protected and
// AutoTellTabsService can mutate it from background paths.
// hitch from blocking with .Wait() while the frame finishes.
// The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is
// now guarded by the shared Plugin.TabsListLock (B3), so this
// tick scheduling is purely hitch-avoidance, not safety.
// Pattern reference: SimpleTweaks
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
Framework.RunOnTick(() =>
@@ -1091,12 +1096,20 @@ public sealed class Plugin : IAsyncDalamudPlugin
// Config.Tabs across the save so JSON includes them. Cloning only the
// unpinned subset keeps the allocation proportional to
// AutoTellTabsLimit (<=15) instead of the full tab list.
var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);
// B3: the strip/restore mutates the tab LIST, so it shares TabsListLock
// with the worker add/remove and the refilter snapshot. Re-entrant: the
// one worker caller (HandleTell) already holds it; framework callers take
// it here. SavePluginConfig runs inside (short, in-memory) — the §8 fallback
// (serialize a copy outside the lock) is a tracked pre-beta to-do.
lock (TabsListLock)
{
var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);
Interface.SavePluginConfig(Config);
Interface.SavePluginConfig(Config);
Config.Tabs.AddRange(unpinnedTempTabs);
Config.Tabs.AddRange(unpinnedTempTabs);
}
}
internal void LanguageChanged(string langCode)