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()
{