Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions src/Tiny11Builder.Core/BuildEngine.Cleanup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@ public sealed partial class BuildEngine
/// <summary>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).</summary>
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 ?
Expand All @@ -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<string> OriginalFileOptions(BuildSession s)
{
var options = s.Recipe.SelectedOptions;
Expand Down
35 changes: 35 additions & 0 deletions tests/Tiny11Builder.Core.Tests/PendingOperationsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BuildFailureException>(() =>
engine.BuildAsync(CoreRecipe(fixture), null, CancellationToken.None));

var cause = Assert.IsType<PendingOperationsException>(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()
{
Expand Down