From 4977fbaaae8f07f2a280e6c9232aa04ad72fa694 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 27 Jul 2026 15:55:01 +0100 Subject: [PATCH 1/3] fix: disable unsafe CAS garbage collection --- GenHub/GenHub.Core/Constants/CasDefaults.cs | 8 +- .../Storage/ICasLifecycleManager.cs | 6 +- .../Interfaces/Storage/ICasService.cs | 5 +- .../Models/Content/ContentRemovalResult.cs | 6 + .../Results/CAS/CasGarbageCollectionResult.cs | 23 +++- .../Models/Storage/GarbageCollectionStats.cs | 20 +++ .../ViewModels/SettingsViewModelTests.cs | 22 +++- ...ationOrchestratorGarbageCollectionTests.cs | 117 ++++++++++++++++++ .../CasGarbageCollectionDisabledTests.cs | 77 ++++++++++++ .../CasGarbageCollectionResultTests.cs | 18 ++- .../ContentReconciliationServiceTests.cs | 26 +++- .../Services/ContentReconciliationService.cs | 11 +- .../ContentReconciliationOrchestrator.cs | 14 +++ .../Settings/ViewModels/SettingsViewModel.cs | 14 ++- .../Storage/Services/CasLifecycleManager.cs | 13 +- .../Storage/Services/CasMaintenanceService.cs | 8 +- .../Features/Storage/Services/CasService.cs | 78 +++--------- 17 files changed, 385 insertions(+), 81 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionDisabledTests.cs diff --git a/GenHub/GenHub.Core/Constants/CasDefaults.cs b/GenHub/GenHub.Core/Constants/CasDefaults.cs index f45fe18ec..161adef5f 100644 --- a/GenHub/GenHub.Core/Constants/CasDefaults.cs +++ b/GenHub/GenHub.Core/Constants/CasDefaults.cs @@ -24,4 +24,10 @@ public static class CasDefaults /// Default garbage collection grace period in days. /// public const int GcGracePeriodDays = 7; -} \ No newline at end of file + + /// + /// Explains why destructive CAS garbage collection is currently unavailable. + /// + public const string GarbageCollectionDisabledMessage = + "CAS garbage collection is disabled until complete reachability tracking is proven safe. No CAS blobs were deleted."; +} diff --git a/GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs b/GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs index cef33ede9..b47995d1f 100644 --- a/GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs +++ b/GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs @@ -37,13 +37,13 @@ Task> UntrackManifestsAsync( CancellationToken cancellationToken = default); /// - /// Runs garbage collection immediately. - /// Should only be called AFTER all untrack operations are complete. + /// Requests garbage collection. + /// Destructive collection is currently disabled until reachability tracking is proven complete. /// /// Whether to force collection regardless of grace period. /// Optional timeout to wait for the GC lock. Defaults to 5 seconds if not specified. /// Cancellation token. - /// Result with GC statistics. + /// A disabled result with zero deletion statistics. Task> RunGarbageCollectionAsync( bool force = false, TimeSpan? lockTimeout = null, diff --git a/GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs b/GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs index fb227afd0..ff57d180c 100644 --- a/GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs +++ b/GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs @@ -53,11 +53,12 @@ public interface ICasService Task> OpenContentStreamAsync(string hash, CancellationToken cancellationToken = default); /// - /// Runs garbage collection to remove unreferenced content. + /// Requests garbage collection of unreferenced content. + /// Destructive collection is currently disabled until reachability tracking is proven complete. /// /// If true, ignores the grace period and deletes all unreferenced objects immediately. /// Cancellation token. - /// The result of the garbage collection operation. + /// A clear disabled result; no CAS blobs are deleted. Task RunGarbageCollectionAsync(bool force = false, CancellationToken cancellationToken = default); /// diff --git a/GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs b/GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs index af670ea7c..d5ccecf86 100644 --- a/GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs +++ b/GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace GenHub.Core.Models.Content; @@ -36,4 +37,9 @@ public record ContentRemovalResult /// Gets the duration of the operation. /// public TimeSpan Duration { get; init; } + + /// + /// Gets non-fatal warnings reported during removal. + /// + public IReadOnlyList Warnings { get; init; } = []; } diff --git a/GenHub/GenHub.Core/Models/Results/CAS/CasGarbageCollectionResult.cs b/GenHub/GenHub.Core/Models/Results/CAS/CasGarbageCollectionResult.cs index fc69d7e08..a90ee7c13 100644 --- a/GenHub/GenHub.Core/Models/Results/CAS/CasGarbageCollectionResult.cs +++ b/GenHub/GenHub.Core/Models/Results/CAS/CasGarbageCollectionResult.cs @@ -1,3 +1,5 @@ +using GenHub.Core.Constants; + namespace GenHub.Core.Models.Results.CAS; /// @@ -5,6 +7,20 @@ namespace GenHub.Core.Models.Results.CAS; /// public class CasGarbageCollectionResult : ResultBase { + /// + /// Creates the fail-closed result returned while destructive garbage collection is disabled. + /// + /// A disabled result that reports zero deletion. + public static CasGarbageCollectionResult CreateDisabled() + { + return new CasGarbageCollectionResult( + false, + CasDefaults.GarbageCollectionDisabledMessage) + { + Disabled = true, + }; + } + /// /// Initializes a new instance of the class. /// @@ -39,6 +55,11 @@ public CasGarbageCollectionResult(bool success, string? error = null, TimeSpan e /// Gets or sets the number of objects that were referenced and kept. public int ObjectsReferenced { get; set; } + /// + /// Gets a value indicating whether destructive garbage collection is disabled. + /// + public bool Disabled { get; init; } + /// Gets the percentage of storage freed. public double PercentageFreed { @@ -63,4 +84,4 @@ public double PercentageFreed return (double)ObjectsDeleted / ObjectsScanned * 100; } } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs b/GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs index be239849d..3df51e7bf 100644 --- a/GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs +++ b/GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs @@ -42,6 +42,11 @@ public record GarbageCollectionStats /// public bool InProgress { get; init; } + /// + /// Gets a value indicating whether collection was blocked because destructive GC is disabled. + /// + public bool Disabled { get; init; } + /// /// Gets a static instance representing a skipped GC operation. /// @@ -69,4 +74,19 @@ public record GarbageCollectionStats Skipped = true, InProgress = true, }; + + /// + /// Gets a static instance representing fail-closed disabled garbage collection. + /// + public static GarbageCollectionStats DisabledResult { get; } = new() + { + ObjectsScanned = 0, + ObjectsReferenced = 0, + ObjectsDeleted = 0, + BytesFreed = 0, + Duration = TimeSpan.Zero, + Skipped = true, + InProgress = false, + Disabled = true, + }; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs index 876514824..9289c20d6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs @@ -12,6 +12,7 @@ using GenHub.Core.Models.GameProfile; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; +using GenHub.Core.Models.Results.CAS; using GenHub.Core.Models.Storage; using GenHub.Core.Models.Workspace; using GenHub.Features.AppUpdate.Interfaces; @@ -338,7 +339,7 @@ public void Constructor_HandlesUserSettingsServiceException() /// /// A representing the asynchronous test operation. [Fact] - public async Task DeleteCasStorageCommand_CallsService() + public async Task DeleteCasStorageCommand_ReportsGarbageCollectionIsDisabled() { // Arrange // Setup stats to return valid data so update method works @@ -350,6 +351,9 @@ public async Task DeleteCasStorageCommand_CallsService() .ReturnsAsync(OperationResult>.CreateSuccess([])); _mockProfileManager.Setup(x => x.GetAllProfilesAsync(It.IsAny())) .ReturnsAsync(ProfileOperationResult>.CreateSuccess([])); + _mockCasService + .Setup(x => x.RunGarbageCollectionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CasGarbageCollectionResult.CreateDisabled()); var viewModel = new SettingsViewModel( _mockConfigService.Object, @@ -370,6 +374,20 @@ public async Task DeleteCasStorageCommand_CallsService() // Assert _mockCasService.Verify(x => x.RunGarbageCollectionAsync(It.IsAny(), It.IsAny()), Times.Once); + _mockNotificationService.Verify( + service => service.ShowInfo( + "CAS Cleanup Disabled", + CasDefaults.GarbageCollectionDisabledMessage, + It.IsAny(), + It.IsAny()), + Times.Once); + _mockNotificationService.Verify( + service => service.ShowSuccess( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); } /// @@ -400,4 +418,4 @@ public async Task UninstallGenHubCommand_CallsService() // Assert _mockUpdateManager.Verify(x => x.Uninstall(), Times.Once); } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs new file mode 100644 index 000000000..ca14468d6 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs @@ -0,0 +1,117 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Storage; +using GenHub.Features.Content.Services.Reconciliation; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace GenHub.Tests.Core.Features.Reconciliation; + +/// +/// Verifies that reconciliation reports disabled garbage collection without failing +/// otherwise-successful manifest operations. +/// +public class ContentReconciliationOrchestratorGarbageCollectionTests +{ + /// + /// Verifies that replacement results expose the disabled-GC warning. + /// + /// A representing the asynchronous test. + [Fact] + public async Task ExecuteContentReplacementAsync_WhenGcDisabled_ReturnsWarning() + { + var reconciliationService = new Mock(); + reconciliationService + .Setup(service => service.OrchestrateBulkUpdateAsync( + It.IsAny>(), + false, + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess( + ReconciliationResult.Empty)); + + var orchestrator = CreateOrchestrator( + reconciliationService, + CreateDisabledLifecycleManager()); + var request = new ContentReplacementRequest + { + ManifestMapping = new Dictionary + { + ["1.0.publisher.mod.old"] = "2.0.publisher.mod.new", + }, + RemoveOldManifests = false, + }; + + var result = await orchestrator.ExecuteContentReplacementAsync(request); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings); + Assert.Equal(0, result.Data.CasObjectsCollected); + Assert.Equal(0, result.Data.BytesFreed); + } + + /// + /// Verifies that removal results expose the disabled-GC warning. + /// + /// A representing the asynchronous test. + [Fact] + public async Task ExecuteContentRemovalAsync_WhenGcDisabled_ReturnsWarning() + { + var reconciliationService = new Mock(); + var lifecycleManager = CreateDisabledLifecycleManager(); + lifecycleManager + .Setup(manager => manager.UntrackManifestsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess( + new BulkUntrackResult(0, 0, []))); + + var orchestrator = CreateOrchestrator(reconciliationService, lifecycleManager); + + var result = await orchestrator.ExecuteContentRemovalAsync([]); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings); + Assert.Equal(0, result.Data.CasObjectsCollected); + Assert.Equal(0, result.Data.BytesFreed); + } + + private static Mock CreateDisabledLifecycleManager() + { + var lifecycleManager = new Mock(); + lifecycleManager + .Setup(manager => manager.RunGarbageCollectionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure( + CasDefaults.GarbageCollectionDisabledMessage, + GarbageCollectionStats.DisabledResult, + TimeSpan.Zero)); + return lifecycleManager; + } + + private static ContentReconciliationOrchestrator CreateOrchestrator( + Mock reconciliationService, + Mock lifecycleManager) + { + var auditLog = new Mock(); + auditLog + .Setup(log => log.LogOperationAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + return new ContentReconciliationOrchestrator( + reconciliationService.Object, + Mock.Of(), + lifecycleManager.Object, + auditLog.Object, + NullLogger.Instance); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionDisabledTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionDisabledTests.cs new file mode 100644 index 000000000..43d6393ba --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionDisabledTests.cs @@ -0,0 +1,77 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Models.Results.CAS; +using GenHub.Core.Models.Storage; +using GenHub.Features.Storage.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace GenHub.Tests.Core.Features.Storage; + +/// +/// Verifies that every programmatic CAS garbage-collection layer fails closed. +/// +public class CasGarbageCollectionDisabledTests +{ + /// + /// Verifies that direct service calls cannot scan or delete CAS blobs, including forced calls. + /// + /// Whether the caller requests forced collection. + /// A representing the asynchronous test. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CasService_RunGarbageCollectionAsync_IsDisabledWithoutStorageAccess(bool force) + { + var storage = new Mock(MockBehavior.Strict); + var referenceTracker = new Mock(MockBehavior.Strict); + var service = new CasService( + storage.Object, + referenceTracker.Object, + NullLogger.Instance, + Options.Create(new CasConfiguration()), + Mock.Of(), + Mock.Of()); + + var result = await service.RunGarbageCollectionAsync(force); + + Assert.False(result.Success); + Assert.True(result.Disabled); + Assert.Equal(CasDefaults.GarbageCollectionDisabledMessage, result.FirstError); + Assert.Equal(0, result.ObjectsDeleted); + Assert.Equal(0, result.BytesFreed); + storage.VerifyNoOtherCalls(); + referenceTracker.VerifyNoOtherCalls(); + } + + /// + /// Verifies that the lifecycle API preserves the disabled result and reports no deletion. + /// + /// A representing the asynchronous test. + [Fact] + public async Task CasLifecycleManager_RunGarbageCollectionAsync_ReportsDisabled() + { + var casService = new Mock(); + casService + .Setup(service => service.RunGarbageCollectionAsync(true, It.IsAny())) + .ReturnsAsync(CasGarbageCollectionResult.CreateDisabled()); + + using var lifecycleManager = new CasLifecycleManager( + Mock.Of(), + casService.Object, + Mock.Of(), + Options.Create(new CasConfiguration()), + NullLogger.Instance); + + var result = await lifecycleManager.RunGarbageCollectionAsync(force: true); + + Assert.False(result.Success); + Assert.NotNull(result.Data); + Assert.True(result.Data.Disabled); + Assert.Equal(CasDefaults.GarbageCollectionDisabledMessage, result.FirstError); + Assert.Equal(0, result.Data.ObjectsDeleted); + Assert.Equal(0, result.Data.BytesFreed); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionResultTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionResultTests.cs index 7a89cff8a..4334e3448 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionResultTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Storage/CasGarbageCollectionResultTests.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Constants; using GenHub.Core.Models.Results.CAS; namespace GenHub.Tests.Core.Features.Storage; @@ -143,4 +144,19 @@ public void Properties_CanBeSetCorrectly() Assert.Equal(40, result.ObjectsReferenced); Assert.Equal(20.0, result.PercentageFreed); } -} \ No newline at end of file + + /// + /// Verifies that the disabled factory returns a clear fail-closed result. + /// + [Fact] + public void CreateDisabled_ReturnsClearDisabledResult() + { + var result = CasGarbageCollectionResult.CreateDisabled(); + + Assert.False(result.Success); + Assert.True(result.Disabled); + Assert.Equal(CasDefaults.GarbageCollectionDisabledMessage, result.FirstError); + Assert.Equal(0, result.ObjectsDeleted); + Assert.Equal(0, result.BytesFreed); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs index 47a1c6140..ad7b8659a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs @@ -256,4 +256,28 @@ public async Task OrchestrateBulkUpdateAsync_WhenManifestResolutionFails_ShouldS It.IsAny()), Times.Never); } -} \ No newline at end of file + + /// + /// Verifies that scheduled garbage collection reports the fail-closed disabled result. + /// + /// A representing the asynchronous test. + [Fact] + public async Task ScheduleGarbageCollectionAsync_WhenDisabled_ReturnsFailure() + { + _casServiceMock + .Setup(service => service.RunGarbageCollectionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure( + GenHub.Core.Constants.CasDefaults.GarbageCollectionDisabledMessage, + GarbageCollectionStats.DisabledResult, + TimeSpan.Zero)); + + var result = await _service.ScheduleGarbageCollectionAsync(force: true); + + result.Success.Should().BeFalse(); + result.FirstError.Should().Be( + GenHub.Core.Constants.CasDefaults.GarbageCollectionDisabledMessage); + } +} diff --git a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs index 3fccea615..bd4247679 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs @@ -354,9 +354,14 @@ public Task ScheduleGarbageCollectionAsync( try { var gcResult = await casLifecycleManager.RunGarbageCollectionAsync(force, lockTimeout: null, cancellationToken); - return gcResult.Success - ? OperationResult.CreateSuccess() - : OperationResult.CreateFailure(gcResult.FirstError ?? "GC failed"); + if (gcResult.Success) + { + return OperationResult.CreateSuccess(); + } + + var error = gcResult.FirstError ?? "GC failed"; + logger.LogWarning("Scheduled garbage collection did not run: {Error}", error); + return OperationResult.CreateFailure(error); } catch (OperationCanceledException) { diff --git a/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs index c290c9a74..2d118013f 100644 --- a/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs @@ -155,6 +155,12 @@ public async Task> ExecuteContentRepla casObjectsCollected = gcResult.Data.ObjectsDeleted; bytesFreed = gcResult.Data.BytesFreed; } + else + { + var warning = gcResult.FirstError ?? "Garbage collection did not run"; + warnings.Add(warning); + logger.LogWarning("[Orchestrator:{OpId}] {Warning}", operationId, warning); + } } } @@ -267,6 +273,7 @@ public async Task> ExecuteContentRemovalAs var stopwatch = Stopwatch.StartNew(); var operationId = Guid.NewGuid().ToString("N")[..ReconciliationConstants.OperationIdLength]; var ids = manifestIds.ToList(); + var warnings = new List(); logger.LogInformation( "[Orchestrator:{OpId}] Starting content removal for {Count} manifests", @@ -366,6 +373,12 @@ public async Task> ExecuteContentRemovalAs casObjectsCollected = gcResult.Data.ObjectsDeleted; bytesFreed = gcResult.Data.BytesFreed; } + else + { + var warning = gcResult.FirstError ?? "Garbage collection did not run"; + warnings.Add(warning); + logger.LogWarning("[Orchestrator:{OpId}] {Warning}", operationId, warning); + } } stopwatch.Stop(); @@ -378,6 +391,7 @@ public async Task> ExecuteContentRemovalAs CasObjectsCollected = casObjectsCollected, BytesFreed = bytesFreed, Duration = stopwatch.Elapsed, + Warnings = warnings, }; await auditLog.LogOperationAsync( diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs index b43c9cbe8..f923312d5 100644 --- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs +++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs @@ -1088,7 +1088,10 @@ private async Task DeleteAllData() _installationService.InvalidateCache(); await UpdateDangerZoneDataAsync(); - _notificationService.ShowSuccess("Data Deleted", "All application data has been deleted successfully.", 5000); + _notificationService.ShowSuccess( + "Data Deleted", + $"Profiles, workspaces, manifests, and user data were deleted. {CasDefaults.GarbageCollectionDisabledMessage}", + 5000); } [RelayCommand] @@ -1112,7 +1115,14 @@ private async Task DeleteCasStorage() { _logger.LogWarning("Deleting CAS storage (forced)"); var result = await _casService.RunGarbageCollectionAsync(force: true, CancellationToken.None); - if (result.ObjectsDeleted == 0) + if (result.Disabled) + { + _notificationService.ShowInfo( + "CAS Cleanup Disabled", + result.FirstError ?? CasDefaults.GarbageCollectionDisabledMessage, + TimeIntervals.NotificationHideDelay.Milliseconds); + } + else if (result.ObjectsDeleted == 0) { if (result.ObjectsReferenced > 0) { diff --git a/GenHub/GenHub/Features/Storage/Services/CasLifecycleManager.cs b/GenHub/GenHub/Features/Storage/Services/CasLifecycleManager.cs index da864569f..0f561ca41 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasLifecycleManager.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasLifecycleManager.cs @@ -181,9 +181,20 @@ public async Task> RunGarbageCollectionA ObjectsDeleted = gcResult.ObjectsDeleted, BytesFreed = gcResult.BytesFreed, Duration = stopwatch.Elapsed, - Skipped = false, + Skipped = gcResult.Disabled, + Disabled = gcResult.Disabled, }; + if (!gcResult.Success) + { + var error = gcResult.FirstError ?? "CAS garbage collection failed"; + logger.LogWarning("Garbage collection did not run: {Error}", error); + return OperationResult.CreateFailure( + error, + stats, + stopwatch.Elapsed); + } + logger.LogInformation( "GC completed: scanned={Scanned}, referenced={Referenced}, deleted={Deleted}, freed={Bytes} bytes", stats.ObjectsScanned, diff --git a/GenHub/GenHub/Features/Storage/Services/CasMaintenanceService.cs b/GenHub/GenHub/Features/Storage/Services/CasMaintenanceService.cs index 757650f51..951fc80b8 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasMaintenanceService.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasMaintenanceService.cs @@ -76,7 +76,13 @@ private async Task RunMaintenanceTasksAsync(CancellationToken cancellationToken) // Run garbage collection var gcResult = await casService.RunGarbageCollectionAsync(cancellationToken: cancellationToken); - if (gcResult.Success) + if (gcResult.Disabled) + { + logger.LogWarning( + "CAS garbage collection did not run: {Reason}", + gcResult.FirstError); + } + else if (gcResult.Success) { logger.LogInformation("CAS garbage collection completed: {ObjectsDeleted} objects deleted, {BytesFreed:N0} bytes freed in {Elapsed}", gcResult.ObjectsDeleted, gcResult.BytesFreed, gcResult.Elapsed); } diff --git a/GenHub/GenHub/Features/Storage/Services/CasService.cs b/GenHub/GenHub/Features/Storage/Services/CasService.cs index 1b961e74a..09df977eb 100644 --- a/GenHub/GenHub/Features/Storage/Services/CasService.cs +++ b/GenHub/GenHub/Features/Storage/Services/CasService.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Enums; @@ -25,8 +26,6 @@ public class CasService( IStreamHashProvider streamHashProvider, ICasPoolManager? poolManager = null) : ICasService { - private readonly CasConfiguration _config = config.Value; - /// public async Task> StoreContentAsync(string sourcePath, string? expectedHash = null, CancellationToken cancellationToken = default) { @@ -257,68 +256,21 @@ public async Task> OpenContentStreamAsync(string hash, C } /// - public async Task RunGarbageCollectionAsync(bool force = false, CancellationToken cancellationToken = default) + public Task RunGarbageCollectionAsync( + bool force = false, + CancellationToken cancellationToken = default) { - var startTime = DateTime.UtcNow; - var result = new CasGarbageCollectionResult(true, (string?)null); - - try - { - logger.LogInformation("Starting CAS garbage collection (force={Force})", force); - - // Get all objects in CAS - var allHashes = await storage.GetAllObjectHashesAsync(cancellationToken); - result.ObjectsScanned = allHashes.Length; - - // Get all referenced hashes - var referencedHashes = await referenceTracker.GetAllReferencedHashesAsync(cancellationToken); - result.ObjectsReferenced = referencedHashes.Count; - - // Find unreferenced objects - var unreferencedHashes = System.Linq.Enumerable.Except(allHashes, referencedHashes); - - // Use configurable grace period unless forced - var gracePeriod = force ? TimeSpan.Zero : _config.GcGracePeriod; - long bytesFreed = 0; - int objectsDeleted = 0; - - foreach (var hash in unreferencedHashes) - { - try - { - var creationTime = await storage.GetObjectCreationTimeAsync(hash, cancellationToken); - if (force || creationTime == null || DateTime.UtcNow - creationTime.Value > gracePeriod) - { - // Get size before deletion - var objectPath = storage.GetObjectPath(hash); - if (File.Exists(objectPath)) - { - var fileInfo = new FileInfo(objectPath); - bytesFreed += fileInfo.Length; - } - - await storage.DeleteObjectAsync(hash, cancellationToken); - objectsDeleted++; - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to delete unreferenced object {Hash}", hash); - } - } - - result.ObjectsDeleted = objectsDeleted; - result.BytesFreed = bytesFreed; - - logger.LogInformation("CAS garbage collection completed: {ObjectsDeleted} objects deleted, {BytesFreed} bytes freed", objectsDeleted, bytesFreed); - } - catch (Exception ex) - { - logger.LogError(ex, "CAS garbage collection failed"); - result = new CasGarbageCollectionResult(false, ex.Message, DateTime.UtcNow - startTime); - } - - return result; + _ = referenceTracker; + _ = config; + + // Re-enable only after references cover every persisted manifest, workspace, user-data + // link, and CAS pool; startup can rebuild and audit that graph; and crash/concurrency + // tests prove that no live blob can be classified as unreachable. + logger.LogWarning( + "{Message} Requested force={Force}", + CasDefaults.GarbageCollectionDisabledMessage, + force); + return Task.FromResult(CasGarbageCollectionResult.CreateDisabled()); } /// From efa0cc421e5e31d8534f439878fb068b9830d2d0 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Wed, 29 Jul 2026 12:38:48 +0100 Subject: [PATCH 2/3] fix: use full notification auto-dismiss duration --- .../GameProfiles/ViewModels/SettingsViewModelTests.cs | 2 +- .../Features/Settings/ViewModels/SettingsViewModel.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs index 9289c20d6..036231d01 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs @@ -378,7 +378,7 @@ public async Task DeleteCasStorageCommand_ReportsGarbageCollectionIsDisabled() service => service.ShowInfo( "CAS Cleanup Disabled", CasDefaults.GarbageCollectionDisabledMessage, - It.IsAny(), + (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds, It.IsAny()), Times.Once); _mockNotificationService.Verify( diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs index f923312d5..74d315300 100644 --- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs +++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs @@ -1120,17 +1120,17 @@ private async Task DeleteCasStorage() _notificationService.ShowInfo( "CAS Cleanup Disabled", result.FirstError ?? CasDefaults.GarbageCollectionDisabledMessage, - TimeIntervals.NotificationHideDelay.Milliseconds); + (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds); } else if (result.ObjectsDeleted == 0) { if (result.ObjectsReferenced > 0) { - _notificationService.ShowInfo("CAS Clean", "All items in CAS are currently in use and cannot be deleted.", TimeIntervals.NotificationHideDelay.Milliseconds); + _notificationService.ShowInfo("CAS Clean", "All items in CAS are currently in use and cannot be deleted.", (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds); } else { - _notificationService.ShowInfo("CAS Empty", "CAS storage is already empty.", TimeIntervals.NotificationHideDelay.Milliseconds); + _notificationService.ShowInfo("CAS Empty", "CAS storage is already empty.", (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds); } } else @@ -1162,7 +1162,7 @@ private async Task DeleteManifests() await _manifestPool.RemoveManifestAsync(manifest.Id); } - _notificationService.ShowSuccess("Manifests Deleted", $"Deleted {count} manifest(s) successfully.", TimeIntervals.NotificationHideDelay.Milliseconds); + _notificationService.ShowSuccess("Manifests Deleted", $"Deleted {count} manifest(s) successfully.", (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds); } await UpdateDangerZoneDataAsync(); From f87fa5e2fe8b42a9928cf3bec7e3afc21904e4a4 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Wed, 29 Jul 2026 12:38:48 +0100 Subject: [PATCH 3/3] fix: record reconciliation warnings in audit metadata --- ...ationOrchestratorGarbageCollectionTests.cs | 17 +++++-- .../ContentReconciliationOrchestrator.cs | 44 ++++++++++++------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs index ca14468d6..fa271e226 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationOrchestratorGarbageCollectionTests.cs @@ -33,9 +33,11 @@ public async Task ExecuteContentReplacementAsync_WhenGcDisabled_ReturnsWarning() .ReturnsAsync(OperationResult.CreateSuccess( ReconciliationResult.Empty)); + var auditEntries = new List(); var orchestrator = CreateOrchestrator( reconciliationService, - CreateDisabledLifecycleManager()); + CreateDisabledLifecycleManager(), + auditEntries); var request = new ContentReplacementRequest { ManifestMapping = new Dictionary @@ -52,6 +54,9 @@ public async Task ExecuteContentReplacementAsync_WhenGcDisabled_ReturnsWarning() Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings); Assert.Equal(0, result.Data.CasObjectsCollected); Assert.Equal(0, result.Data.BytesFreed); + var auditEntry = Assert.Single(auditEntries); + Assert.NotNull(auditEntry.Metadata); + Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, auditEntry.Metadata["warnings"]); } /// @@ -70,7 +75,8 @@ public async Task ExecuteContentRemovalAsync_WhenGcDisabled_ReturnsWarning() .ReturnsAsync(OperationResult.CreateSuccess( new BulkUntrackResult(0, 0, []))); - var orchestrator = CreateOrchestrator(reconciliationService, lifecycleManager); + var auditEntries = new List(); + var orchestrator = CreateOrchestrator(reconciliationService, lifecycleManager, auditEntries); var result = await orchestrator.ExecuteContentRemovalAsync([]); @@ -79,6 +85,9 @@ public async Task ExecuteContentRemovalAsync_WhenGcDisabled_ReturnsWarning() Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings); Assert.Equal(0, result.Data.CasObjectsCollected); Assert.Equal(0, result.Data.BytesFreed); + var auditEntry = Assert.Single(auditEntries); + Assert.NotNull(auditEntry.Metadata); + Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, auditEntry.Metadata["warnings"]); } private static Mock CreateDisabledLifecycleManager() @@ -98,13 +107,15 @@ private static Mock CreateDisabledLifecycleManager() private static ContentReconciliationOrchestrator CreateOrchestrator( Mock reconciliationService, - Mock lifecycleManager) + Mock lifecycleManager, + List? auditEntries = null) { var auditLog = new Mock(); auditLog .Setup(log => log.LogOperationAsync( It.IsAny(), It.IsAny())) + .Callback((entry, _) => auditEntries?.Add(entry)) .Returns(Task.CompletedTask); return new ContentReconciliationOrchestrator( diff --git a/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs index 2d118013f..9b19da684 100644 --- a/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/Reconciliation/ContentReconciliationOrchestrator.cs @@ -177,6 +177,19 @@ public async Task> ExecuteContentRepla Warnings = warnings, }; + var auditMetadata = new Dictionary + { + ["profilesUpdated"] = profilesUpdated.ToString(), + ["workspacesInvalidated"] = workspacesInvalidated.ToString(), + ["manifestsRemoved"] = manifestsRemoved.ToString(), + ["casObjectsCollected"] = casObjectsCollected.ToString(), + ["bytesFreed"] = bytesFreed.ToString(), + }; + if (warnings.Count > 0) + { + auditMetadata["warnings"] = string.Join("; ", warnings); + } + await auditLog.LogOperationAsync( new ReconciliationAuditEntry { @@ -189,14 +202,7 @@ await auditLog.LogOperationAsync( Success = !criticalFailureOccurred, ErrorMessage = criticalFailureOccurred ? string.Join("; ", warnings) : null, Duration = stopwatch.Elapsed, - Metadata = new Dictionary - { - ["profilesUpdated"] = profilesUpdated.ToString(), - ["workspacesInvalidated"] = workspacesInvalidated.ToString(), - ["manifestsRemoved"] = manifestsRemoved.ToString(), - ["casObjectsCollected"] = casObjectsCollected.ToString(), - ["bytesFreed"] = bytesFreed.ToString(), - }, + Metadata = auditMetadata, }, cancellationToken); @@ -394,6 +400,19 @@ public async Task> ExecuteContentRemovalAs Warnings = warnings, }; + var auditMetadata = new Dictionary + { + ["profilesUpdated"] = profilesUpdated.ToString(), + ["workspacesInvalidated"] = invalidatedWorkspacesCount.ToString(), + ["manifestsRemoved"] = manifestsRemoved.ToString(), + ["casObjectsCollected"] = casObjectsCollected.ToString(), + ["bytesFreed"] = bytesFreed.ToString(), + }; + if (warnings.Count > 0) + { + auditMetadata["warnings"] = string.Join("; ", warnings); + } + await auditLog.LogOperationAsync( new ReconciliationAuditEntry { @@ -403,14 +422,7 @@ await auditLog.LogOperationAsync( AffectedManifestIds = ids, Success = !criticalFailureOccurred, ErrorMessage = criticalFailureOccurred ? "One or more manifests failed to untrack or be removed from the pool." : null, - Metadata = new Dictionary - { - ["profilesUpdated"] = profilesUpdated.ToString(), - ["workspacesInvalidated"] = invalidatedWorkspacesCount.ToString(), - ["manifestsRemoved"] = manifestsRemoved.ToString(), - ["casObjectsCollected"] = casObjectsCollected.ToString(), - ["bytesFreed"] = bytesFreed.ToString(), - }, + Metadata = auditMetadata, Duration = stopwatch.Elapsed, }, cancellationToken);