fix(apps): winget launch failure no longer crashes Apply
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 4m44s
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:
@@ -10,7 +10,15 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
|
|||||||
var results = new List<AppInstallResult>();
|
var results = new List<AppInstallResult>();
|
||||||
if (apps.Count == 0) return results;
|
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;
|
var i = 0;
|
||||||
foreach (var app in apps)
|
foreach (var app in apps)
|
||||||
@@ -21,7 +29,7 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
|
|||||||
var id = app.Source.Winget;
|
var id = app.Source.Winget;
|
||||||
if (!string.IsNullOrWhiteSpace(id))
|
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",
|
$"install --id {id} --silent --accept-package-agreements --accept-source-agreements --disable-interactivity",
|
||||||
ct);
|
ct);
|
||||||
ok = r.ExitCode == 0;
|
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);
|
var script = Path.Combine(appsDir, "configure", app.Configure);
|
||||||
// best-effort: configuration failure does not mark the install as failed
|
// 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);
|
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\"", ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,17 +46,38 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// winget (App Installer) is absent from IoT Enterprise LTSC.
|
// Find a usable winget. winget (App Installer) is absent from IoT Enterprise LTSC, and even
|
||||||
// Detect it; if missing, provision via the bundled bootstrap script or the registered package family name.
|
// when present it ships as a WindowsApps execution alias that Process.Start can't always launch
|
||||||
private async Task EnsureWingetAsync(CancellationToken ct)
|
// 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);
|
// 1) Already launchable by name (on PATH for this process)?
|
||||||
if (probe.ExitCode == 0) return;
|
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");
|
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 {{ " +
|
$"-NoProfile -ExecutionPolicy Bypass -Command \"if (Test-Path '{bootstrap}') {{ & '{bootstrap}' }} else {{ " +
|
||||||
"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"",
|
"Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -EA SilentlyContinue }}\"",
|
||||||
ct);
|
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); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ public class AppInstallerTests
|
|||||||
public async Task Bootstraps_winget_when_absent()
|
public async Task Bootstraps_winget_when_absent()
|
||||||
{
|
{
|
||||||
var run = Runner();
|
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"));
|
.ReturnsAsync(new ProcessResult(1, "", "not found"));
|
||||||
var sut = new AppInstaller(run.Object, "C:\\apps");
|
var sut = new AppInstaller(run.Object, "C:\\apps");
|
||||||
await sut.InstallAsync(new[] { App("tb", "Mozilla.Thunderbird") }, new Progress<ApplyProgress>(_ => { }));
|
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.False(res.First(r => r.Id == "bad").Installed);
|
||||||
Assert.True(res.First(r => r.Id == "good").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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user