Files
Libation/Source/LibationFileManager/SingleInstance.cs
T
Cursor Agentandrmcrackan 90856b29a6 Add single-instance guard and make SQLite WAL/SHM cleanup data-safe
Concurrent Libation instances against one LibationFiles folder raced on the
SQLite database, Lucene search index, and log file, and the startup routine
unconditionally deleted LibationContext.db-wal/-shm - discarding committed
transactions when a prior run died abruptly (issue #1931).

- Add SingleInstance (named mutex keyed on the LibationFiles folder). Wire it
  into the Avalonia and WinForms startup so a second launch shows a message and
  exits before any database access, and holds the lock for the process lifetime.
- DeleteOpenSqliteFiles now skips cleanup when the DB is held by another process
  and preserves a non-empty (unrecovered) WAL so SQLite can recover it on open.

Also compare AccountId case-insensitively in AccountsSettings.GetAccount so a
capitalization difference no longer causes spurious 'No account found' errors.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 18:13:58 +00:00

88 lines
2.8 KiB
C#

using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
namespace LibationFileManager;
/// <summary>
/// Process-wide guard that prevents more than one Libation instance from running against the same
/// LibationFiles folder at the same time. Concurrent instances race on the SQLite database, the
/// Lucene search index, and the log file, which can corrupt state (see issue #1931).
/// <para/>
/// The lock is keyed on the LibationFiles location, so separate portable installs that use different
/// folders may still run simultaneously. The returned instance must be kept alive for the lifetime of
/// the process and disposed at exit. The guard fails open: any error acquiring it is treated as "first
/// instance" so it can never block startup on its own.
/// </summary>
public sealed class SingleInstance : IDisposable
{
private readonly Mutex? _mutex;
private readonly bool _hasHandle;
/// <summary>True if this process acquired the lock (no other instance is running for this folder).</summary>
public bool IsFirstInstance => _hasHandle;
private SingleInstance(Mutex? mutex, bool hasHandle)
{
_mutex = mutex;
_hasHandle = hasHandle;
}
/// <summary>
/// Attempts to acquire the single-instance lock for <paramref name="libationFilesLocation"/>.
/// </summary>
public static SingleInstance TryAcquire(string libationFilesLocation)
{
Mutex? mutex = null;
try
{
mutex = new Mutex(initiallyOwned: false, buildMutexName(libationFilesLocation));
bool hasHandle;
try
{
hasHandle = mutex.WaitOne(TimeSpan.Zero, exitContext: false);
}
catch (AbandonedMutexException)
{
// The previous owner exited without releasing (e.g. it crashed). Ownership transfers to us.
hasHandle = true;
}
return new SingleInstance(mutex, hasHandle);
}
catch (Exception ex)
{
// Never let the guard itself prevent startup. Fail open by treating this as the first instance.
Serilog.Log.Logger.Warning(ex, "Could not evaluate the single-instance lock; continuing without it.");
mutex?.Dispose();
return new SingleInstance(null, true);
}
}
private static string buildMutexName(string libationFilesLocation)
{
var normalized = (libationFilesLocation ?? string.Empty).Trim().TrimEnd('/', '\\').ToUpperInvariant();
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
// Local (per-session) scope is sufficient: two OS users cannot safely share one files folder,
// and each user gets their own default folder. The name must be filesystem-safe and unique per folder.
return $"Libation-SingleInstance-{hash}";
}
public void Dispose()
{
try
{
if (_hasHandle)
_mutex?.ReleaseMutex();
}
catch
{
// best effort; the OS releases the handle on process exit regardless
}
_mutex?.Dispose();
}
}