Serialise retention sweeps so the auto and manual paths cannot overlap

Audit findings M-3 and M-4. The 24h auto-sweep launched from
Plugin's constructor and the manual button in the Privacy tab were
both starting a background thread that called DeleteByRetentionPolicy
on the shared MessageStore connection without coordinating. With
unfortunate timing — manual click moments after a fresh plugin load
— two sweeps would race for the same connection and the second
would just re-do work the first one already did, while still
overwriting RetentionLastRunAt.

Move the running flag and a lock object to Plugin so both paths see
the same gate. Each entry point takes the lock long enough to check
and set the flag, then runs the actual delete on its background
thread without holding the lock (other DB operations already happen
without locking; spreading the lock further would suggest a
guarantee we do not actually provide). The Privacy tab keeps a
read-only property that surfaces the shared flag for its UI disable
state — ImGui is single-threaded and bool reads are atomic, so the
lock-free read is fine.
This commit is contained in:
2026-05-02 02:52:34 +02:00
parent 2ce30383d9
commit de0d2c80cd
2 changed files with 40 additions and 5 deletions
+16 -5
View File
@@ -55,7 +55,10 @@ internal sealed class Privacy : ISettingsTab
private long CleanupDeleteCount;
private bool CleanupRunning;
private bool RetentionRunning;
// The retention-running state lives on Plugin so the auto-sweep and
// this manual button see the same flag. UI reads stay lock-free
// because ImGui is single-threaded and bool reads are atomic in .NET.
private bool RetentionRunning => Plugin.RetentionSweepRunning;
// Export form state
private int ExportRangeDays = 30;
@@ -410,10 +413,17 @@ internal sealed class Privacy : ISettingsTab
private void StartRetentionRun()
{
if (RetentionRunning)
return;
// Take the shared retention lock so we cannot fight the auto-sweep
// for the database connection. If the auto-sweep is already in
// flight we just bail — the user can press the button again once
// it finishes.
lock (Plugin.RetentionSweepLock)
{
if (Plugin.RetentionSweepRunning)
return;
Plugin.RetentionSweepRunning = true;
}
RetentionRunning = true;
var policy = Plugin.Config.RetentionPerChannelDays.ToDictionary(p => (int)(ushort)p.Key, p => p.Value);
var defaultDays = Plugin.Config.RetentionDefaultDays;
@@ -445,7 +455,8 @@ internal sealed class Privacy : ISettingsTab
}
finally
{
RetentionRunning = false;
lock (Plugin.RetentionSweepLock)
Plugin.RetentionSweepRunning = false;
}
}) { IsBackground = true }.Start();
}