From 820d3c9a1d7322c5dd98f914dc058fdc06d55203 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 18:34:23 +0800 Subject: [PATCH 01/14] feat: throttle high-frequency log sites via ThrottledLogging Log a throttled warning when a disk's high-usage warning fires, and apply the same package to other repeat-failure log sites (auto-save failures, snapshot pruning/blob GC, helper service reconciliation and pipe errors) so a persistent condition doesn't spam the log file. --- Directory.Packages.props | 1 + src/ManagedDrive.App/App.xaml.cs | 4 +- .../Services/DiskNotificationService.cs | 40 +++++++++++++------ .../ManagedDrive.Core.csproj | 1 + src/ManagedDrive.Core/Mounting/RamDisk.cs | 5 ++- .../Snapshots/SnapshotManager.cs | 17 ++++++-- .../GlobalMountManager.cs | 6 ++- src/ManagedDrive.Service/HelperPipeService.cs | 5 ++- .../ManagedDrive.Service.csproj | 1 + 9 files changed, 58 insertions(+), 22 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a7fb7e8..6a41d56 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,5 +22,6 @@ + \ No newline at end of file diff --git a/src/ManagedDrive.App/App.xaml.cs b/src/ManagedDrive.App/App.xaml.cs index 20c3c92..5406b29 100644 --- a/src/ManagedDrive.App/App.xaml.cs +++ b/src/ManagedDrive.App/App.xaml.cs @@ -153,7 +153,9 @@ private async void App_Startup(object sender, StartupEventArgs e) _trayTooltipController = new(_mainViewModel, _trayIconController); _tempDirCompatChecker = new(_settings, _trayIconController, () => _mainWindow is { IsLoaded: true } ? _mainWindow : null); _mountManager.ActivityDetected += _trayIconController.OnActivityDetected; - _diskNotificationService = new(_mainViewModel, _trayIconController, () => _mainWindow!.IsVisible); + _diskNotificationService = new( + _mainViewModel, _trayIconController, () => _mainWindow!.IsVisible, + _serviceProvider!.GetRequiredService>()); // Constructed before AutoMountDisksAsync so that an auto-mounted disk which is already the // TEMP target gets its global symlink published at startup. Rooted as a field only to keep diff --git a/src/ManagedDrive.App/Services/DiskNotificationService.cs b/src/ManagedDrive.App/Services/DiskNotificationService.cs index d4b194e..f4e305b 100644 --- a/src/ManagedDrive.App/Services/DiskNotificationService.cs +++ b/src/ManagedDrive.App/Services/DiskNotificationService.cs @@ -1,3 +1,5 @@ +using ThrottledLogging; + namespace ManagedDrive.App.Services; /// @@ -9,6 +11,7 @@ public sealed class DiskNotificationService { private readonly HashSet _highUsageDisks = []; private readonly Func _isMainWindowVisible; + private readonly ILogger _logger; private readonly MainViewModel _mainViewModel; private readonly TrayIconController _trayIconController; @@ -19,11 +22,17 @@ public sealed class DiskNotificationService /// The view model owning the disk collection. /// Used to show balloon tips for warnings/failures. /// Queried to set initial activity tracking on newly added disks. - public DiskNotificationService(MainViewModel mainViewModel, TrayIconController trayIconController, Func isMainWindowVisible) + /// Used to record throttled high-usage warnings. + public DiskNotificationService( + MainViewModel mainViewModel, + TrayIconController trayIconController, + Func isMainWindowVisible, + ILogger logger) { _mainViewModel = mainViewModel; _trayIconController = trayIconController; _isMainWindowVisible = isMainWindowVisible; + _logger = logger; _mainViewModel.Disks.CollectionChanged += (_, e) => { @@ -90,6 +99,23 @@ private void OnDiskCapacityAdjusted(DiskViewModel vm) _mainViewModel.StatusText = Loc.Format("Status.CapacityAdjusted", vm.MountPoint, originalMb, newMb); } + private void OnDiskHighUsageWarning(object? sender, EventArgs e) + { + if (sender is not DiskViewModel vm) + { + return; + } + + var title = Loc.Get("Tray.HighUsageTitle"); + var body = Loc.Format("Tray.HighUsageBody", vm.VolumeLabel, vm.MountPoint, vm.UsedPercent); + _trayIconController.ShowBalloonTip(title, body, System.Windows.Forms.ToolTipIcon.Warning); + + _logger.LogWarningThrottled( + $"high-usage:{vm.MountPoint}", TimeSpan.FromMinutes(10), + "Disk {MountPoint} ({VolumeLabel}) usage reached {UsedPercent:F1}%.", + vm.MountPoint, vm.VolumeLabel, vm.UsedPercent); + } + /// /// Tracks every disk currently in the state (which /// fires on both the rising and falling edge, unlike the one-shot @@ -116,18 +142,6 @@ private void OnDiskPropertyChanged(object? sender, PropertyChangedEventArgs e) _trayIconController.SetHighUsageWarningActive(_highUsageDisks.Count > 0); } - private void OnDiskHighUsageWarning(object? sender, EventArgs e) - { - if (sender is not DiskViewModel vm) - { - return; - } - - var title = Loc.Get("Tray.HighUsageTitle"); - var body = Loc.Format("Tray.HighUsageBody", vm.VolumeLabel, vm.MountPoint, vm.UsedPercent); - _trayIconController.ShowBalloonTip(title, body, System.Windows.Forms.ToolTipIcon.Warning); - } - private void OnDiskSaveFailed(object? sender, Exception ex) { if (sender is not DiskViewModel vm) diff --git a/src/ManagedDrive.Core/ManagedDrive.Core.csproj b/src/ManagedDrive.Core/ManagedDrive.Core.csproj index a9e3289..7081d5d 100644 --- a/src/ManagedDrive.Core/ManagedDrive.Core.csproj +++ b/src/ManagedDrive.Core/ManagedDrive.Core.csproj @@ -4,6 +4,7 @@ + diff --git a/src/ManagedDrive.Core/Mounting/RamDisk.cs b/src/ManagedDrive.Core/Mounting/RamDisk.cs index d7d60f3..ffbc8fb 100644 --- a/src/ManagedDrive.Core/Mounting/RamDisk.cs +++ b/src/ManagedDrive.Core/Mounting/RamDisk.cs @@ -1,5 +1,6 @@ using Fsp; using System.Runtime.InteropServices; +using ThrottledLogging; namespace ManagedDrive.Core.Mounting; @@ -479,7 +480,9 @@ public void SaveToImage(IProgress? progress = null) } catch (Exception ex) { - Logger.LogError(ex, "Failed to save disk image to {ImagePath}.", Options.PersistImagePath); + Logger.LogErrorThrottled( + $"save-failed:{Options.PersistImagePath}", TimeSpan.FromMinutes(10), + "Failed to save disk image to {ImagePath}: {Error}", Options.PersistImagePath, ex.Message); SaveFailed?.Invoke(this, ex); throw; } diff --git a/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs b/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs index 979259b..0383e0d 100644 --- a/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs +++ b/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text.RegularExpressions; +using ThrottledLogging; namespace ManagedDrive.Core.Snapshots; @@ -305,11 +306,15 @@ public static void Prune(string mainImagePath, uint? maxCount, ulong? maxTotalBy catch (IOException ex) { // Best-effort pruning; leave this file for the next attempt. - Logger.LogWarning(ex, "Failed to prune snapshot '{Path}'", snapshot.Path); + Logger.LogWarningThrottled( + $"prune-failed:{snapshot.Path}", TimeSpan.FromMinutes(10), + "Failed to prune snapshot '{Path}': {Error}", snapshot.Path, ex.Message); } catch (UnauthorizedAccessException ex) { - Logger.LogWarning(ex, "Failed to prune snapshot '{Path}'", snapshot.Path); + Logger.LogWarningThrottled( + $"prune-failed:{snapshot.Path}", TimeSpan.FromMinutes(10), + "Failed to prune snapshot '{Path}': {Error}", snapshot.Path, ex.Message); } index++; @@ -395,11 +400,15 @@ private static void GarbageCollectBlobs(string mainImagePath) } catch (IOException ex) { - Logger.LogWarning(ex, "Failed to delete unreferenced blob '{Path}'", blobPath); + Logger.LogWarningThrottled( + $"blob-gc-failed:{blobPath}", TimeSpan.FromMinutes(10), + "Failed to delete unreferenced blob '{Path}': {Error}", blobPath, ex.Message); } catch (UnauthorizedAccessException ex) { - Logger.LogWarning(ex, "Failed to delete unreferenced blob '{Path}'", blobPath); + Logger.LogWarningThrottled( + $"blob-gc-failed:{blobPath}", TimeSpan.FromMinutes(10), + "Failed to delete unreferenced blob '{Path}': {Error}", blobPath, ex.Message); } } } diff --git a/src/ManagedDrive.Service/GlobalMountManager.cs b/src/ManagedDrive.Service/GlobalMountManager.cs index 552eed4..b8dc2c7 100644 --- a/src/ManagedDrive.Service/GlobalMountManager.cs +++ b/src/ManagedDrive.Service/GlobalMountManager.cs @@ -2,6 +2,7 @@ using Microsoft.Win32; using System.Runtime.InteropServices; using System.Text.RegularExpressions; +using ThrottledLogging; namespace ManagedDrive.Service; @@ -69,8 +70,9 @@ public void Reconcile() NativeMethods.RemoveGlobalSymlink(letter, devicePath); DeleteRegistryEntry(letter); - logger.LogInformation("Reconciled stale symlink {Letter} -> {Device} (device gone)", - letter, devicePath); + logger.LogInformationThrottled( + $"reconciled-stale:{letter}", TimeSpan.FromMinutes(10), + "Reconciled stale symlink {Letter} -> {Device} (device gone)", letter, devicePath); } } } diff --git a/src/ManagedDrive.Service/HelperPipeService.cs b/src/ManagedDrive.Service/HelperPipeService.cs index 5091c29..a8221fd 100644 --- a/src/ManagedDrive.Service/HelperPipeService.cs +++ b/src/ManagedDrive.Service/HelperPipeService.cs @@ -4,6 +4,7 @@ using ManagedDrive.HelperProtocol; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using ThrottledLogging; namespace ManagedDrive.Service; @@ -39,7 +40,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) catch (Exception ex) { // Best-effort — a malformed or interrupted request must not take down the loop. - logger.LogWarning(ex, "Pipe connection handling failed."); + logger.LogWarningThrottled( + "pipe-connection-failed", TimeSpan.FromMinutes(5), + "Pipe connection handling failed: {Error}", ex.Message); } } } diff --git a/src/ManagedDrive.Service/ManagedDrive.Service.csproj b/src/ManagedDrive.Service/ManagedDrive.Service.csproj index 10de55c..2346449 100644 --- a/src/ManagedDrive.Service/ManagedDrive.Service.csproj +++ b/src/ManagedDrive.Service/ManagedDrive.Service.csproj @@ -8,6 +8,7 @@ + From 6d6a860d27b10a68d1f2f5e802182983c73a65da Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 19:11:54 +0800 Subject: [PATCH 02/14] refactor: dedupe boilerplate across MainViewModel, RamDisk, and image serialization Consolidates repeated path-collision checks, MessageBox boilerplate, compression-level mapping, and legacy-image-header parsing into shared helpers; fixes a UI-thread stall where editing a disk's password during a remount ran SetPassword synchronously outside the Task.Run that wraps the mount; and adds a round-trip test for the DiskOptions/DiskProfile mapping. --- src/ManagedDrive.App/App.xaml.cs | 49 ++-- src/ManagedDrive.App/ManagedDrive.App.csproj | 6 + .../ViewModels/MainViewModel.cs | 232 +++++++----------- .../Views/CreateDiskDialog.xaml.cs | 63 +++-- .../FileSystem/FileNodeMap.cs | 17 +- src/ManagedDrive.Core/Mounting/DiskOptions.cs | 20 ++ src/ManagedDrive.Core/Mounting/RamDisk.cs | 57 +++-- .../Persistence/DiskImageSerializer.cs | 36 ++- .../Snapshots/SnapshotStore.cs | 9 +- .../DiskProfileMappingTests.cs | 49 ++++ 10 files changed, 283 insertions(+), 255 deletions(-) create mode 100644 tests/ManagedDrive.Tests/DiskProfileMappingTests.cs diff --git a/src/ManagedDrive.App/App.xaml.cs b/src/ManagedDrive.App/App.xaml.cs index 5406b29..be9e2da 100644 --- a/src/ManagedDrive.App/App.xaml.cs +++ b/src/ManagedDrive.App/App.xaml.cs @@ -59,19 +59,7 @@ private void App_Exit(object sender, ExitEventArgs e) { _logger.LogInformation("App_Exit invoked."); - if (_sessionEndingSaveHandler != null) - { - SystemEvents.SessionEnding -= _sessionEndingSaveHandler.OnSessionEnding; - } - if (_mountManager != null && _trayIconController != null) - { - _mountManager.ActivityDetected -= _trayIconController.OnActivityDetected; - } - _mainViewModel?.SaveSettings(); - _trayIconController?.Dispose(); - _mainViewModel?.Dispose(); - - _cliPipeServer?.Dispose(); + TeardownBeforeMountManagerDispose(); // Safety net: if ShutdownAsync already disposed the mount manager, this is a no-op. // Bounded so a stuck final save can't hang process exit indefinitely. @@ -440,10 +428,6 @@ private async Task ShutdownAsync() { _logger.LogInformation("ShutdownAsync starting."); - if (_sessionEndingSaveHandler != null) - { - SystemEvents.SessionEnding -= _sessionEndingSaveHandler.OnSessionEnding; - } _isExiting = true; if (_mainViewModel != null) @@ -452,15 +436,7 @@ private async Task ShutdownAsync() ShowMainWindow(); } - _cliPipeServer?.Dispose(); - _mainViewModel?.SaveSettings(); - _trayIconController?.Dispose(); - _mainViewModel?.Dispose(); - - if (_mountManager != null && _trayIconController != null) - { - _mountManager.ActivityDetected -= _trayIconController.OnActivityDetected; - } + TeardownBeforeMountManagerDispose(); await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction, totalBytes) => Application.Current.Dispatcher.BeginInvoke(() => @@ -469,4 +445,25 @@ await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction _logger.LogInformation("ShutdownAsync completed; shutting down application."); Shutdown(); } + + /// + /// Common teardown shared by and , run + /// before either one disposes (which they do differently: a + /// bounded-wait safety net vs. an awaited call with exit-save progress reporting). + /// + private void TeardownBeforeMountManagerDispose() + { + if (_sessionEndingSaveHandler != null) + { + SystemEvents.SessionEnding -= _sessionEndingSaveHandler.OnSessionEnding; + } + if (_mountManager != null && _trayIconController != null) + { + _mountManager.ActivityDetected -= _trayIconController.OnActivityDetected; + } + _cliPipeServer?.Dispose(); + _mainViewModel?.SaveSettings(); + _trayIconController?.Dispose(); + _mainViewModel?.Dispose(); + } } \ No newline at end of file diff --git a/src/ManagedDrive.App/ManagedDrive.App.csproj b/src/ManagedDrive.App/ManagedDrive.App.csproj index 5df0ddb..fcf7bb4 100644 --- a/src/ManagedDrive.App/ManagedDrive.App.csproj +++ b/src/ManagedDrive.App/ManagedDrive.App.csproj @@ -33,6 +33,12 @@ + + + + + diff --git a/src/ManagedDrive.App/ViewModels/MainViewModel.cs b/src/ManagedDrive.App/ViewModels/MainViewModel.cs index bbcaa8e..930f0da 100644 --- a/src/ManagedDrive.App/ViewModels/MainViewModel.cs +++ b/src/ManagedDrive.App/ViewModels/MainViewModel.cs @@ -402,7 +402,7 @@ public void ExitWithoutConfirmation() { _logger.LogInformation("Exit requested via CLI."); - if (IsTempOnAnyRamDisk()) + if (TempDirCompatChecker.IsTempOnAnyDisk(Disks)) { TempDirResetService.Reset(); } @@ -447,25 +447,31 @@ public void ExitWithoutConfirmation() /// /// A sequence of representing all mounted disks. /// - public IEnumerable GetProfiles() + public IEnumerable GetProfiles() => Disks.Select(vm => ToProfile(vm.Disk.Options)); + + /// + /// Maps a live disk's to its persistable + /// counterpart. Inverse of ; kept as a standalone pure function + /// (rather than inlined in ) so both directions of this hand-written + /// field mapping can be round-trip tested independently of any live + /// or mounted disk. + /// + internal static DiskProfile ToProfile(DiskOptions options) => new() { - return Disks.Select(vm => new DiskProfile - { - MountPoint = vm.Disk.Options.MountPoint, - VolumeLabel = vm.Disk.Options.VolumeLabel, - CapacityBytes = vm.Disk.Options.CapacityBytes, - ReadOnly = vm.Disk.Options.ReadOnly, - AutoMount = vm.Disk.Options.AutoMount, - PersistImagePath = vm.Disk.Options.PersistImagePath, - SourceArchivePath = vm.Disk.Options.SourceArchivePath, - AutoSaveIntervalMinutes = vm.Disk.Options.AutoSaveIntervalMinutes, - CompressionLevel = vm.Disk.Options.CompressionLevel, - MaxSnapshotCount = vm.Disk.Options.MaxSnapshotCount, - MaxSnapshotSizeBytes = vm.Disk.Options.MaxSnapshotSizeBytes, - HighUsageWarnPercent = vm.Disk.Options.HighUsageWarnPercent, - SaveImageOnExit = vm.Disk.Options.SaveImageOnExit, - }); - } + MountPoint = options.MountPoint, + VolumeLabel = options.VolumeLabel, + CapacityBytes = options.CapacityBytes, + ReadOnly = options.ReadOnly, + AutoMount = options.AutoMount, + PersistImagePath = options.PersistImagePath, + SourceArchivePath = options.SourceArchivePath, + AutoSaveIntervalMinutes = options.AutoSaveIntervalMinutes, + CompressionLevel = options.CompressionLevel, + MaxSnapshotCount = options.MaxSnapshotCount, + MaxSnapshotSizeBytes = options.MaxSnapshotSizeBytes, + HighUsageWarnPercent = options.HighUsageWarnPercent, + SaveImageOnExit = options.SaveImageOnExit, + }; /// /// Mounts the contents of an archive file as a new read-only disk, for use by the CLI @@ -508,8 +514,7 @@ public IEnumerable GetProfiles() } var otherDisks = GetOtherDiskOptions(excluding: null); - if (otherDisks.Any(d => d.SourceArchivePath != null && - string.Equals(d.SourceArchivePath, archivePath, StringComparison.OrdinalIgnoreCase))) + if (IsPathInUse(otherDisks, archivePath, d => d.SourceArchivePath)) { return (false, Loc.Get("Val.ArchivePathInUse")); } @@ -621,8 +626,7 @@ public async Task MountFromProfileAsync(DiskProfile profile, IProgress d.PersistImagePath != null && - string.Equals(d.PersistImagePath, imagePath, StringComparison.OrdinalIgnoreCase))) + if (IsPathInUse(otherDisks, imagePath, d => d.PersistImagePath)) { return (false, Loc.Get("Val.ImagePathInUse")); } @@ -861,7 +865,7 @@ internal void ShowDiskActivityStatus(string mountPoint, bool isWrite, string fil return null; } - private static DiskOptions ProfileToOptions(DiskProfile p) => new() + internal static DiskOptions ProfileToOptions(DiskProfile p) => new() { MountPoint = p.MountPoint, VolumeLabel = p.VolumeLabel, @@ -987,11 +991,7 @@ private async void ExecuteCloneDisk(DiskViewModel? vm) if (!target.Disk.TryCloneFrom(vm.Disk, out var error)) { _logger.LogWarning("Clone disk failed: {Source} -> {Target}: {Error}", vm.MountPoint, target.MountPoint, error); - MessageBox.Show( - error, - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Warning); + ShowWarning(error); return; } @@ -1020,11 +1020,7 @@ private async void ExecuteCloneDisk(DiskViewModel? vm) catch (Exception ex) { _logger.LogError(ex, "Disk export failed: {Source} -> {ExportPath}.", vm.MountPoint, exportPath); - MessageBox.Show( - Loc.Format("Msg.SaveImageFailed", ex.Message), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Error); + ShowError(Loc.Format("Msg.SaveImageFailed", ex.Message)); } finally { @@ -1125,11 +1121,15 @@ private async void ExecuteEditDisk(DiskViewModel? vm) try { - var disk = await Task.Run(() => _mountManager.Mount(newOptions, currentPassword)); - if (dialog.PasswordChanged) + var disk = await Task.Run(() => { - disk.SetPassword(dialog.Password); - } + var mounted = _mountManager.Mount(newOptions, currentPassword); + if (dialog.PasswordChanged) + { + mounted.SetPassword(dialog.Password); + } + return mounted; + }); vm.Dispose(); Disks.Remove(vm); @@ -1143,11 +1143,7 @@ private async void ExecuteEditDisk(DiskViewModel? vm) _logger.LogError(ex, "Edit disk remount failed: {OldMountPoint} -> {NewMountPoint}.", oldMountPoint, newOptions.MountPoint); vm.Dispose(); Disks.Remove(vm); - MessageBox.Show( - Loc.Format("Msg.MountFailed", ex.Message), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Error); + ShowError(Loc.Format("Msg.MountFailed", ex.Message)); StatusText = Loc.Get("Status.MountFailed"); } } @@ -1196,7 +1192,7 @@ private void ExecuteExit() return; } - var tempOnRamDisk = IsTempOnAnyRamDisk(); + var tempOnRamDisk = TempDirCompatChecker.IsTempOnAnyDisk(Disks); var body = Loc.Get("Msg.ExitConfirmBody"); if (tempOnRamDisk) @@ -1245,22 +1241,14 @@ private void ExecuteFormatDisk(DiskViewModel? vm) if (!vm.Disk.Format()) { _logger.LogWarning("Format disk failed for {MountPoint}: disk is read-only.", vm.MountPoint); - MessageBox.Show( - Loc.Get("Msg.FormatDiskReadOnly"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Warning); + ShowWarning(Loc.Get("Msg.FormatDiskReadOnly")); return; } vm.Refresh(); StatusText = Loc.Format("Status.FormatDisk", vm.MountPoint); _logger.LogInformation("Format disk completed for {MountPoint}.", vm.MountPoint); - MessageBox.Show( - Loc.Format("Msg.FormatDiskSuccess", vm.MountPoint), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Information); + ShowInfo(Loc.Format("Msg.FormatDiskSuccess", vm.MountPoint)); } private async void ExecuteImportArchive() @@ -1278,14 +1266,9 @@ private async void ExecuteImportArchive() } var otherDisks = GetOtherDiskOptions(excluding: null); - if (otherDisks.Any(d => d.SourceArchivePath != null && - string.Equals(d.SourceArchivePath, openDialog.FileName, StringComparison.OrdinalIgnoreCase))) - { - MessageBox.Show( - Loc.Get("Val.ArchivePathInUse"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Warning); + if (IsPathInUse(otherDisks, openDialog.FileName, d => d.SourceArchivePath)) + { + ShowWarning(Loc.Get("Val.ArchivePathInUse")); return; } @@ -1297,18 +1280,12 @@ private async void ExecuteImportArchive() } catch (InvalidDataException) { - MessageBox.Show( - Loc.Get("Val.ImportInvalidArchive"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Warning); + ShowWarning(Loc.Get("Val.ImportInvalidArchive")); return; } - var dialog = new CreateDiskDialog(openDialog.FileName, totalBytes, suggestedLabel, otherDisks, isArchiveImport: true) - { - Owner = Application.Current.MainWindow - }; + var dialog = CreateDiskDialog.ForArchiveImport(openDialog.FileName, totalBytes, suggestedLabel, otherDisks); + dialog.Owner = Application.Current.MainWindow; if (dialog.ShowDialog() != true) { @@ -1343,14 +1320,9 @@ private async void ExecuteImportDisk() } var otherDisks = GetOtherDiskOptions(excluding: null); - if (otherDisks.Any(d => d.PersistImagePath != null && - string.Equals(d.PersistImagePath, openDialog.FileName, StringComparison.OrdinalIgnoreCase))) - { - MessageBox.Show( - Loc.Get("Val.ImagePathInUse"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Warning); + if (IsPathInUse(otherDisks, openDialog.FileName, d => d.PersistImagePath)) + { + ShowWarning(Loc.Get("Val.ImagePathInUse")); return; } @@ -1362,11 +1334,7 @@ private async void ExecuteImportDisk() } catch (InvalidDataException) { - MessageBox.Show( - Loc.Get("Val.ImportInvalidImage"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Warning); + ShowWarning(Loc.Get("Val.ImportInvalidImage")); return; } @@ -1415,20 +1383,12 @@ private async void ExecuteResetTempDirs() if (success) { _logger.LogInformation("Reset TEMP dirs succeeded."); - MessageBox.Show( - Loc.Get("Msg.ResetTempSuccess"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Information); + ShowInfo(Loc.Get("Msg.ResetTempSuccess")); } else { _logger.LogWarning("Reset TEMP dirs failed."); - MessageBox.Show( - Loc.Get("Msg.ResetTempFailed"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Error); + ShowError(Loc.Get("Msg.ResetTempFailed")); } } @@ -1442,11 +1402,7 @@ private async void ExecuteRestoreSnapshot(DiskViewModel? vm) var snapshots = await Task.Run(() => SnapshotManager.ListSnapshots(imagePath)); if (snapshots.Count == 0) { - MessageBox.Show( - Loc.Get("Msg.NoSnapshotsAvailable"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Information); + ShowInfo(Loc.Get("Msg.NoSnapshotsAvailable")); return; } @@ -1479,11 +1435,7 @@ private async void ExecuteRestoreSnapshot(DiskViewModel? vm) if (!success) { _logger.LogWarning("Restore snapshot failed for {MountPoint}: {Error}", vm.MountPoint, error); - MessageBox.Show( - error, - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Warning); + ShowWarning(error); return; } @@ -1534,11 +1486,7 @@ private async void ExecuteSaveImage(DiskViewModel? vm) catch (Exception ex) { _logger.LogError(ex, "Save image failed for {MountPoint}.", vm.MountPoint); - MessageBox.Show( - Loc.Format("Msg.SaveImageFailed", ex.Message), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Error); + ShowError(Loc.Format("Msg.SaveImageFailed", ex.Message)); } finally { @@ -1575,21 +1523,13 @@ private async void ExecuteToggleTempDir(DiskViewModel? vm) if (success) { - MessageBox.Show( - Loc.Get("Msg.ResetTempSuccess"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Information); + ShowInfo(Loc.Get("Msg.ResetTempSuccess")); vm.Refresh(); } else { _logger.LogWarning("TEMP dir reset failed."); - MessageBox.Show( - Loc.Get("Msg.ResetTempFailed"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Error); + ShowError(Loc.Get("Msg.ResetTempFailed")); } } else @@ -1617,22 +1557,14 @@ private async void ExecuteToggleTempDir(DiskViewModel? vm) if (success) { - MessageBox.Show( - Loc.Format("Msg.SetTempDirSuccess", tempPath), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Information); + ShowInfo(Loc.Format("Msg.SetTempDirSuccess", tempPath)); StatusText = Loc.Format("Status.TempDirSet", tempPath); vm.Refresh(); } else { _logger.LogWarning("Set TEMP dir failed: {TempPath}.", tempPath); - MessageBox.Show( - Loc.Get("Msg.SetTempDirFailed"), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Error); + ShowError(Loc.Get("Msg.SetTempDirFailed")); } } } @@ -1710,13 +1642,35 @@ private void ExecuteViewDiskContents(DiskViewModel? vm) private IReadOnlyList GetOtherDiskOptions(DiskViewModel? excluding) => Disks.Where(d => d != excluding).Select(d => d.Disk.Options).ToList(); - private bool IsTempOnAnyRamDisk() - { - var userTemp = Environment.GetEnvironmentVariable("TEMP", EnvironmentVariableTarget.User); - var expandedTemp = string.IsNullOrEmpty(userTemp) ? null : Environment.ExpandEnvironmentVariables(userTemp); - return expandedTemp != null && - Disks.Any(d => expandedTemp.StartsWith(d.MountPoint, StringComparison.OrdinalIgnoreCase)); - } + /// + /// Returns whether any disk in already has + /// set on the field selected by (e.g. + /// or ). Shared by every image/archive path + /// collision check (create, edit, import, CLI mount). + /// + private static bool IsPathInUse(IReadOnlyList otherDisks, string path, Func selector) => + otherDisks.Any(d => selector(d) is { } p && string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); + + /// + /// Shows a modal with the "ManagedDrive" title and an error icon. + /// Shared boilerplate for the many failure paths across this view model that don't need a + /// custom title (contrast ExecuteEditDisk's inline apply-failure message, which reuses + /// Msg.EditDiskConfirmTitle as its title instead). + /// + private static void ShowError(string message) => + MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Error); + + /// + /// Shows a modal with the "ManagedDrive" title and an info icon. + /// + private static void ShowInfo(string message) => + MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Information); + + /// + /// Shows a modal with the "ManagedDrive" title and a warning icon. + /// + private static void ShowWarning(string? message) => + MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Warning); private async Task MountAndAddAsync(DiskOptions options, string? password = null, IProgress? progress = null) { @@ -1737,11 +1691,7 @@ private async Task MountAndAddAsync(DiskOptions options, string? password = null catch (Exception ex) { _logger.LogError(ex, "Mount failed for {MountPoint}.", options.MountPoint); - MessageBox.Show( - Loc.Format("Msg.MountFailed", ex.Message), - "ManagedDrive", - MessageBoxButton.OK, - MessageBoxImage.Error); + ShowError(Loc.Format("Msg.MountFailed", ex.Message)); StatusText = Loc.Get("Status.MountFailed"); } } diff --git a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs index 89f9afa..be8e6f8 100644 --- a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs +++ b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs @@ -215,14 +215,7 @@ public CreateDiskDialog(string importImagePath, ulong importCapacityBytes, strin ClearImagePathButton.IsEnabled = false; ImportNoteText.Visibility = Visibility.Visible; - var (importCapValue, importCapIsGb) = ByteUnitConverter.SplitToUnit(importCapacityBytes); - CapacityUnitBox.SelectedItem = importCapIsGb ? "GB" : "MB"; - _capacityValue = importCapValue; - - _capacityMaximum = Math.Max(_capacityValue, GetMaxCapacityValue()); - CapacitySlider.Maximum = _capacityMaximum; - UpdateCapacityDisplay(); - VolumeLabelBox.Text = importVolumeLabel; + ApplyLockedCapacityAndLabel(importCapacityBytes, importVolumeLabel); CapacitySlider.IsEnabled = false; CapacityUnitBox.IsEnabled = false; @@ -242,7 +235,10 @@ public CreateDiskDialog(string importImagePath, ulong importCapacityBytes, strin /// Initializes the dialog in archive-import mode: capacity and volume label are pre-filled /// from the archive's contents and locked, the disk is forced read-only (archive formats /// don't support writing changes back), and the entire Persistence tab is disabled since an - /// archive-sourced disk has no backing image file to save to. + /// archive-sourced disk has no backing image file to save to. Private: use + /// to construct one of these — an unused trailing parameter is + /// still needed to give this constructor a distinct signature from the image-import one + /// above, but it is now a private implementation detail instead of a public API wart. /// /// Path of the archive file to import. /// Total uncompressed size of the archive's contents, used to pre-fill and lock the capacity fields. @@ -251,11 +247,9 @@ public CreateDiskDialog(string importImagePath, ulong importCapacityBytes, strin /// Options of all other currently active disks, used to validate that the archive file path /// does not collide with another disk's mount point. /// - /// Always true; disambiguate this overload from the image-import constructor. - public CreateDiskDialog(string importArchivePath, ulong importTotalBytes, string importVolumeLabel, - IReadOnlyList otherDisks, bool isArchiveImport) : this(otherDisks) + private CreateDiskDialog(string importArchivePath, ulong importTotalBytes, string importVolumeLabel, + IReadOnlyList otherDisks, bool archiveImportOverloadTag) : this(otherDisks) { - _ = isArchiveImport; _isImportMode = true; _isArchiveImportMode = true; _importArchivePath = importArchivePath; @@ -269,14 +263,7 @@ public CreateDiskDialog(string importArchivePath, ulong importTotalBytes, string ClearImagePathButton.IsEnabled = false; ArchiveImportNoteText.Visibility = Visibility.Visible; - var (archiveCapValue, archiveCapIsGb) = ByteUnitConverter.SplitToUnit(importTotalBytes); - CapacityUnitBox.SelectedItem = archiveCapIsGb ? "GB" : "MB"; - _capacityValue = archiveCapValue; - - _capacityMaximum = Math.Max(_capacityValue, GetMaxCapacityValue()); - CapacitySlider.Maximum = _capacityMaximum; - UpdateCapacityDisplay(); - VolumeLabelBox.Text = importVolumeLabel; + ApplyLockedCapacityAndLabel(importTotalBytes, importVolumeLabel); CapacityRow.IsEnabled = false; VolumeLabelBox.IsEnabled = false; @@ -293,6 +280,21 @@ public CreateDiskDialog(string importArchivePath, ulong importTotalBytes, string UpdateAutoSaveEnabledState(); } + /// + /// Creates a in archive-import mode (see the private + /// constructor above for details). + /// + /// Path of the archive file to import. + /// Total uncompressed size of the archive's contents, used to pre-fill and lock the capacity fields. + /// Suggested volume label (derived from the archive's file name), used to pre-fill and lock the label field. + /// + /// Options of all other currently active disks, used to validate that the archive file path + /// does not collide with another disk's mount point. + /// + public static CreateDiskDialog ForArchiveImport(string importArchivePath, ulong importTotalBytes, string importVolumeLabel, + IReadOnlyList otherDisks) => + new(importArchivePath, importTotalBytes, importVolumeLabel, otherDisks, archiveImportOverloadTag: true); + /// /// The plaintext password entered by the user, when is /// true and this is non-null (set/change password); null together with @@ -478,6 +480,25 @@ private void CompressionLevelBox_SelectionChanged(object sender, SelectionChange UpdateCompressionWarning(); } + /// + /// Pre-fills the capacity slider/unit and volume label from a size/label pair that's locked + /// against user edits (an imported image's or archive's own capacity and label). Shared by + /// the image-import and archive-import constructors; each still sets its own combination of + /// IsEnabled = false afterward, since which specific controls get locked differs + /// between the two modes. + /// + private void ApplyLockedCapacityAndLabel(ulong capacityBytes, string label) + { + var (value, isGb) = ByteUnitConverter.SplitToUnit(capacityBytes); + CapacityUnitBox.SelectedItem = isGb ? "GB" : "MB"; + _capacityValue = value; + + _capacityMaximum = Math.Max(_capacityValue, GetMaxCapacityValue()); + CapacitySlider.Maximum = _capacityMaximum; + UpdateCapacityDisplay(); + VolumeLabelBox.Text = label; + } + private int ComputeMaxValueForUnit(bool isGb) => ByteUnitConverter.MaxValueForUnit(_maxCapacityBytes, isGb); private void EncryptImageBox_CheckedChanged(object sender, RoutedEventArgs e) diff --git a/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs b/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs index 38f000c..3f376b5 100644 --- a/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs +++ b/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs @@ -70,19 +70,14 @@ public void ClearAll() _syncRoot.EnterWriteLock(); try { - var toRemove = new List(); - foreach (var key in _map.Keys) - { - if (key != "\\") - { - toRemove.Add(key); - } - } + var hasRoot = _map.TryGetValue("\\", out var root); + _map.Clear(); + _totalAllocated = 0; - foreach (var key in toRemove) + if (hasRoot) { - _totalAllocated -= _map[key].FileInfo.AllocationSize; - _map.Remove(key); + _map["\\"] = root!; + _totalAllocated = root!.FileInfo.AllocationSize; } } finally diff --git a/src/ManagedDrive.Core/Mounting/DiskOptions.cs b/src/ManagedDrive.Core/Mounting/DiskOptions.cs index 2f71f5a..a68a20f 100644 --- a/src/ManagedDrive.Core/Mounting/DiskOptions.cs +++ b/src/ManagedDrive.Core/Mounting/DiskOptions.cs @@ -26,6 +26,26 @@ public enum ImageCompressionLevel SmallestSize = 3, } +/// +/// Conversion helpers for , shared by every writer that hands +/// it off to a (DiskImageSerializer for +/// image saves, SnapshotStore for snapshot blobs). +/// +internal static class ImageCompressionLevelExtensions +{ + /// + /// Maps to the corresponding . Callers are + /// expected to have already checked and skipped + /// compression entirely in that case; it otherwise falls back to . + /// + public static System.IO.Compression.CompressionLevel ToDotNetCompressionLevel(this ImageCompressionLevel level) => level switch + { + ImageCompressionLevel.Fastest => System.IO.Compression.CompressionLevel.Fastest, + ImageCompressionLevel.SmallestSize => System.IO.Compression.CompressionLevel.SmallestSize, + _ => System.IO.Compression.CompressionLevel.Optimal, + }; +} + /// /// Immutable configuration record used to create and mount a RAM disk. /// diff --git a/src/ManagedDrive.Core/Mounting/RamDisk.cs b/src/ManagedDrive.Core/Mounting/RamDisk.cs index ffbc8fb..4bf4f5d 100644 --- a/src/ManagedDrive.Core/Mounting/RamDisk.cs +++ b/src/ManagedDrive.Core/Mounting/RamDisk.cs @@ -202,24 +202,11 @@ public static RamDisk Create(DiskOptions options, string? password = null, IProg ArchiveNodeMapBuilder.PeekArchive(options.SourceArchivePath, out var totalBytes, out _); var nodeMap = ArchiveNodeMapBuilder.BuildNodeMap(options.SourceArchivePath, (long)totalBytes, progress); - var actualUsed = nodeMap.GetTotalAllocated(); - var capacity = ResolveEffectiveCapacity(options.CapacityBytes, actualUsed); - if (capacity != options.CapacityBytes) - { - originalCapacity = options.CapacityBytes; - } + var capacity = ResolveAndApplyCapacity(nodeMap, options.CapacityBytes, ref options, out originalCapacity); // Archive-sourced disks are always read-only: none of the supported archive // formats support writing changes back, regardless of what options.ReadOnly says. fs = new(capacity, options.VolumeLabel, nodeMap, readOnly: true); - - if (originalCapacity.HasValue) - { - options = options with - { - CapacityBytes = capacity - }; - } } else if (options.PersistImagePath != null && File.Exists(options.PersistImagePath)) @@ -236,22 +223,9 @@ public static RamDisk Create(DiskOptions options, string? password = null, IProg var configuredCapacity = savedCapacity > 0 ? savedCapacity : options.CapacityBytes; var label = string.IsNullOrEmpty(savedLabel) ? options.VolumeLabel : savedLabel; - var actualUsed = nodeMap.GetTotalAllocated(); - var capacity = ResolveEffectiveCapacity(configuredCapacity, actualUsed); - if (capacity != configuredCapacity) - { - originalCapacity = configuredCapacity; - } + var capacity = ResolveAndApplyCapacity(nodeMap, configuredCapacity, ref options, out originalCapacity); fs = new(capacity, label, nodeMap, options.ReadOnly); - - if (originalCapacity.HasValue) - { - options = options with - { - CapacityBytes = capacity - }; - } } else { @@ -667,6 +641,31 @@ public bool TryRestoreFromSnapshot(string snapshotPath, out string? error) internal static ulong ResolveEffectiveCapacity(ulong configuredCapacity, ulong actualUsed) => Math.Max(configuredCapacity, actualUsed); + /// + /// Resolves the effective capacity for a just-loaded against + /// via , and — if + /// that raised the capacity — updates in place to the new value and + /// reports the original in . Shared by 's + /// archive-import and image-load branches. + /// + private static ulong ResolveAndApplyCapacity( + FileNodeMap nodeMap, ulong configuredCapacity, ref DiskOptions options, out ulong? originalCapacity) + { + var actualUsed = nodeMap.GetTotalAllocated(); + var capacity = ResolveEffectiveCapacity(configuredCapacity, actualUsed); + originalCapacity = capacity != configuredCapacity ? configuredCapacity : null; + + if (originalCapacity.HasValue) + { + options = options with + { + CapacityBytes = capacity + }; + } + + return capacity; + } + private static void ConfigureHost(FileSystemHost host) { host.SectorSize = (ushort)FileNode.AllocationUnit; @@ -683,7 +682,7 @@ private static void ConfigureHost(FileSystemHost host) // entirely, shrinking WinFsp's per-handle overhead. Matches the official WinFsp memfs sample. host.PostCleanupWhenModifiedOnly = true; host.VolumeCreationTime = (ulong)DateTimeOffset.UtcNow.ToFileTime(); - host.VolumeSerialNumber = (uint)new Random().Next(int.MaxValue / 2); + host.VolumeSerialNumber = (uint)Random.Shared.Next(int.MaxValue / 2); } /// diff --git a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs index 26dfefa..01d0eb1 100644 --- a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs +++ b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs @@ -151,11 +151,7 @@ public static void PeekHeader( if (version <= 2) { // Legacy layout: capacity/label are inside the optionally compressed payload. - var compressed = version == 2 && level != ImageCompressionLevel.None; - using var payloadReader = compressed - ? new BinaryReader(new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true), System.Text.Encoding.UTF8) - : reader; - + using var payloadReader = OpenLegacyPayloadReader(stream, reader, version, level); capacityBytes = payloadReader.ReadUInt64(); volumeLabel = payloadReader.ReadString(); } @@ -287,7 +283,7 @@ private static void WriteNodeRegion( IProgress? progress) { var payloadStream = compress - ? new GZipStream(target, ToCompressionLevel(level), leaveOpen: true) + ? new GZipStream(target, level.ToDotNetCompressionLevel(), leaveOpen: true) : target; try @@ -351,18 +347,27 @@ private static FileNodeMap LoadLegacy( out string volumeLabel, Action? reportTick = null) { - var compressed = version == 2 && level != ImageCompressionLevel.None; - - using var payloadReader = compressed - ? new BinaryReader(new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true), System.Text.Encoding.UTF8) - : reader; - + using var payloadReader = OpenLegacyPayloadReader(stream, reader, version, level); capacityBytes = payloadReader.ReadUInt64(); volumeLabel = payloadReader.ReadString(); return ReadNodes(payloadReader, reportTick); } + /// + /// Opens the reader over a version 1/2 image's single payload region — gzip-decompressing it + /// first when the image is a compressed version 2 (version 1 is never compressed). Shared by + /// (reads capacity/label only) and (reads + /// capacity/label, then the full node region) so both stay in sync on this legacy layout rule. + /// + private static BinaryReader OpenLegacyPayloadReader(FileStream stream, BinaryReader reader, int version, ImageCompressionLevel level) + { + var compressed = version == 2 && level != ImageCompressionLevel.None; + return compressed + ? new BinaryReader(new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true), System.Text.Encoding.UTF8) + : reader; + } + /// /// Reads a version 3 or 4 image: capacity/label are always plaintext header fields; the node /// region (from node count onward) is compressed and, when encrypted, additionally wrapped in @@ -580,13 +585,6 @@ private static FileNodeMap ReadNodes(BinaryReader payloadReader, Action? reportT return nodeMap; } - private static CompressionLevel ToCompressionLevel(ImageCompressionLevel level) => level switch - { - ImageCompressionLevel.Fastest => CompressionLevel.Fastest, - ImageCompressionLevel.SmallestSize => CompressionLevel.SmallestSize, - _ => CompressionLevel.Optimal, - }; - private static byte[] UnwrapCek( byte[] wrappedCek, string password, diff --git a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs index 1fc0994..df7a848 100644 --- a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs +++ b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs @@ -292,7 +292,7 @@ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileCon if (compress) { - var gzip = new GZipStream(target, ToCompressionLevel(level), leaveOpen: true); + var gzip = new GZipStream(target, level.ToDotNetCompressionLevel(), leaveOpen: true); try { data.CopyTo(gzip, length); @@ -508,13 +508,6 @@ private static (string Path, NodeHeader Header) ReadNodeHeader(BinaryReader read }); } - private static CompressionLevel ToCompressionLevel(ImageCompressionLevel level) => level switch - { - ImageCompressionLevel.Fastest => CompressionLevel.Fastest, - ImageCompressionLevel.SmallestSize => CompressionLevel.SmallestSize, - _ => CompressionLevel.Optimal, - }; - private static void WriteNode(BinaryWriter writer, string path, FileNode node, string blobDirectory, ImageCompressionLevel level, byte[]? cek) { writer.Write(path); diff --git a/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs b/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs new file mode 100644 index 0000000..35b9bef --- /dev/null +++ b/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs @@ -0,0 +1,49 @@ +using ManagedDrive.App.Models; +using ManagedDrive.App.ViewModels; + +namespace ManagedDrive.Tests; + +public sealed class DiskProfileMappingTests +{ + [Fact] + public void ToProfile_ThenProfileToOptions_RoundTripsEveryField() + { + var options = new DiskOptions + { + MountPoint = "R:", + VolumeLabel = "Test Label", + CapacityBytes = 123_456_789UL, + ReadOnly = true, + AutoMount = true, + PersistImagePath = @"C:\images\disk.mdr", + SourceArchivePath = @"C:\archives\disk.zip", + AutoSaveIntervalMinutes = 15, + CompressionLevel = ImageCompressionLevel.SmallestSize, + MaxSnapshotCount = 7, + MaxSnapshotSizeBytes = 999_000_000UL, + HighUsageWarnPercent = 85.5, + SaveImageOnExit = false, + }; + + var profile = MainViewModel.ToProfile(options); + var roundTripped = MainViewModel.ProfileToOptions(profile); + + Assert.Equal(options, roundTripped); + } + + [Fact] + public void ToProfile_ThenProfileToOptions_RoundTripsNullableFieldsWhenUnset() + { + var options = new DiskOptions + { + MountPoint = "S:", + VolumeLabel = "Minimal", + CapacityBytes = 1_048_576UL, + }; + + var profile = MainViewModel.ToProfile(options); + var roundTripped = MainViewModel.ProfileToOptions(profile); + + Assert.Equal(options, roundTripped); + } +} From 3546a2b753563d456d617bda813ec0fe8877fe2a Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 19:27:10 +0800 Subject: [PATCH 03/14] refactor: extract node-metadata IO and MainViewModel stateless helpers Consolidates the identical path/FileInfo/security-descriptor read-write logic shared by DiskImageSerializer and SnapshotStore into NodeMetadataIO, and moves MainViewModel's stateless helper methods into MainViewModelHelpers; documents the CLI adapter's exact contract surface with MainViewModel. --- .../Cli/MainViewModelCliDiskController.cs | 8 +++ .../ViewModels/MainViewModel.cs | 53 +--------------- .../ViewModels/MainViewModelHelpers.cs | 63 +++++++++++++++++++ .../Persistence/DiskImageSerializer.cs | 38 ++--------- .../Persistence/NodeMetadataIO.cs | 55 ++++++++++++++++ .../Snapshots/SnapshotStore.cs | 48 +++++--------- 6 files changed, 147 insertions(+), 118 deletions(-) create mode 100644 src/ManagedDrive.App/ViewModels/MainViewModelHelpers.cs create mode 100644 src/ManagedDrive.Core/Persistence/NodeMetadataIO.cs diff --git a/src/ManagedDrive.App/Cli/MainViewModelCliDiskController.cs b/src/ManagedDrive.App/Cli/MainViewModelCliDiskController.cs index 8ca83e4..5ed30ed 100644 --- a/src/ManagedDrive.App/Cli/MainViewModelCliDiskController.cs +++ b/src/ManagedDrive.App/Cli/MainViewModelCliDiskController.cs @@ -7,6 +7,14 @@ namespace ManagedDrive.App.Cli; /// ManagedDrive.Cli.Core project (which cannot reference the WPF app layer without creating /// a circular project reference) can drive disk mount/unmount/list operations. /// +/// +/// This class is the entire contract surface between the CLI pipe server and : +/// , , +/// , , +/// , and +/// (plus the read-only collection for ). Changing +/// any of their signatures requires updating this adapter in lockstep. +/// internal sealed class MainViewModelCliDiskController(MainViewModel mainViewModel) : ICliDiskController { /// diff --git a/src/ManagedDrive.App/ViewModels/MainViewModel.cs b/src/ManagedDrive.App/ViewModels/MainViewModel.cs index 930f0da..dc9463e 100644 --- a/src/ManagedDrive.App/ViewModels/MainViewModel.cs +++ b/src/ManagedDrive.App/ViewModels/MainViewModel.cs @@ -1,5 +1,6 @@ using ManagedDrive.Cli.Core; using System.Collections.ObjectModel; +using static ManagedDrive.App.ViewModels.MainViewModelHelpers; namespace ManagedDrive.App.ViewModels; @@ -843,28 +844,6 @@ internal void ShowDiskActivityStatus(string mountPoint, bool isWrite, string fil _diskActivityStatusTimer.Start(); } - /// - /// Finds the first free drive letter searching from Z: down to D:, skipping - /// letters already in use by any Windows drive (mounted RAM disks included, since they show - /// up in like any other volume). - /// - /// A free mount point (e.g. "Z:"), or null if none is free. - private static string? FindFreeDriveLetter() - { - var usedLetters = new HashSet( - DriveInfo.GetDrives().Select(d => char.ToUpperInvariant(d.Name[0]))); - - for (var c = 'Z'; c >= 'D'; c--) - { - if (!usedLetters.Contains(c)) - { - return $"{c}:"; - } - } - - return null; - } - internal static DiskOptions ProfileToOptions(DiskProfile p) => new() { MountPoint = p.MountPoint, @@ -1642,36 +1621,6 @@ private void ExecuteViewDiskContents(DiskViewModel? vm) private IReadOnlyList GetOtherDiskOptions(DiskViewModel? excluding) => Disks.Where(d => d != excluding).Select(d => d.Disk.Options).ToList(); - /// - /// Returns whether any disk in already has - /// set on the field selected by (e.g. - /// or ). Shared by every image/archive path - /// collision check (create, edit, import, CLI mount). - /// - private static bool IsPathInUse(IReadOnlyList otherDisks, string path, Func selector) => - otherDisks.Any(d => selector(d) is { } p && string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); - - /// - /// Shows a modal with the "ManagedDrive" title and an error icon. - /// Shared boilerplate for the many failure paths across this view model that don't need a - /// custom title (contrast ExecuteEditDisk's inline apply-failure message, which reuses - /// Msg.EditDiskConfirmTitle as its title instead). - /// - private static void ShowError(string message) => - MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Error); - - /// - /// Shows a modal with the "ManagedDrive" title and an info icon. - /// - private static void ShowInfo(string message) => - MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Information); - - /// - /// Shows a modal with the "ManagedDrive" title and a warning icon. - /// - private static void ShowWarning(string? message) => - MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Warning); - private async Task MountAndAddAsync(DiskOptions options, string? password = null, IProgress? progress = null) { try diff --git a/src/ManagedDrive.App/ViewModels/MainViewModelHelpers.cs b/src/ManagedDrive.App/ViewModels/MainViewModelHelpers.cs new file mode 100644 index 0000000..e908b18 --- /dev/null +++ b/src/ManagedDrive.App/ViewModels/MainViewModelHelpers.cs @@ -0,0 +1,63 @@ +namespace ManagedDrive.App.ViewModels; + +/// +/// Stateless helper methods extracted from : pure functions with no +/// dependency on instance state (mount manager, disk collection, settings store), pulled out to +/// keep the view model focused on stateful command handling. Consumed via +/// using static ManagedDrive.App.ViewModels.MainViewModelHelpers; in MainViewModel.cs +/// so call sites stay unqualified. +/// +internal static class MainViewModelHelpers +{ + /// + /// Returns whether any disk in already has + /// set on the field selected by (e.g. + /// or ). Shared by every image/archive path + /// collision check (create, edit, import, CLI mount). + /// + public static bool IsPathInUse(IReadOnlyList otherDisks, string path, Func selector) => + otherDisks.Any(d => selector(d) is { } p && string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); + + /// + /// Finds the first free drive letter searching from Z: down to D:, skipping + /// letters already in use by any Windows drive (mounted RAM disks included, since they show + /// up in like any other volume). + /// + /// A free mount point (e.g. "Z:"), or null if none is free. + public static string? FindFreeDriveLetter() + { + var usedLetters = new HashSet( + DriveInfo.GetDrives().Select(d => char.ToUpperInvariant(d.Name[0]))); + + for (var c = 'Z'; c >= 'D'; c--) + { + if (!usedLetters.Contains(c)) + { + return $"{c}:"; + } + } + + return null; + } + + /// + /// Shows a modal with the "ManagedDrive" title and an error icon. + /// Shared boilerplate for the many failure paths across that don't + /// need a custom title (contrast ExecuteEditDisk's inline apply-failure message, which + /// reuses Msg.EditDiskConfirmTitle as its title instead). + /// + public static void ShowError(string message) => + MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Error); + + /// + /// Shows a modal with the "ManagedDrive" title and an info icon. + /// + public static void ShowInfo(string message) => + MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Information); + + /// + /// Shows a modal with the "ManagedDrive" title and a warning icon. + /// + public static void ShowWarning(string? message) => + MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Warning); +} diff --git a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs index 01d0eb1..387e5ec 100644 --- a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs +++ b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs @@ -530,30 +530,15 @@ private static void ReadHeader( private static (string Path, FileNode Node) ReadNode(BinaryReader reader) { - var path = reader.ReadString(); + var metadata = NodeMetadataIO.ReadMetadata(reader); + var path = metadata.Path; var node = new FileNode { - FileInfo = - { - FileAttributes = reader.ReadUInt32(), - AllocationSize = reader.ReadUInt64(), - FileSize = reader.ReadUInt64(), - CreationTime = reader.ReadUInt64(), - LastAccessTime = reader.ReadUInt64(), - LastWriteTime = reader.ReadUInt64(), - ChangeTime = reader.ReadUInt64(), - IndexNumber = reader.ReadUInt64(), - HardLinks = reader.ReadUInt32(), - }, + FileInfo = metadata.FileInfo, + FileSecurity = metadata.Security, }; - var secLen = reader.ReadInt32(); - if (secLen > 0) - { - node.FileSecurity = reader.ReadBytes(secLen); - } - var dataLen = reader.ReadInt64(); if (dataLen > 0 && !node.IsDirectory) { @@ -645,20 +630,7 @@ private static byte[] WrapCek( private static void WriteNode(BinaryWriter writer, string path, FileNode node) { - writer.Write(path); - writer.Write(node.FileInfo.FileAttributes); - writer.Write(node.FileInfo.AllocationSize); - writer.Write(node.FileInfo.FileSize); - writer.Write(node.FileInfo.CreationTime); - writer.Write(node.FileInfo.LastAccessTime); - writer.Write(node.FileInfo.LastWriteTime); - writer.Write(node.FileInfo.ChangeTime); - writer.Write(node.FileInfo.IndexNumber); - writer.Write(node.FileInfo.HardLinks); - - var security = node.FileSecurity ?? []; - writer.Write(security.Length); - writer.Write(security); + NodeMetadataIO.WriteMetadata(writer, path, node); if (node is { IsDirectory: false, FileData: not null, FileInfo.FileSize: > 0 }) { diff --git a/src/ManagedDrive.Core/Persistence/NodeMetadataIO.cs b/src/ManagedDrive.Core/Persistence/NodeMetadataIO.cs new file mode 100644 index 0000000..9791f20 --- /dev/null +++ b/src/ManagedDrive.Core/Persistence/NodeMetadataIO.cs @@ -0,0 +1,55 @@ +namespace ManagedDrive.Core.Persistence; + +/// +/// Shared binary read/write logic for the path + + security +/// descriptor portion of a node record. Extracted because and +/// write this exact layout byte-for-byte identically before +/// diverging on how the file's data is stored (inline bytes vs. a content-addressed blob hash). +/// Do not extend this beyond metadata into data/blob territory - the two formats are fundamentally +/// different there. +/// +internal static class NodeMetadataIO +{ + public readonly record struct NodeMetadata(string Path, Fsp.Interop.FileInfo FileInfo, byte[]? Security); + + public static void WriteMetadata(BinaryWriter writer, string path, FileNode node) + { + writer.Write(path); + writer.Write(node.FileInfo.FileAttributes); + writer.Write(node.FileInfo.AllocationSize); + writer.Write(node.FileInfo.FileSize); + writer.Write(node.FileInfo.CreationTime); + writer.Write(node.FileInfo.LastAccessTime); + writer.Write(node.FileInfo.LastWriteTime); + writer.Write(node.FileInfo.ChangeTime); + writer.Write(node.FileInfo.IndexNumber); + writer.Write(node.FileInfo.HardLinks); + + var security = node.FileSecurity ?? []; + writer.Write(security.Length); + writer.Write(security); + } + + public static NodeMetadata ReadMetadata(BinaryReader reader) + { + var path = reader.ReadString(); + + var fileInfo = new Fsp.Interop.FileInfo + { + FileAttributes = reader.ReadUInt32(), + AllocationSize = reader.ReadUInt64(), + FileSize = reader.ReadUInt64(), + CreationTime = reader.ReadUInt64(), + LastAccessTime = reader.ReadUInt64(), + LastWriteTime = reader.ReadUInt64(), + ChangeTime = reader.ReadUInt64(), + IndexNumber = reader.ReadUInt64(), + HardLinks = reader.ReadUInt32(), + }; + + var secLen = reader.ReadInt32(); + var security = secLen > 0 ? reader.ReadBytes(secLen) : null; + + return new NodeMetadata(path, fileInfo, security); + } +} diff --git a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs index df7a848..4118448 100644 --- a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs +++ b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs @@ -485,45 +485,27 @@ private readonly record struct NodeHeader( private static (string Path, NodeHeader Header) ReadNodeHeader(BinaryReader reader) { - var path = reader.ReadString(); + var metadata = NodeMetadataIO.ReadMetadata(reader); + var fileInfo = metadata.FileInfo; var header = new NodeHeader( - FileAttributes: reader.ReadUInt32(), - AllocationSize: reader.ReadUInt64(), - FileSize: reader.ReadUInt64(), - CreationTime: reader.ReadUInt64(), - LastAccessTime: reader.ReadUInt64(), - LastWriteTime: reader.ReadUInt64(), - ChangeTime: reader.ReadUInt64(), - IndexNumber: reader.ReadUInt64(), - HardLinks: reader.ReadUInt32(), - Security: null); - - var secLen = reader.ReadInt32(); - var security = secLen > 0 ? reader.ReadBytes(secLen) : null; - - return (path, header with - { - Security = security - }); + FileAttributes: fileInfo.FileAttributes, + AllocationSize: fileInfo.AllocationSize, + FileSize: fileInfo.FileSize, + CreationTime: fileInfo.CreationTime, + LastAccessTime: fileInfo.LastAccessTime, + LastWriteTime: fileInfo.LastWriteTime, + ChangeTime: fileInfo.ChangeTime, + IndexNumber: fileInfo.IndexNumber, + HardLinks: fileInfo.HardLinks, + Security: metadata.Security); + + return (metadata.Path, header); } private static void WriteNode(BinaryWriter writer, string path, FileNode node, string blobDirectory, ImageCompressionLevel level, byte[]? cek) { - writer.Write(path); - writer.Write(node.FileInfo.FileAttributes); - writer.Write(node.FileInfo.AllocationSize); - writer.Write(node.FileInfo.FileSize); - writer.Write(node.FileInfo.CreationTime); - writer.Write(node.FileInfo.LastAccessTime); - writer.Write(node.FileInfo.LastWriteTime); - writer.Write(node.FileInfo.ChangeTime); - writer.Write(node.FileInfo.IndexNumber); - writer.Write(node.FileInfo.HardLinks); - - var security = node.FileSecurity ?? []; - writer.Write(security.Length); - writer.Write(security); + NodeMetadataIO.WriteMetadata(writer, path, node); if (node.IsDirectory) { From 74acd988fedaeb413e1bd03dd18842b8c376f1b1 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 20:00:01 +0800 Subject: [PATCH 04/14] perf: dedupe UtcNow calls on the Write hot path and cache content hashes for snapshot diffing Write previously took DateTimeOffset.UtcNow three times per call (metadata timestamps, MarkDirty, content-access tracking); it now captures one timestamp and reuses it. FileNode gains a ContentVersion counter bumped only when content bytes actually change (Write/Overwrite/SetFileSizeCore), letting SnapshotManager.ComputeHash cache a file's SHA-256 across repeated diff/GC passes instead of rehashing unchanged files on every dirty auto-save tick. --- src/ManagedDrive.Core/FileSystem/FileNode.cs | 21 +++++++++++++++++++ .../FileSystem/MemoryFileSystem.cs | 21 ++++++++++++++----- .../Snapshots/SnapshotManager.cs | 17 ++++++++++++++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/ManagedDrive.Core/FileSystem/FileNode.cs b/src/ManagedDrive.Core/FileSystem/FileNode.cs index d29e1dd..cab02a8 100644 --- a/src/ManagedDrive.Core/FileSystem/FileNode.cs +++ b/src/ManagedDrive.Core/FileSystem/FileNode.cs @@ -24,6 +24,27 @@ public sealed class FileNode /// public byte[]? FileSecurity; + /// + /// Bumped by every time + /// 's bytes or logical length actually change (write, truncate, + /// overwrite). Lets callers such as SnapshotManager.ComputeHash cache a content + /// hash and cheaply detect whether it is still valid, without relying on wall-clock + /// timestamps (which callers other than the WinFsp write path aren't guaranteed to advance + /// in lockstep with content). + /// + internal ulong ContentVersion; + + /// + /// The content hash last computed for this node, cached against + /// at the time it was computed. null when no hash has been computed yet. + /// + internal byte[]? CachedContentHash; + + /// + /// The value was computed for. + /// + internal ulong CachedContentHashVersion; + /// /// The allocation granularity in bytes. All allocation sizes are rounded up to this boundary. /// diff --git a/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs b/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs index 432ae40..352734e 100644 --- a/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs +++ b/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs @@ -493,6 +493,7 @@ public override int Overwrite( NodeMap.UpdateAllocationSize(node, aligned); node.FileInfo.FileSize = 0; node.FileData = aligned > 0 ? FileContent.CreateZeroed(aligned) : null; + node.ContentVersion++; var now = FileTimeNow(); node.FileInfo.LastAccessTime = now; @@ -805,18 +806,20 @@ public override int Write( if (length > 0 && node.FileData != null) { node.FileData.WriteFrom(buffer, writeOffset, length); + node.ContentVersion++; } bytesTransferred = length; Interlocked.Add(ref _totalBytesWritten, length); - var now = FileTimeNow(); + var nowOffset = DateTimeOffset.UtcNow; + var now = (ulong)nowOffset.ToFileTime(); node.FileInfo.LastAccessTime = now; node.FileInfo.LastWriteTime = now; node.FileInfo.ChangeTime = now; - MarkDirty(); - Interlocked.Exchange(ref _lastContentWriteAccess, new(DateTimeOffset.UtcNow, node.FilePath)); + MarkDirty(nowOffset); + Interlocked.Exchange(ref _lastContentWriteAccess, new(nowOffset, node.FilePath)); fileInfo = node.FileInfo; return STATUS_SUCCESS; } @@ -829,10 +832,17 @@ public override int Write( /// /// Marks the disk's content as changed since the last save. /// - internal void MarkDirty() + internal void MarkDirty() => MarkDirty(DateTimeOffset.UtcNow); + + /// + /// Marks the disk's content as changed since the last save, using a caller-supplied + /// timestamp to avoid redundant calls on hot paths + /// that already captured "now" for other purposes. + /// + private void MarkDirty(DateTimeOffset now) { _isDirty = true; - Interlocked.Exchange(ref _lastContentWriteTicks, DateTimeOffset.UtcNow.UtcTicks); + Interlocked.Exchange(ref _lastContentWriteTicks, now.UtcTicks); ContentAccessed?.Invoke(true); } @@ -1014,6 +1024,7 @@ private int SetFileSizeCore(FileNode node, ulong newSize, bool setAllocationSize node.FileInfo.FileSize = newSize; } + node.ContentVersion++; return STATUS_SUCCESS; } diff --git a/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs b/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs index 0383e0d..58fb493 100644 --- a/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs +++ b/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs @@ -357,12 +357,27 @@ private static string BlobDirectoryFromSnapshotPath(string indexPath) return Path.Combine(directory, baseName + ".snapblobs"); } + /// + /// Computes (or reuses) the SHA-256 hash of 's current content. + /// Cached on the node against , which is bumped only + /// when content actually changes — this avoids rehashing every file on every dirty + /// auto-save tick when only a subset of files changed since the last snapshot. + /// private static byte[] ComputeHash(FileNode node) { + if (node.CachedContentHash is { } cached && node.CachedContentHashVersion == node.ContentVersion) + { + return cached; + } + var fileSize = (int)Math.Min(node.FileInfo.FileSize, (ulong)node.FileData!.Length); using var incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); node.FileData.HashInto(incrementalHash, fileSize); - return incrementalHash.GetHashAndReset(); + var hash = incrementalHash.GetHashAndReset(); + + node.CachedContentHash = hash; + node.CachedContentHashVersion = node.ContentVersion; + return hash; } /// From 804be8ebbafd763b3a6c28092e3eccf9a701abcb Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 20:00:08 +0800 Subject: [PATCH 05/14] perf: seek FileNodeMap directory children via SortedSet range query GetChildren (used by CanDelete on every directory-delete check) scanned the whole sorted namespace from the start to find where a path prefix's run began. FileNodeMap now keeps a plain Dictionary for O(1) node lookup alongside a SortedSet of keys, and GetChildren/RenameDescendants seek directly into the prefix's range via GetViewBetween in O(log n) instead of a full-map scan. --- .../FileSystem/FileNodeMap.cs | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs b/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs index 3f376b5..ca3a43b 100644 --- a/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs +++ b/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs @@ -7,7 +7,13 @@ namespace ManagedDrive.Core.FileSystem; /// public sealed class FileNodeMap : IDisposable { - private readonly SortedDictionary _map = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase); + + // Parallel key index so directory enumeration (GetChildren) can seek directly to a path + // prefix's range in O(log n) via GetViewBetween, instead of scanning the whole namespace + // from the start looking for where the prefix run begins. _map itself is a plain Dictionary + // (O(1) lookup/insert/remove) precisely because it no longer needs to maintain order. + private readonly SortedSet _sortedKeys = new(StringComparer.OrdinalIgnoreCase); // Read/write lock instead of a mutual-exclusion lock: lookups and directory enumerations // (the read-heavy majority) can proceed concurrently, and a full-scan enumeration no longer @@ -50,6 +56,10 @@ public void Add(string filePath, FileNode node) { _totalAllocated -= existing.FileInfo.AllocationSize; } + else + { + _sortedKeys.Add(filePath); + } node.FilePath = filePath; node.LeafName = ComputeLeafName(filePath); @@ -72,11 +82,13 @@ public void ClearAll() { var hasRoot = _map.TryGetValue("\\", out var root); _map.Clear(); + _sortedKeys.Clear(); _totalAllocated = 0; if (hasRoot) { _map["\\"] = root!; + _sortedKeys.Add("\\"); _totalAllocated = root!.FileInfo.AllocationSize; } } @@ -97,7 +109,13 @@ public IReadOnlyList> GetAllNodes() _syncRoot.EnterReadLock(); try { - return [.. _map]; + var result = new List>(_map.Count); + foreach (var key in _sortedKeys) + { + result.Add(new(key, _map[key])); + } + + return result; } finally { @@ -122,26 +140,19 @@ public IEnumerable> GetChildren(string dirPath, s // For root "\" (length 1) the prefix equals dirPath itself; for others append "\" var prefix = dirPath.Length == 1 ? dirPath : (dirPath + "\\"); + // All keys sharing this prefix form a contiguous run in _sortedKeys (OrdinalIgnoreCase + // order). GetViewBetween seeks directly to that range in O(log n) instead of scanning + // the whole namespace from the start looking for where the run begins — the upper bound + // uses '￿', a value greater than any character used in a real path, so the view + // covers exactly "prefix" plus everything that starts with it. + var upperBound = prefix + '￿'; + List> matches = []; _syncRoot.EnterReadLock(); try { - // _map is sorted (OrdinalIgnoreCase), so all keys sharing this prefix form a - // contiguous run. Skip until it starts, collect while it holds, then stop. - foreach (var kvp in _map) + foreach (var path in _sortedKeys.GetViewBetween(prefix, upperBound)) { - var path = kvp.Key; - - if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - if (matches.Count > 0) - { - break; - } - - continue; - } - if (string.Equals(path, dirPath, StringComparison.OrdinalIgnoreCase)) { continue; @@ -160,7 +171,7 @@ public IEnumerable> GetChildren(string dirPath, s continue; } - matches.Add(kvp); + matches.Add(new(path, _map[path])); } } finally @@ -201,6 +212,7 @@ public void Remove(string filePath) { if (_map.Remove(filePath, out var removed)) { + _sortedKeys.Remove(filePath); _totalAllocated -= removed.FileInfo.AllocationSize; } } @@ -222,23 +234,19 @@ public void RenameDescendants(string oldPath, string newPath) try { var prefix = oldPath + "\\"; - var keys = new List(); - foreach (var key in _map.Keys) - { - if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - keys.Add(key); - } - } + var upperBound = prefix + '￿'; + var keys = new List(_sortedKeys.GetViewBetween(prefix, upperBound)); foreach (var key in keys) { var descendant = _map[key]; _map.Remove(key); + _sortedKeys.Remove(key); var newKey = string.Concat(newPath, key.AsSpan(oldPath.Length)); descendant.FilePath = newKey; descendant.LeafName = ComputeLeafName(newKey); _map[newKey] = descendant; + _sortedKeys.Add(newKey); } } finally From 0e56fbc9f62897cc6e9a8b76ba2b00b34f53f0f7 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 20:07:52 +0800 Subject: [PATCH 06/14] docs: update benchmark numbers in README from latest BenchmarkDotNet run --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ce67881..66cfde5 100644 --- a/README.md +++ b/README.md @@ -138,13 +138,13 @@ Measured with [BenchmarkDotNet](https://benchmarkdotnet.org/) (Intel Core i9-139 | Scenario | RAM Disk | NVMe SSD | Ratio | |---|---:|---:|---:| -| Sequential write, 4 KB | 2.2 MB/s | 0.8 MB/s | **2.9× faster** | -| Sequential write, 1 MB | 580 MB/s | 104 MB/s | **5.6× faster** | -| Sequential read, 4 KB / 1 MB | 3.0 MB/s / 643 MB/s | 3.0 MB/s / 677 MB/s | ≈ parity | -| Random 4 KB read, 30 seeks over 16 MB | 1.9–2.3 ms | 1.6 ms | ~1.2–1.4× slower | -| 30× small-file (4 KB) create+write | 51.6 ms (1.72 ms/file) | 80.7 ms (2.69 ms/file) | **1.6× faster** | +| Sequential write, 4 KB | 1.31 MB/s | 0.87 MB/s | **1.5× faster** | +| Sequential write, 1 MB | 321.9 MB/s | 84.6 MB/s | **3.8× faster** | +| Sequential read (OS cache), 4 KB / 1 MB | 3.4 MB/s / 531.6 MB/s | 4.5 MB/s / 955.5 MB/s | ~1.3–1.8× slower | +| Random 4 KB read, 30 seeks over 16 MB | 1.7× slower (OS cache) – 1.6× faster (uncached) | — | mixed | +| 30× small-file (4 KB) create+write | 84.1 ms (2.81 ms/file) | 144.8 ms (4.83 ms/file) | **1.7× faster** | -Writes and small-file create+write win big by skipping block allocation, journaling, and the physical write; sequential reads land near parity with an OS page-cache hit (a user-mode file system can't consistently beat the kernel's own DRAM cache); random reads are modestly slower since each seek pays a kernel–userspace round-trip through WinFsp. Run `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` for current numbers on your own hardware (see [Running Benchmarks](#running-benchmarks) below). +Writes and small-file create+write win big by skipping block allocation, journaling, and the physical write; sequential reads trail the NVMe drive even on an OS page-cache hit (a user-mode file system can't consistently beat the kernel's own DRAM cache, and every WinFsp callback pays a kernel–userspace round trip); random reads land close to parity, faster or slower depending on whether the physical disk's own cache is warm. Run `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` for current numbers on your own hardware (see [Running Benchmarks](#running-benchmarks) below). ### Running Tests @@ -365,13 +365,13 @@ ManagedDrive 使用 **WinFsp**(Windows 文件系统代理)将内存目录树 | 场景 | 内存盘 | NVMe SSD | 倍率 | |---|---:|---:|---:| -| 顺序写入,4 KB | 2.2 MB/s | 0.8 MB/s | **快 2.9×** | -| 顺序写入,1 MB | 580 MB/s | 104 MB/s | **快 5.6×** | -| 顺序读取,4 KB / 1 MB | 3.0 MB/s / 643 MB/s | 3.0 MB/s / 677 MB/s | ≈ 持平 | -| 随机 4 KB 读取,对 16 MB 文件寻址 30 次 | 1.9–2.3 ms | 1.6 ms | 慢 ~1.2–1.4× | -| 30 次小文件(4 KB)创建+写入 | 51.6 ms(1.72 ms/文件) | 80.7 ms(2.69 ms/文件) | **快 1.6×** | +| 顺序写入,4 KB | 1.31 MB/s | 0.87 MB/s | **快 1.5×** | +| 顺序写入,1 MB | 321.9 MB/s | 84.6 MB/s | **快 3.8×** | +| 顺序读取(OS 缓存),4 KB / 1 MB | 3.4 MB/s / 531.6 MB/s | 4.5 MB/s / 955.5 MB/s | 慢 ~1.3–1.8× | +| 随机 4 KB 读取,对 16 MB 文件寻址 30 次 | 慢 1.7×(OS 缓存)~快 1.6×(未缓存) | — | 不一致 | +| 30 次小文件(4 KB)创建+写入 | 84.1 ms(2.81 ms/文件) | 144.8 ms(4.83 ms/文件) | **快 1.7×** | -写入及小文件创建+写入优势明显,因为跳过了物理块分配、日志记录和实际落盘;顺序读取与 OS 页缓存命中基本持平(用户态文件系统无法稳定超越内核自身的 DRAM 缓存);随机读取略慢,因为每次寻址都要经过 WinFsp 的内核–用户态往返。运行 `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` 可在你自己的硬件上获取当前数据(见下方[运行基准测试](#running-benchmarks-zh))。 +写入及小文件创建+写入优势明显,因为跳过了物理块分配、日志记录和实际落盘;顺序读取即便命中 OS 页缓存也不及 NVMe 硬盘(用户态文件系统无法稳定超越内核自身的 DRAM 缓存,且每次 WinFsp 回调都要经过一次内核–用户态往返);随机读取基本持平,具体快慢取决于物理磁盘自身缓存是否命中。运行 `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` 可在你自己的硬件上获取当前数据(见下方[运行基准测试](#running-benchmarks-zh))。 ### 运行测试 From 4db1593d9fb9fa3874a9ceddeadf5509afd97e34 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 20:15:21 +0800 Subject: [PATCH 07/14] perf: use 1MB sequential-scan FileStream buffers for disk image I/O Save/Load/PeekHeader open the .mdr file with the default 4KB buffer, causing many small syscalls when writing per-node metadata directly to the stream (most noticeable on uncompressed images, where there's no GZipStream buffering in front of it). --- .../Persistence/DiskImageSerializer.cs | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs index 387e5ec..ee67b98 100644 --- a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs +++ b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs @@ -53,6 +53,16 @@ namespace ManagedDrive.Core.Persistence; public static class DiskImageSerializer { private const int CekSize = 32; + + /// + /// Buffer size for the image file's . Save/Load are purely sequential, + /// large-volume I/O, so a larger-than-default (4 KB) buffer cuts the number of read/write + /// syscalls substantially — this matters most for uncompressed images, where node metadata is + /// written directly to this stream in many small calls rather than + /// through a buffering . + /// + private const int FileStreamBufferSize = 1024 * 1024; + private const int NonceSize = 12; private const int Pbkdf2Iterations = 210_000; private const int SaltSize = 16; @@ -109,7 +119,13 @@ public static FileNodeMap Load( out byte[]? cek, IProgress? progress = null) { - using var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read); + using var stream = new FileStream(imagePath, new FileStreamOptions + { + Mode = FileMode.Open, + Access = FileAccess.Read, + BufferSize = FileStreamBufferSize, + Options = FileOptions.SequentialScan, + }); using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: false); ReadHeader(reader, out var version, out var level, out var isEncrypted); @@ -143,7 +159,13 @@ public static void PeekHeader( out string volumeLabel, out bool isEncrypted) { - using var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read); + using var stream = new FileStream(imagePath, new FileStreamOptions + { + Mode = FileMode.Open, + Access = FileAccess.Read, + BufferSize = FileStreamBufferSize, + Options = FileOptions.SequentialScan, + }); using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: false); ReadHeader(reader, out var version, out var level, out isEncrypted); @@ -206,7 +228,13 @@ public static void Save( try { - using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write)) + using (var stream = new FileStream(tempPath, new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.Write, + BufferSize = FileStreamBufferSize, + Options = FileOptions.SequentialScan, + })) { using (var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true)) { From 5f9f2b307bdf766a9e4f60546eb39dbb485e5290 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 20:45:04 +0800 Subject: [PATCH 08/14] fix: don't suppress the terminal 1.0 progress report in BusyOverlayViewModel The epsilon check meant to dedupe near-identical intermediate ticks could also swallow a final Report(1.0) that landed within epsilon of the last stored value, leaving the progress bar visibly stuck just short of 100%. --- src/ManagedDrive.App/ViewModels/BusyOverlayViewModel.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ManagedDrive.App/ViewModels/BusyOverlayViewModel.cs b/src/ManagedDrive.App/ViewModels/BusyOverlayViewModel.cs index d6b71c7..e62814e 100644 --- a/src/ManagedDrive.App/ViewModels/BusyOverlayViewModel.cs +++ b/src/ManagedDrive.App/ViewModels/BusyOverlayViewModel.cs @@ -58,7 +58,11 @@ public double Progress get; private set { - if (Math.Abs(field - value) < 0.0001) + // The epsilon check below only suppresses redundant PropertyChanged notifications for + // near-identical intermediate ticks; it must never suppress storing the terminal value + // itself, or a final Report(1.0) that lands within epsilon of the last-stored value + // would leave `field` stuck just short of 1.0 forever (the bar visibly stops early). + if (value < 1.0 && Math.Abs(field - value) < 0.0001) { return; } From a1fc3cef6c3eb441ea22fb7812bc6b7e16375b16 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 20:45:12 +0800 Subject: [PATCH 09/14] fix: don't halve manual-save progress when no snapshot write will follow SaveToImageWithSnapshot always split progress into [0, 0.5] for the image save and [0.5, 1.0] for the snapshot write, even when snapshot retention isn't configured. TryWriteSnapshot then no-ops and jumps straight to 1.0, so the bar visibly stalled around 50% before snapping to done. Now the full [0, 1] range goes to the image save unless a snapshot write may actually happen. --- src/ManagedDrive.Core/Mounting/RamDisk.cs | 33 ++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/ManagedDrive.Core/Mounting/RamDisk.cs b/src/ManagedDrive.Core/Mounting/RamDisk.cs index 4bf4f5d..067715e 100644 --- a/src/ManagedDrive.Core/Mounting/RamDisk.cs +++ b/src/ManagedDrive.Core/Mounting/RamDisk.cs @@ -502,19 +502,44 @@ public void SaveToImageSafe() /// so a manual save and a periodic auto-save never write/prune snapshots concurrently. /// /// - /// Optional progress reporter, updated with a fraction in [0, 1] across both the image save - /// (the first half of the range) and the snapshot write (the second half). + /// Optional progress reporter, updated with a fraction in [0, 1]. When snapshot retention is + /// configured (see /) + /// and a snapshot write may actually happen, this range is split across both the image save + /// (the first half) and the snapshot write (the second half); otherwise the full range is + /// given to the image save alone, so the bar doesn't stall at 50% when no snapshot work + /// follows it. /// public void SaveToImageWithSnapshot(IProgress? progress = null) { lock (_autoSaveLock) { - SaveToImage(progress is null ? null : new Progress(p => progress.Report(p * 0.5))); - TryWriteSnapshot(progress is null ? null : new Progress(p => progress.Report(0.5 + p * 0.5))); + // MappedProgress forwards straight into the caller's IProgress instead of + // wrapping it in a new Progress constructed on this background thread — that + // would capture a non-UI SynchronizationContext and route every tick through an extra, + // unordered ThreadPool hop before it ever reached the UI-bound progress object. + var mayWriteSnapshot = Options.PersistImagePath is not null + && (Options.MaxSnapshotCount is not null || Options.MaxSnapshotSizeBytes is not null); + + if (mayWriteSnapshot) + { + SaveToImage(progress is null ? null : new MappedProgress(progress, 0.5, 0.0)); + TryWriteSnapshot(progress is null ? null : new MappedProgress(progress, 0.5, 0.5)); + } + else + { + SaveToImage(progress); + TryWriteSnapshot(); + } + progress?.Report(1.0); } } + private sealed class MappedProgress(IProgress inner, double scale, double offset) : IProgress + { + public void Report(double value) => inner.Report(offset + (value * scale)); + } + /// /// Sets, changes, or removes this disk's password. Passing a non-null value when the disk is /// not yet encrypted generates a fresh content-encryption key (CEK); passing a non-null value From edf5eaaee7d73aab971afdfb47371527b385b4eb Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 22:49:43 +0800 Subject: [PATCH 10/14] perf: decompress .mdr node region in parallel across Zstd chunks Frame each compressed chunk as [Int32 length][bytes] (mirroring ChunkedGcm's framing) instead of relying on transparent concatenated Zstd frames, so load can dispatch each chunk's decompression to a worker pool the same way save already parallelizes compression. SnapshotStore's blob reader picks up the same ParallelZstd.ReadStream since it shares the writer. --- Directory.Packages.props | 1 + .../ManagedDrive.Core.csproj | 1 + .../Persistence/DiskImageSerializer.cs | 85 +++-- .../Persistence/ParallelZstd.cs | 296 ++++++++++++++++++ .../Snapshots/SnapshotStore.cs | 43 ++- .../DiskImageSerializerTests.cs | 163 ++++++++++ .../SnapshotManagerTests.cs | 87 ++++- .../ZstdConcatenatedFramesTests.cs | 47 +++ 8 files changed, 683 insertions(+), 40 deletions(-) create mode 100644 src/ManagedDrive.Core/Persistence/ParallelZstd.cs create mode 100644 tests/ManagedDrive.Tests/ZstdConcatenatedFramesTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 6a41d56..0756c13 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,5 +23,6 @@ + \ No newline at end of file diff --git a/src/ManagedDrive.Core/ManagedDrive.Core.csproj b/src/ManagedDrive.Core/ManagedDrive.Core.csproj index 7081d5d..055f11f 100644 --- a/src/ManagedDrive.Core/ManagedDrive.Core.csproj +++ b/src/ManagedDrive.Core/ManagedDrive.Core.csproj @@ -5,6 +5,7 @@ + diff --git a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs index ee67b98..47f5531 100644 --- a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs +++ b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs @@ -20,7 +20,7 @@ namespace ManagedDrive.Core.Persistence; /// Image format (little-endian binary): /// /// 4-byte magic "MDRD" -/// Int32 version (currently 4) +/// Int32 version (currently 5) /// Byte holding an value (version 2+ only; absent in version 1, which is always uncompressed) /// Byte IsEncrypted (version 3+ only; absent/false in earlier versions) /// UInt64 capacity in bytes (always plaintext, so callers can preview it without a password) @@ -39,14 +39,33 @@ namespace ManagedDrive.Core.Persistence; /// managed-array limits at roughly 2 GB — kept only so old images keep loading. /// /// -/// Version 4 (current) when encrypted: a random 12-byte base nonce, then a sequence of +/// Version 4+ when encrypted: a random 12-byte base nonce, then a sequence of /// chunks, each independently AES-256-GCM encrypted so no single buffer needs to hold the /// whole node region. Each chunk is [Int32 ciphertext length][16-byte tag][ciphertext /// bytes], terminated by a zero-length chunk. Per-chunk nonces are derived from the base /// nonce by XOR-ing its last 4 bytes with the big-endian chunk index, guaranteeing a unique /// nonce per chunk under the same key/base nonce (see ). +/// Identical layout for both version 4 and 5 — only the compression algorithm wrapped inside +/// differs. +/// +/// When not encrypted (any version): the node region follows directly, compressed whenever the level is not , streamed straight from/to the file rather than buffered. +/// +/// Compression algorithm is determined entirely by the file's version, not by any separate +/// field: versions 1-4 use gzip/deflate (), read-only — nothing writes +/// gzip anymore. Version 5 (current) uses Zstd () for both writing and +/// reading, which compresses substantially faster than gzip at a comparable ratio. +/// +/// +/// Version 5's Zstd-compressed node region (whether encrypted or not) is itself a sequence of +/// independently compressed chunks framed as [Int32 compressed length][compressed bytes], +/// terminated by a zero-length chunk — mirroring 's chunk framing. This +/// lets both and +/// compress/decompress chunks concurrently across a worker pool while still writing/reading +/// them in original order, since chunk boundaries are known up front rather than requiring a +/// full decompress pass to discover. Encrypted images wrap this chunk sequence in +/// 's own (independently sized) chunking, so the two chunk boundaries +/// do not line up — that's fine, since Zstd's chunk framing is entirely self-describing. /// -/// When not encrypted (any version): the node region follows directly, gzip-compressed whenever the level is not , streamed straight from/to the file rather than buffered. /// Node region contents: Int32 node count, then for each node: path, metadata, security descriptor bytes, file data bytes /// /// @@ -67,7 +86,7 @@ public static class DiskImageSerializer private const int Pbkdf2Iterations = 210_000; private const int SaltSize = 16; private const int TagSize = 16; - private const int Version = 4; + private const int Version = 5; private static readonly byte[] Magic = "MDRD"u8.ToArray(); /// @@ -205,6 +224,13 @@ public static void PeekHeader( /// The subsequent gzip compression and (when encrypting) AES-256-GCM chunk encryption happen /// as nodes stream through and are not individually reported. /// + /// + /// Optional advanced override (1-22) of the exact Zstd level used instead of the one mapped + /// from (see ). + /// Purely an encoder-side speed/ratio choice — not persisted in the image itself, since Zstd + /// decompression needs no level parameter, so works identically regardless + /// of what level a given image was originally written with. + /// public static void Save( FileNodeMap nodeMap, ulong capacityBytes, @@ -212,7 +238,8 @@ public static void Save( string imagePath, ImageCompressionLevel level, ImageEncryptionInfo? encryption = null, - IProgress? progress = null) + IProgress? progress = null, + int? customZstdLevel = null) { var compress = level != ImageCompressionLevel.None; var directory = Path.GetDirectoryName(imagePath); @@ -263,13 +290,13 @@ public static void Save( // Node data streams straight into chunked AES-GCM encryption below — // never buffered whole, so there is no ~2 GB ceiling on disk content. using var chunkedStream = new ChunkedGcm.WriteStream(stream, enc.Cek, baseNonce, ChunkedGcm.ChunkSize); - WriteNodeRegion(chunkedStream, compress, level, nodeMap, progress); + WriteNodeRegion(chunkedStream, compress, level, customZstdLevel, nodeMap, progress); chunkedStream.Complete(); } else { writer.Flush(); - WriteNodeRegion(stream, compress, level, nodeMap, progress); + WriteNodeRegion(stream, compress, level, customZstdLevel, nodeMap, progress); } } @@ -295,23 +322,25 @@ public static void Save( /// /// Writes the node-count-prefixed node region for directly into - /// , gzip-compressing on the fly when is - /// set. Never materializes the whole region as a single in-memory buffer, so disk content of - /// any size can be saved regardless of the ~2 GB limit on /managed - /// arrays. The (when used) is explicitly disposed here — rather than + /// , Zstd-compressing on the fly (in parallel across chunks, see + /// ) when is set. Never materializes the + /// whole region as a single in-memory buffer, so disk content of any size can be saved + /// regardless of the ~2 GB limit on /managed arrays. The + /// (when used) is explicitly disposed here — rather than /// relying on 's own disposal with leaveOpen: true, which - /// would skip it — so the deflate stream's final block/trailer is always flushed before + /// would skip it — so every outstanding chunk is compressed and flushed before /// is used for anything else. /// private static void WriteNodeRegion( Stream target, bool compress, ImageCompressionLevel level, + int? customZstdLevel, FileNodeMap nodeMap, IProgress? progress) { var payloadStream = compress - ? new GZipStream(target, level.ToDotNetCompressionLevel(), leaveOpen: true) + ? new ParallelZstd.WriteStream(target, level.ToZstdLevel(customZstdLevel)) : target; try @@ -421,12 +450,14 @@ private static FileNodeMap LoadCurrent( cek = null; var compressed = level != ImageCompressionLevel.None; + var useZstd = version >= 5; + if (!isEncrypted) { // The node region is the last thing in the file for an unencrypted image, so // decompressing straight off the file stream (rather than buffering it) is safe — - // GZipStream simply reads until end of file. - return ReadNodeRegion(stream, compressed, reportTick); + // the decompression stream simply reads until end of file. + return ReadNodeRegion(stream, compressed, useZstd, reportTick); } if (password is null) @@ -446,7 +477,7 @@ private static FileNodeMap LoadCurrent( return version switch { 3 => LoadLegacyEncryptedBlob(stream, reader, resolvedCek, compressed, reportTick), - 4 => LoadChunkedEncrypted(stream, reader, resolvedCek, compressed, reportTick), + 4 or 5 => LoadChunkedEncrypted(stream, reader, resolvedCek, compressed, useZstd, reportTick), _ => throw new InvalidDataException($"Unsupported image version: {version}."), }; } @@ -485,7 +516,7 @@ private static FileNodeMap LoadLegacyEncryptedBlob( // already at (or near) end-of-file here — reportTick will jump close to 1.0 on the // first node and stay there for the rest of this legacy (version 3) path. using var nodeRegionStream = new MemoryStream(plaintext, writable: false); - return ReadNodeRegion(nodeRegionStream, compressed, reportTick); + return ReadNodeRegion(nodeRegionStream, compressed, useZstd: false, reportTick); } finally { @@ -494,15 +525,17 @@ private static FileNodeMap LoadLegacyEncryptedBlob( } /// - /// Version 4's chunked encrypted node region: each chunk was independently AES-256-GCM + /// Version 4/5's chunked encrypted node region: each chunk was independently AES-256-GCM /// encrypted on save, so decryption streams chunk-by-chunk via - /// rather than requiring the whole region in memory at once. + /// rather than requiring the whole region in memory at once. + /// distinguishes the compression algorithm wrapped inside (version 4 = gzip, version 5 = Zstd). /// private static FileNodeMap LoadChunkedEncrypted( FileStream stream, BinaryReader reader, byte[] cek, bool compressed, + bool useZstd, Action? reportTick = null) { var baseNonce = reader.ReadBytes(NonceSize); @@ -510,7 +543,7 @@ private static FileNodeMap LoadChunkedEncrypted( try { using var chunkedStream = new ChunkedGcm.ReadStream(stream, cek, baseNonce); - return ReadNodeRegion(chunkedStream, compressed, reportTick); + return ReadNodeRegion(chunkedStream, compressed, useZstd, reportTick); } catch (CryptographicException) { @@ -520,13 +553,17 @@ private static FileNodeMap LoadChunkedEncrypted( /// /// Reads the node-count-prefixed node region from , transparently - /// gzip-decompressing when is set. Mirrors . + /// decompressing when is set — via Zstd when + /// is set (version 5, current), otherwise via gzip (versions 1-4, + /// read-only). Mirrors . /// - private static FileNodeMap ReadNodeRegion(Stream source, bool compressed, Action? reportTick = null) + private static FileNodeMap ReadNodeRegion(Stream source, bool compressed, bool useZstd, Action? reportTick = null) { using var payloadReader = new BinaryReader( compressed - ? new GZipStream(source, CompressionMode.Decompress, leaveOpen: true) + ? useZstd + ? new ParallelZstd.ReadStream(source) + : new GZipStream(source, CompressionMode.Decompress, leaveOpen: true) : source, System.Text.Encoding.UTF8, leaveOpen: true); @@ -547,7 +584,7 @@ private static void ReadHeader( } version = reader.ReadInt32(); - if (version is not (1 or 2 or 3 or 4)) + if (version is not (1 or 2 or 3 or 4 or 5)) { throw new InvalidDataException($"Unsupported image version: {version}."); } diff --git a/src/ManagedDrive.Core/Persistence/ParallelZstd.cs b/src/ManagedDrive.Core/Persistence/ParallelZstd.cs new file mode 100644 index 0000000..c9c09ee --- /dev/null +++ b/src/ManagedDrive.Core/Persistence/ParallelZstd.cs @@ -0,0 +1,296 @@ +using System.Buffers.Binary; + +namespace ManagedDrive.Core.Persistence; + +/// +/// Write/read helper pair that compresses/decompresses a stream of bytes as a sequence of +/// independently compressed Zstd chunks, each framed as [Int32 compressed length][compressed +/// bytes] and terminated by a zero-length chunk (mirroring 's framing), +/// processed concurrently across a bounded worker pool while still emitting/consuming them in +/// original order. This turns both image/snapshot save and load (otherwise single-threaded +/// bottlenecks with plain /) +/// into parallelizable operations. The explicit length framing (rather than relying on +/// concatenated-frame auto-detection, as a plain would +/// need to scan for) is what lets dispatch each chunk's decompression to +/// the thread pool without first decompressing anything to find chunk boundaries. +/// Used by (the node region of a .mdr image) and +/// (individual content-addressed file blobs). +/// +internal static class ParallelZstd +{ + /// + /// Size of each independently compressed chunk. Large enough that per-chunk compression + /// overhead (frame header/epilogue, a fresh context) stays + /// negligible relative to the data compressed, but small enough to get real parallelism on + /// typical disk-image sizes. Overridable by tests via to + /// exercise the multi-chunk path without allocating real multi-megabyte buffers. + /// + private const int DefaultChunkSize = 4 * 1024 * 1024; + + /// + /// Test-only override for ; means use the + /// production default. Set via InternalsVisibleTo("ManagedDrive.Tests"). + /// + internal static int? TestChunkSizeOverride; + + internal static int ChunkSize => TestChunkSizeOverride ?? DefaultChunkSize; + + /// + /// Write-only that buffers up to bytes at a time + /// and, on each full buffer plus once more on , hands that chunk to a + /// background that Zstd-compresses it independently. Compression runs + /// concurrently (bounded by ), but chunks are always + /// written to in the order they were queued, blocking on the oldest + /// outstanding task if the queue is full — so output ordering matches input ordering + /// regardless of which task happens to finish first. + /// + internal sealed class WriteStream(Stream target, int level, int? maxDegreeOfParallelism = null) : Stream + { + private readonly Queue> _pending = new(); + private readonly int _maxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism ?? Environment.ProcessorCount); + private byte[] _buffer = new byte[ChunkSize]; + private int _bufferLength; + private bool _completed; + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + while (count > 0) + { + var toCopy = Math.Min(count, _buffer.Length - _bufferLength); + Array.Copy(buffer, offset, _buffer, _bufferLength, toCopy); + _bufferLength += toCopy; + offset += toCopy; + count -= toCopy; + + if (_bufferLength == _buffer.Length) + { + FlushChunk(); + } + } + } + + public override void Flush() + { + } + + /// + /// Flushes any partially filled chunk, then drains every outstanding compression task in + /// queued order, writing each result to . Must be called exactly + /// once after all plaintext has been written, before disposing — mirrors + /// 's explicit-completion pattern. + /// + public void Complete() + { + if (_completed) + { + return; + } + + if (_bufferLength > 0) + { + QueueChunk(); + } + + while (_pending.Count > 0) + { + DrainOne(); + } + + WriteChunkHeader(0); + _completed = true; + } + + private void FlushChunk() + { + if (_pending.Count >= _maxDegreeOfParallelism) + { + DrainOne(); + } + + QueueChunk(); + } + + private void QueueChunk() + { + var chunk = _buffer; + var length = _bufferLength; + _buffer = new byte[ChunkSize]; + _bufferLength = 0; + + _pending.Enqueue(Task.Run(() => Compress(chunk, length, level))); + } + + private void DrainOne() + { + var compressed = _pending.Dequeue().GetAwaiter().GetResult(); + WriteChunkHeader(compressed.Length); + target.Write(compressed, 0, compressed.Length); + } + + private void WriteChunkHeader(int length) + { + Span lengthBytes = stackalloc byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, length); + target.Write(lengthBytes); + } + + private static byte[] Compress(byte[] data, int length, int level) + { + using var compressor = new ZstdSharp.Compressor(level); + return compressor.Wrap(data.AsSpan(0, length)).ToArray(); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_completed) + { + Complete(); + } + + base.Dispose(disposing); + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + } + + /// + /// Read-only counterpart to : reads the + /// [length][compressed bytes] chunk sequence written by it, decompressing chunks on a + /// bounded worker pool while yielding decompressed bytes in original order. Chunk headers are + /// read sequentially off (negligible cost), and up to + /// chunks are kept in flight at once so decompression + /// of later chunks overlaps with the caller consuming earlier ones — the same prefetch pattern + /// uses for compression, mirrored for the read side. + /// + internal sealed class ReadStream(Stream source, int? maxDegreeOfParallelism = null) : Stream + { + private readonly Queue> _pending = new(); + private readonly int _maxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism ?? Environment.ProcessorCount); + private byte[] _currentChunk = []; + private int _positionInChunk; + private bool _endOfStream; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + var totalRead = 0; + + while (count > 0) + { + if (_positionInChunk == _currentChunk.Length) + { + // Not gated on _endOfStream alone: that flag only means "no more chunk + // headers left to read," but chunks already prefetched into _pending (read + // ahead of the terminator) still need to be drained. + if (!TryAdvanceChunk()) + { + break; + } + } + + var toCopy = Math.Min(count, _currentChunk.Length - _positionInChunk); + Array.Copy(_currentChunk, _positionInChunk, buffer, offset, toCopy); + _positionInChunk += toCopy; + offset += toCopy; + count -= toCopy; + totalRead += toCopy; + } + + return totalRead; + } + + private bool TryAdvanceChunk() + { + FillPending(); + + if (_pending.Count == 0) + { + _endOfStream = true; + return false; + } + + _currentChunk = _pending.Dequeue().GetAwaiter().GetResult(); + _positionInChunk = 0; + + // Immediately queue the next chunk so decompression of what's now the tail of the + // pending queue overlaps with the caller consuming _currentChunk. + FillPending(); + + return _currentChunk.Length > 0 || TryAdvanceChunk(); + } + + private void FillPending() + { + while (!_endOfStream && _pending.Count < _maxDegreeOfParallelism) + { + if (!TryQueueNextChunk()) + { + break; + } + } + } + + private bool TryQueueNextChunk() + { + if (_endOfStream) + { + return false; + } + + Span lengthBytes = stackalloc byte[4]; + source.ReadExactly(lengthBytes); + var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes); + + if (length == 0) + { + _endOfStream = true; + return false; + } + + var chunk = new byte[length]; + source.ReadExactly(chunk); + + _pending.Enqueue(Task.Run(() => Decompress(chunk))); + return true; + } + + private static byte[] Decompress(byte[] compressed) + { + using var decompressor = new ZstdSharp.Decompressor(); + return decompressor.Unwrap(compressed).ToArray(); + } + + public override void Flush() => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + } +} diff --git a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs index 4118448..8483342 100644 --- a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs +++ b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs @@ -24,6 +24,14 @@ internal static class SnapshotStore /// private const int BlobFlagChunked = 0b100; + /// + /// Marks a blob's compressed payload as Zstd rather than gzip. Only meaningful when + /// is also set. Never set for blobs written before Zstd + /// support existed (they stay gzip forever, since content-addressed blobs are never rewritten + /// once they exist), so old blobs keep loading via the gzip branch in . + /// + private const int BlobFlagZstd = 0b1000; + private const int BlobNonceSize = 12; private const int BlobTagSize = 16; private const int Version = 1; @@ -190,7 +198,8 @@ internal static void Write( string blobDirectory, ImageCompressionLevel level, byte[]? cek, - IProgress? progress = null) + IProgress? progress = null, + int? customZstdLevel = null) { Directory.CreateDirectory(blobDirectory); @@ -221,7 +230,7 @@ internal static void Write( var written = 0; foreach (var kvp in nodes) { - WriteNode(writer, kvp.Key, kvp.Value, blobDirectory, level, cek); + WriteNode(writer, kvp.Key, kvp.Value, blobDirectory, level, cek, customZstdLevel); written++; progress?.Report((double)written / nodes.Count); } @@ -252,14 +261,17 @@ internal static void Write( /// /// Writes the blob for if it doesn't already exist. Streams - /// straight from through gzip compression + /// straight from through Zstd compression /// and (when is set) chunked AES-256-GCM encryption directly into the /// destination file — no whole-file buffer is ever materialized, so a single blob's size is /// not limited by 's ~2 GB cap. New encrypted blobs always use the /// chunked layout (, flagged via ); see - /// for the legacy whole-blob layout this format replaces. + /// for the legacy whole-blob layout this format replaces. New compressed + /// blobs are always flagged — nothing writes gzip anymore, but blobs + /// written before this flag existed stay gzip forever since content-addressed blobs already on + /// disk are never rewritten. /// - private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileContent data, long length, ImageCompressionLevel level, byte[]? cek) + private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileContent data, long length, ImageCompressionLevel level, byte[]? cek, int? customZstdLevel) { var blobPath = HashToBlobPath(blobDirectory, hash); if (File.Exists(blobPath)) @@ -270,7 +282,7 @@ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileCon Directory.CreateDirectory(Path.GetDirectoryName(blobPath)!); var compress = level != ImageCompressionLevel.None; - var flag = (compress ? BlobFlagCompressed : 0) | (cek is not null ? BlobFlagEncrypted | BlobFlagChunked : 0); + var flag = (compress ? BlobFlagCompressed | BlobFlagZstd : 0) | (cek is not null ? BlobFlagEncrypted | BlobFlagChunked : 0); var tempPath = blobPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; @@ -292,17 +304,17 @@ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileCon if (compress) { - var gzip = new GZipStream(target, level.ToDotNetCompressionLevel(), leaveOpen: true); + var zstd = new ParallelZstd.WriteStream(target, level.ToZstdLevel(customZstdLevel)); try { - data.CopyTo(gzip, length); + data.CopyTo(zstd, length); } finally { // Explicitly disposed (rather than relying on leaveOpen semantics further - // up the chain) so the deflate stream's final block is flushed before the - // chunked encryption below is completed. - gzip.Dispose(); + // up the chain) so every outstanding chunk is compressed and flushed before + // the chunked encryption below is completed. + zstd.Dispose(); } } else @@ -351,6 +363,7 @@ private static FileContent ReadBlob(string blobDirectory, byte[] hash, string no var compressed = (flag & BlobFlagCompressed) != 0; var encrypted = (flag & BlobFlagEncrypted) != 0; var chunked = (flag & BlobFlagChunked) != 0; + var useZstd = (flag & BlobFlagZstd) != 0; Stream plaintextStream; byte[]? legacyPlaintext = null; @@ -402,7 +415,9 @@ private static FileContent ReadBlob(string blobDirectory, byte[] hash, string no } var sourceStream = compressed - ? new GZipStream(plaintextStream, CompressionMode.Decompress) + ? useZstd + ? new ParallelZstd.ReadStream(plaintextStream) + : new GZipStream(plaintextStream, CompressionMode.Decompress) : plaintextStream; var aligned = FileNode.AlignToAllocationUnit(allocationSize); @@ -503,7 +518,7 @@ private static (string Path, NodeHeader Header) ReadNodeHeader(BinaryReader read return (metadata.Path, header); } - private static void WriteNode(BinaryWriter writer, string path, FileNode node, string blobDirectory, ImageCompressionLevel level, byte[]? cek) + private static void WriteNode(BinaryWriter writer, string path, FileNode node, string blobDirectory, ImageCompressionLevel level, byte[]? cek, int? customZstdLevel) { NodeMetadataIO.WriteMetadata(writer, path, node); @@ -527,7 +542,7 @@ private static void WriteNode(BinaryWriter writer, string path, FileNode node, s hash = incrementalHash.GetHashAndReset(); } - EnsureBlobWritten(blobDirectory, hash, node.FileData, fileSize, level, cek); + EnsureBlobWritten(blobDirectory, hash, node.FileData, fileSize, level, cek, customZstdLevel); writer.Write((byte)1); // HasBlob marker writer.Write(hash); diff --git a/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs b/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs index e0b8002..3185444 100644 --- a/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs +++ b/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs @@ -398,6 +398,169 @@ public void Load_LegacyVersion3EncryptedWholeBlobImage_StillLoads() } } + [Fact] + public void Save_WritesVersion5Header() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr"); + try + { + var map = new FileNodeMap(); + map.Add("\\", MakeDir()); + DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "MyLabel", path, ImageCompressionLevel.Fastest); + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + using var reader = new BinaryReader(stream); + reader.ReadBytes(4); // magic + Assert.Equal(5, reader.ReadInt32()); + } + finally + { + File.Delete(path); + } + } + + [Theory] + [InlineData(ImageCompressionLevel.Fastest)] + [InlineData(ImageCompressionLevel.Optimal)] + [InlineData(ImageCompressionLevel.SmallestSize)] + public void Save_Compressed_DoesNotUseGzip(ImageCompressionLevel level) + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr"); + try + { + var map = new FileNodeMap(); + map.Add("\\", MakeDir()); + map.Add("\\File.txt", MakeFile("hello world, this is compressible content"u8.ToArray())); + DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "MyLabel", path, level); + + var bytes = File.ReadAllBytes(path); + + // The gzip magic (0x1F 0x8B) should not appear right at the start of the node + // region — a weak but simple signal that the payload was Zstd- rather than + // gzip-compressed. (The node region starts right after the plaintext header.) + var headerLength = 4 + 4 + 1 + 1 + 8 + (4 + "MyLabel".Length); + Assert.False(bytes[headerLength] == 0x1F && bytes[headerLength + 1] == 0x8B); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Load_LegacyVersion4GzipCompressedImage_StillLoads() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr"); + try + { + byte[] nodeRegion; + using (var nodeRegionStream = new MemoryStream()) + { + using (var gzip = new GZipStream(nodeRegionStream, CompressionLevel.Fastest, leaveOpen: true)) + using (var payloadWriter = new BinaryWriter(gzip, System.Text.Encoding.UTF8, leaveOpen: true)) + { + payloadWriter.Write(0); // node count + } + + nodeRegion = nodeRegionStream.ToArray(); + } + + using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) + using (var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8)) + { + writer.Write("MDRD"u8.ToArray()); + writer.Write(4); // legacy gzip-compressed version, unencrypted + writer.Write((byte)ImageCompressionLevel.Fastest); + writer.Write((byte)0); // isEncrypted + writer.Write(2048UL); + writer.Write("LegacyGzipLabel"); + writer.Write(nodeRegion); + } + + var loaded = DiskImageSerializer.Load(path, out var capacityBytes, out var volumeLabel, password: null, out var cek); + + Assert.Equal(2048UL, capacityBytes); + Assert.Equal("LegacyGzipLabel", volumeLabel); + Assert.Equal(0, loaded.Count); + Assert.Null(cek); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void SaveThenLoad_WithParallelZstdAcrossMultipleChunks_RoundTrips() + { + // Force a tiny chunk size so a handful of KB of node data spans several independently + // compressed chunks, exercising the parallel Zstd compression path without allocating a + // real multi-megabyte buffer. + ParallelZstd.TestChunkSizeOverride = 64; + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr"); + try + { + var map = new FileNodeMap(); + map.Add("\\", MakeDir()); + for (var i = 0; i < 50; i++) + { + map.Add($"\\File{i}.txt", MakeFile(System.Text.Encoding.UTF8.GetBytes($"content for file number {i}"))); + } + + DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "ChunkedLabel", path, ImageCompressionLevel.Fastest); + + var loaded = DiskImageSerializer.Load(path, out var capacityBytes, out var volumeLabel, password: null, out var cek); + + Assert.Equal(1024UL * 1024, capacityBytes); + Assert.Equal("ChunkedLabel", volumeLabel); + Assert.Equal(51, loaded.Count); + for (var i = 0; i < 50; i++) + { + Assert.True(loaded.TryGet($"\\File{i}.txt", out var node)); + var expected = System.Text.Encoding.UTF8.GetBytes($"content for file number {i}"); + Assert.Equal(expected, node!.FileData!.ToArray(expected.Length)); + } + + Assert.Null(cek); + } + finally + { + ParallelZstd.TestChunkSizeOverride = null; + File.Delete(path); + } + } + + [Theory] + [InlineData(1)] + [InlineData(19)] + [InlineData(22)] + public void SaveThenLoad_WithCustomZstdLevel_RoundTrips(int customZstdLevel) + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr"); + try + { + var map = new FileNodeMap(); + map.Add("\\", MakeDir()); + map.Add("\\File.txt", MakeFile("hello world"u8.ToArray())); + + DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "MyLabel", path, ImageCompressionLevel.Fastest, + customZstdLevel: customZstdLevel); + + var loaded = DiskImageSerializer.Load(path, out var capacityBytes, out var volumeLabel, password: null, out var cek); + + Assert.Equal(1024UL * 1024, capacityBytes); + Assert.Equal("MyLabel", volumeLabel); + Assert.Equal(2, loaded.Count); + Assert.True(loaded.TryGet("\\File.txt", out var node)); + Assert.Equal("hello world"u8.ToArray(), node!.FileData!.ToArray("hello world"u8.Length)); + Assert.Null(cek); + } + finally + { + File.Delete(path); + } + } + private static FileNode MakeDir() => new() { FileInfo = { FileAttributes = (uint)FileAttributes.Directory }, diff --git a/tests/ManagedDrive.Tests/SnapshotManagerTests.cs b/tests/ManagedDrive.Tests/SnapshotManagerTests.cs index 7054924..9b2e1d2 100644 --- a/tests/ManagedDrive.Tests/SnapshotManagerTests.cs +++ b/tests/ManagedDrive.Tests/SnapshotManagerTests.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using System.Security.Cryptography; namespace ManagedDrive.Tests; @@ -414,6 +415,87 @@ public void LoadSnapshot_LegacyWholeBlobEncryptedBlob_StillLoads() Assert.Equal(content, node!.FileData!.ToArray(content.Length)); } + [Fact] + public void WriteSnapshot_CompressedBlob_FlagsZstd() + { + WriteSnapshotWithFile(new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), "\\a.txt", + "hello world, this is compressible content"u8.ToArray(), ImageCompressionLevel.Fastest); + + var blobPath = Directory.EnumerateFiles(BlobDirectory, "*.blob", SearchOption.AllDirectories).Single(); + var flag = File.ReadAllBytes(blobPath)[0]; + + Assert.Equal(0b1001, flag); // Compressed | Zstd + } + + [Fact] + public void LoadSnapshot_LegacyGzipCompressedBlob_StillLoads() + { + var content = "hello world, this is compressible content"u8.ToArray(); + + // Write a normal snapshot first so the index/blob directory exist with the expected + // layout, then hand-overwrite the blob it produced in the pre-Zstd gzip format (flag + // without the Zstd bit) to simulate a blob written before BlobFlagZstd existed. + var nodeMap = new FileNodeMap(); + nodeMap.Add("\\", MakeDir()); + nodeMap.Add("\\a.txt", MakeFile(content)); + SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath, + new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), ImageCompressionLevel.Fastest); + + var blobPath = Directory.EnumerateFiles(BlobDirectory, "*.blob", SearchOption.AllDirectories).Single(); + + using (var stream = new FileStream(blobPath, FileMode.Create, FileAccess.Write)) + { + stream.WriteByte(0b001); // Compressed, not Zstd (legacy gzip), not Encrypted + using var gzip = new GZipStream(stream, CompressionLevel.Fastest, leaveOpen: true); + gzip.Write(content); + } + + var snapshot = Assert.Single(SnapshotManager.ListSnapshots(_mainImagePath)); + var loaded = SnapshotManager.LoadSnapshot(snapshot.Path, out _, out _); + + Assert.True(loaded.TryGet("\\a.txt", out var node)); + Assert.Equal(content, node!.FileData!.ToArray(content.Length)); + } + + [Fact] + public void LoadSnapshot_MixedGzipAndZstdBlobsInSameIndex_BothLoad() + { + var legacyContent = "hello world, this is compressible content"u8.ToArray(); + var freshContent = "a completely different piece of content"u8.ToArray(); + + var nodeMap = new FileNodeMap(); + nodeMap.Add("\\", MakeDir()); + nodeMap.Add("\\legacy.txt", MakeFile(legacyContent)); + SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath, + new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), ImageCompressionLevel.Fastest); + + // Hand-overwrite the first snapshot's blob in the pre-Zstd gzip format, simulating + // content written before Zstd support existed. + var legacyBlobPath = Directory.EnumerateFiles(BlobDirectory, "*.blob", SearchOption.AllDirectories).Single(); + using (var stream = new FileStream(legacyBlobPath, FileMode.Create, FileAccess.Write)) + { + stream.WriteByte(0b001); // Compressed, not Zstd + using var gzip = new GZipStream(stream, CompressionLevel.Fastest, leaveOpen: true); + gzip.Write(legacyContent); + } + + // A second snapshot adds a new file, which is written fresh (Zstd), while still + // referencing the untouched legacy gzip blob for the unchanged file. + nodeMap.Add("\\fresh.txt", MakeFile(freshContent)); + SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath, + new(2026, 1, 2, 0, 0, 0, TimeSpan.Zero), ImageCompressionLevel.Fastest); + + Assert.Equal(2, BlobCount); + + var latest = SnapshotManager.ListSnapshots(_mainImagePath).OrderBy(s => s.TimestampUtc).Last(); + var loaded = SnapshotManager.LoadSnapshot(latest.Path, out _, out _); + + Assert.True(loaded.TryGet("\\legacy.txt", out var legacyNode)); + Assert.Equal(legacyContent, legacyNode!.FileData!.ToArray(legacyContent.Length)); + Assert.True(loaded.TryGet("\\fresh.txt", out var freshNode)); + Assert.Equal(freshContent, freshNode!.FileData!.ToArray(freshContent.Length)); + } + [Fact] public void LoadSnapshot_TruncatedBlob_ThrowsInvalidData() { @@ -709,11 +791,12 @@ private static FileNode MakeFile(byte[] content) }; } - private void WriteSnapshotWithFile(DateTimeOffset timestampUtc, string path, byte[] content) + private void WriteSnapshotWithFile(DateTimeOffset timestampUtc, string path, byte[] content, + ImageCompressionLevel level = ImageCompressionLevel.None) { var nodeMap = new FileNodeMap(); nodeMap.Add("\\", MakeDir()); nodeMap.Add(path, MakeFile(content)); - SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath, timestampUtc, ImageCompressionLevel.None); + SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath, timestampUtc, level); } } \ No newline at end of file diff --git a/tests/ManagedDrive.Tests/ZstdConcatenatedFramesTests.cs b/tests/ManagedDrive.Tests/ZstdConcatenatedFramesTests.cs new file mode 100644 index 0000000..8487534 --- /dev/null +++ b/tests/ManagedDrive.Tests/ZstdConcatenatedFramesTests.cs @@ -0,0 +1,47 @@ +using ZstdSharp; + +namespace ManagedDrive.Tests; + +/// +/// Guards the assumption the parallel Zstd node-region writer relies on: independently +/// compressed Zstd frames, written back-to-back into one stream, decompress transparently as +/// if they were a single frame. If this ever stopped being true, DecompressionStream +/// would need explicit per-chunk framing instead. +/// +public sealed class ZstdConcatenatedFramesTests +{ + [Fact] + public void DecompressionStream_ReadsMultipleConcatenatedIndependentFrames() + { + var chunk1 = System.Text.Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("Hello World! ", 500))); + var chunk2 = System.Text.Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("Goodbye World! ", 500))); + + byte[] frame1, frame2; + using (var c1 = new Compressor(3)) + { + frame1 = c1.Wrap(chunk1).ToArray(); + } + + using (var c2 = new Compressor(3)) + { + frame2 = c2.Wrap(chunk2).ToArray(); + } + + using var concatenated = new MemoryStream(); + concatenated.Write(frame1); + concatenated.Write(frame2); + concatenated.Position = 0; + + using var decompressed = new MemoryStream(); + using (var ds = new DecompressionStream(concatenated)) + { + ds.CopyTo(decompressed); + } + + var expected = new byte[chunk1.Length + chunk2.Length]; + Buffer.BlockCopy(chunk1, 0, expected, 0, chunk1.Length); + Buffer.BlockCopy(chunk2, 0, expected, chunk1.Length, chunk2.Length); + + Assert.Equal(expected, decompressed.ToArray()); + } +} From 999ccc8af7dccb2b7b4571fc856cfa4a4794cabd Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 22:51:28 +0800 Subject: [PATCH 11/14] feat: add advanced custom Zstd compression level override Let a disk's save/snapshot compression optionally override the exact Zstd level (1-22) instead of the coarse Fastest/Optimal/SmallestSize mapping, exposed as an advanced option in CreateDiskDialog and persisted per-disk via DiskProfile/DiskOptions. Bumps SharpCompress to 0.50.3. --- Directory.Packages.props | 4 +- .../Localization/Strings.en-US.xaml | 4 +- .../Localization/Strings.zh-CN.xaml | 4 +- src/ManagedDrive.App/Models/DiskProfile.cs | 9 +++ .../ViewModels/MainViewModel.cs | 2 + .../Views/CreateDiskDialog.xaml | 56 ++++++++++++----- .../Views/CreateDiskDialog.xaml.cs | 61 ++++++++++++++++--- .../DiskCreation/CreateDiskOptionsBuilder.cs | 21 +++++++ src/ManagedDrive.Core/Mounting/DiskOptions.cs | 29 ++++++++- src/ManagedDrive.Core/Mounting/RamDisk.cs | 6 +- .../Snapshots/SnapshotManager.cs | 5 +- .../CreateDiskOptionsBuilderTests.cs | 48 +++++++++++++++ .../DiskProfileMappingTests.cs | 1 + 13 files changed, 216 insertions(+), 34 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0756c13..00e66b6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + @@ -23,6 +23,6 @@ - + \ No newline at end of file diff --git a/src/ManagedDrive.App/Localization/Strings.en-US.xaml b/src/ManagedDrive.App/Localization/Strings.en-US.xaml index 62083a2..ddb59f4 100644 --- a/src/ManagedDrive.App/Localization/Strings.en-US.xaml +++ b/src/ManagedDrive.App/Localization/Strings.en-US.xaml @@ -73,7 +73,9 @@ Fast Balanced Max - Balanced/Max compression may significantly increase save times. Choose carefully. + Custom Zstd level: + Advanced: override the compression preset above with an exact Zstd level (1 = fastest, 22 = smallest). Leave unchecked to use the preset's level. + Enter a custom Zstd level between 1 and 22. Save image on exit Auto-save the image, interval (minutes): Interval (min) diff --git a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml index 0f9166c..9059dce 100644 --- a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml +++ b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml @@ -73,7 +73,9 @@ 快速 均衡 最高 - 均衡/最高压缩可能会显著增加保存时间,请谨慎选择。 + 自定义 Zstd 级别: + 高级选项:用精确的 Zstd 级别覆盖上方的预设档位(1=最快,22=最小)。不勾选则使用预设档位对应的级别。 + 请输入 1 到 22 之间的自定义 Zstd 级别。 退出时保存镜像 自动保存镜像,间隔(分钟): 间隔(分钟) diff --git a/src/ManagedDrive.App/Models/DiskProfile.cs b/src/ManagedDrive.App/Models/DiskProfile.cs index e268ecf..87c26a1 100644 --- a/src/ManagedDrive.App/Models/DiskProfile.cs +++ b/src/ManagedDrive.App/Models/DiskProfile.cs @@ -70,6 +70,15 @@ public uint? AutoSaveIntervalMinutes /// public ImageCompressionLevel CompressionLevel { get; init; } = ImageCompressionLevel.Fastest; + /// + /// Gets or sets the optional advanced override (1-22) of the exact Zstd level used instead of + /// the preset mapped from . null means use the preset. + /// + public int? CustomZstdLevel + { + get; init; + } + /// /// Gets or sets the optional maximum number of retained snapshot images. null /// disables count-based snapshot pruning. diff --git a/src/ManagedDrive.App/ViewModels/MainViewModel.cs b/src/ManagedDrive.App/ViewModels/MainViewModel.cs index dc9463e..44a839d 100644 --- a/src/ManagedDrive.App/ViewModels/MainViewModel.cs +++ b/src/ManagedDrive.App/ViewModels/MainViewModel.cs @@ -468,6 +468,7 @@ public void ExitWithoutConfirmation() SourceArchivePath = options.SourceArchivePath, AutoSaveIntervalMinutes = options.AutoSaveIntervalMinutes, CompressionLevel = options.CompressionLevel, + CustomZstdLevel = options.CustomZstdLevel, MaxSnapshotCount = options.MaxSnapshotCount, MaxSnapshotSizeBytes = options.MaxSnapshotSizeBytes, HighUsageWarnPercent = options.HighUsageWarnPercent, @@ -855,6 +856,7 @@ internal void ShowDiskActivityStatus(string mountPoint, bool isWrite, string fil SourceArchivePath = p.SourceArchivePath, AutoSaveIntervalMinutes = p.AutoSaveIntervalMinutes, CompressionLevel = p.CompressionLevel, + CustomZstdLevel = p.CustomZstdLevel, MaxSnapshotCount = p.MaxSnapshotCount, MaxSnapshotSizeBytes = p.MaxSnapshotSizeBytes, HighUsageWarnPercent = p.HighUsageWarnPercent, diff --git a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml index 9c429b8..1056a98 100644 --- a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml +++ b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml @@ -14,7 +14,7 @@ - + @@ -140,6 +140,8 @@ + + @@ -205,30 +207,54 @@ SelectionChanged="CompressionLevelBox_SelectionChanged"/> - - + + + + + + + + + + + + + + + + + + - - - - + @@ -253,11 +279,11 @@ - - + @@ -287,7 +313,7 @@ - + @@ -316,7 +342,7 @@ - + diff --git a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs index be8e6f8..5f01772 100644 --- a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs +++ b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs @@ -26,6 +26,7 @@ public partial class CreateDiskDialog private readonly bool _wasEncrypted; private int _capacityMaximum = 99999999; private int _capacityValue = 2; + private int _customZstdLevelValue = 3; private int _highUsageWarnPercentValue = 90; private int _intervalValue = 10; private int _snapshotCountValue = 10; @@ -69,6 +70,7 @@ public CreateDiskDialog(IReadOnlyList? otherDisks = null) } CompressionLevelBox.SelectedIndex = CompressionLevels.IndexOf(ImageCompressionLevel.Fastest); + CustomZstdLevelValue = _customZstdLevelValue; UpdateCompressionLevelState(); UpdateAutoSaveEnabledState(); UpdateHighUsageWarnPercentState(); @@ -121,6 +123,13 @@ public CreateDiskDialog(DiskOptions existing, IReadOnlyList? otherD ImagePathBox.Text = existing.PersistImagePath ?? string.Empty; CompressionLevelBox.SelectedIndex = CompressionLevels.IndexOf(existing.CompressionLevel); SaveOnExitBox.IsChecked = existing.SaveImageOnExit; + + if (existing.CustomZstdLevel is { } customZstdLevel) + { + CustomZstdLevelBox.IsChecked = true; + CustomZstdLevelValue = customZstdLevel; + } + UpdateCompressionLevelState(); UpdateAutoSaveEnabledState(); @@ -336,6 +345,17 @@ private int CapacityValue } } + private int CustomZstdLevelValue + { + get => _customZstdLevelValue; + set + { + _customZstdLevelValue = Math.Clamp(value, 1, 22); + CustomZstdLevelSlider.Value = _customZstdLevelValue; + CustomZstdLevelValueText?.Text = _customZstdLevelValue.ToString(); + } + } + private int HighUsageWarnPercentValue { get => _highUsageWarnPercentValue; @@ -438,6 +458,8 @@ private CreateDiskInput BuildInput() HighUsageWarnPercentValue = _highUsageWarnPercentValue, CompressionLevel = (CompressionLevelBox.SelectedItem as CompressionLevelItem)?.Level ?? ImageCompressionLevel.Fastest, + CustomZstdLevelEnabled = CustomZstdLevelRow.IsEnabled && CustomZstdLevelBox.IsChecked == true, + CustomZstdLevelValue = _customZstdLevelValue, SaveImageOnExit = SaveOnExitBox.IsChecked == true, EncryptChecked = EncryptImageBox.IsChecked == true, Password1 = PasswordBox1.Password, @@ -477,7 +499,17 @@ private void ClearImagePath_Click(object sender, RoutedEventArgs e) private void CompressionLevelBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { - UpdateCompressionWarning(); + UpdateCustomZstdLevelRowState(); + } + + private void CustomZstdLevelBox_CheckedChanged(object sender, RoutedEventArgs e) + { + UpdateCustomZstdLevelRowState(); + } + + private void CustomZstdLevelSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + CustomZstdLevelValue = (int)e.NewValue; } /// @@ -578,6 +610,7 @@ private void LoadDriveLetters(char? reservedLetter) CreateDiskValidationError.BadSnapshotCount => Loc.Get("Val.BadSnapshotCount"), CreateDiskValidationError.BadSnapshotSize => Loc.Get("Val.BadSnapshotSize"), CreateDiskValidationError.BadHighUsagePercent => Loc.Get("Val.BadHighUsagePercent"), + CreateDiskValidationError.BadCustomZstdLevel => Loc.Get("Val.BadCustomZstdLevel"), CreateDiskValidationError.PasswordRequired => Loc.Get("Val.PasswordRequired"), CreateDiskValidationError.PasswordMismatch => Loc.Get("Val.PasswordMismatch"), CreateDiskValidationError.PasswordTooShort => @@ -750,17 +783,29 @@ private void UpdateCompressionLevelState() // Toggle the whole row (label + combo) so the label greys out with the control when a // read-only disk has nothing to compress. CompressionLevelRow.IsEnabled = !string.IsNullOrEmpty(ImagePathBox.Text) && ReadOnlyBox.IsChecked != true; - UpdateCompressionWarning(); + UpdateCustomZstdLevelRowState(); } - private void UpdateCompressionWarning() + /// + /// The custom-Zstd-level override only makes sense when compression is actually happening — + /// disabled whenever the main compression row is disabled (read-only/no image), or when + /// is selected (nothing to compress at all). + /// + private void UpdateCustomZstdLevelRowState() { - if (CompressionWarningText is null) - return; var level = (CompressionLevelBox.SelectedItem as CompressionLevelItem)?.Level; - var show = CompressionLevelRow.IsEnabled - && level is ImageCompressionLevel.Optimal or ImageCompressionLevel.SmallestSize; - CompressionWarningText.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + CustomZstdLevelRow.IsEnabled = CompressionLevelRow.IsEnabled && level != ImageCompressionLevel.None; + if (!CustomZstdLevelRow.IsEnabled) + { + CustomZstdLevelBox.IsChecked = false; + } + + var customEnabled = CustomZstdLevelBox.IsChecked == true; + CustomZstdLevelPanel.IsEnabled = customEnabled; + + // A custom Zstd level overrides the preset entirely, so grey out the preset dropdown + // while it's active rather than leaving it selectable but silently ignored. + CompressionLevelBox.IsEnabled = CompressionLevelRow.IsEnabled && !customEnabled; } private void UpdateHighUsageWarnPercentState() diff --git a/src/ManagedDrive.Core/DiskCreation/CreateDiskOptionsBuilder.cs b/src/ManagedDrive.Core/DiskCreation/CreateDiskOptionsBuilder.cs index dda18f2..23e48dc 100644 --- a/src/ManagedDrive.Core/DiskCreation/CreateDiskOptionsBuilder.cs +++ b/src/ManagedDrive.Core/DiskCreation/CreateDiskOptionsBuilder.cs @@ -76,6 +76,9 @@ public enum CreateDiskValidationError /// The high-usage warning percentage was out of range. BadHighUsagePercent, + /// The custom Zstd compression level was out of range. + BadCustomZstdLevel, + /// Encryption was enabled but no password was entered. PasswordRequired, @@ -162,6 +165,12 @@ public sealed record CreateDiskInput /// The selected compression level. public ImageCompressionLevel CompressionLevel { get; init; } = ImageCompressionLevel.Fastest; + /// Whether the advanced custom-Zstd-level override is enabled. + public bool CustomZstdLevelEnabled { get; init; } + + /// The custom Zstd level (1-22), used only when is set. + public int CustomZstdLevelValue { get; init; } + /// Whether to save the image on exit. public bool SaveImageOnExit { get; init; } @@ -349,6 +358,17 @@ public static CreateDiskBuildResult Build(CreateDiskInput input) return Fail(CreateDiskValidationError.BadHighUsagePercent); } + int? customZstdLevel = null; + if (input.CompressionLevel != ImageCompressionLevel.None && input.CustomZstdLevelEnabled) + { + if (input.CustomZstdLevelValue < 1 || input.CustomZstdLevelValue > 22) + { + return Fail(CreateDiskValidationError.BadCustomZstdLevel); + } + + customZstdLevel = input.CustomZstdLevelValue; + } + var passwordResult = ResolvePassword(input); if (!passwordResult.Success) { @@ -365,6 +385,7 @@ public static CreateDiskBuildResult Build(CreateDiskInput input) PersistImagePath = imagePath, AutoSaveIntervalMinutes = autoSaveIntervalMinutes, CompressionLevel = input.CompressionLevel, + CustomZstdLevel = customZstdLevel, MaxSnapshotCount = maxSnapshotCount, MaxSnapshotSizeBytes = maxSnapshotSizeBytes, HighUsageWarnPercent = highUsageWarnPercent, diff --git a/src/ManagedDrive.Core/Mounting/DiskOptions.cs b/src/ManagedDrive.Core/Mounting/DiskOptions.cs index a68a20f..138ed65 100644 --- a/src/ManagedDrive.Core/Mounting/DiskOptions.cs +++ b/src/ManagedDrive.Core/Mounting/DiskOptions.cs @@ -28,13 +28,14 @@ public enum ImageCompressionLevel /// /// Conversion helpers for , shared by every writer that hands -/// it off to a (DiskImageSerializer for -/// image saves, SnapshotStore for snapshot blobs). +/// it off to a compression stream (DiskImageSerializer for image saves, SnapshotStore +/// for snapshot blobs). /// internal static class ImageCompressionLevelExtensions { /// - /// Maps to the corresponding . Callers are + /// Maps to the corresponding , used only + /// when reading legacy gzip-compressed images/blobs (nothing writes gzip anymore). Callers are /// expected to have already checked and skipped /// compression entirely in that case; it otherwise falls back to . /// @@ -44,6 +45,20 @@ internal static class ImageCompressionLevelExtensions ImageCompressionLevel.SmallestSize => System.IO.Compression.CompressionLevel.SmallestSize, _ => System.IO.Compression.CompressionLevel.Optimal, }; + + /// + /// Maps to the corresponding Zstd compression level (1-22 range), used for all newly written + /// images/blobs. Callers are expected to have already checked + /// and skipped compression entirely in that case. (typically + /// ) overrides the preset mapping when set, letting + /// advanced users pick an exact Zstd level instead of one of the three coarse presets. + /// + public static int ToZstdLevel(this ImageCompressionLevel level, int? customLevel = null) => customLevel ?? level switch + { + ImageCompressionLevel.Fastest => 1, + ImageCompressionLevel.SmallestSize => 19, + _ => 3, + }; } /// @@ -126,6 +141,14 @@ public uint? AutoSaveIntervalMinutes /// public ImageCompressionLevel CompressionLevel { get; init; } = ImageCompressionLevel.Fastest; + /// + /// Optional advanced override of the exact Zstd compression level (1-22) used instead of the + /// preset mapped from . null (the default) means use the + /// preset's level. Has no effect when is + /// — that always skips compression regardless. + /// + public int? CustomZstdLevel { get; init; } + /// /// Optional maximum number of timestamped snapshot images to retain alongside /// . null disables count-based snapshot pruning. diff --git a/src/ManagedDrive.Core/Mounting/RamDisk.cs b/src/ManagedDrive.Core/Mounting/RamDisk.cs index 067715e..01c744b 100644 --- a/src/ManagedDrive.Core/Mounting/RamDisk.cs +++ b/src/ManagedDrive.Core/Mounting/RamDisk.cs @@ -450,7 +450,8 @@ public void SaveToImage(IProgress? progress = null) Options.PersistImagePath, Options.CompressionLevel, _password is not null && _cek is not null ? new ImageEncryptionInfo(_password, _cek) : null, - progress); + progress, + Options.CustomZstdLevel); } catch (Exception ex) { @@ -934,7 +935,8 @@ private void TryWriteSnapshot(IProgress? progress = null) DateTimeOffset.UtcNow, Options.CompressionLevel, _cek, - progress); + progress, + Options.CustomZstdLevel); SnapshotManager.Prune(path, Options.MaxSnapshotCount, Options.MaxSnapshotSizeBytes); } diff --git a/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs b/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs index 58fb493..35c6152 100644 --- a/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs +++ b/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs @@ -336,10 +336,11 @@ public static void WriteSnapshot( DateTimeOffset timestampUtc, ImageCompressionLevel level, byte[]? cek = null, - IProgress? progress = null) + IProgress? progress = null, + int? customZstdLevel = null) { var indexPath = BuildSnapshotPath(mainImagePath, timestampUtc); - SnapshotStore.Write(nodeMap, capacityBytes, volumeLabel, indexPath, BlobDirectory(mainImagePath), level, cek, progress); + SnapshotStore.Write(nodeMap, capacityBytes, volumeLabel, indexPath, BlobDirectory(mainImagePath), level, cek, progress, customZstdLevel); } private static string BlobDirectory(string mainImagePath) => SnapshotStore.ComputeBlobDirectory(mainImagePath); diff --git a/tests/ManagedDrive.Tests/CreateDiskOptionsBuilderTests.cs b/tests/ManagedDrive.Tests/CreateDiskOptionsBuilderTests.cs index b695096..f87a184 100644 --- a/tests/ManagedDrive.Tests/CreateDiskOptionsBuilderTests.cs +++ b/tests/ManagedDrive.Tests/CreateDiskOptionsBuilderTests.cs @@ -343,6 +343,54 @@ private static CreateDiskInput EncryptedInput(out DirectoryInfo dir, string p1, }; } + [Fact] + public void Build_CustomZstdLevelDisabled_LeavesCustomZstdLevelNull() + { + var result = CreateDiskOptionsBuilder.Build(ValidCreateInput()); + + Assert.True(result.Success); + Assert.Null(result.Options!.CustomZstdLevel); + } + + [Fact] + public void Build_CustomZstdLevelEnabled_SetsCustomZstdLevel() + { + var input = ValidCreateInput() with { CustomZstdLevelEnabled = true, CustomZstdLevelValue = 12 }; + + var result = CreateDiskOptionsBuilder.Build(input); + + Assert.True(result.Success); + Assert.Equal(12, result.Options!.CustomZstdLevel); + } + + [Theory] + [InlineData(0)] + [InlineData(23)] + public void Build_CustomZstdLevelOutOfRange_ReturnsBadCustomZstdLevel(int value) + { + var input = ValidCreateInput() with { CustomZstdLevelEnabled = true, CustomZstdLevelValue = value }; + + var result = CreateDiskOptionsBuilder.Build(input); + + Assert.Equal(CreateDiskValidationError.BadCustomZstdLevel, result.Error); + } + + [Fact] + public void Build_CustomZstdLevelEnabledButCompressionNone_IgnoresCustomZstdLevel() + { + var input = ValidCreateInput() with + { + CompressionLevel = ImageCompressionLevel.None, + CustomZstdLevelEnabled = true, + CustomZstdLevelValue = 12, + }; + + var result = CreateDiskOptionsBuilder.Build(input); + + Assert.True(result.Success); + Assert.Null(result.Options!.CustomZstdLevel); + } + private static CreateDiskInput ValidCreateInput() => new() { MountPoint = "Z:", diff --git a/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs b/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs index 35b9bef..d275fd2 100644 --- a/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs +++ b/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs @@ -19,6 +19,7 @@ public void ToProfile_ThenProfileToOptions_RoundTripsEveryField() SourceArchivePath = @"C:\archives\disk.zip", AutoSaveIntervalMinutes = 15, CompressionLevel = ImageCompressionLevel.SmallestSize, + CustomZstdLevel = 19, MaxSnapshotCount = 7, MaxSnapshotSizeBytes = 999_000_000UL, HighUsageWarnPercent = 85.5, From b55f867287dc70a94d460ea8b4750b39400b448c Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 23:12:03 +0800 Subject: [PATCH 12/14] docs: complete third-party notices and link them from About dialog List every distributed NuGet dependency (ZstdSharp.Port, ThrottledLogging, System.CommandLine, Spectre.Console, YamlDotNet, Microsoft.Extensions.*, Serilog and its sinks) in THIRD-PARTY-NOTICES.md, and replace the per-package WinFsp/SharpCompress hyperlinks in AboutDialog with a single link to that file so future dependency additions only require a doc update. --- THIRD-PARTY-NOTICES.md | 44 ++++++++++++++++++- .../Localization/Strings.en-US.xaml | 5 +-- .../Localization/Strings.zh-CN.xaml | 5 +-- src/ManagedDrive.App/Views/AboutDialog.xaml | 19 +------- .../Views/AboutDialog.xaml.cs | 6 +-- 5 files changed, 49 insertions(+), 30 deletions(-) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index ee79185..a9c4815 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -11,6 +11,48 @@ ManagedDrive uses the following open-source software. ## SharpCompress -- **Package:** `SharpCompress` 0.49.1 +- **Package:** `SharpCompress` 0.50.3 - **Copyright:** Copyright (c) 2025 Adam Hathcock - **License:** [MIT License](https://github.com/adamhathcock/sharpcompress/blob/master/LICENSE) + +## ZstdSharp.Port + +- **Package:** `ZstdSharp.Port` 0.8.8 +- **Copyright:** Copyright (c) Oleg Stepanischev +- **License:** [MIT License](https://github.com/oleg-st/ZstdSharp/blob/master/LICENSE) + +## ThrottledLogging + +- **Package:** `ThrottledLogging` 1.0.10 +- **Copyright:** Copyright (c) coldhighsun +- **License:** [MIT License](https://github.com/coldhighsun/ThrottledLogging/blob/master/LICENSE) + +## System.CommandLine + +- **Package:** `System.CommandLine` 2.0.10 +- **Copyright:** © Microsoft Corporation +- **License:** [MIT License](https://github.com/dotnet/command-line-api/blob/main/LICENSE.md) + +## Spectre.Console + +- **Package:** `Spectre.Console` 0.57.2 +- **Copyright:** Copyright (c) Patrik Svensson, Phil Scott, Nils Andresen, Cédric Luthi +- **License:** [MIT License](https://github.com/spectreconsole/spectre.console/blob/main/LICENSE.md) + +## YamlDotNet + +- **Package:** `YamlDotNet` 18.1.0 +- **Copyright:** Copyright (c) Antoine Aubry and contributors +- **License:** [MIT License](https://github.com/aaubry/YamlDotNet/blob/master/LICENSE.txt) + +## Microsoft.Extensions.* (Hosting.WindowsServices, Logging, Logging.Abstractions, DependencyInjection) + +- **Packages:** `Microsoft.Extensions.Hosting.WindowsServices`, `Microsoft.Extensions.Logging`, `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.DependencyInjection` 10.0.10 +- **Copyright:** © Microsoft Corporation +- **License:** [MIT License](https://github.com/dotnet/runtime/blob/main/LICENSE.TXT) + +## Serilog and Serilog sinks (Serilog, Serilog.Extensions.Logging, Serilog.Sinks.File, Serilog.Sinks.Async) + +- **Packages:** `Serilog` 4.4.0, `Serilog.Extensions.Logging` 10.0.0, `Serilog.Sinks.File` 7.0.0, `Serilog.Sinks.Async` 2.1.0 +- **Copyright:** Copyright © Serilog Contributors +- **License:** [Apache License 2.0](https://github.com/serilog/serilog/blob/dev/LICENSE) diff --git a/src/ManagedDrive.App/Localization/Strings.en-US.xaml b/src/ManagedDrive.App/Localization/Strings.en-US.xaml index ddb59f4..429fb69 100644 --- a/src/ManagedDrive.App/Localization/Strings.en-US.xaml +++ b/src/ManagedDrive.App/Localization/Strings.en-US.xaml @@ -317,10 +317,7 @@ An in-memory RAM disk for Windows. © 2026 coldhighsun View on GitHub - Powered by WinFsp - © 2015-2026 Bill Zissimopoulos — GNU GPLv2 (FUSE exception) or commercial license - Powered by SharpCompress - © 2025 Adam Hathcock — MIT License + Third-Party Notices A new version ({0}) is available. Click to download. diff --git a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml index 9059dce..3f0841a 100644 --- a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml +++ b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml @@ -317,10 +317,7 @@ Windows 内存虚拟磁盘工具。 © 2026 coldhighsun 在 GitHub 上查看 - 基于 WinFsp 构建 - © 2015-2026 Bill Zissimopoulos — GNU GPLv2(附 FUSE 例外)或商业许可 - 基于 SharpCompress 构建 - © 2025 Adam Hathcock — MIT 许可证 + 第三方声明 发现新版本 {0},点击下载。 diff --git a/src/ManagedDrive.App/Views/AboutDialog.xaml b/src/ManagedDrive.App/Views/AboutDialog.xaml index 756d84e..e74df0b 100644 --- a/src/ManagedDrive.App/Views/AboutDialog.xaml +++ b/src/ManagedDrive.App/Views/AboutDialog.xaml @@ -57,25 +57,10 @@ - - + + - - - - - - - diff --git a/src/ManagedDrive.App/Views/AboutDialog.xaml.cs b/src/ManagedDrive.App/Views/AboutDialog.xaml.cs index e9852f0..0c99fd7 100644 --- a/src/ManagedDrive.App/Views/AboutDialog.xaml.cs +++ b/src/ManagedDrive.App/Views/AboutDialog.xaml.cs @@ -8,8 +8,7 @@ namespace ManagedDrive.App.Views; public partial class AboutDialog { private const string GitHubUrl = "https://github.com/coldhighsun/ManagedDrive"; - private const string SharpCompressUrl = "https://github.com/adamhathcock/sharpcompress"; - private const string WinFspUrl = "https://winfsp.dev/"; + private const string ThirdPartyNoticesUrl = "https://github.com/coldhighsun/ManagedDrive/blob/main/THIRD-PARTY-NOTICES.md"; private readonly UpdateCheckService? _updateCheckService; public AboutDialog(UpdateCheckService? updateCheckService = null) @@ -19,8 +18,7 @@ public AboutDialog(UpdateCheckService? updateCheckService = null) VersionText.Text = UpdateCheckService.GetRunningVersion(); GitHubLink.NavigateUri = new(GitHubUrl); - WinFspLink.NavigateUri = new(WinFspUrl); - SharpCompressLink.NavigateUri = new(SharpCompressUrl); + ThirdPartyNoticesLink.NavigateUri = new(ThirdPartyNoticesUrl); _ = CheckForUpdateAsync(); } From 9df3c37e009666411531dd8ac6fd616d7402adaa Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 23:27:41 +0800 Subject: [PATCH 13/14] feat: support custom Zstd compression level override in mdrive mount Wires DiskOptions.CustomZstdLevel through the CLI mount path (CliMountOverrides, MountOptionsFactory.MountOverrides, and MainViewModel.MountImageAsync) so --custom-zstd-level (1-22) can override the preset level mapped from --compression, matching the GUI's existing custom-level support. --- README.md | 6 +-- .../ViewModels/MainViewModel.cs | 1 + .../CliCommandProcessor.cs | 13 ++++++ .../CliMountOverrides.cs | 10 +++++ .../Mounting/MountOptionsFactory.cs | 4 ++ .../MountOptionsFactoryTests.cs | 40 +++++++++++++++++++ 6 files changed, 71 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 66cfde5..2c5d8aa 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ BenchmarkDotNet will prompt you to pick which benchmark class(es) to run (`Seque `mdrive.exe` ships alongside `ManagedDrive.exe` and forwards commands to the running app over a named pipe, so scripts can drive ManagedDrive without opening the UI. If the app isn't already running, `mdrive` launches it and retries for up to 10 seconds before giving up. ```powershell -mdrive mount C:\disks\scratch.mdr R: --auto-mount --compression Optimal +mdrive mount C:\disks\scratch.mdr R: --auto-mount --compression Optimal --custom-zstd-level 19 mdrive list mdrive save R: mdrive format R: --yes @@ -179,7 +179,7 @@ mdrive exit | Command | Description | |---|---| -| `mount [options]` | Mounts an existing `.mdr` image at a drive letter. Options: `--read-only`, `--auto-mount`, `--auto-save-minutes`, `--compression `, `--max-snapshot-count`, `--max-snapshot-size-mb`, `--high-usage-warn-percent`, `--password`, `--password-file` (mutually exclusive; needed only if the image is encrypted — `--password-file` reads the first line of a file and is recommended over `--password` to avoid exposing it in shell history or the process list). Any option left unset keeps the image's saved profile value (or its default). | +| `mount [options]` | Mounts an existing `.mdr` image at a drive letter. Options: `--read-only`, `--auto-mount`, `--auto-save-minutes`, `--compression `, `--custom-zstd-level <1-22>` (overrides the preset Zstd level mapped from `--compression`; only takes effect when the compression level is not `None`), `--max-snapshot-count`, `--max-snapshot-size-mb`, `--high-usage-warn-percent`, `--password`, `--password-file` (mutually exclusive; needed only if the image is encrypted — `--password-file` reads the first line of a file and is recommended over `--password` to avoid exposing it in shell history or the process list). Any option left unset keeps the image's saved profile value (or its default). | | `mount-archive [drive-letter]` | Imports an archive (zip/7z/rar/tar/...) as a read-only disk and opens it in Explorer once mounted. `drive-letter` is optional — if omitted, the first free letter from `Z:` down to `D:` is used. Used internally by the Explorer right-click menu entry. | | `unmount ` | Unmounts a mounted disk. | | `format --yes` | Deletes all files on a mounted disk. Requires `--yes`/`-y` to confirm. | @@ -398,7 +398,7 @@ BenchmarkDotNet 会提示你选择要运行的基准测试类(`SequentialReadW `mdrive.exe` 随 `ManagedDrive.exe` 一同发布,通过命名管道将命令转发给正在运行的应用,因此脚本无需打开界面即可操作 ManagedDrive。若应用尚未运行,`mdrive` 会自动启动它,并在最长 10 秒内重试。 ```powershell -mdrive mount C:\disks\scratch.mdr R: --auto-mount --compression Optimal +mdrive mount C:\disks\scratch.mdr R: --auto-mount --compression Optimal --custom-zstd-level 19 mdrive list mdrive save R: mdrive format R: --yes diff --git a/src/ManagedDrive.App/ViewModels/MainViewModel.cs b/src/ManagedDrive.App/ViewModels/MainViewModel.cs index 44a839d..e9fe396 100644 --- a/src/ManagedDrive.App/ViewModels/MainViewModel.cs +++ b/src/ManagedDrive.App/ViewModels/MainViewModel.cs @@ -665,6 +665,7 @@ public async Task MountFromProfileAsync(DiskProfile profile, IProgress ExecuteAsync(string[] args, ICliDiskControl { Description = "Image compression level: None, Fastest, Optimal, or SmallestSize. If omitted, keeps the saved profile's value (or the default: Fastest).", }; + var mountCustomZstdLevelOption = new Option("--custom-zstd-level") + { + Description = "Custom Zstd compression level (1-22), overriding the preset mapping for --compression. Only takes effect when the compression level is not None.", + }; var mountMaxSnapshotCountOption = new Option("--max-snapshot-count") { Description = "Maximum number of retained snapshots. If omitted, keeps the saved profile's value (or the default: unlimited).", @@ -71,6 +75,7 @@ public static async Task ExecuteAsync(string[] args, ICliDiskControl mountCommand.Options.Add(mountAutoMountOption); mountCommand.Options.Add(mountAutoSaveMinutesOption); mountCommand.Options.Add(mountCompressionOption); + mountCommand.Options.Add(mountCustomZstdLevelOption); mountCommand.Options.Add(mountMaxSnapshotCountOption); mountCommand.Options.Add(mountMaxSnapshotSizeMbOption); mountCommand.Options.Add(mountHighUsageWarnPercentOption); @@ -79,9 +84,16 @@ public static async Task ExecuteAsync(string[] args, ICliDiskControl mountCommand.SetAction(async (parseResult, _) => { var maxSnapshotSizeMb = parseResult.GetValue(mountMaxSnapshotSizeMbOption); + var customZstdLevel = parseResult.GetValue(mountCustomZstdLevelOption); var password = parseResult.GetValue(mountPasswordOption); var passwordFile = parseResult.GetValue(mountPasswordFileOption); + if (customZstdLevel is < 1 or > 22) + { + outcome = new CliOutcome(false, "--custom-zstd-level must be between 1 and 22.", null, 1); + return 1; + } + if (password is not null && passwordFile is not null) { outcome = new CliOutcome(false, "--password and --password-file cannot both be specified.", null, 1); @@ -107,6 +119,7 @@ public static async Task ExecuteAsync(string[] args, ICliDiskControl AutoMount = parseResult.GetValue(mountAutoMountOption), AutoSaveIntervalMinutes = parseResult.GetValue(mountAutoSaveMinutesOption), CompressionLevel = parseResult.GetValue(mountCompressionOption), + CustomZstdLevel = customZstdLevel, MaxSnapshotCount = parseResult.GetValue(mountMaxSnapshotCountOption), MaxSnapshotSizeBytes = maxSnapshotSizeMb * 1024UL * 1024UL, HighUsageWarnPercent = parseResult.GetValue(mountHighUsageWarnPercentOption), diff --git a/src/ManagedDrive.Cli.Core/CliMountOverrides.cs b/src/ManagedDrive.Cli.Core/CliMountOverrides.cs index e4ff47d..d7ec254 100644 --- a/src/ManagedDrive.Cli.Core/CliMountOverrides.cs +++ b/src/ManagedDrive.Cli.Core/CliMountOverrides.cs @@ -37,6 +37,16 @@ public ImageCompressionLevel? CompressionLevel get; init; } + /// + /// Gets the custom Zstd compression level (1-22) to use instead of the preset mapping for + /// . Only takes effect when the compression level is not + /// . + /// + public int? CustomZstdLevel + { + get; init; + } + /// /// Gets the maximum number of snapshots to retain. /// diff --git a/src/ManagedDrive.Core/Mounting/MountOptionsFactory.cs b/src/ManagedDrive.Core/Mounting/MountOptionsFactory.cs index 10f50df..63d98d7 100644 --- a/src/ManagedDrive.Core/Mounting/MountOptionsFactory.cs +++ b/src/ManagedDrive.Core/Mounting/MountOptionsFactory.cs @@ -20,6 +20,9 @@ public sealed record MountOverrides /// Overrides the compression level when non-null. public ImageCompressionLevel? CompressionLevel { get; init; } + /// Overrides the custom Zstd compression level (1-22) when non-null. + public int? CustomZstdLevel { get; init; } + /// Overrides the maximum snapshot count when non-null. public uint? MaxSnapshotCount { get; init; } @@ -83,6 +86,7 @@ public static DiskOptions BuildImageOptions( AutoMount = overrides.AutoMount ?? baseOptions.AutoMount, AutoSaveIntervalMinutes = overrides.AutoSaveIntervalMinutes ?? baseOptions.AutoSaveIntervalMinutes, CompressionLevel = overrides.CompressionLevel ?? baseOptions.CompressionLevel, + CustomZstdLevel = overrides.CustomZstdLevel ?? baseOptions.CustomZstdLevel, MaxSnapshotCount = overrides.MaxSnapshotCount ?? baseOptions.MaxSnapshotCount, MaxSnapshotSizeBytes = overrides.MaxSnapshotSizeBytes ?? baseOptions.MaxSnapshotSizeBytes, HighUsageWarnPercent = overrides.HighUsageWarnPercent ?? baseOptions.HighUsageWarnPercent, diff --git a/tests/ManagedDrive.Tests/MountOptionsFactoryTests.cs b/tests/ManagedDrive.Tests/MountOptionsFactoryTests.cs index f795696..5a1fff2 100644 --- a/tests/ManagedDrive.Tests/MountOptionsFactoryTests.cs +++ b/tests/ManagedDrive.Tests/MountOptionsFactoryTests.cs @@ -80,6 +80,46 @@ public void BuildImageOptions_OverridesWinOverProfile() Assert.Equal(5U, options.MaxSnapshotCount); } + [Fact] + public void BuildImageOptions_CustomZstdLevelOverrideWinsOverProfile() + { + var profile = new DiskOptions + { + MountPoint = "OLD:", + CapacityBytes = 1, + VolumeLabel = "L", + PersistImagePath = Image, + CompressionLevel = ImageCompressionLevel.Optimal, + CustomZstdLevel = 5, + }; + + var overrides = new MountOverrides { CustomZstdLevel = 19 }; + + var options = MountOptionsFactory.BuildImageOptions( + profile, "R:", Image, 4UL * 1024 * 1024, "L", overrides); + + Assert.Equal(19, options.CustomZstdLevel); + } + + [Fact] + public void BuildImageOptions_NoCustomZstdLevelOverride_ReusesProfileValue() + { + var profile = new DiskOptions + { + MountPoint = "OLD:", + CapacityBytes = 1, + VolumeLabel = "L", + PersistImagePath = Image, + CompressionLevel = ImageCompressionLevel.Optimal, + CustomZstdLevel = 5, + }; + + var options = MountOptionsFactory.BuildImageOptions( + profile, "R:", Image, 4UL * 1024 * 1024, "L", overrides: new()); + + Assert.Equal(5, options.CustomZstdLevel); + } + [Fact] public void BuildArchiveOptions_ForcesReadOnlyAndSetsSourcePath() { From 2c4c2cfb03c8caddd981576cbea623acf09c4378 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 1 Aug 2026 23:39:11 +0800 Subject: [PATCH 14/14] refactor: apply IDE code cleanup (member ordering, minor doc fixes) --- .../Infrastructure/WindowMaximizeHelper.cs | 8 +- .../ViewModels/DiskViewModel.cs | 86 ++--- .../ViewModels/MainViewModel.cs | 1 + .../Views/CreateDiskDialog.xaml.cs | 1 + src/ManagedDrive.Core/Mounting/RamDisk.cs | 2 +- .../Persistence/ParallelZstd.cs | 314 +++++++++--------- .../DiskProfileMappingTests.cs | 1 - 7 files changed, 205 insertions(+), 208 deletions(-) diff --git a/src/ManagedDrive.App/Infrastructure/WindowMaximizeHelper.cs b/src/ManagedDrive.App/Infrastructure/WindowMaximizeHelper.cs index d0dde81..fe02d5c 100644 --- a/src/ManagedDrive.App/Infrastructure/WindowMaximizeHelper.cs +++ b/src/ManagedDrive.App/Infrastructure/WindowMaximizeHelper.cs @@ -18,10 +18,10 @@ public static void HookMaximizeBehavior(Window window) window.SourceInitialized += (_, _) => { var handle = new WindowInteropHelper(window).Handle; - if (HwndSource.FromHwnd(handle) is HwndSource source) + if (HwndSource.FromHwnd(handle) is { } source) { - source.AddHook((IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) => - WndProc(hwnd, msg, wParam, lParam, ref handled, window)); + source.AddHook((hwnd, msg, _, lParam, ref handled) => + WndProc(hwnd, msg, lParam, ref handled, window)); } }; @@ -65,7 +65,7 @@ private static void ClampMaximizedToWorkArea(Window window) true); } - private static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled, Window window) + private static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr lParam, ref bool handled, Window window) { if (msg == WM_GETMINMAXINFO) { diff --git a/src/ManagedDrive.App/ViewModels/DiskViewModel.cs b/src/ManagedDrive.App/ViewModels/DiskViewModel.cs index 1fa981e..9a94354 100644 --- a/src/ManagedDrive.App/ViewModels/DiskViewModel.cs +++ b/src/ManagedDrive.App/ViewModels/DiskViewModel.cs @@ -15,13 +15,6 @@ public sealed class DiskViewModel : INotifyPropertyChanged, IDisposable /// private const double HighUsageResetGap = 5.0; - /// - /// Throttle window for : the first access in a burst is - /// reported immediately, subsequent accesses within this window are coalesced into a single - /// trailing report when it elapses. - /// - private static readonly TimeSpan ActivityThrottleWindow = TimeSpan.FromMilliseconds(300); - /// /// Number of speed samples retained for the read/write history popup: 30 minutes at the /// 2-second cadence. Sized generously since the buffer is only @@ -30,10 +23,17 @@ public sealed class DiskViewModel : INotifyPropertyChanged, IDisposable /// private const int SpeedHistoryLength = 900; + /// + /// Throttle window for : the first access in a burst is + /// reported immediately, subsequent accesses within this window are coalesced into a single + /// trailing report when it elapses. + /// + private static readonly TimeSpan ActivityThrottleWindow = TimeSpan.FromMilliseconds(300); + private readonly DispatcherTimer _activityThrottleTimer; - private readonly DispatcherTimer _refreshTimer; private readonly double[] _readSpeedHistory = new double[SpeedHistoryLength]; private readonly ThroughputTracker _readThroughput = new(); + private readonly DispatcherTimer _refreshTimer; private readonly double[] _writeSpeedHistory = new double[SpeedHistoryLength]; private readonly ThroughputTracker _writeThroughput = new(); @@ -271,6 +271,18 @@ public RelayCommand OpenInExplorerCommand /// public string? PersistImagePath => Disk.Options.PersistImagePath; + /// + /// Gets the most recently sampled read throughput, formatted as e.g. "1.2 MB/s". + /// + public string ReadSpeedFormatted => ByteFormatter.FormatRate(_readBytesPerSecond); + + /// + /// Gets the last 30 minutes of sampled read-speed history (bytes/sec), oldest first, for + /// display in the hover-triggered history chart. Only meaningfully consumed while that + /// popup is open; sampling itself runs unconditionally in . + /// + public IReadOnlyList ReadSpeedHistory => SnapshotHistory(_readSpeedHistory); + /// /// Gets whether this disk has auto-save enabled, controlling visibility of the /// last-image-save timestamp on the disk card. @@ -290,29 +302,6 @@ public RelayCommand OpenInExplorerCommand /// public string? SourcePath => Disk.Options.SourceArchivePath ?? Disk.Options.PersistImagePath; - /// - /// Gets the most recently sampled read throughput, formatted as e.g. "1.2 MB/s". - /// - public string ReadSpeedFormatted => ByteFormatter.FormatRate(_readBytesPerSecond); - - /// - /// Gets the last 30 minutes of sampled read-speed history (bytes/sec), oldest first, for - /// display in the hover-triggered history chart. Only meaningfully consumed while that - /// popup is open; sampling itself runs unconditionally in . - /// - public IReadOnlyList ReadSpeedHistory => SnapshotHistory(_readSpeedHistory); - - /// - /// Gets the most recently sampled write throughput, formatted as e.g. "1.2 MB/s". - /// - public string WriteSpeedFormatted => ByteFormatter.FormatRate(_writeBytesPerSecond); - - /// - /// Gets the last 30 minutes of sampled write-speed history (bytes/sec), oldest first. See - /// . - /// - public IReadOnlyList WriteSpeedHistory => SnapshotHistory(_writeSpeedHistory); - /// /// Gets the amount of used space formatted as a human-readable string. /// @@ -331,6 +320,17 @@ public RelayCommand OpenInExplorerCommand /// public string VolumeLabel => Disk.Options.VolumeLabel; + /// + /// Gets the most recently sampled write throughput, formatted as e.g. "1.2 MB/s". + /// + public string WriteSpeedFormatted => ByteFormatter.FormatRate(_writeBytesPerSecond); + + /// + /// Gets the last 30 minutes of sampled write-speed history (bytes/sec), oldest first. See + /// . + /// + public IReadOnlyList WriteSpeedHistory => SnapshotHistory(_writeSpeedHistory); + /// public void Dispose() { @@ -344,18 +344,6 @@ public void Dispose() _writeThroughput.Reset(); } - /// - /// Reorders a fixed-length ring buffer written via into - /// oldest-first order for display. - /// - private double[] SnapshotHistory(double[] buffer) - { - var result = new double[buffer.Length]; - Array.Copy(buffer, _speedHistoryHead, result, 0, buffer.Length - _speedHistoryHead); - Array.Copy(buffer, 0, result, buffer.Length - _speedHistoryHead, _speedHistoryHead); - return result; - } - /// /// Refreshes usage statistics, volume label, and capacity immediately. Always recomputes /// usage and re-evaluates the high-usage warning (it drives a tray balloon, so it must keep @@ -539,6 +527,18 @@ private void ReportActivity(bool isWrite) } } + /// + /// Reorders a fixed-length ring buffer written via into + /// oldest-first order for display. + /// + private double[] SnapshotHistory(double[] buffer) + { + var result = new double[buffer.Length]; + Array.Copy(buffer, _speedHistoryHead, result, 0, buffer.Length - _speedHistoryHead); + Array.Copy(buffer, 0, result, buffer.Length - _speedHistoryHead, _speedHistoryHead); + return result; + } + /// /// Event data for . /// diff --git a/src/ManagedDrive.App/ViewModels/MainViewModel.cs b/src/ManagedDrive.App/ViewModels/MainViewModel.cs index e9fe396..75c1554 100644 --- a/src/ManagedDrive.App/ViewModels/MainViewModel.cs +++ b/src/ManagedDrive.App/ViewModels/MainViewModel.cs @@ -562,6 +562,7 @@ public void ExitWithoutConfirmation() /// Errors are surfaced via . /// /// The profile to mount. + /// An optional progress reporter. /// /// true if the disk was mounted successfully; false if mounting failed /// (the failure reason is surfaced via ). diff --git a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs index 5f01772..11e460e 100644 --- a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs +++ b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs @@ -256,6 +256,7 @@ public CreateDiskDialog(string importImagePath, ulong importCapacityBytes, strin /// Options of all other currently active disks, used to validate that the archive file path /// does not collide with another disk's mount point. /// + /// Unused parameter to differentiate the constructor signature. private CreateDiskDialog(string importArchivePath, ulong importTotalBytes, string importVolumeLabel, IReadOnlyList otherDisks, bool archiveImportOverloadTag) : this(otherDisks) { diff --git a/src/ManagedDrive.Core/Mounting/RamDisk.cs b/src/ManagedDrive.Core/Mounting/RamDisk.cs index 01c744b..a2da00c 100644 --- a/src/ManagedDrive.Core/Mounting/RamDisk.cs +++ b/src/ManagedDrive.Core/Mounting/RamDisk.cs @@ -40,7 +40,7 @@ private RamDisk(MemoryFileSystem fs, FileSystemHost host, DiskOptions options) public event Action? ContentAccessed; /// - /// Occurs whenever an image save or snapshot write fails, whether triggered manually, + /// Raised whenever an image save or snapshot write fails, whether triggered manually, /// by the periodic auto-save timer, or by the final save on unmount/dispose. The /// exception is also rethrown to the caller for saves that are awaited synchronously /// (e.g. a manual save); this event exists so background failures that would otherwise diff --git a/src/ManagedDrive.Core/Persistence/ParallelZstd.cs b/src/ManagedDrive.Core/Persistence/ParallelZstd.cs index c9c09ee..d633d2a 100644 --- a/src/ManagedDrive.Core/Persistence/ParallelZstd.cs +++ b/src/ManagedDrive.Core/Persistence/ParallelZstd.cs @@ -18,6 +18,12 @@ namespace ManagedDrive.Core.Persistence; /// internal static class ParallelZstd { + /// + /// Test-only override for ; means use the + /// production default. Set via InternalsVisibleTo("ManagedDrive.Tests"). + /// + internal static int? TestChunkSizeOverride; + /// /// Size of each independently compressed chunk. Large enough that per-chunk compression /// overhead (frame header/epilogue, a fresh context) stays @@ -27,147 +33,8 @@ internal static class ParallelZstd /// private const int DefaultChunkSize = 4 * 1024 * 1024; - /// - /// Test-only override for ; means use the - /// production default. Set via InternalsVisibleTo("ManagedDrive.Tests"). - /// - internal static int? TestChunkSizeOverride; - internal static int ChunkSize => TestChunkSizeOverride ?? DefaultChunkSize; - /// - /// Write-only that buffers up to bytes at a time - /// and, on each full buffer plus once more on , hands that chunk to a - /// background that Zstd-compresses it independently. Compression runs - /// concurrently (bounded by ), but chunks are always - /// written to in the order they were queued, blocking on the oldest - /// outstanding task if the queue is full — so output ordering matches input ordering - /// regardless of which task happens to finish first. - /// - internal sealed class WriteStream(Stream target, int level, int? maxDegreeOfParallelism = null) : Stream - { - private readonly Queue> _pending = new(); - private readonly int _maxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism ?? Environment.ProcessorCount); - private byte[] _buffer = new byte[ChunkSize]; - private int _bufferLength; - private bool _completed; - - public override bool CanRead => false; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - { - while (count > 0) - { - var toCopy = Math.Min(count, _buffer.Length - _bufferLength); - Array.Copy(buffer, offset, _buffer, _bufferLength, toCopy); - _bufferLength += toCopy; - offset += toCopy; - count -= toCopy; - - if (_bufferLength == _buffer.Length) - { - FlushChunk(); - } - } - } - - public override void Flush() - { - } - - /// - /// Flushes any partially filled chunk, then drains every outstanding compression task in - /// queued order, writing each result to . Must be called exactly - /// once after all plaintext has been written, before disposing — mirrors - /// 's explicit-completion pattern. - /// - public void Complete() - { - if (_completed) - { - return; - } - - if (_bufferLength > 0) - { - QueueChunk(); - } - - while (_pending.Count > 0) - { - DrainOne(); - } - - WriteChunkHeader(0); - _completed = true; - } - - private void FlushChunk() - { - if (_pending.Count >= _maxDegreeOfParallelism) - { - DrainOne(); - } - - QueueChunk(); - } - - private void QueueChunk() - { - var chunk = _buffer; - var length = _bufferLength; - _buffer = new byte[ChunkSize]; - _bufferLength = 0; - - _pending.Enqueue(Task.Run(() => Compress(chunk, length, level))); - } - - private void DrainOne() - { - var compressed = _pending.Dequeue().GetAwaiter().GetResult(); - WriteChunkHeader(compressed.Length); - target.Write(compressed, 0, compressed.Length); - } - - private void WriteChunkHeader(int length) - { - Span lengthBytes = stackalloc byte[4]; - BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, length); - target.Write(lengthBytes); - } - - private static byte[] Compress(byte[] data, int length, int level) - { - using var compressor = new ZstdSharp.Compressor(level); - return compressor.Wrap(data.AsSpan(0, length)).ToArray(); - } - - protected override void Dispose(bool disposing) - { - if (disposing && !_completed) - { - Complete(); - } - - base.Dispose(disposing); - } - - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - - public override void SetLength(long value) => throw new NotSupportedException(); - } - /// /// Read-only counterpart to : reads the /// [length][compressed bytes] chunk sequence written by it, decompressing chunks on a @@ -179,12 +46,11 @@ protected override void Dispose(bool disposing) /// internal sealed class ReadStream(Stream source, int? maxDegreeOfParallelism = null) : Stream { - private readonly Queue> _pending = new(); private readonly int _maxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism ?? Environment.ProcessorCount); + private readonly Queue> _pending = new(); private byte[] _currentChunk = []; - private int _positionInChunk; private bool _endOfStream; - + private int _positionInChunk; public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; @@ -196,6 +62,8 @@ public override long Position set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) { var totalRead = 0; @@ -224,6 +92,29 @@ public override int Read(byte[] buffer, int offset, int count) return totalRead; } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + private static byte[] Decompress(byte[] compressed) + { + using var decompressor = new ZstdSharp.Decompressor(); + return decompressor.Unwrap(compressed).ToArray(); + } + + private void FillPending() + { + while (!_endOfStream && _pending.Count < _maxDegreeOfParallelism) + { + if (!TryQueueNextChunk()) + { + break; + } + } + } + private bool TryAdvanceChunk() { FillPending(); @@ -244,17 +135,6 @@ private bool TryAdvanceChunk() return _currentChunk.Length > 0 || TryAdvanceChunk(); } - private void FillPending() - { - while (!_endOfStream && _pending.Count < _maxDegreeOfParallelism) - { - if (!TryQueueNextChunk()) - { - break; - } - } - } - private bool TryQueueNextChunk() { if (_endOfStream) @@ -278,19 +158,135 @@ private bool TryQueueNextChunk() _pending.Enqueue(Task.Run(() => Decompress(chunk))); return true; } + } - private static byte[] Decompress(byte[] compressed) + /// + /// Write-only that buffers up to bytes at a time + /// and, on each full buffer plus once more on , hands that chunk to a + /// background that Zstd-compresses it independently. Compression runs + /// concurrently (bounded by ), but chunks are always + /// written to in the order they were queued, blocking on the oldest + /// outstanding task if the queue is full — so output ordering matches input ordering + /// regardless of which task happens to finish first. + /// + internal sealed class WriteStream(Stream target, int level, int? maxDegreeOfParallelism = null) : Stream + { + private readonly int _maxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism ?? Environment.ProcessorCount); + private readonly Queue> _pending = new(); + private byte[] _buffer = new byte[ChunkSize]; + private int _bufferLength; + private bool _completed; + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position { - using var decompressor = new ZstdSharp.Decompressor(); - return decompressor.Unwrap(compressed).ToArray(); + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); } - public override void Flush() => throw new NotSupportedException(); + /// + /// Flushes any remaining buffered bytes as a final chunk, then waits for all pending + /// + public void Complete() + { + if (_completed) + { + return; + } - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + if (_bufferLength > 0) + { + QueueChunk(); + } + + while (_pending.Count > 0) + { + DrainOne(); + } + + WriteChunkHeader(0); + _completed = true; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + while (count > 0) + { + var toCopy = Math.Min(count, _buffer.Length - _bufferLength); + Array.Copy(buffer, offset, _buffer, _bufferLength, toCopy); + _bufferLength += toCopy; + offset += toCopy; + count -= toCopy; + + if (_bufferLength == _buffer.Length) + { + FlushChunk(); + } + } + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_completed) + { + Complete(); + } + + base.Dispose(disposing); + } + + private static byte[] Compress(byte[] data, int length, int level) + { + using var compressor = new ZstdSharp.Compressor(level); + return compressor.Wrap(data.AsSpan(0, length)).ToArray(); + } + + private void DrainOne() + { + var compressed = _pending.Dequeue().GetAwaiter().GetResult(); + WriteChunkHeader(compressed.Length); + target.Write(compressed, 0, compressed.Length); + } + + private void FlushChunk() + { + if (_pending.Count >= _maxDegreeOfParallelism) + { + DrainOne(); + } + + QueueChunk(); + } + + private void QueueChunk() + { + var chunk = _buffer; + var length = _bufferLength; + _buffer = new byte[ChunkSize]; + _bufferLength = 0; + + _pending.Enqueue(Task.Run(() => Compress(chunk, length, level))); + } + + private void WriteChunkHeader(int length) + { + Span lengthBytes = stackalloc byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, length); + target.Write(lengthBytes); + } } -} +} \ No newline at end of file diff --git a/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs b/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs index d275fd2..f5e475d 100644 --- a/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs +++ b/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs @@ -1,4 +1,3 @@ -using ManagedDrive.App.Models; using ManagedDrive.App.ViewModels; namespace ManagedDrive.Tests;