diff --git a/docs/usage.md b/docs/usage.md index fae01c10..52734085 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -130,14 +130,17 @@ target is deleted. It does not scan the running PC for things to delete. Component cleanup runs after removals but before enabling .NET Framework 3.5: offline feature changes can leave work that completes only when Windows first -boots. If cleanup returns `0x800F0806` (pending operations), a serviceable -Tiny11/Custom build keeps that work and reports cleanup as **Skipped** with a -warning; the image may be larger. Other cleanup errors remain fatal. Pending -actions are never deleted or reverted to get past this error. -When that exact pending-state error occurs, selected Edge/WebView2 file removal -is also skipped for the run, because its WinSxS files may be referenced by -pending servicing. The report records that those files were kept; it does not -claim the requested removal succeeded. These warnings survive resume. +boots. Before invoking cleanup, the builder checks the mounted image for +pending markers, package states and feature states. If pending work is found, +serviceable Tiny11/Custom builds skip the DISM cleanup command and warn that +superseded components may remain, avoiding the `0x800F0806` error. If pending +work appears after the check and DISM still returns that code, cleanup is +deferred the same way. Other cleanup errors remain fatal. Pending actions are +never deleted or reverted to get past this error. +When pending servicing is detected, selected Edge/WebView2 file removal is +also skipped for the run, because its WinSxS files may be referenced by that +work. The report records that those files were kept; it does not claim the +requested removal succeeded. These warnings survive resume. Core and explicit Custom component-payload removal also check pending files, package states and feature states immediately before deleting component files, even when cleanup is off. If enabling .NET 3.5 leaves pending work, those diff --git a/src/Tiny11Builder.Core/BuildEngine.Cleanup.cs b/src/Tiny11Builder.Core/BuildEngine.Cleanup.cs index 144661ae..9b255d52 100644 --- a/src/Tiny11Builder.Core/BuildEngine.Cleanup.cs +++ b/src/Tiny11Builder.Core/BuildEngine.Cleanup.cs @@ -5,13 +5,24 @@ public sealed partial class BuildEngine /// CBS_E_PENDING: DISM will not clean the component store while operations are pending (https://learn.microsoft.com/windows-hardware/manufacture/desktop/dism-operating-system-package-servicing-command-line-options). internal const int PendingOperationsExitCode = unchecked((int)0x800F0806); - private const string PendingSkipReason = "DISM reported pending operations (0x800F0806). They stay pending until first boot and nothing was reverted. " + + private const string PendingSkipReason = "DISM has pending operations (0x800F0806); they remain until first boot, and nothing was reverted. " + "Superseded components remain, so the image may be larger."; private static bool RemovesComponentPayloads(BuildRecipe recipe) => SetupInstaller.RequiresLegacy(recipe); private static async Task CleanComponentsAsync(BuildSession s, CancellationToken token) { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("Checking an offline image for pending operations requires Windows."); + var scan = await PendingOperations.ScanAsync(s.Dism, s.Mount, token).ConfigureAwait(false); + if (scan.Pending.Count > 0) + { + if (RemovesComponentPayloads(s.Recipe)) throw Refuse(s, scan.Pending, null); + foreach (var item in scan.Pending) s.Log("Pending operation before component cleanup: " + item); + DeferCleanupForPending(s); + return; + } + try { await s.Dism.RunAsync(token, s.Recipe.Flavor == BuildFlavor.Core ? @@ -23,12 +34,17 @@ await s.Dism.RunAsync(token, s.Recipe.Flavor == BuildFlavor.Core ? token.ThrowIfCancellationRequested(); if (RemovesComponentPayloads(s.Recipe)) throw Refuse(s, [$"DISM exit code {error.ExitCode} (0x{error.ExitCode:X8}) during component cleanup"], error); - s.CleanupDeferredForPending = true; - s.Warn("Component cleanup was skipped: " + PendingSkipReason); - s.Tracking.SkipStage(PendingSkipReason); + DeferCleanupForPending(s); } } + private static void DeferCleanupForPending(BuildSession s) + { + s.CleanupDeferredForPending = true; + s.Warn("Component cleanup was skipped: " + PendingSkipReason); + s.Tracking.SkipStage(PendingSkipReason); + } + private static IReadOnlyList OriginalFileOptions(BuildSession s) { var options = s.Recipe.SelectedOptions; diff --git a/tests/Tiny11Builder.Core.Tests/PendingOperationsTests.cs b/tests/Tiny11Builder.Core.Tests/PendingOperationsTests.cs index e01eb5dc..3aa5b24e 100644 --- a/tests/Tiny11Builder.Core.Tests/PendingOperationsTests.cs +++ b/tests/Tiny11Builder.Core.Tests/PendingOperationsTests.cs @@ -208,6 +208,41 @@ public async Task Original_file_removals_pass_through_unchanged_when_no_cleanup_ e => e.Contains("/Get-Packages") || e.Contains("/Get-Features")); } + [Fact] + public async Task Pending_servicing_is_detected_before_cleanup_is_invoked() + { + using var fixture = new BuildFixture(); + const string packages = "Package Identity : RollupFix~31bf3856ad364e35~amd64~~1.0\r\n" + + "State : Install Pending\r\nRelease Type : Security Update\r\nInstall Time : \r\n"; + var engine = Engine(fixture, args => args.Contains("/Get-Packages") ? new(0, packages) : null); + var result = await engine.BuildAsync(fixture.Recipe, null, CancellationToken.None); + + Assert.Contains(result.Warnings, warning => warning.StartsWith("Component cleanup was skipped", StringComparison.Ordinal) && + warning.Contains("0x800F0806")); + Assert.DoesNotContain(fixture.Events, e => e.Contains("/Cleanup-Image")); + Assert.Equal("Skipped", JobScenarios.StageStates(result.WorkDirectory)["Component cleanup"]); + Assert.True(File.Exists(result.IsoPath)); + Assert.Contains("Pending operation before component cleanup: package RollupFix~31bf3856ad364e35~amd64~~1.0: Install Pending", + File.ReadAllText(Path.Combine(result.WorkDirectory, "build.log"))); + } + + [Fact] + public async Task Core_refuses_pending_servicing_before_cleanup_or_component_pruning() + { + using var fixture = new BuildFixture(); + const string packages = "Package Identity : RollupFix~31bf3856ad364e35~amd64~~1.0\r\n" + + "State : Install Pending\r\nRelease Type : Security Update\r\nInstall Time : \r\n"; + var engine = Engine(fixture, args => args.Contains("/Get-Packages") ? new(0, packages) : null); + var failure = await Assert.ThrowsAsync(() => + engine.BuildAsync(CoreRecipe(fixture), null, CancellationToken.None)); + + var cause = Assert.IsType(failure.InnerException); + Assert.Equal("package RollupFix~31bf3856ad364e35~amd64~~1.0: Install Pending", Assert.Single(cause.Evidence)); + Assert.DoesNotContain(fixture.Events, e => e.Contains("/Cleanup-Image") || e is "TrimCore" or "RemoveOriginalFiles"); + Assert.Contains(fixture.Events, e => e.Contains("/Unmount-Image") && e.Contains("/Discard")); + Assert.False(File.Exists(fixture.Recipe.OutputPath)); + } + [Fact] public async Task A_deferred_cleanup_keeps_every_Edge_file_and_says_so_while_other_original_removals_stay() {