perf(tells): build the tab outside the lock, guard pin transitions
HandleTell was one atomic block, and PreloadHistory sat inside it -- so every new tell partner held TabsListLock across a store query that sorted the whole receiver history before returning a row. That is the lock the draw thread and the message worker both wait on. Now three steps: look for an existing tab under the lock, build the new one (including history) without it, then commit under the lock again. Splitting it opens a window where the world can change, so the second block re-checks: - FindTempTab again, in case something else created the tab meanwhile. The message goes to that one instead. Not in the first block's early return -- HandleTell runs after the delivery loop, so an existing tab already has it and adding again would duplicate the line. - A generation counter, bumped by OnLogout under the same lock. A logout in between wipes the unpinned pool, and without this the freshly built tab would outlive it and show up for a character we already left. Not via CurrentContentId: its getter falls back to a cached value, so the comparison can silently pass. - The pool cap moves into CommitTempTab and stays there exactly once. Evaluating it twice would evict a tab on every spawn. Pin, unpin and promote take the lock around the flag change now -- they decide pool membership and whether a save strips the tab. SaveConfig stays outside, so no fsync lands on the click path. DropOldestTempTab removes by reference: the index came from an earlier Select in the same block and would point at the wrong tab if anything shifted the list.
This commit is contained in:
@@ -26,6 +26,11 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
// MessageManager refilter can share it. See Plugin.TabsListLock / B3.
|
// MessageManager refilter can share it. See Plugin.TabsListLock / B3.
|
||||||
private object TabsListLock => _plugin.TabsListLock;
|
private object TabsListLock => _plugin.TabsListLock;
|
||||||
|
|
||||||
|
// Bumped whenever something wipes unpinned temp tabs wholesale (logout).
|
||||||
|
// HandleTell reads it before releasing the lock and re-checks after, so a
|
||||||
|
// tab built in between is discarded instead of outliving the wipe.
|
||||||
|
private int _tabGeneration;
|
||||||
|
|
||||||
// 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
|
||||||
// in their own bucket. A configurable cap is a vault-backlog anchor for
|
// in their own bucket. A configurable cap is a vault-backlog anchor for
|
||||||
@@ -150,15 +155,19 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Three steps, because building the tab pulls history out of the store and
|
||||||
|
// that must not happen under TabsListLock (B3 rule; the query sorts the whole
|
||||||
|
// receiver history). Step 1 and 3 are locked, step 2 is not.
|
||||||
|
int generation;
|
||||||
lock (TabsListLock)
|
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)
|
||||||
{
|
{
|
||||||
// Already routed via MessageManager pipeline. Repair the
|
// Already routed via MessageManager pipeline — no AddMessage here,
|
||||||
// tell-target if the fallback hit a pinned tab whose
|
// HandleTell runs after the delivery loop. Repair the tell-target if
|
||||||
// TellTarget didn't survive a previous round-trip — keeps
|
// the fallback hit a pinned tab whose TellTarget didn't survive a
|
||||||
// FindTempTab fast on the next message.
|
// previous round-trip — keeps FindTempTab fast on the next message.
|
||||||
if (
|
if (
|
||||||
existing.IsPinned
|
existing.IsPinned
|
||||||
&& (existing.TellTarget is null || !existing.TellTarget.IsSet())
|
&& (existing.TellTarget is null || !existing.TellTarget.IsSet())
|
||||||
@@ -175,12 +184,29 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
|
generation = _tabGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tab = BuildTempTabWithHistory(partner.Value, message);
|
||||||
|
|
||||||
|
lock (TabsListLock)
|
||||||
|
{
|
||||||
|
// A logout in between wiped the unpinned pool; committing now would
|
||||||
|
// resurrect a tab for a character we already left.
|
||||||
|
if (generation != _tabGeneration)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Someone else (self-test, UI) may have created the tab while we built
|
||||||
|
// ours. Hand the message to theirs and drop what we built — unlike the
|
||||||
|
// early return above, this tab appeared after the delivery loop ran.
|
||||||
|
var raced = FindTempTab(partner.Value.Name, partner.Value.World);
|
||||||
|
if (raced != null)
|
||||||
{
|
{
|
||||||
DropOldestTempTab();
|
raced.AddMessage(message, unread: true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SpawnTempTab(partner.Value, message);
|
CommitTempTab(tab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +300,9 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var dropped = victim.Tab;
|
var dropped = victim.Tab;
|
||||||
Plugin.Config.Tabs.RemoveAt(victim.Index);
|
// By reference, not by index: the index came from a Select() earlier in
|
||||||
|
// this block and would point at the wrong tab if anything shifted the list.
|
||||||
|
Plugin.Config.Tabs.Remove(dropped);
|
||||||
|
|
||||||
// Re-anchor the UI selection if it pointed at the dropped tab, and close any
|
// Re-anchor the UI selection if it pointed at the dropped tab, and close any
|
||||||
// pop-out window the dropped tab owned. Both run on the PendingMessage worker
|
// pop-out window the dropped tab owned. Both run on the PendingMessage worker
|
||||||
@@ -290,7 +318,10 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SpawnTempTab((string Name, uint World) partner, Message currentMessage)
|
// Runs WITHOUT TabsListLock: PreloadHistory hits the store, which used to hold
|
||||||
|
// the lock across a query that sorted the whole receiver history. The tab is not
|
||||||
|
// public until CommitTempTab adds it, so building it unlocked is safe.
|
||||||
|
private Tab BuildTempTabWithHistory((string Name, uint World) partner, Message currentMessage)
|
||||||
{
|
{
|
||||||
var tab = BuildTempTab(partner.Name, partner.World);
|
var tab = BuildTempTab(partner.Name, partner.World);
|
||||||
|
|
||||||
@@ -306,10 +337,21 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
tab.PopOut = true;
|
tab.PopOut = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller MUST hold TabsListLock.
|
||||||
|
private void CommitTempTab(Tab tab)
|
||||||
|
{
|
||||||
|
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
|
||||||
|
{
|
||||||
|
DropOldestTempTab();
|
||||||
|
}
|
||||||
|
|
||||||
Plugin.Config.Tabs.Add(tab);
|
Plugin.Config.Tabs.Add(tab);
|
||||||
|
|
||||||
// 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). CommitTempTab runs on the
|
||||||
// PendingMessage worker thread under Plugin.TabsListLock; 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
|
||||||
@@ -472,6 +514,11 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
|
Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
|
||||||
|
|
||||||
|
// HandleTell builds a tab outside the lock; bumping here lets it detect
|
||||||
|
// that the world moved on and drop what it built. Read and compared under
|
||||||
|
// the same lock, so no volatile needed.
|
||||||
|
_tabGeneration++;
|
||||||
|
|
||||||
// Re-anchor the UI selection if the active tab was one of the stripped
|
// Re-anchor the UI selection if the active tab was one of the stripped
|
||||||
// unpinned temp tabs (reference predicate, not an index). Logout is a
|
// unpinned temp tabs (reference predicate, not an index). Logout is a
|
||||||
// framework-thread event, so this is already serialized with Draw — no
|
// framework-thread event, so this is already serialized with Draw — no
|
||||||
@@ -493,16 +540,23 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PinnedTempTabCount >= MaxPinnedTempTabs)
|
// Count and flag under one lock so the cap can't be raced. SaveConfig stays
|
||||||
|
// OUTSIDE -- holding TabsListLock across a save would put an fsync on the
|
||||||
|
// click path, which is what B6 just removed elsewhere.
|
||||||
|
lock (TabsListLock)
|
||||||
{
|
{
|
||||||
WrapperUtil.AddNotification(
|
if (PinnedTempTabCount >= MaxPinnedTempTabs)
|
||||||
string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs),
|
{
|
||||||
NotificationType.Warning
|
WrapperUtil.AddNotification(
|
||||||
);
|
string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs),
|
||||||
return false;
|
NotificationType.Warning
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tab.IsPinned = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
tab.IsPinned = true;
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
$"[Pin] Pinned tab '{tab.Name}' target={tab.TellTarget?.Name}@{tab.TellTarget?.World}"
|
$"[Pin] Pinned tab '{tab.Name}' target={tab.TellTarget?.Name}@{tab.TellTarget?.World}"
|
||||||
);
|
);
|
||||||
@@ -519,13 +573,18 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
// If the unpinned pool is already full, dropping the oldest before
|
// If the unpinned pool is already full, dropping the oldest before
|
||||||
// flipping the flag avoids counting the just-unpinned tab as a drop
|
// flipping the flag avoids counting the just-unpinned tab as a drop
|
||||||
// candidate.
|
// candidate. Under lock, since DropOldestTempTab mutates the list.
|
||||||
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
|
// SaveConfig stays outside, see TryPin.
|
||||||
|
lock (TabsListLock)
|
||||||
{
|
{
|
||||||
DropOldestTempTab();
|
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
|
||||||
|
{
|
||||||
|
DropOldestTempTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
tab.IsPinned = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
tab.IsPinned = false;
|
|
||||||
_logger.LogDebug("[Pin] Unpinned tab '{TabName}'", tab.Name);
|
_logger.LogDebug("[Pin] Unpinned tab '{TabName}'", tab.Name);
|
||||||
_plugin.SaveConfig();
|
_plugin.SaveConfig();
|
||||||
}
|
}
|
||||||
@@ -542,7 +601,12 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
// see StripTellBindingOnPromote; clearing Tab.TellTarget alone would leave
|
// see StripTellBindingOnPromote; clearing Tab.TellTarget alone would leave
|
||||||
// CurrentChannel.Channel == Tell + a stale target and route a typed line
|
// CurrentChannel.Channel == Tell + a stale target and route a typed line
|
||||||
// silently as /tell to the old partner.
|
// silently as /tell to the old partner.
|
||||||
TabLifecycleHelpers.StripTellBindingOnPromote(tab);
|
// Flips IsTempTab/IsPinned, which decide pool membership and whether a save
|
||||||
|
// strips the tab. Under lock so a concurrent save sees one or the other, never
|
||||||
|
// half. SaveConfig stays outside, see TryPin.
|
||||||
|
lock (TabsListLock)
|
||||||
|
TabLifecycleHelpers.StripTellBindingOnPromote(tab);
|
||||||
|
|
||||||
_logger.LogDebug($"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)");
|
_logger.LogDebug($"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)");
|
||||||
_plugin.SaveConfig();
|
_plugin.SaveConfig();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user