fix(config): guard the shared config maps, restore lost fields in Tab.Clone

TabsListLock never covered ChatColours, PrivacyPersistChannels or
RetentionPerChannelDays, yet the settings UI mutates them from the draw thread
while the retention thread can be serializing the same config. Adding a new key
to a dictionary or a new element to a set invalidates a running enumeration, so
this could throw from inside JsonConvert.SerializeObject.

Not a corner case: the colour picker lists 66 channels but only 25 ship with a
default, so the first edit of any of the remaining ones inserts a new key -- and
the reset button removes a key, which makes the next edit a fresh insert again.

The readers matter as much as the writers. IsAllowedForStorage runs per message
on the worker thread and asks PrivacyPersistChannels whether a channel may be
stored; a Contains racing an Add that resizes buckets can answer wrong, and that
answer decides whether a message is written to disk. The retention sweep
enumerates RetentionPerChannelDays on the framework thread while the wizard can
clear it -- Clear does not throw there, it just cuts the enumeration short, so
the sweep would run on half a policy.

New ConfigMapsLock covers all of it. It sits inside TabsListLock (that edge is
real, AutoTellTabsService calls SaveConfig while holding the tabs lock), never
the other way round -- so every call site closes the lock before saving.

Tab.Clone silently dropped Icon and ChatCodes, both serialized. A reflection
test now walks the serialized fields so a future one cannot slip past.

Also: CurrentTab read Count and [0] as two separate accesses.
This commit is contained in:
2026-08-17 07:27:48 +02:00
parent 2b4243599e
commit eaed0b13e0
6 changed files with 84 additions and 23 deletions
+31 -4
View File
@@ -193,14 +193,36 @@ public sealed class Plugin : IAsyncDalamudPlugin
// MessageList's SemaphoreSlim inner — never the reverse.
internal readonly object TabsListLock = new();
// Guards the serialized config maps that the draw thread mutates while a
// background save may be serializing them: ChatColours, PrivacyPersistChannels
// and RetentionPerChannelDays. TabsListLock does not cover these.
// Ordering: ConfigMapsLock sits INSIDE TabsListLock (that edge is real, via
// AutoTellTabsService calling SaveConfig under the tabs lock). Never the other
// way round -- so SaveConfig must never be called while holding ConfigMapsLock.
internal readonly object ConfigMapsLock = new();
internal DateTime GameStarted { get; }
// Couples "current tab" to the real UI selection. The chat hooks are
// installed before MainWindow is Phase-1 resolved, so the null-conditional
// fallback to Tabs[0] is load-bearing — it keeps the pre-coupling behavior
// in that early window rather than being merely defensive.
internal Tab CurrentTab =>
MainWindow?.ActiveTab ?? (Config.Tabs.Count > 0 ? Config.Tabs[0] : new Tab());
// Read once into a local: Count and [0] as two separate accesses can be split
// by a removal on another thread. Only reachable before MainWindow exists.
internal Tab CurrentTab
{
get
{
if (MainWindow?.ActiveTab is { } active)
return active;
lock (TabsListLock)
{
var tabs = Config.Tabs;
return tabs.Count > 0 ? tabs[0] : new Tab();
}
}
}
public Plugin()
{
@@ -940,8 +962,13 @@ public sealed class Plugin : IAsyncDalamudPlugin
var policy = new Dictionary<int, int>();
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
policy[(int)(ushort)type] = days;
foreach (var (type, days) in Config.RetentionPerChannelDays)
policy[(int)(ushort)type] = days;
// This is the enumerator the wizard's Clear() cuts short. Reading under the
// same lock the writers take keeps the policy snapshot whole.
lock (ConfigMapsLock)
{
foreach (var (type, days) in Config.RetentionPerChannelDays)
policy[(int)(ushort)type] = days;
}
var defaultDays = Config.RetentionDefaultDays;
// IsBackground = true so a stuck sweep never blocks plugin unload.