Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions 2-Aquiis.Application/Services/DatabaseUnlockService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public DatabaseUnlockService(
}

/// <summary>
/// Archive encrypted database and create fresh database when password forgotten
/// Archive encrypted database and create fresh database when password forgotten.
/// </summary>
/// <param name="databasePath">Path to encrypted database</param>
/// <returns>(Success, ArchivedPath, ErrorMessage)</returns>
Expand All @@ -92,12 +92,54 @@ public DatabaseUnlockService(
var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
var archivedPath = Path.Combine(backupsDir, $"{dbFileNameWithoutExt}.{timestamp}.encrypted.db");

// Move encrypted database to backups
File.Move(databasePath, archivedPath);
// On Windows the OS enforces mandatory file locks. Even after SqliteConnection is
// disposed, the connection pool keeps the Win32 file handle open until explicitly
// cleared. Clear all pools and give the GC a chance to release any lingering
// handles before we attempt the file move.
SqliteConnection.ClearAllPools();
if (OperatingSystem.IsWindows())
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}

// Move the main database file. Retry once on Windows in case a finalizer
// hadn't yet released its handle on the first attempt.
try
{
File.Move(databasePath, archivedPath);
}
catch (IOException) when (OperatingSystem.IsWindows())
{
_logger.LogWarning("File move failed on first attempt (Windows lock), retrying after 500 ms");
Comment on lines +113 to +115
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Windows retry path you catch IOException but don’t log the exception details, which makes it harder to diagnose persistent lock issues in the field. Consider capturing the exception (catch (IOException ex)) and logging it (e.g., _logger.LogWarning(ex, ...)) before the retry.

Suggested change
catch (IOException) when (OperatingSystem.IsWindows())
{
_logger.LogWarning("File move failed on first attempt (Windows lock), retrying after 500 ms");
catch (IOException ex) when (OperatingSystem.IsWindows())
{
_logger.LogWarning(ex, "File move failed on first attempt (Windows lock), retrying after 500 ms");

Copilot uses AI. Check for mistakes.
await Task.Delay(500);
File.Move(databasePath, archivedPath);
}

// Remove WAL companion files if present. These are created when WAL mode is active
// and must be cleaned up so the fresh database starts without a stale journal.
// On Windows these files may also be locked; delete rather than move since the
// archived backup doesn't need them.
foreach (var sidecar in new[] { databasePath + "-wal", databasePath + "-shm" })
{
if (!File.Exists(sidecar)) continue;
try
{
File.Delete(sidecar);
_logger.LogInformation("Removed WAL companion file: {Sidecar}", sidecar);
}
catch (Exception ex)
{
// Non-fatal: a stale WAL without its main database is harmless.
_logger.LogWarning("Could not remove WAL companion file {Sidecar}: {Message}", sidecar, ex.Message);
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WAL sidecar deletion failure path only logs ex.Message and drops the exception/stack trace. Logging the exception object (e.g., _logger.LogWarning(ex, ...)) will preserve the full context for troubleshooting.

Suggested change
_logger.LogWarning("Could not remove WAL companion file {Sidecar}: {Message}", sidecar, ex.Message);
_logger.LogWarning(ex, "Could not remove WAL companion file {Sidecar}", sidecar);

Copilot uses AI. Check for mistakes.
Comment on lines +120 to +135
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app explicitly enables SQLite WAL mode (see SqlCipherConnectionInterceptor.cs:60-63), so the -wal file can contain committed transactions not yet checkpointed into the main DB. Deleting databasePath-wal/-shm here can make the archived backup incomplete/unrestorable. Instead, archive the sidecar files alongside the moved DB (e.g., move/rename to ${archivedPath}-wal and ${archivedPath}-shm), and only delete them from the original location after they’ve been safely moved (or at least avoid deleting if the move fails).

Suggested change
// Remove WAL companion files if present. These are created when WAL mode is active
// and must be cleaned up so the fresh database starts without a stale journal.
// On Windows these files may also be locked; delete rather than move since the
// archived backup doesn't need them.
foreach (var sidecar in new[] { databasePath + "-wal", databasePath + "-shm" })
{
if (!File.Exists(sidecar)) continue;
try
{
File.Delete(sidecar);
_logger.LogInformation("Removed WAL companion file: {Sidecar}", sidecar);
}
catch (Exception ex)
{
// Non-fatal: a stale WAL without its main database is harmless.
_logger.LogWarning("Could not remove WAL companion file {Sidecar}: {Message}", sidecar, ex.Message);
// Move WAL companion files alongside the archived database if present. These are
// created when WAL mode is active and may contain committed transactions that have
// not yet been checkpointed into the main database file. For the backup to be
// complete and restorable, we archive these sidecar files together with the main DB.
var sidecars = new[]
{
new { Source = databasePath + "-wal", Destination = archivedPath + "-wal" },
new { Source = databasePath + "-shm", Destination = archivedPath + "-shm" }
};
foreach (var sidecar in sidecars)
{
if (!File.Exists(sidecar.Source)) continue;
try
{
if (File.Exists(sidecar.Destination))
{
File.Delete(sidecar.Destination);
}
File.Move(sidecar.Source, sidecar.Destination);
_logger.LogInformation("Archived WAL companion file from {Source} to {Destination}", sidecar.Source, sidecar.Destination);
}
catch (Exception ex)
{
// Non-fatal: if we can't move the sidecar, log and continue. The main
// database has already been moved, and leaving the original WAL/SHM
// next to the soon-to-be-recreated database is safer than deleting it.
_logger.LogWarning(
"Could not archive WAL companion file {Source} to {Destination}: {Message}",
sidecar.Source,
sidecar.Destination,
ex.Message);

Copilot uses AI. Check for mistakes.
}
}

_logger.LogInformation("Encrypted database archived to: {ArchivedPath}", archivedPath);

// New unencrypted database will be created automatically on app restart
// The app will detect no database exists and go through first-time setup
// New database will be created automatically on app restart — the app detects
// no database exists and runs through first-time setup / migrations.
return (true, archivedPath, null);
}
catch (Exception ex)
Expand Down