Files
HellionChat/HellionChat/MessageManager.cs
T
JonKazama-Hellion 24dff3cc2e fix(messages): snapshot the tab list before delivering a message
ProcessMessage walked Config.Tabs live on the worker thread while SaveConfig's
strip and the auto-tell spawn mutated the same list under TabsListLock. The
resulting "collection was modified" was caught by the pending-message handler
and only logged -- so the message was dropped entirely: no tab entry, no sound,
and MessageProcessed never fired, which also meant no tell tab and no routing.
Silent message loss, exactly under the load where it hurts.

The loop now runs over a snapshot taken under the lock. AddMessage stays
outside it, so the lock order (list outer, MessageList inner) is unchanged.

SelectNotificationSound reports which tab it picked, so playback can skip a tab
that disappeared between snapshot and sound -- otherwise the snapshot would let
an evicted tab still make noise.

While here: the current tab was read twice despite the comment claiming it was
snapshotted once.
2026-08-17 07:27:21 +02:00

558 lines
20 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text;
using Dalamud.Game.Chat;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using HellionChat._Helpers;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
using Lumina.Text.Expressions;
using Lumina.Text.Payloads;
using Lumina.Text.ReadOnly;
using Microsoft.Extensions.Logging;
namespace HellionChat;
internal class MessageManager : IAsyncDisposable
{
internal const int MessageDisplayLimit = 10_000;
private Plugin Plugin { get; }
private readonly ILogger<MessageManager> _logger;
internal MessageStore Store { get; }
private Dictionary<ChatType, NameFormatting> Formats { get; } = [];
private ulong LastContentId { get; set; }
// PendingSync (main thread) → PendingAsync (worker thread); LinkedList for O(1) Last access
private LinkedList<PendingMessage> PendingSync { get; } = [];
private ConcurrentQueue<PendingMessage> PendingAsync { get; } = [];
private readonly Thread PendingMessageThread;
private readonly CancellationTokenSource PendingThreadCancellationToken = new();
private Hook<RaptureLogModule.Delegates.AddMsgSourceEntry>? ContentIdResolverHook { get; init; }
internal ulong CurrentContentId
{
get
{
var contentId = Plugin.PlayerState.ContentId;
return contentId == 0 ? LastContentId : contentId;
}
}
// Auto-Tell-Tabs hook: fires after a message is processed and stored, allowing
// AutoTellTabsService to spawn or refresh temp tabs without coupling.
public event Action<Message>? MessageProcessed;
internal unsafe MessageManager(
Plugin plugin,
ILogger<MessageManager> logger,
ILoggerFactory loggerFactory
)
{
Plugin = plugin;
_logger = logger;
Store = new MessageStore(
DatabasePath(),
Plugin.PlatformUtil,
loggerFactory.CreateLogger<MessageStore>(),
loggerFactory
);
PendingMessageThread = new Thread(() =>
ProcessPendingMessages(PendingThreadCancellationToken.Token)
)
{
IsBackground = true,
};
PendingMessageThread.Start();
ContentIdResolverHook =
Plugin.GameInteropProvider.HookFromAddress<RaptureLogModule.Delegates.AddMsgSourceEntry>(
RaptureLogModule.MemberFunctionPointers.AddMsgSourceEntry,
ContentIdResolver
);
ContentIdResolverHook.Enable();
Plugin.ChatGui.ChatMessageUnhandled += ChatMessage;
Plugin.Framework.Update += OnFrameworkUpdate;
Plugin.ClientState.Logout += Logout;
}
public async ValueTask DisposeAsync()
{
ContentIdResolverHook?.Dispose();
Plugin.ClientState.Logout -= Logout;
Plugin.Framework.Update -= OnFrameworkUpdate;
Plugin.ChatGui.ChatMessageUnhandled -= ChatMessage;
await PendingThreadCancellationToken.CancelAsync();
// 10s cooperative window; Thread.Abort is gone since .NET 5, so a
// stuck worker has to ride out the next AppDomain unload.
var deadline = TimeSpan.FromSeconds(10);
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < deadline && PendingMessageThread.IsAlive)
await Task.Delay(100);
if (PendingMessageThread.IsAlive)
_logger.LogWarning(
"PendingMessageThread did not observe cancellation within 10s. "
+ "Worker remains on background thread; next plugin reload releases it."
);
PendingThreadCancellationToken.Dispose();
Store.Dispose();
}
internal static string DatabasePath()
{
return Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat-sqlite.db");
}
private void Logout(int _, int __)
{
LastContentId = 0;
}
private void OnFrameworkUpdate(IFramework framework)
{
var contentId = Plugin.PlayerState.ContentId;
if (contentId != 0)
LastContentId = contentId;
// Drain the PendingSync queue into the PendingAsync queue.
while (PendingSync.First is { } first)
{
PendingSync.RemoveFirst();
PendingAsync.Enqueue(first.Value);
}
}
private void ProcessPendingMessages(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
if (PendingAsync.TryDequeue(out var pendingMessage))
{
try
{
ProcessMessage(pendingMessage);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing pending message");
}
}
else
{
Thread.Sleep(1);
}
}
}
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
foreach (var tab in tabsSnapshot.Where(t => !t.IsTempTab))
tab.Clear();
}
internal void FilterAllTabs()
{
DateTimeOffset? since = null;
if (!Plugin.Config.FilterIncludePreviousSessions)
since = Plugin.GameStarted;
using var messages = Store.GetMostRecentMessages(CurrentContentId, since);
// TempTabs excluded (live state from AutoTellTabsService). Bucket via the
// pure MapMessagesToTabs so the assignment stays testable outside Dalamud (B3-1).
// B3: snapshot under the shared lock (list copy only — short critical
// section). The Store query above and the AddSortPrune writes below stay
// OUTSIDE the lock (lock order: list outer, MessageList inner).
List<Tab> nonTempTabs;
lock (Plugin.TabsListLock)
nonTempTabs = Plugin.Config.Tabs.Where(t => !t.IsTempTab).ToList();
var buckets = MapMessagesToTabs(nonTempTabs, messages);
// Apply messages to chat log all at once.
foreach (var tab in nonTempTabs)
tab.Messages.AddSortPrune(buckets[tab], MessageDisplayLimit);
if (!messages.DidError)
return;
WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error);
// Mark failed messages as deleted to prevent retry attempts
var failedIds = messages.FailedMessageIds();
_logger.LogInformation(
$"Marking {failedIds.Count} messages as deleted due to parse failures"
);
foreach (var msgId in messages.FailedMessageIds())
{
_logger.LogDebug($"Marking message '{msgId}' as deleted due to parse failure");
Store.DeleteMessage(msgId);
}
}
// 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()
{
Task.Run(() =>
{
var stopwatch = Stopwatch.StartNew();
try
{
FilterAllTabs();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in FilterAllTabs");
}
// v1.4.9 R3 profiling: Information so the xllog tail surfaces this
// without a Debug filter. Belt-and-suspenders for future plugin-load
// regressions; remains in place after Sub-Task 3.4 Befund.
_logger.LogInformation($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms");
});
}
public (SeString? Sender, SeString? Message) LastMessage = (null, null);
private void ChatMessage(IChatMessage message)
{
LastMessage = (message.Sender, message.Message);
var pendingMessage = new PendingMessage
{
ContentId = 0,
AccountId = 0,
LogKind = message.LogKind,
SourceKind = message.SourceKind,
TargetKind = message.TargetKind,
Sender = message.Sender,
Content = message.Message,
};
// Update colour codes.
GlobalParametersCache.Refresh();
// Delay to next tick to get content ID from ContentIdResolver hook
PendingSync.AddLast(pendingMessage);
}
private unsafe void ContentIdResolver(
RaptureLogModule* agent,
ulong contentId,
ulong accountId,
int messageIndex,
ushort worldId,
ushort chatType
)
{
try
{
ContentIdResolverHook?.Original(
agent,
contentId,
accountId,
messageIndex,
worldId,
chatType
);
if (PendingSync.Last is not { } last)
return;
last.Value.ContentId = contentId;
last.Value.AccountId = accountId;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ContentIdResolver");
}
}
private void ProcessMessage(PendingMessage pendingMessage)
{
var chatCode = new ChatCode(
pendingMessage.LogKind,
pendingMessage.SourceKind,
pendingMessage.TargetKind
);
NameFormatting? formatting = null;
if (pendingMessage.Sender.Payloads.Count > 0)
formatting = FormatFor(chatCode.Type);
var senderChunks = new List<Chunk>();
if (formatting is { IsPresent: true })
{
senderChunks.Add(
new TextChunk(ChunkSource.None, null, formatting.Before)
{
FallbackColour = chatCode.Type,
}
);
senderChunks.AddRange(
ChunkUtil.ToChunks(pendingMessage.Sender, ChunkSource.Sender, chatCode.Type)
);
senderChunks.Add(
new TextChunk(ChunkSource.None, null, formatting.After)
{
FallbackColour = chatCode.Type,
}
);
}
var contentChunks = ChunkUtil
.ToChunks(pendingMessage.Content, ChunkSource.Content, chatCode.Type)
.ToList();
var message = new Message(
CurrentContentId,
pendingMessage.ContentId,
pendingMessage.AccountId,
chatCode,
senderChunks,
contentChunks,
pendingMessage.Sender,
pendingMessage.Content
);
if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
Store.UpsertMessage(message);
// Snapshot the list, not just the active tab. This loop runs on the worker
// thread while SaveConfig's strip and the auto-tell spawn mutate Config.Tabs
// under TabsListLock — enumerating it live throws "collection was modified",
// and the catch in ProcessPendingMessages swallows that, silently dropping
// the whole message: no tab entry, no sound, no MessageProcessed.
List<Tab> tabsSnapshot;
lock (Plugin.TabsListLock)
tabsSnapshot = Plugin.Config.Tabs.ToList();
// Snapshot the active tab and whether it shows this message ONCE, so the
// whole loop sees a consistent value (the getter is a cross-thread read of
// MainWindow.ActiveTab).
var currentTab = Plugin.CurrentTab;
var currentTabMatches = currentTab.Matches(message);
foreach (var tab in tabsSnapshot)
{
if (tab.Matches(message))
tab.AddMessage(message, ShouldCountUnread(tab, currentTab, currentTabMatches));
}
// Deliberate O(2n): the sound pick re-walks the tab list so the selection
// stays pure and SelfTest-able; AddMessage above and playback below keep
// the side effects.
var notificationSound = SelectNotificationSound(
tabsSnapshot,
currentTab,
message,
Plugin.Config.PlaySounds,
out var soundSource
);
// The snapshot can outlive a tab (eviction, logout). Playing its sound would
// be an audible artefact for a tab that is already gone, so re-check first.
if (notificationSound is not null && soundSource is not null)
{
bool sourceStillPresent;
lock (Plugin.TabsListLock)
sourceStillPresent = Plugin.Config.Tabs.Contains(soundSource);
if (!sourceStillPresent)
notificationSound = null;
}
if (notificationSound is { } soundId)
{
if (soundId is >= 1 and <= 16)
{
// ProcessMessage runs on the PendingMessageThread worker; the native
// UIGlobals.PlaySoundEffect must be marshalled onto the framework
// thread (reference_dalamud_framework_thread).
Plugin.Framework.RunOnFrameworkThread(() =>
{
unsafe
{
UIGlobals.PlaySoundEffect(soundId);
}
});
}
else if (soundId >= 17)
{
// Custom bundled sounds (ids 17-19) go through NAudio WaveOutEvent.
// NAudio manages its own playback thread, so no framework marshalling needed.
Plugin.CustomAudioPlayer.Play((int)soundId - 16, Plugin.Config.CustomSoundVolume);
}
// soundId == 0 (hand-edited config) falls through: plays nothing.
}
MessageProcessed?.Invoke(message);
}
// Pure: picks the sound id for the first inactive tab that wants one, or null.
// No AddMessage, no store write — those stay in the ProcessMessage loop so this
// is exercisable from the SelfTest without polluting tab state. The "first
// match wins" semantics live here via the running 'picked is null' guard,
// keeping a message matching several background tabs from stacking sounds.
// TEST-MIRROR: ../_Helpers/TabSoundDecision.cs
// Unseen ("count only what you haven't seen") suppresses unread on an inactive
// tab when the active tab ALSO shows this message — you already saw it in the
// tab you're looking at (1.5.6 / upstream ChatTwo behavior). Pre-F2 the "active
// tab" was wrongly pinned to Tabs[0], so this fired against the wrong tab; F2
// recoupled CurrentTab to the REAL active tab, so currentTabMatches is now
// measured against the tab you actually see. All -> always counts; None ->
// counts here and is gated out at the display layer. Pure + SelfTest-able.
internal static bool ShouldCountUnread(Tab tab, Tab currentTab, bool currentTabMatches) =>
!(
tab.UnreadMode == UnreadMode.Unseen
&& !ReferenceEquals(currentTab, tab)
&& currentTabMatches
);
// Reports the tab the sound came from, so the caller can drop it if that tab
// disappeared between snapshot and playback.
internal static uint? SelectNotificationSound(
IEnumerable<Tab> tabs,
Tab currentTab,
Message probe,
bool playSounds,
out Tab? source
)
{
uint? picked = null;
source = null;
foreach (var tab in tabs)
{
if (!tab.Matches(probe))
continue;
if (
picked is null
&& TabSoundDecision.ShouldPlay(
currentTab == tab,
tab.EnableNotificationSound,
playSounds
)
)
{
picked = tab.NotificationSoundId;
source = tab;
}
}
return picked;
}
// SelfTest hook — same name discipline as InputBar.TestBuildOutgoingForSelfTest.
internal static uint? TestSelectNotificationSoundForSelfTest(
IEnumerable<Tab> tabs,
Tab currentTab,
Message probe,
bool playSounds
) => SelectNotificationSound(tabs, currentTab, probe, playSounds, out _);
internal class NameFormatting
{
internal string Before { get; private set; } = string.Empty;
internal string After { get; private set; } = string.Empty;
internal bool IsPresent { get; private set; } = true;
internal static NameFormatting Empty()
{
return new NameFormatting { IsPresent = false };
}
internal static NameFormatting Of(string before, string after)
{
return new NameFormatting { Before = before, After = after };
}
}
private NameFormatting FormatFor(ChatType type)
{
if (Formats.TryGetValue(type, out var cached))
return cached;
var formats = Sheets.LogKindSheet.GetRow((uint)type).Format.ToList();
static bool IsStringParam(ReadOnlySePayload payload, byte num)
{
if (payload.MacroCode != MacroCode.String)
return false;
return payload.TryGetExpression(out var expr1)
&& expr1.TryGetParameterExpression(out var expressionType, out var operand)
&& expressionType == (byte)ExpressionType.LocalString
&& operand.TryGetInt(out var lstrIndex)
&& lstrIndex == num;
}
var firstStringParam = formats.FindIndex(payload => IsStringParam(payload, 1));
var secondStringParam = formats.FindIndex(payload => IsStringParam(payload, 2));
if (firstStringParam == -1 || secondStringParam == -1)
return NameFormatting.Empty();
var before = formats
.GetRange(0, firstStringParam)
.Where(payload => payload.Type == ReadOnlySePayloadType.Text)
.Select(text => Encoding.UTF8.GetString(text.Body.Span));
var after = formats
.GetRange(firstStringParam + 1, secondStringParam - firstStringParam)
.Where(payload => payload.Type == ReadOnlySePayloadType.Text)
.Select(text => Encoding.UTF8.GetString(text.Body.Span));
var nameFormatting = NameFormatting.Of(string.Join("", before), string.Join("", after));
Formats[type] = nameFormatting;
return nameFormatting;
}
private class PendingMessage
{
public ulong ContentId; // 0 if unknown
public ulong AccountId; // 0 if unknown
public XivChatType LogKind;
public XivChatRelationKind SourceKind;
public XivChatRelationKind TargetKind;
public required SeString Sender;
public required SeString Content;
}
}