Files
LightlessClient/LightlessSync/FileCache/FileCompactor.cs

777 lines
27 KiB
C#

using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Channels;
using static LightlessSync.Utils.FileSystemHelper;
namespace LightlessSync.FileCache;
public sealed class FileCompactor : IDisposable
{
public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U;
public const ulong WOF_PROVIDER_FILE = 2UL;
private readonly ConcurrentDictionary<string, byte> _pendingCompactions;
private readonly ILogger<FileCompactor> _logger;
private readonly LightlessConfigService _lightlessConfigService;
private readonly DalamudUtilService _dalamudUtilService;
private readonly Channel<string> _compactionQueue;
private readonly CancellationTokenSource _compactionCts = new();
private readonly Task _compactionWorker;
private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo = new()
{
Algorithm = (int)CompressionAlgorithm.XPRESS8K,
Flags = 0
};
private enum CompressionAlgorithm
{
NO_COMPRESSION = -2,
LZNT1 = -1,
XPRESS4K = 0,
LZX = 1,
XPRESS8K = 2,
XPRESS16K = 3
}
public FileCompactor(ILogger<FileCompactor> logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService)
{
_pendingCompactions = new(StringComparer.OrdinalIgnoreCase);
_logger = logger;
_lightlessConfigService = lightlessConfigService;
_dalamudUtilService = dalamudUtilService;
_compactionQueue = Channel.CreateUnbounded<string>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
_compactionWorker = Task.Factory.StartNew(() => ProcessQueueAsync(_compactionCts.Token), _compactionCts.Token, TaskCreationOptions.LongRunning,TaskScheduler.Default).Unwrap();
}
public bool MassCompactRunning { get; private set; }
public string Progress { get; private set; } = string.Empty;
public void CompactStorage(bool compress)
{
MassCompactRunning = true;
try
{
var allFiles = Directory.EnumerateFiles(_lightlessConfigService.Current.CacheFolder).ToList();
int total = allFiles.Count;
int current = 0;
foreach (var file in allFiles)
{
current++;
Progress = $"{current}/{total}";
try
{
if (compress)
CompactFile(file);
else
DecompressFile(file);
}
catch (IOException ioEx)
{
_logger.LogDebug(ioEx, "File {file} locked or busy, skipping", file);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error compacting/decompressing file {file}", file);
}
}
}
finally
{
MassCompactRunning = false;
Progress = string.Empty;
}
}
/// <summary>
/// Write all bytes into a directory async
/// </summary>
/// <param name="filePath">Bytes will be writen to this filepath</param>
/// <param name="bytes">Bytes that have to be written</param>
/// <param name="token">Cancellation Token for interupts</param>
/// <returns>Writing Task</returns>
public async Task WriteAllBytesAsync(string filePath, byte[] bytes, CancellationToken token)
{
var dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
if (_lightlessConfigService.Current.UseCompactor)
EnqueueCompaction(filePath);
}
/// <summary>
/// Gets the File size for an BTRFS or NTFS file system for the given FileInfo
/// </summary>
/// <param name="path">Amount of blocks used in the disk</param>
public long GetFileSizeOnDisk(FileInfo fileInfo)
{
var fsType = GetFilesystemType(fileInfo.FullName, _dalamudUtilService.IsWine);
if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine)
{
var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine);
var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize);
var size = (long)hosize << 32 | losize;
return ((size + blockSize - 1) / blockSize) * blockSize;
}
if (fsType == FilesystemType.Btrfs)
{
try
{
var realPath = fileInfo.FullName.Replace("\"", "\\\"", StringComparison.Ordinal);
var psi = new ProcessStartInfo("stat", $"-c %b \"{realPath}\"")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Could not start stat");
var outp = proc.StandardOutput.ReadToEnd();
var err = proc.StandardError.ReadToEnd();
proc.WaitForExit();
if (proc.ExitCode != 0)
throw new InvalidOperationException($"stat failed: {err}");
if (!long.TryParse(outp.Trim(), out var blocks))
throw new InvalidOperationException($"invalid stat output: {outp}");
// st_blocks are always 512-byte on Linux enviroment.
return blocks * 512L;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed stat size for {file}, fallback to Length", fileInfo.FullName);
}
}
return fileInfo.Length;
}
/// <summary>
/// Compressing the given path with BTRFS or NTFS file system.
/// </summary>
/// <param name="path">Path of the decompressed/normal file</param>
private void CompactFile(string filePath)
{
var fi = new FileInfo(filePath);
if (!fi.Exists)
{
_logger.LogTrace("Skip compact: missing {file}", filePath);
return;
}
var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine);
_logger.LogTrace("Detected filesystem {fs} for {file} (isWine={wine})", fsType, filePath, _dalamudUtilService.IsWine);
var oldSize = fi.Length;
int blockSize = GetBlockSizeForPath(fi.FullName, _logger, _dalamudUtilService.IsWine);
if (oldSize < Math.Max(blockSize, 8 * 1024))
{
_logger.LogTrace("Skip compact: {file} < block {block}", filePath, blockSize);
return;
}
if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine)
{
if (!IsWOFCompactedFile(filePath))
{
_logger.LogDebug("NTFS compact XPRESS8K: {file}", filePath);
if (WOFCompressFile(filePath))
{
var newSize = GetFileSizeOnDisk(fi);
_logger.LogDebug("NTFS compressed {file} {old} -> {new}", filePath, oldSize, newSize);
}
else
{
_logger.LogWarning("NTFS compression failed or unavailable for {file}", filePath);
}
}
else
{
_logger.LogTrace("Already NTFS-compressed: {file}", filePath);
}
return;
}
if (fsType == FilesystemType.Btrfs)
{
if (!IsBtrfsCompressedFile(filePath))
{
_logger.LogDebug("Btrfs compress zstd: {file}", filePath);
if (BtrfsCompressFile(filePath))
{
var newSize = GetFileSizeOnDisk(fi);
_logger.LogDebug("Btrfs compressed {file} {old} -> {new}", filePath, oldSize, newSize);
}
else
{
_logger.LogWarning("Btrfs compression failed or unavailable for {file}", filePath);
}
}
else
{
_logger.LogTrace("Already Btrfs-compressed: {file}", filePath);
}
return;
}
_logger.LogTrace("Skip compact: unsupported FS for {file}", filePath);
}
/// <summary>
/// Decompressing the given path with BTRFS file system or NTFS file system.
/// </summary>
/// <param name="path">Path of the compressed file</param>
private void DecompressFile(string path)
{
_logger.LogDebug("Decompress request: {file}", path);
var fsType = GetFilesystemType(path, _dalamudUtilService.IsWine);
if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine)
{
try
{
bool flowControl = DecompressWOFFile(path, out FileStream fs);
if (!flowControl)
{
return;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "NTFS decompress error {file}", path);
}
}
if (fsType == FilesystemType.Btrfs)
{
try
{
bool flowControl = DecompressBtrfsFile(path);
if (!flowControl)
{
return;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Btrfs decompress error {file}", path);
}
}
}
/// <summary>
/// Decompress an BTRFS File
/// </summary>
/// <param name="path">Path of the compressed file</param>
/// <returns>Decompessing state</returns>
private bool DecompressBtrfsFile(string path)
{
try
{
var opts = GetMountOptionsForPath(path);
if (opts.Contains("compress", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Cannot safely decompress {file}: mount options include compression ({opts})", path, opts);
return false;
}
string realPath = ToLinuxPathIfWine(path, _dalamudUtilService.IsWine);
var psi = new ProcessStartInfo
{
FileName = _dalamudUtilService.IsWine ? "/bin/bash" : "btrfs",
Arguments = _dalamudUtilService.IsWine
? $"-c \"btrfs filesystem defragment -- '{EscapeSingle(realPath)}'\""
: $"filesystem defragment -- \"{realPath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = "/"
};
using var proc = Process.Start(psi);
if (proc == null)
{
_logger.LogWarning("Failed to start btrfs defragment for {file}", path);
return false;
}
var stdout = proc.StandardOutput.ReadToEnd();
var stderr = proc.StandardError.ReadToEnd();
proc.WaitForExit();
if (proc.ExitCode != 0)
{
_logger.LogWarning("btrfs defragment failed for {file}: {err}", path, stderr);
return false;
}
if (!string.IsNullOrWhiteSpace(stdout))
_logger.LogTrace("btrfs defragment output {file}: {out}", path, stdout.Trim());
_logger.LogInformation("Btrfs rewritten uncompressed: {file}", path);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Btrfs decompress error {file}", path);
}
return true;
}
/// <summary>
/// Decompress an NTFS File
/// </summary>
/// <param name="path">Path of the compressed file</param>
/// <returns>Decompessing state</returns>
private bool DecompressWOFFile(string path, out FileStream fs)
{
fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read);
var handle = fs.SafeFileHandle;
if (handle.IsInvalid)
{
_logger.LogWarning("Invalid handle: {file}", path);
return false;
}
if (!DeviceIoControl(handle, FSCTL_DELETE_EXTERNAL_BACKING,
IntPtr.Zero, 0, IntPtr.Zero, 0,
out _, IntPtr.Zero))
{
int err = Marshal.GetLastWin32Error();
if (err == 342)
{
_logger.LogTrace("File {file} not externally backed (already decompressed)", path);
}
else
{
_logger.LogWarning("DeviceIoControl failed for {file} with Win32 error {err}", path, err);
}
}
else
{
_logger.LogTrace("Successfully decompressed NTFS file {file}", path);
}
return true;
}
private static string EscapeSingle(string p) => p.Replace("'", "'\\'", StringComparison.Ordinal);
private static string ToLinuxPathIfWine(string path, bool isWine)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return path;
if (!IsProbablyWine() && !isWine)
return path;
string realPath = path;
if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase))
realPath = "/" + path[3..].Replace('\\', '/');
else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase))
realPath = Path.Combine(Environment.GetEnvironmentVariable("HOME") ?? "/home", path[3..].Replace('\\', '/')).Replace('\\', '/');
return realPath;
}
/// <summary>
/// Compress an WOF File
/// </summary>
/// <param name="path">Path of the decompressed/normal file</param>
/// <returns>Compessing state</returns>
private bool WOFCompressFile(string path)
{
FileStream? fs = null;
int size = Marshal.SizeOf<WOF_FILE_COMPRESSION_INFO_V1>();
IntPtr efInfoPtr = Marshal.AllocHGlobal(size);
try
{
Marshal.StructureToPtr(_efInfo, efInfoPtr, fDeleteOld: false);
ulong length = (ulong)size;
const int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read);
break;
}
catch (IOException)
{
if (attempt == maxRetries - 1)
{
_logger.LogWarning("File still in use after {attempts} attempts, skipping compression for {file}", maxRetries, path);
return false;
}
int delay = 150 * (attempt + 1);
_logger.LogTrace("File in use, retrying in {delay}ms for {file}", delay, path);
Thread.Sleep(delay);
}
}
if (fs == null)
{
_logger.LogWarning("Failed to open {file} for compression; skipping", path);
return false;
}
var handle = fs.SafeFileHandle;
if (handle.IsInvalid)
{
_logger.LogWarning("Invalid file handle for {file}", path);
return false;
}
int ret = WofSetFileDataLocation(handle, WOF_PROVIDER_FILE, efInfoPtr, length);
// 0x80070158 is WOF error whenever compression fails in an non-fatal way.
if (ret != 0 && ret != unchecked((int)0x80070158))
{
_logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X"));
return false;
}
return true;
}
catch (DllNotFoundException)
{
_logger.LogTrace("WofUtil.dll not available; skipping NTFS compaction for {file}", path);
return false;
}
catch (EntryPointNotFoundException)
{
_logger.LogTrace("WOF entrypoint missing (Wine/older OS); skipping NTFS compaction for {file}", path);
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error compacting file {path}", path);
return false;
}
finally
{
fs?.Dispose();
if (efInfoPtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(efInfoPtr);
}
}
}
/// <summary>
/// Checks if an File is compacted with WOF compression
/// </summary>
/// <param name="path">Path of the file</param>
/// <returns>State of the file</returns>
private static bool IsWOFCompactedFile(string filePath)
{
try
{
uint buf = (uint)Marshal.SizeOf<WOF_FILE_COMPRESSION_INFO_V1>();
int result = WofIsExternalFile(filePath, out int isExternal, out _, out var info, ref buf);
if (result != 0 || isExternal == 0)
return false;
return info.Algorithm == (int)CompressionAlgorithm.XPRESS8K
|| info.Algorithm == (int)CompressionAlgorithm.XPRESS4K
|| info.Algorithm == (int)CompressionAlgorithm.XPRESS16K
|| info.Algorithm == (int)CompressionAlgorithm.LZX
|| info.Algorithm == (int)CompressionAlgorithm.LZNT1
|| info.Algorithm == (int)CompressionAlgorithm.NO_COMPRESSION;
}
catch
{
return false;
}
}
/// <summary>
/// Checks if an File is compacted with Btrfs compression
/// </summary>
/// <param name="path">Path of the file</param>
/// <returns>State of the file</returns>
private bool IsBtrfsCompressedFile(string path)
{
try
{
bool isWine = _dalamudUtilService?.IsWine ?? false;
string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path;
var psi = new ProcessStartInfo
{
FileName = isWine ? "/bin/bash" : "filefrag",
Arguments = isWine
? $"-c \"filefrag -v '{EscapeSingle(realPath)}'\""
: $"-v \"{realPath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = "/"
};
using var proc = Process.Start(psi);
if (proc == null)
{
_logger.LogWarning("Failed to start filefrag for {file}", path);
return false;
}
string stdout = proc.StandardOutput.ReadToEnd();
string stderr = proc.StandardError.ReadToEnd();
proc.WaitForExit();
if (proc.ExitCode != 0 && !string.IsNullOrWhiteSpace(stderr))
{
_logger.LogTrace("filefrag exited with code {code}: {stderr}", proc.ExitCode, stderr);
}
bool compressed = stdout.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase);
_logger.LogTrace("Btrfs compression check for {file}: {compressed}", path, compressed);
return compressed;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to detect Btrfs compression for {file}", path);
return false;
}
}
/// <summary>
/// Compress an Btrfs File
/// </summary>
/// <param name="path">Path of the decompressed/normal file</param>
/// <returns>Compessing state</returns>
private bool BtrfsCompressFile(string path)
{
try
{
bool isWine = _dalamudUtilService?.IsWine ?? false;
string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path;
if (isWine && IsProbablyWine())
{
if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase))
{
realPath = "/" + path[3..].Replace('\\', '/');
}
else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase))
{
string linuxHome = Environment.GetEnvironmentVariable("HOME") ?? "/home";
realPath = Path.Combine(linuxHome, path[3..].Replace('\\', '/')).Replace('\\', '/');
}
_logger.LogTrace("Detected Wine environment. Converted path for compression: {realPath}", realPath);
}
const int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
break;
}
catch (IOException)
{
if (attempt == maxRetries - 1)
{
_logger.LogWarning("File still in use after {attempts} attempts; skipping btrfs compression for {file}", maxRetries, path);
return false;
}
int delay = 150 * (attempt + 1);
_logger.LogTrace("File busy, retrying in {delay}ms for {file}", delay, path);
Thread.Sleep(delay);
}
}
string command = $"btrfs filesystem defragment -czstd -- \"{realPath}\"";
var psi = new ProcessStartInfo
{
FileName = isWine ? "/bin/bash" : "btrfs",
Arguments = isWine ? $"-c \"{command}\"" : $"filesystem defragment -czstd -- \"{realPath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = "/"
};
using var proc = Process.Start(psi);
if (proc == null)
{
_logger.LogWarning("Failed to start btrfs defragment for compression of {file}", path);
return false;
}
string stdout = proc.StandardOutput.ReadToEnd();
string stderr = proc.StandardError.ReadToEnd();
try
{
proc.WaitForExit();
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Process.WaitForExit threw under Wine for {file}", path);
}
if (proc.ExitCode != 0)
{
_logger.LogWarning("btrfs defragment failed for {file}: {stderr}", path, stderr);
return false;
}
if (!string.IsNullOrWhiteSpace(stdout))
_logger.LogTrace("btrfs defragment output for {file}: {stdout}", path, stdout.Trim());
_logger.LogInformation("Compressed btrfs file successfully: {file}", path);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error running btrfs defragment for {file}", path);
return false;
}
}
private void EnqueueCompaction(string filePath)
{
if (!_pendingCompactions.TryAdd(filePath, 0))
return;
var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine);
if (fsType != FilesystemType.NTFS && fsType != FilesystemType.Btrfs)
{
_logger.LogTrace("Skip enqueue (unsupported fs) {fs} {file}", fsType, filePath);
_pendingCompactions.TryRemove(filePath, out _);
return;
}
if (!_compactionQueue.Writer.TryWrite(filePath))
{
_pendingCompactions.TryRemove(filePath, out _);
_logger.LogDebug("Failed to enqueue compaction {file}", filePath);
}
}
private async Task ProcessQueueAsync(CancellationToken token)
{
try
{
while (await _compactionQueue.Reader.WaitToReadAsync(token).ConfigureAwait(false))
{
while (_compactionQueue.Reader.TryRead(out var filePath))
{
try
{
if (token.IsCancellationRequested)
{
return;
}
if (!_lightlessConfigService.Current.UseCompactor)
{
continue;
}
if (!File.Exists(filePath))
{
_logger.LogTrace("Skip compact (missing) {file}", filePath);
continue;
}
CompactFile(filePath);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error compacting file {file}", filePath);
}
finally
{
_pendingCompactions.TryRemove(filePath, out _);
}
}
}
}
catch (OperationCanceledException)
{
_logger.LogDebug("Compaction queue cancelled");
}
}
[StructLayout(LayoutKind.Sequential, Pack = 1)]
private struct WOF_FILE_COMPRESSION_INFO_V1
{
public int Algorithm;
public ulong Flags;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped);
[DllImport("kernel32.dll")]
private static extern uint GetCompressedFileSizeW([In, MarshalAs(UnmanagedType.LPWStr)] string lpFileName, [Out, MarshalAs(UnmanagedType.U4)] out uint lpFileSizeHigh);
[DllImport("WofUtil.dll")]
private static extern int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WOF_FILE_COMPRESSION_INFO_V1 Info, ref uint BufferLength);
[DllImport("WofUtil.dll", SetLastError = true)]
private static extern int WofSetFileDataLocation(SafeFileHandle FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length);
public void Dispose()
{
_compactionQueue.Writer.TryComplete();
_compactionCts.Cancel();
try
{
_compactionWorker.Wait(TimeSpan.FromSeconds(5));
}
catch
{
//ignore on catch ^^
}
finally
{
_compactionCts.Dispose();
}
GC.SuppressFinalize(this);
}
}