Check free temporary space

This PR adds checks for free temporary space when starting the server and when restoring.

On systems that have limited space in the temp folder a warning will now be shown.

This is intended to capture issues on some Docker systems where /tmp is mounted in memory instead of being disk backed.

It will also detect the issue on other systems that are space constrained.
This commit is contained in:
Kenneth Skovhede
2026-02-06 10:37:27 +01:00
parent e6c0b9b045
commit 05ea1dfafe
4 changed files with 262 additions and 109 deletions
+3 -109
View File
@@ -24,8 +24,6 @@ using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
using Duplicati.Library.Utility.Options;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Duplicati.Library.Backend
{
@@ -451,72 +449,12 @@ namespace Duplicati.Library.Backend
throw new FileMissingException(Strings.FileBackend.FileNotFoundError(ex.Message), ex);
}
/// <summary>
/// Gets the drive or volume information for the specified path.
/// </summary>
/// <returns>The drive or volume information, or null if it could not be determined.</returns>
private DriveInfo? GetDrive()
{
string root;
if (!OperatingSystem.IsWindows())
{
string path = Util.AppendDirSeparator(systemIO.PathGetFullPath(m_path));
// If the built-in .NET DriveInfo works, use it
try { return new DriveInfo(path); }
catch { }
root = "/";
//Find longest common prefix from mounted devices
//TODO: Can trick this with symlinks, where the symlink is on one mounted volume,
// and the actual storage is on another
foreach (var di in DriveInfo.GetDrives())
if (path.StartsWith(Util.AppendDirSeparator(di.Name), StringComparison.Ordinal) && di.Name.Length > root.Length)
root = di.Name;
}
else
{
root = systemIO.GetPathRoot(m_path);
}
// On Windows, DriveInfo is only valid for lettered drives. (e.g., not for UNC paths and shares)
// So only attempt to get it if we aren't on Windows or if the root starts with a letter.
if (!OperatingSystem.IsWindows() || (root.Length > 0 && char.IsLetter(root[0])))
{
try
{
return new DriveInfo(root);
}
catch (ArgumentException)
{
// If there was a problem, fall back to returning null
}
}
return null;
}
/// <inheritdoc />
public Task<IQuotaInfo?> GetQuotaInfoAsync(CancellationToken cancelToken)
{
var driveInfo = this.GetDrive();
if (driveInfo != null)
{
// Check that the total space is above 0, because Mono sometimes reports 0 for unknown file systems
// If the drive actually has a total size of 0, this should be obvious immediately due to write errors
if (driveInfo.TotalSize > 0)
{
return Task.FromResult<IQuotaInfo?>(new QuotaInfo(driveInfo.TotalSize, driveInfo.AvailableFreeSpace));
}
}
if (OperatingSystem.IsWindows())
{
// If we can't get the DriveInfo on Windows, fallback to GetFreeDiskSpaceEx
// https://stackoverflow.com/questions/2050343/programmatically-determining-space-available-from-unc-path
return Task.FromResult<IQuotaInfo?>(GetDiskFreeSpace(m_path));
}
var spaceInfo = Utility.Utility.GetFreeSpaceForPath(m_path);
if (spaceInfo != null)
return Task.FromResult<IQuotaInfo?>(new QuotaInfo(spaceInfo.Value.TotalSpace, spaceInfo.Value.FreeSpace));
return Task.FromResult<IQuotaInfo?>(null);
}
@@ -536,50 +474,6 @@ namespace Duplicati.Library.Backend
return Task.CompletedTask;
}
/// <summary>
/// Get the disk free space using the Win32 API's GetDiskFreeSpaceEx function.
/// </summary>
/// <param name="directory">Directory</param>
/// <returns>Quota info</returns>
[MethodImpl(MethodImplOptions.NoInlining)]
[SupportedOSPlatform("windows")]
public static QuotaInfo? GetDiskFreeSpace(string directory)
{
ulong available;
ulong total;
if (WindowsDriveHelper.GetDiskFreeSpaceEx(directory, out available, out total, out _))
{
return new QuotaInfo((long)total, (long)available);
}
else
{
return null;
}
}
/// <summary>
/// Provides helper methods for working with Windows drives.
/// </summary>
[SupportedOSPlatform("windows")]
private static class WindowsDriveHelper
{
/// <summary>
/// Gets the free disk space for the specified directory using the Win32 API.
/// </summary>
/// <param name="lpDirectoryName">The directory name to check</param>
/// <param name="lpFreeBytesAvailable">The available free bytes</param>
/// <param name="lpTotalNumberOfBytes">The total number of bytes</param>
/// <param name="lpTotalNumberOfFreeBytes">The total number of free bytes</param>
/// <returns></returns>
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetDiskFreeSpaceEx(
string lpDirectoryName,
out ulong lpFreeBytesAvailable,
out ulong lpTotalNumberOfBytes,
out ulong lpTotalNumberOfFreeBytes);
}
/// <summary>
/// Verifies that the target file size matches the source file size.
/// </summary>
@@ -51,6 +51,42 @@ namespace Duplicati.Library.Main.Operation
m_result = result;
}
/// <summary>
/// Checks if the tempdir has enough free space relative to the volume size.
/// Logs a warning if free space is less than 4 times the volume size.
/// </summary>
private void CheckTempDirFreeSpace()
{
try
{
// Get the tempdir path
var tempDir = TempFolder.SystemTempPath;
// Get free space in tempdir using the Utility method
var spaceInfo = Library.Utility.Utility.GetFreeSpaceForPath(tempDir);
if (spaceInfo == null)
{
// Could not determine free space, skip the check
return;
}
// Get the volume size from options
var volumeSize = m_options.VolumeSize;
// Check if free space is less than 4 times the volume size
if (spaceInfo.Value.FreeSpace < volumeSize * Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER)
{
Logging.Log.WriteWarningMessage(LOGTAG, "TempDirLowSpace", null,
$"The temporary folder '{tempDir}' has limited free space ({Library.Utility.Utility.FormatSizeString(spaceInfo.Value.FreeSpace)}). " +
$"It is recommended to have at least {Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER} times the volume size ({Library.Utility.Utility.FormatSizeString(volumeSize * Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER)}) available for optimal restore operation.");
}
}
catch
{
// Ignore errors during free space check
}
}
/// <summary>
/// Gets the compression module by parsing the filename
/// </summary>
@@ -106,6 +142,9 @@ namespace Duplicati.Library.Main.Operation
{
m_result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Begin);
// Check tempdir free space before starting restore
CheckTempDirFreeSpace();
// Set the restore path in the results for logging purposes
var restorePath = restoreDestination.TargetDestination;
m_result.RestorePath = string.IsNullOrEmpty(restorePath)
+91
View File
@@ -65,6 +65,11 @@ namespace Duplicati.Library.Utility
/// </summary>
public const FileAttributes ATTRIBUTE_ERROR = (FileAttributes)(1 << 30);
/// <summary>
/// The multiplier used to determine if there is enough free space on the temporary volume
/// </summary>
public const long VOLUME_SIZE_FREE_SPACE_MULTIPLIER = 10;
/// <summary>
/// The callback delegate type used to collecting file information
/// </summary>
@@ -2064,5 +2069,91 @@ namespace Duplicati.Library.Utility
return value;
}
/// <summary>
/// Gets the free and total space available for the specified path.
/// Uses the same approach as FileBackend.GetQuotaInfoAsync.
/// </summary>
/// <param name="path">The path to check</param>
/// <returns>A tuple with (freeSpace, totalSpace) in bytes, or null if it could not be determined</returns>
public static (long FreeSpace, long TotalSpace)? GetFreeSpaceForPath(string path)
{
try
{
// Get the drive info for the path
var root = Path.GetPathRoot(path);
if (string.IsNullOrEmpty(root))
return null;
// On Windows, DriveInfo is only valid for lettered drives
if (OperatingSystem.IsWindows() && root.Length > 0 && char.IsLetter(root[0]))
{
try
{
var driveInfo = new DriveInfo(root);
if (driveInfo.TotalSize > 0)
return (driveInfo.AvailableFreeSpace, driveInfo.TotalSize);
}
catch (ArgumentException)
{
// Fall through to Win32 API fallback
}
// Fallback to Win32 API for UNC paths and other cases
return GetDiskFreeSpaceWin32(path);
}
else if (!OperatingSystem.IsWindows())
{
var driveInfo = new DriveInfo(root);
if (driveInfo.TotalSize > 0)
return (driveInfo.AvailableFreeSpace, driveInfo.TotalSize);
}
else
{
// Windows but not a lettered drive (e.g., UNC path)
return GetDiskFreeSpaceWin32(path);
}
}
catch
{
// Ignore errors and return null
}
return null;
}
/// <summary>
/// Gets the free and total disk space using the Win32 API's GetDiskFreeSpaceEx function.
/// </summary>
/// <param name="directory">Directory</param>
/// <returns>A tuple with (freeSpace, totalSpace) in bytes, or null if it could not be determined</returns>
[MethodImpl(MethodImplOptions.NoInlining)]
[SupportedOSPlatform("windows")]
private static (long FreeSpace, long TotalSpace)? GetDiskFreeSpaceWin32(string directory)
{
if (!OperatingSystem.IsWindows())
return null;
try
{
if (GetDiskFreeSpaceEx(directory, out var available, out var total, out _))
return ((long)available, (long)total);
}
catch
{
// Ignore errors
}
return null;
}
[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
[SupportedOSPlatform("windows")]
private static extern bool GetDiskFreeSpaceEx(
string lpDirectoryName,
out ulong lpFreeBytesAvailable,
out ulong lpTotalNumberOfBytes,
out ulong lpTotalNumberOfFreeBytes);
}
}
+129
View File
@@ -639,6 +639,9 @@ namespace Duplicati.Server
connection.DismissNotification(n.ID);
}
// Check tempdir free space against volume sizes
CheckTempDirFreeSpace(connection, commandlineOptions);
// Emit warnings if the application has been updated
if (connection.ApplicationSettings.LastConfigIssueCheckVersion != UpdaterManager.SelfVersion.Version)
{
@@ -688,6 +691,132 @@ namespace Duplicati.Server
}
}
/// <summary>
/// Checks if the tempdir has enough free space relative to the volume sizes of configured backups.
/// Emits a warning if free space is less than 4 times the volume size.
/// </summary>
/// <param name="connection">The database connection</param>
/// <param name="commandlineOptions">The commandline options</param>
private static void CheckTempDirFreeSpace(Connection connection, Dictionary<string, string> commandlineOptions)
{
const string TEMPDIR_WARNING_ACTION = "config:issue:tempdir-low-space";
// Get the tempdir path - check Connection settings first, then fall back to system default
var tempDir = TempFolder.SystemTempPath;
var serverSettings = connection.GetSettings(Connection.ANY_BACKUP_ID);
var tempDirSetting = serverSettings?.FirstOrDefault(s =>
string.Equals(s.Name, "tempdir", StringComparison.OrdinalIgnoreCase) || string.Equals(s.Name, "--tempdir", StringComparison.OrdinalIgnoreCase));
if (tempDirSetting != null && !string.IsNullOrEmpty(tempDirSetting.Value))
tempDir = tempDirSetting.Value;
// Get free space in tempdir using the Utility method
var spaceInfo = Library.Utility.Utility.GetFreeSpaceForPath(tempDir);
// Could not determine free space, skip the check
if (spaceInfo == null)
return;
// Get the maximum volume size from all configured backups
var maxVolumeSize = GetMaxVolumeSize(connection, commandlineOptions);
// Check if free space is less than 4 times the volume size
if (spaceInfo.Value.FreeSpace < maxVolumeSize * Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER)
{
// Emit warning
connection.RegisterNotification(
Serialization.NotificationType.Warning,
"Low temporary folder space",
$"The temporary folder '{tempDir}' has limited free space ({Library.Utility.Utility.FormatSizeString(spaceInfo.Value.FreeSpace)}). " +
$"It is recommended to have at least {Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER} times the volume size ({Library.Utility.Utility.FormatSizeString(maxVolumeSize * Library.Utility.Utility.VOLUME_SIZE_FREE_SPACE_MULTIPLIER)}) available for optimal operation.",
null,
null,
TEMPDIR_WARNING_ACTION,
null,
"TempDirLowSpace",
null,
(self, all) =>
{
return all.FirstOrDefault(x => x.Action == TEMPDIR_WARNING_ACTION) ?? self;
}
);
}
else
{
// Clear any existing warning
var existingNotifications = connection.GetNotifications().Where(x => x.Action == TEMPDIR_WARNING_ACTION).ToList();
foreach (var n in existingNotifications)
connection.DismissNotification(n.ID);
}
}
/// <summary>
/// Gets the maximum volume size from all configured backups.
/// </summary>
/// <param name="connection">The database connection</param>
/// <param name="commandlineOptions">The commandline options</param>
/// <returns>The maximum volume size in bytes</returns>
private static long GetMaxVolumeSize(Connection connection, Dictionary<string, string> commandlineOptions)
{
const string DEFAULT_VOLUME_SIZE = "50mb";
long maxVolumeSize = 0;
// Check if there's a global dblock-size in commandline options
if (commandlineOptions.TryGetValue("dblock-size", out var globalDblockSize) && !string.IsNullOrEmpty(globalDblockSize))
{
try
{
maxVolumeSize = Sizeparser.ParseSize(globalDblockSize, "mb");
}
catch
{
// Ignore parsing errors
}
}
// Check if there's a global dblock-size in the database settings
var serverSettings = connection.GetSettings(Connection.ANY_BACKUP_ID);
var defaultDblockSize = serverSettings?.FirstOrDefault(s => string.Equals(s.Name, "dblock-size", StringComparison.OrdinalIgnoreCase) || string.Equals(s.Name, "--dblock-size", StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(defaultDblockSize?.Value))
try
{
maxVolumeSize = Math.Max(Sizeparser.ParseSize(defaultDblockSize.Value, "mb"), maxVolumeSize);
}
catch
{
// Ignore parsing errors
}
// If no global setting, use default
maxVolumeSize = Math.Max(maxVolumeSize, Sizeparser.ParseSize(DEFAULT_VOLUME_SIZE, "mb"));
// Check all configured backups for their volume sizes
foreach (var backup in connection.Backups)
{
try
{
// Get settings for this backup
var backupId = long.Parse(backup.ID);
var settings = connection.GetSettings(backupId);
// Look for dblock-size setting
var dblockSizeSetting = settings?.FirstOrDefault(s =>
string.Equals(s.Name, "dblock-size", StringComparison.OrdinalIgnoreCase) || string.Equals(s.Name, "--dblock-size", StringComparison.OrdinalIgnoreCase));
if (dblockSizeSetting != null && !string.IsNullOrEmpty(dblockSizeSetting.Value))
{
var backupVolumeSize = Sizeparser.ParseSize(dblockSizeSetting.Value, "mb");
if (backupVolumeSize > maxVolumeSize)
maxVolumeSize = backupVolumeSize;
}
}
catch
{
// Ignore errors for individual backups
}
}
return maxVolumeSize;
}
/// <summary>
/// Creates the application instance to ensure a single instance is running for the current user
/// </summary>