fix(apps): winget launch failure no longer crashes Apply
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 4m44s

On IoT LTSC winget is absent, so Process.Start('winget') throws Win32Exception
('cannot find the file specified') rather than returning non-zero. That throw
propagated out of InstallAsync and failed the entire Apply ('Configuration failed').

AppInstaller is now fully exception-safe: a TryRunAsync wrapper converts launch
throws into a failed run, winget is resolved defensively (PATH -> bootstrap+re-probe
-> WindowsApps alias path) and when unavailable the installer skips apps and marks
them not-installed instead of throwing. Per-app launch throws are isolated too.
Two new tests cover probe-throws-skips and per-app-throw-isolated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sysadmin
2026-06-10 01:23:49 +01:00
parent f6dac0fdfd
commit 3daa770584
2 changed files with 80 additions and 10 deletions

View File

@@ -10,7 +10,15 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
var results = new List<AppInstallResult>();
if (apps.Count == 0) return results;
await EnsureWingetAsync(ct);
// App installs are non-critical: a missing/broken winget (e.g. offline IoT LTSC) must
// NEVER fail onboarding. Resolve winget defensively; if it can't be found, skip installs.
var winget = await ResolveWingetAsync(progress, ct);
if (winget is null)
{
progress.Report(new($"App installer unavailable - skipping {apps.Count} app(s)", 80));
foreach (var app in apps) results.Add(new AppInstallResult(app.Id, false));
return results;
}
var i = 0;
foreach (var app in apps)
@@ -21,7 +29,7 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
var id = app.Source.Winget;
if (!string.IsNullOrWhiteSpace(id))
{
var r = await runner.RunAsync("winget",
var r = await TryRunAsync(winget,
$"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity",
ct);
ok = r.ExitCode == 0;
@@ -29,7 +37,7 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
{
var script = Path.Combine(appsDir, "configure", app.Configure);
// best-effort: configuration failure does not mark the install as failed
await runner.RunAsync("powershell.exe",
await TryRunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct);
}
}
@@ -38,17 +46,38 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
return results;
}
// winget (App Installer) is absent from IoT Enterprise LTSC.
// Detect it; if missing, provision via the bundled bootstrap script or the registered package family name.
private async Task EnsureWingetAsync(CancellationToken ct)
// Find a usable winget. winget (App Installer) is absent from IoT Enterprise LTSC, and even
// when present it ships as a WindowsApps execution alias that Process.Start can't always launch
// by bare name. Returns the invocation target ("winget" or a full path), or null if unavailable.
private async Task<string?> ResolveWingetAsync(IProgress<ApplyProgress> progress, CancellationToken ct)
{
var probe = await runner.RunAsync("winget", "--version", ct);
if (probe.ExitCode == 0) return;
// 1) Already launchable by name (on PATH for this process)?
if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget";
// 2) Provision App Installer via the bundled bootstrap (or registered package), then re-probe.
progress.Report(new("Preparing app installer", 68));
var bootstrap = Path.Combine(appsDir, "bootstrap-winget.ps1");
await runner.RunAsync("powershell.exe",
await TryRunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " +
"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"",
ct);
if ((await TryRunAsync("winget", "--version", ct)).ExitCode == 0) return "winget";
// 3) Fall back to the WindowsApps execution-alias path (bare-name launch can fail under
// UseShellExecute=false even when winget is installed).
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var aliased = Path.Combine(local, "Microsoft", "WindowsApps", "winget.exe");
if (File.Exists(aliased) && (await TryRunAsync(aliased, "--version", ct)).ExitCode == 0) return aliased;
return null;
}
// Launching a missing executable throws Win32Exception ("cannot find the file specified")
// rather than returning a non-zero code. Treat any launch failure as a failed run so the
// installer's continue-on-failure logic covers it and onboarding never crashes.
private async Task<ProcessResult> TryRunAsync(string file, string args, CancellationToken ct)
{
try { return await runner.RunAsync(file, args, ct); }
catch (Exception ex) { return new ProcessResult(-1, "", ex.Message); }
}
}

View File

@@ -38,7 +38,8 @@ public class AppInstallerTests
public async Task Bootstraps_winget_when_absent()
{
var run = Runner();
run.Setup(r => r.RunAsync("winget", It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
// Any winget probe (bare name OR the WindowsApps alias path) reports absent.
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(1, "", "not found"));
var sut = new AppInstaller(run.Object, "C:\\apps");
await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress<ApplyProgress>(_ => { }));
@@ -62,4 +63,44 @@ public class AppInstallerTests
Assert.False(res.First(r => r.Id == "bad").Installed);
Assert.True(res.First(r => r.Id == "good").Installed);
}
[Fact]
public async Task Winget_launch_exception_does_not_crash_apply_and_skips_installs()
{
// On IoT LTSC winget is absent, so Process.Start throws Win32Exception instead of
// returning a non-zero code. The installer must swallow it, skip installs, and NOT throw.
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ThrowsAsync(new System.ComponentModel.Win32Exception("The system cannot find the file specified."));
run.Setup(r => r.RunAsync("powershell.exe", It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var sut = new AppInstaller(run.Object, "C:\\apps");
var res = await sut.InstallAsync(new[] { App("vscodium", "VSCodium.VSCodium"), App("git", "Git.Git") },
new Progress<ApplyProgress>(_ => { }));
Assert.Equal(2, res.Count);
Assert.All(res, r => Assert.False(r.Installed));
// never attempted an install once winget was unresolved
run.Verify(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("install --id")), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task App_install_exception_is_isolated_from_the_rest()
{
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("--version")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "v1.8", ""));
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("Bad.App")), It.IsAny<CancellationToken>()))
.ThrowsAsync(new System.ComponentModel.Win32Exception("boom"));
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.Is<string>(s => s.Contains("Good.App")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(0, "", ""));
var sut = new AppInstaller(run.Object, "C:\\apps");
var res = await sut.InstallAsync(new[] { App("bad", "Bad.App"), App("good", "Good.App") },
new Progress<ApplyProgress>(_ => { }));
Assert.False(res.First(r => r.Id == "bad").Installed);
Assert.True(res.First(r => r.Id == "good").Installed);
}
}