fix(threading): protect AutoTranslate cache and bound framework waits
- Util/AutoTranslate.cs introduces a single EntriesLock object and serializes every read and write of the static Entries dictionary and ValidEntries hash set behind it. PreloadCache spawns a worker thread that fills both while the main thread reads them via the Matching / ReplaceWithPayload / StartsWithCommand entry points; without the lock the underlying collection access was undefined. AllEntries() splits into a thin lock wrapper plus a private BuildEntriesLocked() helper that runs under the lock - Ui/SettingsTabs/Privacy.cs bounds the .Wait() on the framework refresh after a manual retention sweep and after the privacy cleanup. A hung framework tick previously could deadlock the background worker thread. Five-second timeout, log on miss
This commit is contained in:
@@ -455,11 +455,18 @@ internal sealed class Privacy : ISettingsTab
|
|||||||
|
|
||||||
if (deleted > 0)
|
if (deleted > 0)
|
||||||
{
|
{
|
||||||
Plugin.Framework.Run(() =>
|
// Bound the wait so a hung framework tick can't deadlock
|
||||||
|
// the background retention worker. Five seconds is well
|
||||||
|
// beyond a normal frame; if we time out we log and let
|
||||||
|
// the next FilterAllTabsAsync call recover the state.
|
||||||
|
if (!Plugin.Framework.Run(() =>
|
||||||
|
{
|
||||||
|
Plugin.MessageManager.ClearAllTabs();
|
||||||
|
Plugin.MessageManager.FilterAllTabsAsync();
|
||||||
|
}).Wait(TimeSpan.FromSeconds(5)))
|
||||||
{
|
{
|
||||||
Plugin.MessageManager.ClearAllTabs();
|
Plugin.Log.Warning("Retention sweep: framework refresh timed out after 5s.");
|
||||||
Plugin.MessageManager.FilterAllTabsAsync();
|
}
|
||||||
}).Wait();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
WrapperUtil.AddNotification(string.Format(HellionStrings.Retention_Success, deleted), NotificationType.Success);
|
WrapperUtil.AddNotification(string.Format(HellionStrings.Retention_Success, deleted), NotificationType.Success);
|
||||||
@@ -615,11 +622,17 @@ internal sealed class Privacy : ISettingsTab
|
|||||||
var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed);
|
var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed);
|
||||||
Plugin.Log.Information($"Privacy cleanup: deleted {deleted} messages");
|
Plugin.Log.Information($"Privacy cleanup: deleted {deleted} messages");
|
||||||
|
|
||||||
Plugin.Framework.Run(() =>
|
// Bound the wait so a hung framework tick can't deadlock
|
||||||
|
// the background cleanup worker. See the matching comment in
|
||||||
|
// the retention path above for rationale.
|
||||||
|
if (!Plugin.Framework.Run(() =>
|
||||||
|
{
|
||||||
|
Plugin.MessageManager.ClearAllTabs();
|
||||||
|
Plugin.MessageManager.FilterAllTabsAsync();
|
||||||
|
}).Wait(TimeSpan.FromSeconds(5)))
|
||||||
{
|
{
|
||||||
Plugin.MessageManager.ClearAllTabs();
|
Plugin.Log.Warning("Privacy cleanup: framework refresh timed out after 5s.");
|
||||||
Plugin.MessageManager.FilterAllTabsAsync();
|
}
|
||||||
}).Wait();
|
|
||||||
|
|
||||||
WrapperUtil.AddNotification(string.Format(HellionStrings.Cleanup_Success, deleted), NotificationType.Success);
|
WrapperUtil.AddNotification(string.Format(HellionStrings.Cleanup_Success, deleted), NotificationType.Success);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ internal static class AutoTranslate
|
|||||||
private static readonly Dictionary<ClientLanguage, List<AutoTranslateEntry>> Entries = new();
|
private static readonly Dictionary<ClientLanguage, List<AutoTranslateEntry>> Entries = new();
|
||||||
private static readonly HashSet<(uint, uint)> ValidEntries = [];
|
private static readonly HashSet<(uint, uint)> ValidEntries = [];
|
||||||
|
|
||||||
|
// Serializes all reads and writes against Entries / ValidEntries.
|
||||||
|
// PreloadCache spawns a worker thread that fills both, while the main
|
||||||
|
// thread reads them via Matching / ReplaceWithPayload / StartsWithCommand
|
||||||
|
// — without this lock the HashSet/Dictionary access is undefined.
|
||||||
|
private static readonly object EntriesLock = new();
|
||||||
|
|
||||||
private static Parser<char, (string name, Maybe<IEnumerable<ISelectorPart>> selector)> Parser()
|
private static Parser<char, (string name, Maybe<IEnumerable<ISelectorPart>> selector)> Parser()
|
||||||
{
|
{
|
||||||
var sheetName = Any
|
var sheetName = Any
|
||||||
@@ -68,9 +74,17 @@ internal static class AutoTranslate
|
|||||||
|
|
||||||
private static List<AutoTranslateEntry> AllEntries()
|
private static List<AutoTranslateEntry> AllEntries()
|
||||||
{
|
{
|
||||||
if (Entries.TryGetValue(Plugin.DataManager.Language, out var entries))
|
lock (EntriesLock)
|
||||||
return entries;
|
{
|
||||||
|
if (Entries.TryGetValue(Plugin.DataManager.Language, out var entries))
|
||||||
|
return entries;
|
||||||
|
|
||||||
|
return BuildEntriesLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<AutoTranslateEntry> BuildEntriesLocked()
|
||||||
|
{
|
||||||
var shouldAdd = ValidEntries.Count == 0;
|
var shouldAdd = ValidEntries.Count == 0;
|
||||||
|
|
||||||
var parser = Parser();
|
var parser = Parser();
|
||||||
@@ -229,7 +243,10 @@ internal static class AutoTranslate
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
// populate the list of valid entries
|
// populate the list of valid entries
|
||||||
if (ValidEntries.Count == 0)
|
bool needBuild;
|
||||||
|
lock (EntriesLock)
|
||||||
|
needBuild = ValidEntries.Count == 0;
|
||||||
|
if (needBuild)
|
||||||
AllEntries();
|
AllEntries();
|
||||||
|
|
||||||
var start = -1;
|
var start = -1;
|
||||||
@@ -244,7 +261,10 @@ internal static class AutoTranslate
|
|||||||
var parts = tag[4..^1].Split(',', 2);
|
var parts = tag[4..^1].Split(',', 2);
|
||||||
if (parts.Length == 2 && uint.TryParse(parts[0], out var group) && uint.TryParse(parts[1], out var key))
|
if (parts.Length == 2 && uint.TryParse(parts[0], out var group) && uint.TryParse(parts[1], out var key))
|
||||||
{
|
{
|
||||||
var payload = ValidEntries.Contains((group, key)) ? CreateFixedTranslation(group, key) : [];
|
bool isValid;
|
||||||
|
lock (EntriesLock)
|
||||||
|
isValid = ValidEntries.Contains((group, key));
|
||||||
|
var payload = isValid ? CreateFixedTranslation(group, key) : [];
|
||||||
|
|
||||||
var oldBytes = bytes.ToArray();
|
var oldBytes = bytes.ToArray();
|
||||||
var lengthDiff = payload.Length - (i - start);
|
var lengthDiff = payload.Length - (i - start);
|
||||||
@@ -271,7 +291,10 @@ internal static class AutoTranslate
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
// populate the list of valid entries
|
// populate the list of valid entries
|
||||||
if (ValidEntries.Count == 0)
|
bool needBuild;
|
||||||
|
lock (EntriesLock)
|
||||||
|
needBuild = ValidEntries.Count == 0;
|
||||||
|
if (needBuild)
|
||||||
AllEntries();
|
AllEntries();
|
||||||
|
|
||||||
for (var i = 0; i < search.Length; i++)
|
for (var i = 0; i < search.Length; i++)
|
||||||
@@ -289,7 +312,10 @@ internal static class AutoTranslate
|
|||||||
var parts = tag[4..^1].Split(',', 2);
|
var parts = tag[4..^1].Split(',', 2);
|
||||||
if (parts.Length == 2 && uint.TryParse(parts[0], out var group) && uint.TryParse(parts[1], out var key))
|
if (parts.Length == 2 && uint.TryParse(parts[0], out var group) && uint.TryParse(parts[1], out var key))
|
||||||
{
|
{
|
||||||
if (!ValidEntries.Contains((group, key)))
|
bool isValid;
|
||||||
|
lock (EntriesLock)
|
||||||
|
isValid = ValidEntries.Contains((group, key));
|
||||||
|
if (!isValid)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var evaluated = Plugin.Evaluator.Evaluate(new ReadOnlySeString(CreateFixedTranslation(group, key))).ToString();
|
var evaluated = Plugin.Evaluator.Evaluate(new ReadOnlySeString(CreateFixedTranslation(group, key))).ToString();
|
||||||
|
|||||||
Reference in New Issue
Block a user