fix(selftests): take TabsListLock around Config.Tabs mutations

The three sidebar/coupling steps add, insert and remove tabs straight from
the framework thread while the message worker mutates the same list under
TabsListLock. CurrentTabCouplingStep's Insert(0, ...) is the worst of them:
it shifts every index, so DropOldestTempTab can remove the wrong tab between
its index lookup and RemoveAt.

Locks sit around the individual mutations, never around a Draw call, so no
step holds the lock across rendering.
This commit is contained in:
2026-08-17 06:49:42 +02:00
parent ce5973aea9
commit 99dca8cb31
3 changed files with 24 additions and 8 deletions
@@ -69,7 +69,10 @@ internal sealed class CurrentTabCouplingStep : ISelfTestStep
// Insert a victim at index 0: a regressed index-0 getter would return
// THIS instead of ActiveTab, so ReferenceEquals would catch it.
var victim = new Tab { Name = "selftest-coupling-victim" };
Plugin.Config.Tabs.Insert(0, victim);
// Insert shifts every index; the worker's DropOldestTempTab holds
// TabsListLock across its index lookup and removal.
lock (_plugin.TabsListLock)
Plugin.Config.Tabs.Insert(0, victim);
try
{
if (!ReferenceEquals(_plugin.CurrentTab, _plugin.MainWindow.ActiveTab))
@@ -97,7 +100,8 @@ internal sealed class CurrentTabCouplingStep : ISelfTestStep
}
finally
{
Plugin.Config.Tabs.Remove(victim);
lock (_plugin.TabsListLock)
Plugin.Config.Tabs.Remove(victim);
}
}
finally
@@ -49,7 +49,10 @@ internal sealed class SidebarGreetedGlyphStep : ISelfTestStep
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
Plugin.Config.Tabs.Add(injected);
// Config.Tabs is mutated by the message worker under TabsListLock; a
// framework-thread writer must take the same lock.
lock (plugin.TabsListLock)
Plugin.Config.Tabs.Add(injected);
Tab? active = null;
var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded
try
@@ -91,7 +94,8 @@ internal sealed class SidebarGreetedGlyphStep : ISelfTestStep
}
finally
{
Plugin.Config.Tabs.Remove(injected);
lock (plugin.TabsListLock)
Plugin.Config.Tabs.Remove(injected);
Plugin.Config.AutoTellTabsShowGreetedToggle = savedFlag;
Plugin.Config.SidebarWidth = savedSidebarWidth;
}
@@ -50,8 +50,13 @@ internal sealed class SidebarSectionHeaderStep : ISelfTestStep
}
injected.Add(BuildTempProbe("Tell Probe@SelfTest", pinned: false));
injected.Add(BuildTempProbe("Pinned Probe@SelfTest", pinned: true));
foreach (var tab in injected)
Plugin.Config.Tabs.Add(tab);
// Config.Tabs is mutated by the message worker under TabsListLock; a
// framework-thread writer must take the same lock.
lock (plugin.TabsListLock)
{
foreach (var tab in injected)
Plugin.Config.Tabs.Add(tab);
}
Tab? active = null;
var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded
@@ -84,8 +89,11 @@ internal sealed class SidebarSectionHeaderStep : ISelfTestStep
}
finally
{
foreach (var tab in injected)
Plugin.Config.Tabs.Remove(tab);
lock (plugin.TabsListLock)
{
foreach (var tab in injected)
Plugin.Config.Tabs.Remove(tab);
}
Plugin.Config.AutoTellTabsCompactDisplay = savedCompact;
Plugin.Config.SidebarWidth = savedSidebarWidth;
}