perf(tabs): share Plugin.TabsListLock across AutoTellTabsService + MessageManager refilter + SaveConfig (B3)
This commit is contained in:
@@ -21,7 +21,10 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
private readonly MessageManager _messageManager;
|
private readonly MessageManager _messageManager;
|
||||||
private readonly MessageStore _store;
|
private readonly MessageStore _store;
|
||||||
private readonly ILogger<AutoTellTabsService> _logger;
|
private readonly ILogger<AutoTellTabsService> _logger;
|
||||||
private readonly object _tempTabsLock = new();
|
|
||||||
|
// Tabs-list structure lock now lives on Plugin (neutral owner) so the
|
||||||
|
// MessageManager refilter can share it. See Plugin.TabsListLock / B3.
|
||||||
|
private object TabsListLock => _plugin.TabsListLock;
|
||||||
|
|
||||||
// Hard cap on pinned TempTabs so the sidebar doesn't inflate over years
|
// Hard cap on pinned TempTabs so the sidebar doesn't inflate over years
|
||||||
// of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live
|
// of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live
|
||||||
@@ -147,7 +150,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lock (_tempTabsLock)
|
lock (TabsListLock)
|
||||||
{
|
{
|
||||||
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
|
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
@@ -240,16 +243,20 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lock-protected lookup for the framework-thread caller (TellRouterService).
|
// Lock-protected lookup for the framework-thread caller (TellRouterService).
|
||||||
// Config.Tabs is mutated under _tempTabsLock on the PendingMessage worker thread,
|
// Config.Tabs is mutated under the shared Plugin.TabsListLock on the worker thread,
|
||||||
// so a framework-tick reader must take the same lock to avoid enumerating the list
|
// so a framework-tick reader must take the same lock to avoid enumerating the list
|
||||||
// mid-mutation.
|
// mid-mutation.
|
||||||
internal Tab? FindTempTabSafe(string name, uint world)
|
internal Tab? FindTempTabSafe(string name, uint world)
|
||||||
{
|
{
|
||||||
lock (_tempTabsLock)
|
lock (TabsListLock)
|
||||||
return FindTempTab(name, world);
|
return FindTempTab(name, world);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void DropOldestTempTab()
|
internal void DropOldestTempTab()
|
||||||
|
{
|
||||||
|
// B3: lock the list-structure ops so the (currently caller-less) Unpin path
|
||||||
|
// can't race the worker; re-entrant when HandleTell already holds the lock.
|
||||||
|
lock (TabsListLock)
|
||||||
{
|
{
|
||||||
// Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are
|
// Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are
|
||||||
// never drop candidates. They leave the bucket only via Unpin or
|
// never drop candidates. They leave the bucket only via Unpin or
|
||||||
@@ -281,6 +288,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
_plugin.MainWindow?.ResetActiveTabIfRemoved(dropped);
|
_plugin.MainWindow?.ResetActiveTabIfRemoved(dropped);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void SpawnTempTab((string Name, uint World) partner, Message currentMessage)
|
private void SpawnTempTab((string Name, uint World) partner, Message currentMessage)
|
||||||
{
|
{
|
||||||
@@ -302,7 +310,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
// Actually open the pop-out window for the flagged tab — without this the
|
// Actually open the pop-out window for the flagged tab — without this the
|
||||||
// flag was dead (a PopOut tab with no window). SpawnTempTab runs on the
|
// flag was dead (a PopOut tab with no window). SpawnTempTab runs on the
|
||||||
// PendingMessage worker thread under _tempTabsLock; TryOpen does
|
// PendingMessage worker thread under Plugin.TabsListLock; TryOpen does
|
||||||
// OnTabActivated + Bind (window state Draw reads), so marshal onto the
|
// OnTabActivated + Bind (window state Draw reads), so marshal onto the
|
||||||
// framework thread. If the pool is full, drop the flag so it never claims a
|
// framework thread. If the pool is full, drop the flag so it never claims a
|
||||||
// window it didn't get (flag/window parity).
|
// window it didn't get (flag/window parity).
|
||||||
@@ -428,7 +436,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lock (_tempTabsLock)
|
lock (TabsListLock)
|
||||||
{
|
{
|
||||||
// Guard against frame-race: sidebar might render a tab already removed by LRU or logout
|
// Guard against frame-race: sidebar might render a tab already removed by LRU or logout
|
||||||
if (!Plugin.Config.Tabs.Contains(tab))
|
if (!Plugin.Config.Tabs.Contains(tab))
|
||||||
@@ -442,7 +450,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
private void OnLogout(int type, int code)
|
private void OnLogout(int type, int code)
|
||||||
{
|
{
|
||||||
lock (_tempTabsLock)
|
lock (TabsListLock)
|
||||||
{
|
{
|
||||||
// Pinned TempTabs must survive char-switch — that's the whole point
|
// Pinned TempTabs must survive char-switch — that's the whole point
|
||||||
// of pinning. Only unpinned ones get stripped.
|
// of pinning. Only unpinned ones get stripped.
|
||||||
|
|||||||
@@ -163,8 +163,15 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
|
|
||||||
internal void ClearAllTabs()
|
internal void ClearAllTabs()
|
||||||
{
|
{
|
||||||
|
// B3: snapshot the tab LIST under the shared lock so the worker-thread
|
||||||
|
// add/remove can't tear the enumeration; tab.Clear() then runs lock-free
|
||||||
|
// (each tab's Messages has its own SemaphoreSlim — lock order: list outer).
|
||||||
|
List<Tab> tabsSnapshot;
|
||||||
|
lock (Plugin.TabsListLock)
|
||||||
|
tabsSnapshot = Plugin.Config.Tabs.ToList();
|
||||||
|
|
||||||
// TempTabs are session-only (not persisted); exclude them to preserve Tell history
|
// TempTabs are session-only (not persisted); exclude them to preserve Tell history
|
||||||
foreach (var tab in Plugin.Config.Tabs.Where(t => !t.IsTempTab))
|
foreach (var tab in tabsSnapshot.Where(t => !t.IsTempTab))
|
||||||
tab.Clear();
|
tab.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,18 +183,19 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
|
|
||||||
using var messages = Store.GetMostRecentMessages(CurrentContentId, since);
|
using var messages = Store.GetMostRecentMessages(CurrentContentId, since);
|
||||||
|
|
||||||
// TempTabs are excluded; they maintain live state from AutoTellTabsService
|
// TempTabs excluded (live state from AutoTellTabsService). Bucket via the
|
||||||
var pendingTabs = Plugin
|
// pure MapMessagesToTabs so the assignment stays testable outside Dalamud (B3-1).
|
||||||
.Config.Tabs.Where(t => !t.IsTempTab)
|
// B3: snapshot under the shared lock (list copy only — short critical
|
||||||
.Select(tab => (tab, new List<Message>()))
|
// section). The Store query above and the AddSortPrune writes below stay
|
||||||
.ToList();
|
// OUTSIDE the lock (lock order: list outer, MessageList inner).
|
||||||
foreach (var message in messages)
|
List<Tab> nonTempTabs;
|
||||||
foreach (var (_, pendingMessages) in pendingTabs.Where(ptab => ptab.Item1.Matches(message)))
|
lock (Plugin.TabsListLock)
|
||||||
pendingMessages.Add(message);
|
nonTempTabs = Plugin.Config.Tabs.Where(t => !t.IsTempTab).ToList();
|
||||||
|
var buckets = MapMessagesToTabs(nonTempTabs, messages);
|
||||||
|
|
||||||
// Apply messages to chat log all at once.
|
// Apply messages to chat log all at once.
|
||||||
foreach (var (tab, pendingMessages) in pendingTabs)
|
foreach (var tab in nonTempTabs)
|
||||||
tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit);
|
tab.Messages.AddSortPrune(buckets[tab], MessageDisplayLimit);
|
||||||
|
|
||||||
if (!messages.DidError)
|
if (!messages.DidError)
|
||||||
return;
|
return;
|
||||||
@@ -206,6 +214,26 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pure message->tab bucketing for the refilter. Dalamud-free + static so the
|
||||||
|
// assignment can be unit-pinned in the build suite; the live caller owns the
|
||||||
|
// Store query, the snapshot and the SemaphoreSlim writes.
|
||||||
|
internal static Dictionary<Tab, List<Message>> MapMessagesToTabs(
|
||||||
|
IReadOnlyList<Tab> tabs,
|
||||||
|
IEnumerable<Message> messages
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var buckets = new Dictionary<Tab, List<Message>>(tabs.Count);
|
||||||
|
foreach (var tab in tabs)
|
||||||
|
buckets[tab] = new List<Message>();
|
||||||
|
|
||||||
|
foreach (var message in messages)
|
||||||
|
foreach (var tab in tabs)
|
||||||
|
if (tab.Matches(message))
|
||||||
|
buckets[tab].Add(message);
|
||||||
|
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
internal void FilterAllTabsAsync()
|
internal void FilterAllTabsAsync()
|
||||||
{
|
{
|
||||||
Task.Run(() =>
|
Task.Run(() =>
|
||||||
|
|||||||
+18
-5
@@ -189,6 +189,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
internal readonly object RetentionSweepLock = new();
|
internal readonly object RetentionSweepLock = new();
|
||||||
internal volatile bool RetentionSweepRunning;
|
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; }
|
internal DateTime GameStarted { get; }
|
||||||
|
|
||||||
// Couples "current tab" to the real UI selection. The chat hooks are
|
// 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.");
|
Log.Information($"Retention sweep deleted {deleted} expired messages.");
|
||||||
// Schedule on the next framework tick to avoid the ~194ms
|
// Schedule on the next framework tick to avoid the ~194ms
|
||||||
// hitch from blocking with .Wait() while the framework
|
// hitch from blocking with .Wait() while the frame finishes.
|
||||||
// finishes the current frame. Tabs-list mutation must
|
// The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is
|
||||||
// stay on the framework thread because Plugin.Config.Tabs
|
// now guarded by the shared Plugin.TabsListLock (B3), so this
|
||||||
// (Configuration.cs:222) is not lock-protected and
|
// tick scheduling is purely hitch-avoidance, not safety.
|
||||||
// AutoTellTabsService can mutate it from background paths.
|
|
||||||
// Pattern reference: SimpleTweaks
|
// Pattern reference: SimpleTweaks
|
||||||
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
|
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
|
||||||
Framework.RunOnTick(() =>
|
Framework.RunOnTick(() =>
|
||||||
@@ -1091,6 +1096,13 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
// Config.Tabs across the save so JSON includes them. Cloning only the
|
// Config.Tabs across the save so JSON includes them. Cloning only the
|
||||||
// unpinned subset keeps the allocation proportional to
|
// unpinned subset keeps the allocation proportional to
|
||||||
// AutoTellTabsLimit (<=15) instead of the full tab list.
|
// AutoTellTabsLimit (<=15) instead of the full tab list.
|
||||||
|
// 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();
|
var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
|
||||||
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);
|
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);
|
||||||
|
|
||||||
@@ -1098,6 +1110,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
|
|
||||||
Config.Tabs.AddRange(unpinnedTempTabs);
|
Config.Tabs.AddRange(unpinnedTempTabs);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal void LanguageChanged(string langCode)
|
internal void LanguageChanged(string langCode)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user