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:
2026-08-17 18:38:08 +02:00
parent c34024a18b
commit 3583dfc032
+86 -22
View File
@@ -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();
} }