Merge branch 'master' into feature/lock-down-permissions-on-data-folder

This commit is contained in:
Kenneth Skovhede
2026-07-12 11:56:14 +02:00
committed by GitHub
43 changed files with 1600 additions and 142 deletions
+36 -36
View File
@@ -269,11 +269,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -309,11 +309,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -347,11 +347,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -386,11 +386,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -424,11 +424,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -463,11 +463,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -500,11 +500,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -540,11 +540,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -579,11 +579,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -613,11 +613,11 @@ jobs:
- ubuntu-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -646,11 +646,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -684,11 +684,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -720,11 +720,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -758,11 +758,11 @@ jobs:
# - macos-latest
# steps:
# - name: Set up .NET
# uses: actions/setup-dotnet@v4
# uses: actions/setup-dotnet@v5
# with:
# dotnet-version: 10.x
# - name: Checkout source
# uses: actions/checkout@v4
# uses: actions/checkout@v7
# - name: Restore NuGet dependencies
# run: >-
# dotnet restore
@@ -794,11 +794,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -831,11 +831,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -869,11 +869,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -910,11 +910,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
+3 -3
View File
@@ -19,12 +19,12 @@ jobs:
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Create dummy changelog file
working-directory: ./ReleaseBuilder
@@ -41,6 +41,6 @@ jobs:
# selenium:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/checkout@v7
# - name: Selenium
# run: pipeline/selenium/test.sh
+12 -12
View File
@@ -89,11 +89,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -122,11 +122,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -157,11 +157,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -192,11 +192,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -227,11 +227,11 @@ jobs:
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
@@ -252,11 +252,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: >-
dotnet restore
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
actions: write
steps:
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
days-before-issue-stale: 15
days-before-issue-close: 15
+9 -9
View File
@@ -19,12 +19,12 @@ jobs:
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: dotnet restore Duplicati.slnx
@@ -49,12 +49,12 @@ jobs:
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Restore NuGet dependencies
run: dotnet restore Duplicati.slnx
@@ -71,13 +71,13 @@ jobs:
name: Playwright UI tests
runs-on: ubuntu-latest
steps:
- uses: actions/setup-dotnet@v4
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install NPM dependencies
run: npm ci
- name: Install Playwright browsers
@@ -112,7 +112,7 @@ jobs:
- name: Upload Playwright test results on failure
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-test-results
path: |
@@ -123,7 +123,7 @@ jobs:
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-html-report
path: playwright-report/
+11
View File
@@ -498,6 +498,17 @@ namespace Duplicati.CommandLine
return 0;
}
public static int Find(TextWriter outwriter, Action<Duplicati.Library.Main.Controller> setup, List<string> args, Dictionary<string, string> options, Library.Utility.IFilter filter)
{
// Unlike "list" (which shows only the newest version by default), "find" searches every
// backup version, so a file is located regardless of which version it appears in.
// Respect an explicit --all-versions / --version / --time supplied by the user.
if (!options.ContainsKey("all-versions") && !options.ContainsKey("version") && !options.ContainsKey("time"))
options["all-versions"] = "true";
return List(outwriter, setup, args, options, filter);
}
public static int List(TextWriter outwriter, Action<Duplicati.Library.Main.Controller> setup, List<string> args, Dictionary<string, string> options, Library.Utility.IFilter filter)
{
filter = filter ?? new Duplicati.Library.Utility.FilterExpression();
+14 -4
View File
@@ -76,7 +76,7 @@ namespace Duplicati.CommandLine
["help"] = Commands.Help,
["example"] = Commands.Examples,
["examples"] = Commands.Examples,
["find"] = Commands.List,
["find"] = Commands.Find,
["list"] = Commands.List,
["list-filesets"] = Commands.ListFilesets,
["list-folder-content"] = Commands.ListFolderContent,
@@ -107,7 +107,7 @@ namespace Duplicati.CommandLine
["readlockinfo"] = Commands.ReadLockInfo,
["system-info"] = Commands.SystemInfo,
["systeminfo"] = Commands.SystemInfo,
["send-mail"] = Commands.SendMail,
["send-mail"] = Commands.SendMail,
["sync"] = Commands.Sync
};
@@ -393,15 +393,25 @@ namespace Duplicati.CommandLine
foreach (KeyValuePair<String, String> keyvalue in opt)
options[keyvalue.Key] = keyvalue.Value;
var command = cargs.Count >= 1 ? cargs[0] : string.Empty;
var isBackup = command.Equals("backup", StringComparison.OrdinalIgnoreCase);
var isTestFilters = command.Equals("test-filters", StringComparison.OrdinalIgnoreCase)
|| command.Equals("test-filter", StringComparison.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty(newtarget))
{
if (cargs.Count <= 1)
// test-filters takes source paths as positional arguments and has no target, so a
// --target from the parameters file must not become a bogus positional source (#4812).
if (isTestFilters)
Library.Logging.Log.WriteVerboseMessage(LOGTAG, "NotUsingTarget", Strings.Program.SkippingTargetArgumentOnTestFilters);
else if (cargs.Count <= 1)
cargs.Add(newtarget);
else
cargs[1] = newtarget;
}
if (cargs.Count >= 1 && cargs[0].Equals("backup", StringComparison.OrdinalIgnoreCase))
// backup and test-filters take source paths as positional arguments.
if (isBackup || isTestFilters)
cargs.AddRange(newsource);
else if (newsource.Count > 0)
Library.Logging.Log.WriteVerboseMessage(LOGTAG, "NotUsingBackupSources", Strings.Program.SkippingSourceArgumentsOnNonBackupOperation);
+1
View File
@@ -54,6 +54,7 @@ namespace Duplicati.CommandLine.Strings
public static string QuietConsoleOptionLong { get { return LC.L(@"If this option is set, progress reports and other messages that would normally go to the console will be redirected to the log."); } }
public static string QuietConsoleOptionShort { get { return LC.L(@"Disable console output"); } }
public static string SkippingSourceArgumentsOnNonBackupOperation { get { return @"The --source argument was specified in the parameter file, but the current operation is not a backup operation, so the argument is ignored."; } }
public static string SkippingTargetArgumentOnTestFilters { get { return @"The --target argument was specified in the parameter file, but the test-filters operation has no target, so the argument is ignored."; } }
public static string AutoUpdateOptionShort { get { return LC.L(@"Toggle automatic updates"); } }
public static string AutoUpdateOptionLong { get { return LC.L(@"Set this option if you prefer to have the commandline version automatically update"); } }
public static string PortableModeOptionShort { get { return LC.L(@"Use portable mode"); } }
+1 -1
View File
@@ -103,7 +103,7 @@ Usage: %CLI_EXE% backup <storage-URL> "<source-path>" [<options>]
Usage: %CLI_EXE% find <storage-URL> ["<filename>"] [<options>]
Finds specific files in specific backups. If <filename> is specified, all occurrences of <filename> in the backup are listed. <filename> can contain * and ? as wildcards. File names in [brackets] are interpreted as regular expression. Latest backup is searched by default. If entire path is specified, all available versions of the file are listed. If no <filename> is specified, a list of all available backups is shown.
Finds specific files in specific backups. If <filename> is specified, all occurrences of <filename> in the backup are listed. <filename> can contain * and ? as wildcards. File names in [brackets] are interpreted as regular expression. The "find" command searches all backup versions by default, while "list" shows only the latest version by default (use --all-versions with "list" to search every version). If no <filename> is specified, a list of all available backups is shown.
--time=<time>
Shows what the files looked like at a specific time. Absolute and relative times can be specified.
+1 -5
View File
@@ -365,11 +365,7 @@ namespace Duplicati.Library.Backend
if (m_httpClient == null)
{
var httpHandler = new HttpClientHandler();
// Custom certificate validation to throw exception on failure
var validator = new SslCertificateValidator(m_certificateOptions.AcceptAllCertificates, m_certificateOptions.AcceptSpecificCertificateHashes, m_certificateOptions.IgnoreRevocationFailure);
httpHandler.ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) =>
validator.ValidateServerCertificate(sender, cert, chain, sslPolicyErrors);
HttpClientHelper.ConfigureHandlerCertificateValidator(httpHandler, m_certificateOptions.AcceptAllCertificates, m_certificateOptions.AcceptSpecificCertificateHashes, m_certificateOptions.IgnoreRevocationFailure);
if (m_useIntegratedAuthentication)
{
@@ -273,4 +273,35 @@ namespace Duplicati.Library.Interface
: base(message, helpId, innerException)
{ }
}
/// <summary>
/// Exception indicating that the backup database is currently locked by another running operation
/// and cannot be accessed concurrently.
/// </summary>
[Serializable]
public class DatabaseLockedException : UserInformationException
{
/// <summary>
/// The path to the database file that is locked.
/// </summary>
public readonly string DatabasePath;
public DatabaseLockedException(string databasePath)
: base(LC.L($"The backup database is currently in use by another operation and cannot be accessed concurrently. Please wait for the running operation to finish and try again."), "DatabaseLocked")
{
DatabasePath = databasePath;
}
public DatabaseLockedException(string databasePath, Exception innerException)
: base(LC.L($"The backup database is currently in use by another operation and cannot be accessed concurrently. Please wait for the running operation to finish and try again."), "DatabaseLocked", innerException)
{
DatabasePath = databasePath;
}
public DatabaseLockedException(string message, string helpId, Exception innerException)
: base(message, helpId, innerException)
{
DatabasePath = string.Empty;
}
}
}
@@ -309,7 +309,7 @@ namespace Duplicati.Library.Interface
IEnumerable<IListResultRemoteVolume> RemoteVolumes { get; }
}
public interface IDeleteResults : IBasicResults
public interface IDeleteResults : IBasicResults, IBackendStatsticsReporter
{
IEnumerable<Tuple<long, DateTime>> DeletedSets { get; }
ICompactResults CompactResults { get; }
@@ -377,7 +377,7 @@ namespace Duplicati.Library.Interface
IEnumerable<IFileEntry> Files { get; }
}
public interface ICompactResults : IBasicResults, IResultsWithVacuum
public interface ICompactResults : IBasicResults, IResultsWithVacuum, IBackendStatsticsReporter
{
long DeletedFileCount { get; }
long DownloadedFileCount { get; }
@@ -121,17 +121,40 @@ internal class ReusableTransaction(SqliteConnection con, SqliteTransaction? tran
}
catch (Exception ex)
{
Logging.Log.WriteErrorMessage(LOGTAG, "ReusableTransaction dispose", ex, "Transaction disposed with error: {0}", ex.Message);
throw;
if (!IsInactiveTransactionException(ex))
{
Logging.Log.WriteErrorMessage(LOGTAG, "ReusableTransaction dispose", ex, "Transaction disposed with error: {0}", ex.Message);
throw;
}
Logging.Log.WriteWarningMessage(LOGTAG, "ReusableTransactionAlreadyCompleted", ex, "Transaction was already completed during dispose: {0}", ex.Message);
}
finally
{
m_disposed = true;
await m_transaction.DisposeAsync().ConfigureAwait(false);
try
{
await m_transaction.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
if (!IsInactiveTransactionException(ex))
throw;
Logging.Log.WriteWarningMessage(LOGTAG, "ReusableTransactionAlreadyDisposed", ex, "Transaction was already completed before dispose: {0}", ex.Message);
}
}
}
}
// Microsoft.Data.Sqlite throws InvalidOperationException ("This SqliteTransaction has completed;
// it is no longer usable.") when a transaction is rolled back or disposed after it has already
// been completed. Unfortunately there is nothing specific that can be used to differentiate it
// from other exceptions that might occur during disposal, so we rely on the exception type, source and message.
private static bool IsInactiveTransactionException(Exception ex)
=> ex is InvalidOperationException
&& ex.Message.StartsWith("This SqliteTransaction has completed", StringComparison.Ordinal);
/// <summary>
/// Rolls back the transaction and restarts it.
/// </summary>
+5 -1
View File
@@ -211,6 +211,7 @@ public class CompactResultsDto : BasicResultsDto
public long UploadedFileSize { get; set; }
public bool Dryrun { get; set; }
public VacuumResultsDto VacuumResults { get; set; }
public BackendStatisticsDto BackendStatistics { get; set; }
public static CompactResultsDto FromResults(ICompactResults results)
{
@@ -232,7 +233,8 @@ public class CompactResultsDto : BasicResultsDto
DownloadedFileSize = results.DownloadedFileSize,
UploadedFileSize = results.UploadedFileSize,
Dryrun = results.Dryrun,
VacuumResults = VacuumResultsDto.FromResults(results.VacuumResults)
VacuumResults = VacuumResultsDto.FromResults(results.VacuumResults),
BackendStatistics = BackendStatisticsDto.FromResults(results.BackendStatistics)
};
}
}
@@ -310,6 +312,7 @@ public class DeleteResultsDto : BasicResultsDto
{
public List<Tuple<long, DateTime>> DeletedSets { get; set; } = new();
public CompactResultsDto CompactResults { get; set; }
public BackendStatisticsDto BackendStatistics { get; set; }
public bool Dryrun { get; set; }
public static DeleteResultsDto FromResults(IDeleteResults results)
@@ -327,6 +330,7 @@ public class DeleteResultsDto : BasicResultsDto
Interrupted = results.Interrupted,
DeletedSets = results.DeletedSets?.ToList() ?? new List<Tuple<long, DateTime>>(),
CompactResults = CompactResultsDto.FromResults(results.CompactResults),
BackendStatistics = BackendStatisticsDto.FromResults(results.BackendStatistics),
Dryrun = results.Dryrun
};
}
@@ -146,6 +146,8 @@ public class CompactResultsWrapper : BasicResultsWrapper, ICompactResults
get => _dto.VacuumResults == null ? null : new VacuumResultsWrapper(_dto.VacuumResults);
set => throw new InvalidOperationException("Cannot set property on wrapper");
}
public IBackendStatstics BackendStatistics => _dto.BackendStatistics == null ? null : new BackendStatisticsWrapper(_dto.BackendStatistics);
}
/// <summary>
@@ -188,6 +190,8 @@ public class DeleteResultsWrapper : BasicResultsWrapper, IDeleteResults
public IEnumerable<Tuple<long, DateTime>> DeletedSets => _dto.DeletedSets;
public ICompactResults CompactResults => _dto.CompactResults == null ? null : new CompactResultsWrapper(_dto.CompactResults);
public bool Dryrun => _dto.Dryrun;
public IBackendStatstics BackendStatistics => _dto.BackendStatistics == null ? null : new BackendStatisticsWrapper(_dto.BackendStatistics);
}
/// <summary>
@@ -42,14 +42,18 @@ public static class LogExceptionHelper
/// <param name="message">The message to log</param>
public static void LogCommonWarning(Exception? ex, string logtag, string id, string path, string message = "Failed to process path: {0}")
{
// For known, expected path problems the warning message already describes the cause,
// so the exception (and its stack trace) is not attached: it would only bloat the logs
// (issue #6426). The fallback below keeps the exception so genuinely unexpected failures
// remain fully debuggable.
if (ex.IsPermissionDeniedException())
Log.WriteWarningMessage(logtag, "PermissionDenied", ex, "Excluding path due to permission denied: {0}", path);
Log.WriteWarningMessage(logtag, "PermissionDenied", null, "Excluding path due to permission denied: {0}", path);
else if (ex.IsFileLockedException())
Log.WriteWarningMessage(logtag, "FileLocked", ex, "Excluding path due to file locked: {0}", path);
Log.WriteWarningMessage(logtag, "FileLocked", null, "Excluding path due to file locked: {0}", path);
else if (ex.IsPathNotFoundException())
Log.WriteWarningMessage(logtag, "PathNotFound", ex, "Excluding path due to path not found: {0}", path);
Log.WriteWarningMessage(logtag, "PathNotFound", null, "Excluding path due to path not found: {0}", path);
else if (ex.IsPathTooLongException())
Log.WriteWarningMessage(logtag, "PathTooLong", ex, "Excluding path due to path too long: {0}", path);
Log.WriteWarningMessage(logtag, "PathTooLong", null, "Excluding path due to path too long: {0}", path);
else
Log.WriteWarningMessage(logtag, id, ex, message, path);
@@ -64,16 +64,48 @@ namespace Duplicati.Library.Main.Operation
if (File.Exists(path))
throw new UserInformationException(string.Format("Cannot recreate database because file already exists: {0}", path), "RecreateTargetDatabaseExists");
await using var db =
await LocalDatabase.CreateLocalDatabaseAsync(path, "Recreate", true, null, m_result.TaskControl.ProgressToken)
try
{
await using var db =
await LocalDatabase.CreateLocalDatabaseAsync(path, "Recreate", true, null, m_result.TaskControl.ProgressToken)
.ConfigureAwait(false);
await DoRunAsync(backendManager, db, false, filter, filelistfilter, blockprocessor).ConfigureAwait(false);
// Ensure database is consistent after the recreate
await db
.VerifyConsistencyAsync(m_options.Blocksize, m_options.BlockhashSize, true, m_result.TaskControl.ProgressToken)
.ConfigureAwait(false);
}
catch (UserInformationException uex) when (uex.HelpID == "EmptyRemoteLocation" || uex.HelpID == "EmptyRemoteLocationWithPrefix")
{
// The remote destination is empty (#6205): the freshly-created database is empty and
// would block further operations - a fresh backup cannot run, and even a retry fails
// because RunAsync refuses to run when the file already exists. Remove it so the next
// run starts cleanly. The database connection is already disposed by the await-using
// above (the using scope is left before this catch runs), so the file is no longer
// locked.
//
// Only the empty-destination case is cleaned up. Other recreate failures - notably a
// database with missing blocks / broken filelists (help id "DatabaseIsBrokenConsiderPurge")
// - intentionally KEEP the recreated database so the user can run list-broken-files /
// purge-broken-files against it; those exceptions do not match this filter and propagate
// with the database left in place. This is best-effort and must never mask the error.
try
{
if (File.Exists(path))
{
File.Delete(path);
Logging.Log.WriteInformationMessage(LOGTAG, "RemovedIncompleteRecreateDatabase", "Removed incomplete recreate database at {0}", path);
}
}
catch (Exception ex)
{
Logging.Log.WriteWarningMessage(LOGTAG, "RecreateCleanupFailed", ex, "Failed to remove incomplete recreate database at {0}: {1}", path, ex.Message);
}
await DoRunAsync(backendManager, db, false, filter, filelistfilter, blockprocessor).ConfigureAwait(false);
// Ensure database is consistent after the recreate
await db
.VerifyConsistencyAsync(m_options.Blocksize, m_options.BlockhashSize, true, m_result.TaskControl.ProgressToken)
.ConfigureAwait(false);
throw;
}
}
/// <summary>
@@ -99,10 +99,32 @@ public interface IQueueRunnerService
/// <returns>A list of tuples containing the task ID and backup ID</returns>
IList<Tuple<long, string?>> GetQueueWithIds();
/// <summary>
/// Cancels the database lock wait for the currently running task, if its ID matches
/// <paramref name="taskID"/> and it is still waiting to acquire the database lock.
/// If the task has already acquired the lock and is executing, this has no effect —
/// call <see cref="IQueuedTask.AbortAsync"/> or <see cref="IQueuedTask.StopAsync"/>
/// to interrupt a running task. This method is intended to be called *before*
/// <see cref="IQueuedTask.AbortAsync"/>/<see cref="IQueuedTask.StopAsync"/> so that
/// a task blocked on lock acquisition is unblocked rather than left stuck.
/// </summary>
/// <param name="taskID">The ID of the currently running task whose lock wait should be cancelled.</param>
void CancelCurrentTaskLockWait(long taskID);
/// <summary>
/// Runs a task immediately, bypassing the queue.
/// Note that the task will run concurrently with the queue tasks and may cause database lock issues.
/// If the task's backup database is already in use by a queued task (e.g. a running backup),
/// this method throws <see cref="Duplicati.Library.Interface.DatabaseLockedException"/> immediately
/// rather than waiting or causing a concurrent "database is locked" error.
/// <para>
/// Only tasks that expose a database path via <see cref="Duplicati.Library.RestAPI.Runner.GetEffectiveDBPath"/>
/// (i.e. <see cref="Duplicati.Library.RestAPI.IRunnerData"/>-backed tasks) are protected by the
/// database lock. In production all tasks are <see cref="Duplicati.Library.RestAPI.IRunnerData"/>
/// instances; non-runner <see cref="IQueuedTask"/> implementations (test mocks) return a
/// <c>null</c> path and bypass locking.
/// </para>
/// </summary>
/// <param name="task">The task to run</param>
/// <exception cref="Duplicati.Library.Interface.DatabaseLockedException">Thrown when the backup database is already in use.</exception>
Task<IBasicResults?> RunImmediatelyAsync(IQueuedTask task);
}
}
+67 -1
View File
@@ -1164,7 +1164,7 @@ namespace Duplicati.Server
private static void UpdateMetadataStatistics(IBackup backup, IParsedBackendStatistics r)
{
if (r != null)
if (r != null && r.RemoteCalls > 0)
{
backup.Metadata["LastBackupDate"] = Utility.SerializeDateTime(r.LastBackupDate.ToUniversalTime());
backup.Metadata["BackupListCount"] = r.BackupListCount.ToString();
@@ -1217,6 +1217,16 @@ namespace Duplicati.Server
UpdateMetadataLastSync(backup, r6);
}
if (result is IDeleteResults r7)
{
if (r7.CompactResults != null)
{
UpdateMetadataLastCompact(backup, r7.CompactResults);
if (!r7.CompactResults.Interrupted && r7.CompactResults.DeletedFileCount > 0 && r7.CompactResults.BackendStatistics is IParsedBackendStatistics p)
UpdateMetadataStatistics(backup, p);
}
}
if (result is IBackupResults r)
{
if (!result.Interrupted)
@@ -1412,6 +1422,62 @@ namespace Duplicati.Server
options["remote-sync-json-config"] = JsonSerializer.Serialize(config, jsonOptions);
}
/// <summary>
/// Returns the effective local database path for an <see cref="IQueuedTask"/> instance.
/// In production, all queued tasks are <see cref="IRunnerData"/> instances created by the
/// <c>Runner.Create*</c> factories, so this delegates to
/// <see cref="GetEffectiveDBPath(IRunnerData?)"/>. If the task is not an
/// <see cref="IRunnerData"/> (only possible in tests with mock <see cref="IQueuedTask"/>
/// implementations), the result is <c>null</c>.
/// </summary>
/// <returns>The effective path, or <c>null</c></returns>
public static string? GetEffectiveDBPath(IQueuedTask? runnerData)
=> GetEffectiveDBPath(runnerData as IRunnerData);
/// <summary>
/// Returns the effective local database path for a <see cref="IRunnerData"/> instance.
/// The precedence mirrors <see cref="ApplyOptions"/>: the <c>dbpath</c> extra option takes
/// priority, then the backup's <c>--dbpath</c> advanced setting, then the stored
/// <see cref="Serialization.Interface.IBackup.DBPath"/>. If <c>no-local-db</c> is set
/// (either as an extra option or as a backup advanced setting), the result is <c>null</c>.
/// </summary>
/// <returns>The effective path, or <c>null</c></returns>
public static string? GetEffectiveDBPath(IRunnerData? runnerData)
{
if (runnerData == null)
return null;
if (runnerData.ExtraOptions != null)
{
if (runnerData.ExtraOptions.ContainsKey("no-local-db") && Utility.ParseBoolOption(runnerData.ExtraOptions.AsReadOnly(), "no-local-db"))
return null;
if (runnerData.ExtraOptions.TryGetValue("dbpath", out var dbpath) && !string.IsNullOrWhiteSpace(dbpath))
return dbpath;
}
if (runnerData.Backup == null)
return null;
var setting = runnerData.Backup.Settings?.FirstOrDefault(s => s.Name.Equals($"--no-local-db", StringComparison.OrdinalIgnoreCase));
if (setting != null && Utility.ParseBool(setting.Value, true))
return null;
return GetEffectiveDBPath(runnerData.Backup);
}
/// <summary>
/// Returns the effective local database path for a backup: the "--dbpath" advanced option if
/// it is set, otherwise the stored <see cref="Serialization.Interface.IBackup.DBPath"/>. This
/// mirrors the precedence applied by <see cref="ApplyOptions"/> so that every operation agrees
/// on which database file to use (see issue #1698).
/// </summary>
public static string GetEffectiveDBPath(Serialization.Interface.IBackup backup)
{
var dbpath = backup.Settings?
.FirstOrDefault(s => s.Name.Equals("--dbpath", StringComparison.OrdinalIgnoreCase))?.Value;
return string.IsNullOrWhiteSpace(dbpath) ? backup.DBPath : dbpath;
}
internal static Dictionary<string, string?> ApplyOptions(Connection databaseConnection, Serialization.Interface.IBackup backup, Dictionary<string, string?> options, out string url)
{
url = backup.TargetURL;
+14 -1
View File
@@ -41,6 +41,13 @@ namespace Duplicati.Library.SQLiteHelper
/// </summary>
private static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(SQLiteLoader));
/// <summary>
/// The default busy timeout (in milliseconds) applied to every connection opened by
/// <see cref="OpenSQLiteFileAsync"/> via the connection string. This is a defense-in-depth
/// measure that ensures an active reader does not cause failed commits.
/// </summary>
private const int DefaultBusyTimeoutMs = 10000;
/// <summary>
/// Helper method with logic to handle opening a database in possibly encrypted format.
/// </summary>
@@ -284,6 +291,12 @@ namespace Duplicati.Library.SQLiteHelper
con.ConnectionString = $"Data Source={path};Pooling=false";
await con.OpenAsync().ConfigureAwait(false);
using (var cmd = con.CreateCommand())
{
cmd.CommandText = $"PRAGMA busy_timeout={DefaultBusyTimeoutMs}";
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
// A database file we create is always locked down. For a pre-existing file we skip
// the lockdown when the operator has opted in to an insecure data folder, mirroring
// the data-folder behavior (a deliberately shared folder must not have its contents
@@ -295,7 +308,7 @@ namespace Duplicati.Library.SQLiteHelper
return;
try { SystemIO.IO_OS.FileSetPermissionUserRWOnly(path); }
catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "SQLiteFilePermissionError", ex, "Failed to set permissions on SQLite file '{0}'", path); }
catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "SQLiteFilePermissionError", ex, "Failed to set permissions on SQLite file '{0}'", path); }
}
/// <summary>
@@ -59,7 +59,10 @@ public class SslCertificateValidator(bool acceptAll, string[]? validHashes, bool
using var certificate = cert as X509Certificate2 ?? new X509Certificate2(cert ?? throw new ArgumentNullException(nameof(cert)));
// Validate date range before anything else, reject expired certs
// Validate date range before anything else, reject expired certs.
// NotBefore/NotAfter are returned by .NET as DateTime with Kind=Local (the UTC
// instant expressed in local time), so DateTime.Now (also Kind=Local) is the
// correct comparison; using UtcNow here would skew the window by the timezone offset.
if (!IsDateValid(certificate, now))
return false;
@@ -77,7 +80,11 @@ public class SslCertificateValidator(bool acceptAll, string[]? validHashes, bool
}
// If requested, ignore revocation check failures (e.g. OCSP server offline or status unknown)
// If requested, ignore revocation check failures (e.g. OCSP server offline or status unknown).
// This strips revocation-only flags from the sslPolicyErrors that the TLS stack reported using
// its own chain object. The explicit chain built below operates on a separate X509Chain and
// applies the same soft-fail logic independently, so both the reported-policy path and the
// verify path honor ignoreRevocationFailure consistently.
if (ignoreRevocationFailure)
sslPolicyErrors = FilterRevocationErrors(sslPolicyErrors, chain);
@@ -85,7 +92,70 @@ public class SslCertificateValidator(bool acceptAll, string[]? validHashes, bool
throw new InvalidCertificateException(certificate.GetCertHashString(), sslPolicyErrors);
// If no hash is found, perform the standard validations
return sslPolicyErrors == SslPolicyErrors.None && certificate.Verify();
if (sslPolicyErrors != SslPolicyErrors.None)
return false;
// certificate.Verify() builds its own X509Chain with a default policy that
// hard-fails when a revocation check cannot be completed (OCSP/CRL endpoint
// unreachable). The OS-native TLS stacks instead soft-fail in that situation:
// the certificate is accepted when the revocation status is merely unknown
// (not confirmed revoked). Build the chain explicitly so we can replicate that
// behavior and honor the ignoreRevocationFailure setting.
//
// Note: ChainPolicy.TrustMode is left at its default (UseSystemDefault), which
// resolves intermediate/root trust through the OS trust store exactly as
// certificate.Verify() did, so the trust-root behaviour of the previous
// implementation is preserved.
using var verifyChain = new X509Chain();
// The primary mechanism for honoring ignoreRevocationFailure: skip the online
// revocation fetch entirely so an unreachable OCSP/CRL endpoint cannot fail the
// chain. The post-build soft-fail below is a safety net for platforms that
// still surface revocation status flags even under NoCheck, not the main path.
verifyChain.ChainPolicy.RevocationMode =
ignoreRevocationFailure ? X509RevocationMode.NoCheck : X509RevocationMode.Online;
bool chainValid = verifyChain.Build(certificate);
// Soft-fail on unreachable revocation checks, but only when the caller has
// explicitly requested it via ignoreRevocationFailure. This replicates the
// OS-native TLS behavior of accepting a certificate whose revocation status is
// merely unknown (OCSP/CRL endpoint unreachable) rather than confirmed revoked.
//
// This block is a safety net for the NoCheck mode above: on some platforms
// Build() can still report revocation-related flags despite RevocationMode being
// NoCheck, so strip those flags here rather than relying on NoCheck alone.
//
// The guard requires a non-empty ChainStatus: if Build() returned false with no
// status flags (rare platform edge case), we must not silently accept the
// certificate, as that would widen trust beyond what any status indicates.
// A confirmed Revoked flag, PartialChain, or any other non-revocation status
// still fails the chain because the All() predicate would be false. The using
// scope ensures verifyChain is disposed even if Build() throws and the outer
// catch wraps the exception.
if (!chainValid && ignoreRevocationFailure && verifyChain.ChainStatus.Length > 0 &&
verifyChain.ChainStatus.All(s => (s.Status & ~RevocationFailureFlags) == 0))
chainValid = true;
if (!chainValid)
{
var chainStatus = string.Join("; ", verifyChain.ChainStatus.Select(s => $"{s.Status}={s.StatusInformation?.Trim()}"));
var elementDetails = verifyChain.ChainElements.Count == 0
? "(no chain elements)"
: string.Join(" | ", verifyChain.ChainElements.Select(e =>
{
var elemStatus = e.ChainElementStatus.Length == 0
? "(none)"
: string.Join(", ", e.ChainElementStatus.Select(s => $"{s.Status}={s.StatusInformation?.Trim()}"));
return $"{e.Certificate.Subject} [{elemStatus}]";
}));
Duplicati.Library.Logging.Log.WriteWarningMessage(
"SslCertificateValidator",
"VerifyChainFailed",
null,
$"Certificate chain validation failed for {certificate.Subject} (issuer={certificate.Issuer}, " +
$"thumbprint={certificate.Thumbprint}). Chain status: {chainStatus}. Per-element: {elementDetails}");
}
return chainValid;
}
catch (InvalidCertificateException)
{
+15 -5
View File
@@ -44,6 +44,13 @@ namespace Duplicati.Library.Utility
private static readonly object m_lock = new object();
private static readonly Dictionary<string, System.Diagnostics.StackTrace> m_fileTrace = new Dictionary<string, System.Diagnostics.StackTrace>();
// Process-wide monotonic counter appended to each generated name to guarantee
// uniqueness. The timestamp is only second-precision and the Guid is truncated to
// 8 characters, so under load (many temp files created in the same second by the
// same caller) those alone can collide, which would produce duplicate file paths
// and throw from the m_fileTrace.Add below.
private static long m_uniqueCounter;
public static System.Diagnostics.StackTrace GetStackTraceForTempFile(string filename)
{
lock (m_lock)
@@ -53,17 +60,20 @@ namespace Duplicati.Library.Utility
return null;
}
private static string GenerateUniqueName()
internal static string GenerateUniqueName()
{
// The trailing sequence number guarantees a unique name even when the caller,
// second-precision timestamp, and truncated Guid all coincide.
var seq = System.Threading.Interlocked.Increment(ref m_uniqueCounter);
var st = new System.Diagnostics.StackTrace();
foreach (var f in st.GetFrames())
{
var asm = f.GetMethod()?.DeclaringType?.Assembly;
if (asm != null && asm != typeof(TempFile).Assembly)
{
var n = string.Format("{0}_{1}_{2}_{3}", f.GetMethod().DeclaringType.FullName, f.GetMethod().Name, Library.Utility.Utility.SerializeDateTime(DateTime.UtcNow), Guid.NewGuid().ToString().Substring(0, 8));
var n = string.Format("{0}_{1}_{2}_{3}_{4}", f.GetMethod().DeclaringType.FullName, f.GetMethod().Name, Library.Utility.Utility.SerializeDateTime(DateTime.UtcNow), Guid.NewGuid().ToString().Substring(0, 8), seq);
if (n.IndexOfAny(System.IO.Path.GetInvalidFileNameChars()) >= 0)
n = string.Format("{0}_{1}_{2}_{3}", f.GetMethod().DeclaringType.Name, f.GetMethod().Name, Library.Utility.Utility.SerializeDateTime(DateTime.UtcNow), Guid.NewGuid().ToString().Substring(0, 8));
n = string.Format("{0}_{1}_{2}_{3}_{4}", f.GetMethod().DeclaringType.Name, f.GetMethod().Name, Library.Utility.Utility.SerializeDateTime(DateTime.UtcNow), Guid.NewGuid().ToString().Substring(0, 8), seq);
if (n.IndexOfAny(System.IO.Path.GetInvalidFileNameChars()) < 0)
{
lock (m_lock)
@@ -73,13 +83,13 @@ namespace Duplicati.Library.Utility
}
}
var s = Guid.NewGuid().ToString();
var s = string.Format("{0}_{1}", Guid.NewGuid(), seq);
lock (m_lock)
m_fileTrace.Add(s, st);
return s;
}
#else
private static string GenerateUniqueName()
internal static string GenerateUniqueName()
{
return APPLICATION_PREFIX + Guid.NewGuid().ToString();
}
@@ -3,16 +3,12 @@ backupApp.directive('waitArea', function() {
restrict: 'E',
scope: {
taskid: '=taskid',
text: '=text',
allowCancel: '=allowCancel'
text: '=text'
},
templateUrl: 'templates/waitarea.html',
controller: function($scope, ServerStatus, AppService) {
controller: function($scope, ServerStatus) {
$scope.ServerStatus = ServerStatus;
$scope.serverstate = ServerStatus.watch($scope);
$scope.cancelTask = function() {
AppService.post('/task/' + $scope.taskid + '/stop');
};
}
}
});
@@ -149,7 +149,7 @@
</div>
<div ng-show="connecting == true &amp;&amp; taskid != null">
<wait-area taskid="taskid" text="ConnectionProgress" allow-cancel="true"></wait-area>
<wait-area taskid="taskid" text="ConnectionProgress"></wait-area>
</div>
<div ng-show="connecting == true &amp;&amp; taskid == null">
@@ -15,8 +15,4 @@
{{'Server is currently paused,' | translate}} <a style="cursor: pointer" ng-click="ServerStatus.resume()" translate>resume now</a>
</div>
<div ng-show="allowCancel">
<a href class="button" ng-click="cancelTask()" translate>Cancel</a>
</div>
</div>
+2 -2
View File
@@ -193,8 +193,8 @@ namespace Duplicati.UnitTest
opts["blocksize"] = "10kb";
opts["backup-test-samples"] = "0";
opts["unittest-mode"] = "true";
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
opts["snapshot-policy"] = "Off";
opts["allow-sleep"] = "true";
opts["snapshot-policy"] = "Off";
return opts;
}
@@ -0,0 +1,225 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.WebserverCore.Services;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest
{
/// <summary>
/// Tests for <see cref="DatabaseLockTracker"/>, which coordinates access to local
/// backup database files between queued and immediate operations.
/// </summary>
[TestFixture]
public class DatabaseLockTrackerTests
{
/// <summary>
/// A simple value-returning async disposable used in tests.
/// </summary>
private static async Task DisposeAsync(IAsyncDisposable disposable)
=> await disposable.DisposeAsync().ConfigureAwait(false);
[Test]
public async Task TryAcquireReturnsLockWhenFree()
{
var tracker = new DatabaseLockTracker();
var handle = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(handle, "TryAcquire should succeed when the database is not locked");
await DisposeAsync(handle!);
}
[Test]
public async Task TryAcquireReturnsNullWhenAlreadyLocked()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first, "First TryAcquire should succeed");
var second = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNull(second, "Second TryAcquire should fail when the database is locked");
await DisposeAsync(first!);
}
[Test]
public async Task AcquireAsyncWaitsForExistingLock()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
var acquireTask = tracker.AcquireAsync("/tmp/test.sqlite", CancellationToken.None);
Assert.IsFalse(acquireTask.IsCompleted, "AcquireAsync should be waiting for the lock");
await DisposeAsync(first!);
var second = await acquireTask;
Assert.IsNotNull(second, "AcquireAsync should complete after the lock is released");
await DisposeAsync(second);
}
[Test]
public async Task ReleaseAllowsSubsequentAcquire()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
await DisposeAsync(first!);
var second = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(second, "TryAcquire should succeed after the lock is released");
await DisposeAsync(second!);
}
[Test]
public async Task DifferentPathsAreIndependent()
{
var tracker = new DatabaseLockTracker();
var lockA = tracker.TryAcquire("/tmp/a.sqlite");
Assert.IsNotNull(lockA);
var lockB = tracker.TryAcquire("/tmp/b.sqlite");
Assert.IsNotNull(lockB, "TryAcquire on a different path should succeed independently");
await DisposeAsync(lockA!);
await DisposeAsync(lockB!);
}
[Test]
public async Task AcquireAsyncCancelsWhenTokenFires()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
using var cts = new CancellationTokenSource();
var acquireTask = tracker.AcquireAsync("/tmp/test.sqlite", cts.Token);
Assert.IsFalse(acquireTask.IsCompleted);
cts.Cancel();
Assert.ThrowsAsync<System.OperationCanceledException>(async () => await acquireTask);
await DisposeAsync(first!);
}
[Test]
public async Task DoubleDisposeIsSafe()
{
var tracker = new DatabaseLockTracker();
var handle = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(handle);
await DisposeAsync(handle!);
await DisposeAsync(handle!);
var next = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(next, "After double-dispose, the lock should be releasable exactly once");
await DisposeAsync(next!);
}
[Test]
public async Task TryAcquireOnLockedReleasesCorrectly()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
var second = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNull(second);
await DisposeAsync(first!);
var third = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(third, "After releasing the first lock, TryAcquire should succeed");
await DisposeAsync(third!);
}
[Test]
public async Task RelativeAndAbsolutePathMapToSameLock()
{
var tracker = new DatabaseLockTracker();
var absolute = tracker.TryAcquire(System.IO.Path.GetFullPath("/tmp/test.sqlite"));
Assert.IsNotNull(absolute);
var relative = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNull(relative, "Path normalization should map relative and absolute paths to the same lock");
await DisposeAsync(absolute!);
}
[Test]
public async Task AcquireAsyncPreservesOrder()
{
var tracker = new DatabaseLockTracker();
var first = tracker.TryAcquire("/tmp/test.sqlite");
Assert.IsNotNull(first);
var order = new System.Collections.Generic.List<int>();
var t2 = Task.Run(async () =>
{
await using var h = await tracker.AcquireAsync("/tmp/test.sqlite", CancellationToken.None);
order.Add(2);
});
var t3 = Task.Run(async () =>
{
await using var h = await tracker.AcquireAsync("/tmp/test.sqlite", CancellationToken.None);
order.Add(3);
});
await Task.Delay(50);
order.Add(1);
await DisposeAsync(first!);
await Task.WhenAll(t2, t3);
Assert.AreEqual(1, order[0], "First lock holder should complete first");
}
[Test]
public void TryAcquireEmptyPathThrows()
{
var tracker = new DatabaseLockTracker();
Assert.Throws<ArgumentException>(() => tracker.TryAcquire(""));
}
[Test]
public void TryAcquireWhitespacePathThrows()
{
var tracker = new DatabaseLockTracker();
Assert.Throws<ArgumentException>(() => tracker.TryAcquire(" "));
}
[Test]
public void AcquireAsyncEmptyPathThrows()
{
var tracker = new DatabaseLockTracker();
Assert.ThrowsAsync<ArgumentException>(async () => await tracker.AcquireAsync("", CancellationToken.None));
}
}
}
@@ -62,6 +62,7 @@ public class FolderStatusServiceTests
public void Resume() { }
public void Pause() { }
public IList<Tuple<long, string?>> GetQueueWithIds() => new List<Tuple<long, string?>>();
public void CancelCurrentTaskLockWait(long taskID) { }
public Task<IBasicResults?> RunImmediatelyAsync(IQueuedTask task) => Task.FromResult<IBasicResults?>(null);
}
+187
View File
@@ -0,0 +1,187 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Duplicati.Server.Database;
using Duplicati.Server.Serialization.Interface;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest
{
/// <summary>
/// https://github.com/duplicati/duplicati/issues/1698
/// "Show log" and "Database delete" read/deleted the stored <c>Backup.DBPath</c> directly, while
/// most operations honor a "--dbpath" advanced option. When those disagreed, Show log opened the
/// wrong/empty database ("no such table: LogData"). <see cref="Duplicati.Server.Runner.GetEffectiveDBPath"/>
/// reconciles them with the same precedence as the runner; these tests cover that precedence.
/// </summary>
[TestFixture]
public class Issue1698
{
private static Backup CreateBackup(string? storedDbPath, params (string Name, string Value)[] settings)
{
var backup = new Backup
{
ID = null,
Name = "Test Backup",
Description = "",
Tags = new string[0],
TargetURL = "file:///test",
Sources = new string[0],
Settings = settings.Select(s => (ISetting)new Setting { Name = s.Name, Value = s.Value }).ToArray(),
Filters = new IFilter[0],
Metadata = new Dictionary<string, string>()
};
if (storedDbPath != null)
backup.SetDBPath(storedDbPath);
return backup;
}
[Test]
public void UsesStoredDbPathWhenNoAdvancedOption()
{
var backup = CreateBackup("/stored/path.sqlite");
Assert.AreEqual("/stored/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(backup));
}
[Test]
public void AdvancedDbPathOptionOverridesStoredDbPath()
{
var backup = CreateBackup("/stored/path.sqlite", ("--dbpath", "/override/path.sqlite"));
Assert.AreEqual("/override/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(backup));
}
[Test]
public void AdvancedDbPathMatchIsCaseInsensitiveOnName()
{
var backup = CreateBackup("/stored/path.sqlite", ("--DBPath", "/override/path.sqlite"));
Assert.AreEqual("/override/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(backup));
}
[Test]
public void BlankAdvancedDbPathFallsBackToStoredDbPath()
{
var backup = CreateBackup("/stored/path.sqlite", ("--dbpath", " "));
Assert.AreEqual("/stored/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(backup));
}
// ---- Tests for GetEffectiveDBPath(IQueuedTask) / GetEffectiveDBPath(IRunnerData) ----
private static Duplicati.Server.Runner.IRunnerData CreateRunnerTask(
Backup backup,
IDictionary<string, string?>? extraOptions = null)
=> Duplicati.Server.Runner.CreateTask(
Duplicati.Server.Serialization.DuplicatiOperation.BackupOrSync,
backup,
extraOptions ?? new Dictionary<string, string?>());
[Test]
public void RunnerDataFallsBackToBackupDbPath()
{
var backup = CreateBackup("/stored/path.sqlite");
var task = CreateRunnerTask(backup);
Assert.AreEqual("/stored/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(task));
}
[Test]
public void RunnerDataRespectsBackupAdvancedDbPath()
{
var backup = CreateBackup("/stored/path.sqlite", ("--dbpath", "/override/path.sqlite"));
var task = CreateRunnerTask(backup);
Assert.AreEqual("/override/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(task));
}
[Test]
public void RunnerDataExtraDbPathOverridesBackup()
{
var backup = CreateBackup("/stored/path.sqlite", ("--dbpath", "/backup-override.sqlite"));
var task = CreateRunnerTask(backup, new Dictionary<string, string?> { ["dbpath"] = "/extra-override.sqlite" });
Assert.AreEqual("/extra-override.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(task));
}
[Test]
public void RunnerDataExtraNoLocalDbReturnsNull()
{
var backup = CreateBackup("/stored/path.sqlite");
var task = CreateRunnerTask(backup, new Dictionary<string, string?> { ["no-local-db"] = "true" });
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath(task));
}
[Test]
public void RunnerDataBackupNoLocalDbSettingReturnsNull()
{
var backup = CreateBackup("/stored/path.sqlite", ("--no-local-db", "true"));
var task = CreateRunnerTask(backup);
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath(task));
}
[Test]
public void RunnerDataExtraNoLocalDbFalseReturnsDbPath()
{
var backup = CreateBackup("/stored/path.sqlite");
var task = CreateRunnerTask(backup, new Dictionary<string, string?> { ["no-local-db"] = "false" });
Assert.AreEqual("/stored/path.sqlite", Duplicati.Server.Runner.GetEffectiveDBPath(task));
}
[Test]
public void RunnerDataNullReturnsNull()
{
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath((Duplicati.Server.Runner.IRunnerData?)null));
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath((Duplicati.Server.Serialization.Interface.IQueuedTask?)null));
}
[Test]
public void RunnerDataNonRunnerDataQueuedTaskReturnsNull()
{
// A non-IRunnerData IQueuedTask implementation should cast to null and return null.
var nonRunnerTask = new NonRunnerQueuedTask();
Assert.IsNull(Duplicati.Server.Runner.GetEffectiveDBPath(nonRunnerTask));
}
/// <summary>
/// A minimal <see cref="IQueuedTask"/> implementation that is NOT an
/// <see cref="Duplicati.Server.Runner.IRunnerData"/>, used to verify that
/// <see cref="Duplicati.Server.Runner.GetEffectiveDBPath(IQueuedTask)"/> safely returns
/// null for non-runner tasks.
/// </summary>
private sealed class NonRunnerQueuedTask : Duplicati.Server.Serialization.Interface.IQueuedTask
{
public long TaskID => 0;
public string? BackupID => null;
public Duplicati.Server.Serialization.DuplicatiOperation Operation => Duplicati.Server.Serialization.DuplicatiOperation.BackupOrSync;
public Func<Task>? OnStarting { get; set; }
public Func<Exception?, Task>? OnFinished { get; set; }
public DateTime? TaskStarted { get; set; }
public DateTime? TaskFinished { get; set; }
public Task UpdateThrottleSpeedsAsync(string? uploadSpeed, string? downloadSpeed) => Task.CompletedTask;
public Task StopAsync() => Task.CompletedTask;
public Task AbortAsync() => Task.CompletedTask;
public Task PauseAsync(bool alsoTransfers) => Task.CompletedTask;
public Task ResumeAsync() => Task.CompletedTask;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest
{
public class Issue2287 : BasicSetupHelper
{
/// <summary>
/// https://github.com/duplicati/duplicati/issues/2287
/// The "find" command must locate a file in ANY backup version, not only the newest one.
/// A bare filename is turned into a wildcard filter (e.g. "*/target.txt"), which previously
/// searched only the newest version; the all-versions fallback only triggered when the newest
/// version had zero matches, so a file present only in an older version was silently missed
/// whenever the newest version still contained a same-named file. "find" now searches all
/// versions by default, while "list" keeps its newest-only default.
/// </summary>
[Test]
[Category("Targeted")]
public async Task FindSearchesAllVersionsAsync()
{
var testopts = TestOptions.Expand(new { no_encryption = true });
var subA = Path.Combine(DATAFOLDER, "sub_a");
var subB = Path.Combine(DATAFOLDER, "sub_b");
Directory.CreateDirectory(subA);
Directory.CreateDirectory(subB);
var fileA = Path.Combine(subA, "target.txt");
var fileB = Path.Combine(subB, "target.txt");
File.WriteAllText(fileA, "present in newest and old version");
File.WriteAllText(fileB, "present only in the old version");
// Version 1: both sub_a/target.txt and sub_b/target.txt exist.
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
{
var res = await c.BackupAsync(new[] { DATAFOLDER });
Assert.AreEqual(0, res.Errors.Count());
}
System.Threading.Thread.Sleep(TimeSpan.FromSeconds(3));
// Version 0 (newest): sub_b/target.txt is removed, so it now exists only in the old version.
File.Delete(fileB);
Directory.Delete(subB);
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
{
var res = await c.BackupAsync(new[] { DATAFOLDER });
Assert.AreEqual(0, res.Errors.Count());
}
// "find target.txt" must surface the older-only sub_b/target.txt as well as the newest one.
var findOutput = RunListCommand(Duplicati.CommandLine.Commands.Find, "target.txt", testopts);
Assert.IsTrue(findOutput.Contains("sub_a"), "find should list the newest match:" + Environment.NewLine + findOutput);
Assert.IsTrue(findOutput.Contains("sub_b"), "find should also list the older-only match:" + Environment.NewLine + findOutput);
// "list target.txt" keeps the newest-only behaviour, so it must NOT include the older-only file.
var listOutput = RunListCommand(Duplicati.CommandLine.Commands.List, "target.txt", testopts);
Assert.IsTrue(listOutput.Contains("sub_a"), "list should list the newest match:" + Environment.NewLine + listOutput);
Assert.IsFalse(listOutput.Contains("sub_b"), "list should not list the older-only match:" + Environment.NewLine + listOutput);
}
private string RunListCommand(
Func<TextWriter, Action<Library.Main.Controller>, List<string>, Dictionary<string, string>, Library.Utility.IFilter, int> command,
string filename,
Dictionary<string, string> baseopts)
{
using var sw = new StringWriter();
// The list/find commands mutate the args and options, so hand each invocation its own copies.
var args = new List<string> { "file://" + TARGETFOLDER, filename };
var opts = new Dictionary<string, string>(baseopts);
command(sw, _ => { }, args, opts, null);
return sw.ToString();
}
}
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest
{
public class Issue4812 : BasicSetupHelper
{
/// <summary>
/// https://github.com/duplicati/duplicati/issues/4812
/// `test-filters --parameters-file=...` must test the --source paths listed in the
/// parameters file. Previously the parameters-file handling only appended --source for the
/// `backup` command and always injected --target as the first positional argument, so for
/// test-filters the --source entries were dropped and the --target was tested as the (bogus)
/// source path — matching zero files.
/// </summary>
[Test]
[Category("Targeted")]
public void TestFiltersUsesSourceFromParametersFile()
{
// A source folder with a known number of plain files.
var sourceFolder = Path.Combine(DATAFOLDER, "src");
Directory.CreateDirectory(sourceFolder);
const int fileCount = 3;
for (var i = 0; i < fileCount; i++)
File.WriteAllText(Path.Combine(sourceFolder, $"file{i}.txt"), "content " + i);
// A shared-style parameters file carrying both --target (used by backup) and --source.
var paramFile = Path.Combine(DATAFOLDER, "params.txt");
File.WriteAllLines(paramFile, new[]
{
"--source=" + sourceFolder,
"--target=file://" + TARGETFOLDER
});
var sb = new StringBuilder();
var err = new StringBuilder();
int exit;
using (var sw = new StringWriter(sb))
using (var ew = new StringWriter(err))
exit = Duplicati.CommandLine.Program.RunCommandLine(sw, ew, _ => { }, new[]
{
"test-filters",
"--parameters-file=" + paramFile
});
var output = sb.ToString();
Assert.AreEqual(0, exit, "test-filters should succeed" + Environment.NewLine + output + Environment.NewLine + err);
var m = Regex.Match(output, @"Matched (\d+) files");
Assert.IsTrue(m.Success, "Expected a 'Matched N files' summary" + Environment.NewLine + output);
var matched = int.Parse(m.Groups[1].Value);
// The --source folder's files must have been tested. Before the fix this was 0, because
// --target was injected as the tested path and --source was ignored.
Assert.AreEqual(fileCount, matched, "test-filters should have matched the --source files" + Environment.NewLine + output);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Threading.Tasks;
using Duplicati.Library.Main.Database;
using Microsoft.Data.Sqlite;
using NUnit.Framework;
namespace Duplicati.UnitTest;
public class Issue5827
{
[Test]
public async Task DisposeDoesNotThrowWhenTransactionAlreadyCompletedAsync()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
await using var reusableTransaction = new ReusableTransaction(connection, transaction);
await transaction.CommitAsync();
Assert.DoesNotThrowAsync(async () => await reusableTransaction.DisposeAsync().AsTask());
}
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Duplicati.Library.Main;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest
{
public class Issue6205 : BasicSetupHelper
{
/// <summary>
/// https://github.com/duplicati/duplicati/issues/6205
/// If all files are deleted from the remote destination and the user then runs repair
/// (which recreates the local database), the recreate fails because the destination is
/// empty. Previously the partial/empty database that was created was left on disk, which
/// blocked every subsequent operation — a fresh backup could not run, and even retrying
/// the repair failed because the recreate refuses to run when the database file exists.
/// The failed recreate must remove the incomplete database so a fresh backup can proceed.
/// </summary>
[Test]
[Category("Targeted")]
public async Task RepairOnEmptyDestinationDoesNotLeavePartialDatabase()
{
var options = new Dictionary<string, string>(this.TestOptions)
{
["no-encryption"] = "true"
};
File.WriteAllText(Path.Combine(this.DATAFOLDER, "a.txt"), "hello");
File.WriteAllText(Path.Combine(this.DATAFOLDER, "b.txt"), "world");
// Initial backup: creates the remote volumes and the local database.
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
var backupResults = await c.BackupAsync([this.DATAFOLDER]);
Assert.AreEqual(0, backupResults.Errors.Count());
}
Assert.IsTrue(File.Exists(options["dbpath"]), "The local database should exist after a backup");
// Simulate "all files deleted on the remote": empty the destination and drop the
// local database so the next repair goes through the recreate-from-remote path.
File.Delete(options["dbpath"]);
foreach (var f in Directory.GetFiles(this.TARGETFOLDER))
File.Delete(f);
// Repair now has to recreate the database from an empty destination, which fails.
Exception repairFailure = null;
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
try
{
await c.RepairAsync();
}
catch (Exception ex)
{
repairFailure = ex;
}
}
Assert.IsNotNull(repairFailure, "Repair against an empty destination is expected to fail");
// The incomplete recreate database must NOT be left behind (this is the fix).
Assert.IsFalse(File.Exists(options["dbpath"]),
"The incomplete recreate database should have been removed after the failed repair");
// With the leftover database gone, a fresh backup must be able to run again.
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
var backupResults = await c.BackupAsync([this.DATAFOLDER]);
Assert.AreEqual(0, backupResults.Errors.Count(),
"A fresh backup should succeed after the failed repair cleaned up the partial database");
}
}
}
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Duplicati.Library.Logging;
using Duplicati.Library.Main.Operation.Backup;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest
{
/// <summary>
/// https://github.com/duplicati/duplicati/issues/6426
/// Warnings for known, expected path problems (permission denied, locked file, path not
/// found, path too long) must not carry the exception, so their (large) stack traces do not
/// bloat the log. Genuinely unexpected errors still keep the exception for debugging.
/// </summary>
public class Issue6426
{
private static List<LogEntry> CaptureWarnings(Exception ex, string callerId)
{
var captured = new List<LogEntry>();
using (Log.StartScope(e => captured.Add(e)))
LogExceptionHelper.LogCommonWarning(ex, "TestTag", callerId, "/some/path");
return captured.Where(e => e.Level == LogMessageType.Warning).ToList();
}
[Test]
[Category("Targeted")]
public void KnownPathWarningsCarryNoException()
{
// These exception types are recognised as "known problems" on every platform.
var cases = new (Exception Exception, string ExpectedId)[]
{
(new UnauthorizedAccessException("denied"), "PermissionDenied"),
(new FileNotFoundException("missing"), "PathNotFound"),
(new PathTooLongException("too long"), "PathTooLong"),
};
foreach (var c in cases)
{
var warnings = CaptureWarnings(c.Exception, "SomeCallerId");
Assert.AreEqual(1, warnings.Count, "Expected exactly one warning for " + c.ExpectedId);
Assert.AreEqual(c.ExpectedId, warnings[0].Id, "Unexpected warning id");
Assert.IsNull(warnings[0].Exception,
c.ExpectedId + " is a known problem and must not attach the exception (no stack trace)");
}
}
[Test]
[Category("Targeted")]
public void UnknownPathWarningKeepsExceptionForDebugging()
{
var ex = new InvalidOperationException("unexpected");
var warnings = CaptureWarnings(ex, "CustomId");
Assert.AreEqual(1, warnings.Count);
Assert.AreEqual("CustomId", warnings[0].Id, "Unknown errors should use the caller-supplied id");
Assert.AreSame(ex, warnings[0].Exception, "Unknown errors must keep the exception for debugging");
}
}
}
@@ -0,0 +1,82 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using Duplicati.Library.Utility;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
namespace Duplicati.UnitTest
{
/// <summary>
/// Regression tests for <see cref="TempFile"/> unique-name generation. In DEBUG builds
/// the name is built from the caller, a second-precision timestamp and a truncated Guid,
/// which could collide when many temp files are created in the same second by the same
/// caller — producing duplicate paths and throwing from the internal tracking dictionary.
/// The generator now appends a process-wide counter to guarantee uniqueness.
/// </summary>
public class TempFileUniqueNameTests
{
[Test]
[Category("Utility")]
public void GeneratedNamesAreUniqueUnderConcurrentLoad()
{
const int threads = 8;
const int perThread = 12500;
const int expected = threads * perThread;
var names = new ConcurrentBag<string>();
var errors = new ConcurrentQueue<Exception>();
var work = new Task[threads];
for (var t = 0; t < threads; t++)
{
work[t] = Task.Run(() =>
{
try
{
for (var i = 0; i < perThread; i++)
names.Add(TempFile.GenerateUniqueName());
}
catch (Exception ex)
{
// Before the fix, a name collision throws ArgumentException from the
// tracking dictionary's Add; capture it rather than faulting the task.
errors.Enqueue(ex);
}
});
}
Task.WaitAll(work);
Assert.IsEmpty(errors, "Generating unique temp names must not throw: " + (errors.IsEmpty ? "" : errors.ToArray()[0].ToString()));
var all = names.ToArray();
Assert.AreEqual(expected, all.Length, "Every generation should have produced a name");
var distinct = new HashSet<string>(all, StringComparer.Ordinal);
Assert.AreEqual(all.Length, distinct.Count, "Generated temp file names must all be unique");
}
}
}
+23 -2
View File
@@ -31,6 +31,27 @@ namespace Duplicati.UnitTest
{
public class ZipFallbackTest : BasicSetupHelper
{
private static async Task DeleteDatabaseWithRetryAsync(string path)
{
const int maxAttempts = 10;
for (var attempt = 1; ; attempt++)
{
try
{
File.Delete(path);
return;
}
catch (IOException) when (OperatingSystem.IsWindows() && attempt < maxAttempts)
{
// Windows can retain SQLite file handles until pending finalizers complete.
GC.Collect();
GC.WaitForPendingFinalizers();
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
}
}
}
[Test]
[Category("Targeted")]
public async Task FallbackToSharpCompressOnDecompressLzmaStreamsAsync()
@@ -50,7 +71,7 @@ namespace Duplicati.UnitTest
}
// Delete the local database
File.Delete(DBFILE);
await DeleteDatabaseWithRetryAsync(DBFILE);
// Switch back to built-in compression
testopts.Remove("zip-compression-method");
@@ -116,7 +137,7 @@ namespace Duplicati.UnitTest
}
// Delete the local database
File.Delete(DBFILE);
await DeleteDatabaseWithRetryAsync(DBFILE);
// Recreate the database
using (var c = new Library.Main.Controller("file://" + TARGETFOLDER, testopts, null))
@@ -0,0 +1,48 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
namespace Duplicati.WebserverCore.Abstractions;
/// <summary>
/// Tracks which local backup database files are currently in use by running operations,
/// preventing concurrent access that would cause SQLite "database is locked" errors.
/// </summary>
public interface IDatabaseLockTracker
{
/// <summary>
/// Attempts to acquire a lock on the given database path, waiting until the lock
/// is available. Used by queued tasks (backup, restore, etc.) which should wait
/// for any concurrent immediate operations to finish.
/// </summary>
/// <param name="dbPath">The normalized, full path to the database file.</param>
/// <param name="cancellationToken">A cancellation token to cancel the wait.</param>
/// <returns>A disposable handle that releases the lock when disposed.</returns>
Task<IAsyncDisposable> AcquireAsync(string dbPath, CancellationToken cancellationToken);
/// <summary>
/// Attempts to acquire a lock on the given database path, failing immediately if
/// the database is already in use. Used by immediate (non-queued) operations such
/// as listing filesets from the restore UI.
/// </summary>
/// <param name="dbPath">The normalized, full path to the database file.</param>
/// <returns>A disposable handle that releases the lock when disposed, or <c>null</c> if the database is already locked.</returns>
IAsyncDisposable? TryAcquire(string dbPath);
}
@@ -45,12 +45,12 @@ public class BackupGet : IEndpointV1
=> ExecuteGetFilesAsync(queueRunnerService, GetBackup(connection, id), filter, time, allVersions ?? false, prefixOnly ?? false, folderContents ?? false, new Dictionary<string, string>()))
.RequireAuthorization();
group.MapGet("/backup/{id}/log", ([FromServices] Connection connection, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize)
=> ExecuteGetLog(connection, GetBackup(connection, id), offset, pagesize ?? 100))
group.MapGet("/backup/{id}/log", ([FromServices] Connection connection, [FromServices] IDatabaseLockTracker databaseLockTracker, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize)
=> ExecuteGetLog(connection, databaseLockTracker, GetBackup(connection, id), offset, pagesize ?? 100))
.RequireAuthorization();
group.MapGet("/backup/{id}/remotelog", ([FromServices] Connection connection, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize)
=> ExecuteGetRemotelog(connection, GetBackup(connection, id), offset, pagesize ?? 100))
group.MapGet("/backup/{id}/remotelog", ([FromServices] Connection connection, [FromServices] IDatabaseLockTracker databaseLockTracker, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize)
=> ExecuteGetRemotelog(connection, databaseLockTracker, GetBackup(connection, id), offset, pagesize ?? 100))
.RequireAuthorization();
group.MapGet("/backup/{id}/filesets", ([FromServices] Connection connection, [FromServices] IQueueRunnerService queueRunnerService, [FromRoute] string id, [FromQuery(Name = "include-metadata")] bool? includeMetadata, [FromQuery(Name = "from-remote-only")] bool? fromRemoteOnly)
@@ -185,32 +185,56 @@ public class BackupGet : IEndpointV1
private static async Task<Dictionary<string, object>> ExecuteGetFilesAsync(IQueueRunnerService queueRunnerService, IBackup bk, string? filter, string? timestring, bool allVersions, bool prefixOnly, bool folderContents, Dictionary<string, string> extraValues)
=> await SearchFilesAsync(queueRunnerService, bk, filter, timestring, allVersions, prefixOnly, folderContents, extraValues).ConfigureAwait(false);
private static List<Dictionary<string, object>> ExecuteGetLog(Connection connection, IBackup bk, long? offset, long pagesize)
private static async Task<List<Dictionary<string, object>>> ExecuteGetLog(Connection connection, IDatabaseLockTracker databaseLockTracker, IBackup bk, long? offset, long pagesize)
{
if (!File.Exists(bk.DBPath))
// Use the effective database path (honoring a "--dbpath" advanced option) so that the log is
// read from the same database the backup actually uses (see issue #1698).
var dbpath = Runner.GetEffectiveDBPath(bk);
if (!File.Exists(dbpath))
return new List<Dictionary<string, object>>();
using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(bk.DBPath))
using (var cmd = con.CreateCommand())
return LogData.DumpTable(cmd, "LogData", "ID", offset, pagesize);
var dbLock = databaseLockTracker.TryAcquire(dbpath)
?? throw new DatabaseLockedException(dbpath);
try
{
using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(dbpath))
using (var cmd = con.CreateCommand())
return LogData.DumpTable(cmd, "LogData", "ID", offset, pagesize);
}
finally
{
await dbLock.DisposeAsync().ConfigureAwait(false);
}
}
private static List<Dictionary<string, object>> ExecuteGetRemotelog(Connection connection, IBackup bk, long? offset, long pagesize)
private static async Task<List<Dictionary<string, object>>> ExecuteGetRemotelog(Connection connection, IDatabaseLockTracker databaseLockTracker, IBackup bk, long? offset, long pagesize)
{
if (!File.Exists(bk.DBPath))
var dbpath = Runner.GetEffectiveDBPath(bk);
if (!File.Exists(dbpath))
return new List<Dictionary<string, object>>();
using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(bk.DBPath))
using (var cmd = con.CreateCommand())
var dbLock = databaseLockTracker.TryAcquire(dbpath)
?? throw new DatabaseLockedException(dbpath);
try
{
var dt = LogData.DumpTable(cmd, "RemoteOperation", "ID", offset, pagesize);
using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(dbpath))
using (var cmd = con.CreateCommand())
{
var dt = LogData.DumpTable(cmd, "RemoteOperation", "ID", offset, pagesize);
// Unwrap raw data to a string
foreach (var n in dt)
try { n["Data"] = System.Text.Encoding.UTF8.GetString((byte[])n["Data"]); }
catch { }
// Unwrap raw data to a string
foreach (var n in dt)
try { n["Data"] = System.Text.Encoding.UTF8.GetString((byte[])n["Data"]); }
catch { }
return dt;
return dt;
}
}
finally
{
await dbLock.DisposeAsync().ConfigureAwait(false);
}
}
@@ -105,7 +105,9 @@ public class BackupPost : IEndpointV1
=> connection.GetBackup(id) ?? throw new NotFoundException("Backup not found");
private static void ExecuteDeleteDb(IBackup backup)
=> File.Delete(backup.DBPath);
// Delete the effective database (honoring a "--dbpath" advanced option) so the database the
// backup actually uses is removed, not a stale DBPath that may not exist (see issue #1698).
=> File.Delete(Runner.GetEffectiveDBPath(backup));
private static void UpdateDatabasePath(Connection connection, IBackup backup, string targetpath, bool move)
{
@@ -67,6 +67,7 @@ public static class ServiceCollectionsExtensions
.AddSingleton<IRemoteControllerRegistration, RemoteControllerRegistrationService>()
.AddSingleton<ISystemInfoProvider, SystemInfoProvider>()
.AddSingleton<IQueueRunnerService, QueueRunnerService>()
.AddSingleton<IDatabaseLockTracker, DatabaseLockTracker>()
.AddSingleton<IProgressStateProviderService, ProgressStateProviderService>()
.AddTransient<INotificationService, NotificationService>()
.AddTransient<IBackupListService, BackupListService>()
@@ -0,0 +1,103 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Collections.Concurrent;
using Duplicati.WebserverCore.Abstractions;
namespace Duplicati.WebserverCore.Services;
/// <summary>
/// Tracks which local backup database files are currently in use by running operations,
/// preventing concurrent access that would cause SQLite "database is locked" errors.
/// </summary>
/// <remarks>
/// Each database path gets its own <see cref="SemaphoreSlim"/> (initial count 1).
/// Queued tasks call <see cref="AcquireAsync"/> which waits for the lock.
/// Immediate tasks call <see cref="TryAcquire"/> which fails instantly if locked.
/// <para>
/// Semaphore entries are intentionally not removed on release. Removing them is racy: a
/// concurrent acquirer could take the lock between a release and a dictionary removal,
/// after which a new acquirer would create a fresh semaphore for the same path, breaking
/// mutual exclusion. The dictionary is bounded by the number of unique database paths
/// (one per backup), which is a small, long-lived set, so the lack of cleanup does not
/// cause unbounded growth in practice.
/// </para>
/// </remarks>
public class DatabaseLockTracker : IDatabaseLockTracker
{
private const int MaxLockCount = 1;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(Library.Utility.Utility.ClientFilenameStringComparer);
/// <inheritdoc />
public async Task<IAsyncDisposable> AcquireAsync(string dbPath, CancellationToken cancellationToken)
{
var semaphore = GetOrCreateSemaphore(dbPath);
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
return new LockRelease(semaphore);
}
/// <inheritdoc />
public IAsyncDisposable? TryAcquire(string dbPath)
{
var semaphore = GetOrCreateSemaphore(dbPath);
if (!semaphore.Wait(0))
return null;
return new LockRelease(semaphore);
}
/// <summary>
/// Normalizes the database path and gets (or creates) the semaphore for it.
/// </summary>
private SemaphoreSlim GetOrCreateSemaphore(string dbPath)
{
var normalized = NormalizePath(dbPath);
if (string.IsNullOrEmpty(normalized))
throw new ArgumentException("Database path cannot be null or empty", nameof(dbPath));
return _locks.GetOrAdd(normalized, _ => new SemaphoreSlim(MaxLockCount, MaxLockCount));
}
/// <summary>
/// Normalizes a file path so that the same database file always maps to the same key,
/// regardless of path separators or relative vs. absolute forms.
/// </summary>
private static string NormalizePath(string dbPath)
{
if (string.IsNullOrWhiteSpace(dbPath))
return string.Empty;
return Path.GetFullPath(dbPath);
}
/// <summary>
/// A disposable handle that releases the semaphore when disposed.
/// </summary>
private sealed class LockRelease(SemaphoreSlim semaphore) : IAsyncDisposable
{
private int _released;
public ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _released, 1) == 0)
semaphore.Release();
return ValueTask.CompletedTask;
}
}
}
@@ -37,7 +37,8 @@ public class QueueRunnerService(
EventPollNotify eventPollNotify,
INotificationUpdateService notificationUpdateService,
IProgressStateProviderService progressStateProviderService,
IApplicationSettings applicationSettings) : IQueueRunnerService
IApplicationSettings applicationSettings,
IDatabaseLockTracker databaseLockTracker) : IQueueRunnerService, IDisposable
{
private readonly object _lock = new();
/// <summary>
@@ -51,9 +52,10 @@ public class QueueRunnerService(
private static readonly int MAX_TASK_RESULT_CACHE_SIZE = 100;
private readonly List<IQueuedTask> _tasks = new();
private (Task? Task, IQueuedTask? QueuedTask) _current;
private (Task? Task, IQueuedTask? QueuedTask, CancellationTokenSource? LockCts) _current;
private bool _isPaused;
private bool _isTerminated;
private readonly CancellationTokenSource _terminateCts = new();
public long AddTask(IQueuedTask task)
=> AddTask(task, false);
@@ -101,6 +103,7 @@ public class QueueRunnerService(
public void Terminate(bool wait)
{
_isTerminated = true;
_terminateCts.Cancel();
if (wait)
{
var task = _current.Task;
@@ -118,20 +121,27 @@ public class QueueRunnerService(
// Clean up completed tasks
if (_current.Task != null && _current.Task.IsCompleted)
_current = (null, null);
{
_current.LockCts?.Dispose();
_current = (null, null, null);
}
if (_tasks.Count == 0)
return;
var nextTask = _tasks[0];
_tasks.RemoveAt(0);
_current = (Task.Run(() => RunTaskAsync(nextTask), CancellationToken.None), nextTask);
_current = (Task.Run(() => RunTaskAsync(nextTask), CancellationToken.None), nextTask, null);
}
}
private async Task RunTaskAsync(IQueuedTask task)
{
var completed = false;
IAsyncDisposable? dbLock = null;
var lockCts = CancellationTokenSource.CreateLinkedTokenSource(_terminateCts.Token);
lock (_lock)
_current = (_current.Task, _current.QueuedTask, lockCts);
try
{
eventPollNotify.SignalNewEvent();
@@ -140,6 +150,8 @@ public class QueueRunnerService(
if (task.OnStarting != null)
await task.OnStarting().ConfigureAwait(false);
dbLock = await AcquireDatabaseLockAsync(task, lockCts.Token).ConfigureAwait(false);
await Runner.RunAsync(connection, eventPollNotify, notificationUpdateService, progressStateProviderService, applicationSettings, task, true).ConfigureAwait(false);
// If the task is completed, don't call OnFinished again
@@ -160,9 +172,14 @@ public class QueueRunnerService(
}
finally
{
if (dbLock != null)
await dbLock.DisposeAsync().ConfigureAwait(false);
task.TaskFinished = DateTime.UtcNow;
lock (_lock)
_current = (null, null);
{
lockCts.Dispose();
_current = (null, null, null);
}
eventPollNotify.SignalNewEvent();
eventPollNotify.SignalTaskQueueUpdate();
eventPollNotify.SignalTaskCompleted(task.TaskID);
@@ -215,6 +232,55 @@ public class QueueRunnerService(
/// <inheritdoc/>
public async Task<IBasicResults?> RunImmediatelyAsync(IQueuedTask task)
{
return await Runner.RunAsync(connection, eventPollNotify, notificationUpdateService, progressStateProviderService, applicationSettings, task, false).ConfigureAwait(false);
IAsyncDisposable? dbLock = null;
var dbPath = Runner.GetEffectiveDBPath(task);
// If we have a database to work with, make sure we can get the lock
if (!string.IsNullOrWhiteSpace(dbPath))
dbLock = databaseLockTracker.TryAcquire(dbPath)
?? throw new DatabaseLockedException(dbPath);
try
{
return await Runner.RunAsync(connection, eventPollNotify, notificationUpdateService, progressStateProviderService, applicationSettings, task, false).ConfigureAwait(false);
}
finally
{
if (dbLock != null)
await dbLock.DisposeAsync().ConfigureAwait(false);
}
}
/// <inheritdoc/>
public void CancelCurrentTaskLockWait(long taskID)
{
lock (_lock)
{
if (_current.QueuedTask?.TaskID == taskID && _current.LockCts != null)
_current.LockCts.Cancel();
}
}
/// <summary>
/// Acquires the database lock for a queued task, waiting until the lock is available
/// or the cancellation token is cancelled. Tasks without a database path (e.g. custom
/// runner tasks) skip locking.
/// </summary>
private async Task<IAsyncDisposable?> AcquireDatabaseLockAsync(IQueuedTask task, CancellationToken cancellationToken)
{
var dbPath = Runner.GetEffectiveDBPath(task);
if (string.IsNullOrWhiteSpace(dbPath))
return null;
return await databaseLockTracker.AcquireAsync(dbPath, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Disposes resources held by the queue runner, including the termination
/// cancellation token source used to cancel tasks waiting on the database lock.
/// </summary>
public void Dispose()
{
_terminateCts.Dispose();
}
}
@@ -108,6 +108,8 @@ public class TaskQueueService(IQueueRunnerService queueRunnerService) : ITaskQue
if (task == null)
throw new NotFoundException("No such task found");
queueRunnerService.CancelCurrentTaskLockWait(task.TaskID);
if (abort)
await task.AbortAsync().ConfigureAwait(false);
else