From a64b3314b835e063f787f0e3385d2ac4d82d764e Mon Sep 17 00:00:00 2001 From: awss Date: Mon, 21 Sep 2026 21:24:26 +0100 Subject: [PATCH 1/2] Relaunch UniGetUI through /bin/sh instead of powershell.exe off Windows CoreTools.ScheduleRelaunchAfterExit started "powershell.exe -Command Wait-Process ...; Start-Process ..." on every platform. On macOS and Linux there is no powershell.exe, so accepting a restart (after a dependency install, from settings, or from the portable-import banner) threw a Win32Exception out of the shutdown coordinator: the app went away and never came back, and the exception was reported on the next start. Split the start-info construction into CreateRelaunchStartInfo. Windows keeps the same PowerShell command. Elsewhere a detached /bin/sh helper waits for the current pid with kill -0 (0.2 s polls, 30 s cap) and then starts the executable, or opens the .app bundle with "open -na" when the executable lives inside one on macOS. This is the shape the self-updater already uses for its post-exit swap; arguments are positional so no path is interpolated into the script. Tests pin the Windows command, the helper's arguments and script, the bundle detection, and run the helper for real against a stand-in process on Linux and macOS. Relates to #5237 --- .../RelaunchTests.cs | 141 ++++++++++++++++++ src/UniGetUI.Core.Tools/InternalsVisibleTo.cs | 3 + src/UniGetUI.Core.Tools/Tools.cs | 76 +++++++++- 3 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs create mode 100644 src/UniGetUI.Core.Tools/InternalsVisibleTo.cs diff --git a/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs b/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs new file mode 100644 index 0000000000..ecde0dd0af --- /dev/null +++ b/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs @@ -0,0 +1,141 @@ +using System.Diagnostics; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Core.Tools.Tests; + +public class RelaunchTests +{ + [Fact] + public void CreateRelaunchStartInfo_OnWindows_WaitsAndStartsThroughPowerShell() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo( + 4242, + @"C:\Users\O'Brien\UniGetUI\UniGetUI.exe" + ); + + Assert.Equal("powershell.exe", startInfo.FileName); + Assert.Equal( + "-NoProfile -WindowStyle Hidden -Command \"Wait-Process -Id 4242; " + + @"Start-Process -FilePath 'C:\Users\O''Brien\UniGetUI\UniGetUI.exe'""", + startInfo.Arguments + ); + Assert.Empty(startInfo.ArgumentList); + Assert.False(startInfo.UseShellExecute); + Assert.True(startInfo.CreateNoWindow); + } + + [Fact] + public void CreateRelaunchStartInfo_OffWindows_WaitsForThePidInAShellHelper() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo(4242, "/opt/unigetui/UniGetUI"); + + Assert.Equal("/bin/sh", startInfo.FileName); + Assert.Equal("", startInfo.Arguments); + Assert.Equal(6, startInfo.ArgumentList.Count); + Assert.Equal("-c", startInfo.ArgumentList[0]); + Assert.Contains("kill -0 \"$pid\"", startInfo.ArgumentList[1]); + Assert.Contains("\"$exe\" >/dev/null 2>&1 &", startInfo.ArgumentList[1]); + Assert.Contains("/usr/bin/open -na \"$bundle\"", startInfo.ArgumentList[1]); + Assert.Equal("sh", startInfo.ArgumentList[2]); + Assert.Equal("4242", startInfo.ArgumentList[3]); + Assert.Equal("/opt/unigetui/UniGetUI", startInfo.ArgumentList[4]); + Assert.Equal("", startInfo.ArgumentList[5]); + Assert.False(startInfo.UseShellExecute); + } + + [Fact] + public void CreateRelaunchStartInfo_OnMacOs_PassesTheBundleToTheHelper() + { + if (!OperatingSystem.IsMacOS()) + { + return; + } + + ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo( + 4242, + "/Applications/UniGetUI.app/Contents/MacOS/UniGetUI" + ); + + Assert.Equal("/Applications/UniGetUI.app", startInfo.ArgumentList[5]); + } + + [Theory] + [InlineData("/Applications/UniGetUI.app/Contents/MacOS/UniGetUI", "UniGetUI.app")] + [InlineData("/Users/me/My Apps/UniGetUI.app/Contents/MacOS/UniGetUI", "UniGetUI.app")] + public void FindAppBundle_ReturnsTheBundleThatContainsTheExecutable(string executable, string bundleName) + { + string? bundle = CoreTools.FindAppBundle(executable); + + Assert.NotNull(bundle); + Assert.Equal(bundleName, Path.GetFileName(bundle)); + Assert.EndsWith(bundleName, bundle); + } + + [Theory] + [InlineData("/opt/unigetui/UniGetUI")] + [InlineData("/home/me/UniGetUI.app.bak/bin/UniGetUI")] + [InlineData("UniGetUI")] + public void FindAppBundle_ReturnsNullOutsideABundle(string executable) + { + Assert.Null(CoreTools.FindAppBundle(executable)); + } + + // The real helper: a sleeping child stands in for the exiting UniGetUI, and the "executable" is + // a script that leaves a marker. The marker must not appear while the child is alive. + [Fact] + public async Task RelaunchHelper_StartsTheExecutableOnlyAfterTheProcessExits() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + string directory = Path.Combine(Path.GetTempPath(), "unigetui-relaunch-" + Path.GetRandomFileName()); + Directory.CreateDirectory(directory); + string marker = Path.Combine(directory, "relaunched"); + string target = Path.Combine(directory, "target.sh"); + File.WriteAllText(target, $"#!/bin/sh\ntouch \"{marker}\"\n"); + File.SetUnixFileMode(target, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + using var standIn = Process.Start(new ProcessStartInfo("sleep", "1.5") { UseShellExecute = false })!; + try + { + using var helper = Process.Start(CoreTools.CreateRelaunchStartInfo(standIn.Id, target)); + Assert.NotNull(helper); + + await Task.Delay(500); + Assert.False(standIn.HasExited); + Assert.False(File.Exists(marker), "the helper must not start the executable while the process is alive"); + + await standIn.WaitForExitAsync(); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!File.Exists(marker) && DateTime.UtcNow < deadline) + { + await Task.Delay(100); + } + + Assert.True(File.Exists(marker), "the helper did not start the executable after the process exited"); + await helper.WaitForExitAsync(); + Assert.Equal(0, helper.ExitCode); + } + finally + { + if (!standIn.HasExited) + { + standIn.Kill(); + } + + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/src/UniGetUI.Core.Tools/InternalsVisibleTo.cs b/src/UniGetUI.Core.Tools/InternalsVisibleTo.cs new file mode 100644 index 0000000000..55ff8a4021 --- /dev/null +++ b/src/UniGetUI.Core.Tools/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.Core.Tools.Tests")] diff --git a/src/UniGetUI.Core.Tools/Tools.cs b/src/UniGetUI.Core.Tools/Tools.cs index 074ae393f8..030fca80db 100644 --- a/src/UniGetUI.Core.Tools/Tools.cs +++ b/src/UniGetUI.Core.Tools/Tools.cs @@ -165,20 +165,82 @@ public static void RelaunchProcess() public static void ScheduleRelaunchAfterExit(string? executablePath = null) { executablePath ??= CoreData.UniGetUIExecutableFile; - int currentProcessId = Environment.ProcessId; - string escapedExecutablePath = executablePath.Replace("'", "''"); - string command = - $"Wait-Process -Id {currentProcessId}; Start-Process -FilePath '{escapedExecutablePath}'"; + ProcessStartInfo startInfo = CreateRelaunchStartInfo(Environment.ProcessId, executablePath); + Logger.Debug($"Scheduling a relaunch of {executablePath} through {startInfo.FileName}"); + using var process = Process.Start(startInfo); + } + + /// + /// Builds the detached process that waits for to exit and + /// then starts : PowerShell on Windows, a /bin/sh helper + /// elsewhere (the same shape the self-updater uses), which opens the .app bundle on macOS. + /// + internal static ProcessStartInfo CreateRelaunchStartInfo(int currentProcessId, string executablePath) + { + if (OperatingSystem.IsWindows()) + { + string escapedExecutablePath = executablePath.Replace("'", "''"); + string command = + $"Wait-Process -Id {currentProcessId}; Start-Process -FilePath '{escapedExecutablePath}'"; - using var process = Process.Start( - new ProcessStartInfo + return new ProcessStartInfo { FileName = "powershell.exe", Arguments = $"-NoProfile -WindowStyle Hidden -Command \"{command}\"", UseShellExecute = false, CreateNoWindow = true, - } + }; + } + + // Positional arguments ($1=pid, $2=executable, $3=bundle or empty) keep the paths out of + // the script text. The wait gives up after 30 seconds, like the self-updater's helper. + const string script = """ + pid="$1"; exe="$2"; bundle="$3" + i=0 + while kill -0 "$pid" 2>/dev/null && [ "$i" -lt 150 ]; do sleep 0.2; i=$((i+1)); done + if [ -n "$bundle" ]; then + /usr/bin/open -na "$bundle" + else + "$exe" >/dev/null 2>&1 & + fi + """; + + var startInfo = new ProcessStartInfo + { + FileName = "/bin/sh", + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add(script); + startInfo.ArgumentList.Add("sh"); + startInfo.ArgumentList.Add(currentProcessId.ToString(CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(executablePath); + startInfo.ArgumentList.Add( + OperatingSystem.IsMacOS() ? FindAppBundle(executablePath) ?? "" : "" ); + return startInfo; + } + + /// + /// Returns the .app bundle that contains , or null when the + /// executable is not inside one. + /// + internal static string? FindAppBundle(string executablePath) + { + for ( + string? directory = Path.GetDirectoryName(executablePath); + directory is not null; + directory = Path.GetDirectoryName(directory) + ) + { + if (Path.GetFileName(directory).EndsWith(".app", StringComparison.OrdinalIgnoreCase)) + { + return directory; + } + } + + return null; } /// From 1342c47f342fd45435d3c95c423a9af3937310ec Mon Sep 17 00:00:00 2001 From: awss Date: Tue, 22 Sep 2026 18:03:18 +0100 Subject: [PATCH 2/2] Keep UniGetUI open when the relaunch helper cannot start Review follow-up for the off-Windows relaunch. TryScheduleRelaunchAfterExit (was ScheduleRelaunchAfterExit) catches the Process.Start failures (Win32Exception, InvalidOperationException, PlatformNotSupportedException), logs them and returns false, as AvaloniaAutoUpdater.TrySpawnSwapHelper does. AppRestartHelper then cancels the restart: with a main window it throws from the coordinator callback, which rolls the shutdown back and keeps the window open, and it logs instead of leaving the exception in the fire-and-forget task; without one it skips Shutdown(). RelaunchProcess no longer exits when the helper did not start. The /bin/sh helper waits for the pid with no cap, like Wait-Process on Windows, so it never starts a second instance while the first is still shutting down. On macOS it falls back to starting the executable when open rejects the bundle. Tests for one platform use WindowsFact, UnixFact or MacOSFact, so they report as skipped elsewhere instead of passing without running. New: TryStartRelaunchHelper returning false for a helper that cannot start, and the real helper falling back to the executable when the bundle cannot be opened. Relates to #5237 --- .../Infrastructure/AppRestartHelper.cs | 26 +++++++- .../PlatformFacts.cs | 37 +++++++++++ .../RelaunchTests.cs | 66 +++++++++++-------- src/UniGetUI.Core.Tools/Tools.cs | 39 +++++++++-- 4 files changed, 132 insertions(+), 36 deletions(-) create mode 100644 src/UniGetUI.Core.Tools.Tests/PlatformFacts.cs diff --git a/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs index f541199b4c..2361f2d698 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs @@ -1,5 +1,6 @@ using Avalonia.Controls.ApplicationLifetimes; using UniGetUI.Avalonia.Views; +using UniGetUI.Core.Logging; using UniGetUI.Core.Tools; namespace UniGetUI.Avalonia.Infrastructure; @@ -8,6 +9,8 @@ internal static class AppRestartHelper { private const string LauncherExecutableName = "UniGetUI.exe"; + private sealed class RelaunchNotScheduledException : Exception; + public static void Restart() => _ = RestartAsync(); private static async Task RestartAsync() @@ -16,12 +19,29 @@ private static async Task RestartAsync() if (MainWindow.Instance is { } mainWindow) { - await mainWindow.RequestQuitApplicationAsync( - () => CoreTools.ScheduleRelaunchAfterExit(executablePath)); + // A throw from the callback makes the coordinator cancel the shutdown, so the window stays open. + try + { + await mainWindow.RequestQuitApplicationAsync(() => + { + if (!CoreTools.TryScheduleRelaunchAfterExit(executablePath)) + throw new RelaunchNotScheduledException(); + }); + } + catch (RelaunchNotScheduledException) + { + Logger.Warn("Restart cancelled: the relaunch helper could not be started, UniGetUI stays open"); + } + + return; + } + + if (!CoreTools.TryScheduleRelaunchAfterExit(executablePath)) + { + Logger.Warn("Restart cancelled: the relaunch helper could not be started, UniGetUI stays open"); return; } - CoreTools.ScheduleRelaunchAfterExit(executablePath); (global::Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); } diff --git a/src/UniGetUI.Core.Tools.Tests/PlatformFacts.cs b/src/UniGetUI.Core.Tools.Tests/PlatformFacts.cs new file mode 100644 index 0000000000..0242cb43b1 --- /dev/null +++ b/src/UniGetUI.Core.Tools.Tests/PlatformFacts.cs @@ -0,0 +1,37 @@ +namespace UniGetUI.Core.Tools.Tests; + +// xUnit 2 has no runtime skip; setting Skip in the attribute makes a test for another platform +// report as skipped instead of passing without running. + +public sealed class WindowsFactAttribute : FactAttribute +{ + public WindowsFactAttribute() + { + if (!OperatingSystem.IsWindows()) + { + Skip = "Runs on Windows only"; + } + } +} + +public sealed class UnixFactAttribute : FactAttribute +{ + public UnixFactAttribute() + { + if (OperatingSystem.IsWindows()) + { + Skip = "Runs on Linux and macOS only"; + } + } +} + +public sealed class MacOSFactAttribute : FactAttribute +{ + public MacOSFactAttribute() + { + if (!OperatingSystem.IsMacOS()) + { + Skip = "Runs on macOS only"; + } + } +} diff --git a/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs b/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs index ecde0dd0af..f5be4aa3df 100644 --- a/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs +++ b/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs @@ -1,18 +1,14 @@ using System.Diagnostics; +using System.Runtime.Versioning; using UniGetUI.Core.Tools; namespace UniGetUI.Core.Tools.Tests; public class RelaunchTests { - [Fact] + [WindowsFact] public void CreateRelaunchStartInfo_OnWindows_WaitsAndStartsThroughPowerShell() { - if (!OperatingSystem.IsWindows()) - { - return; - } - ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo( 4242, @"C:\Users\O'Brien\UniGetUI\UniGetUI.exe" @@ -29,14 +25,9 @@ public void CreateRelaunchStartInfo_OnWindows_WaitsAndStartsThroughPowerShell() Assert.True(startInfo.CreateNoWindow); } - [Fact] + [UnixFact] public void CreateRelaunchStartInfo_OffWindows_WaitsForThePidInAShellHelper() { - if (OperatingSystem.IsWindows()) - { - return; - } - ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo(4242, "/opt/unigetui/UniGetUI"); Assert.Equal("/bin/sh", startInfo.FileName); @@ -45,7 +36,7 @@ public void CreateRelaunchStartInfo_OffWindows_WaitsForThePidInAShellHelper() Assert.Equal("-c", startInfo.ArgumentList[0]); Assert.Contains("kill -0 \"$pid\"", startInfo.ArgumentList[1]); Assert.Contains("\"$exe\" >/dev/null 2>&1 &", startInfo.ArgumentList[1]); - Assert.Contains("/usr/bin/open -na \"$bundle\"", startInfo.ArgumentList[1]); + Assert.Contains("/usr/bin/open -na \"$bundle\" || \"$exe\" >/dev/null 2>&1 &", startInfo.ArgumentList[1]); Assert.Equal("sh", startInfo.ArgumentList[2]); Assert.Equal("4242", startInfo.ArgumentList[3]); Assert.Equal("/opt/unigetui/UniGetUI", startInfo.ArgumentList[4]); @@ -53,14 +44,9 @@ public void CreateRelaunchStartInfo_OffWindows_WaitsForThePidInAShellHelper() Assert.False(startInfo.UseShellExecute); } - [Fact] + [MacOSFact] public void CreateRelaunchStartInfo_OnMacOs_PassesTheBundleToTheHelper() { - if (!OperatingSystem.IsMacOS()) - { - return; - } - ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo( 4242, "/Applications/UniGetUI.app/Contents/MacOS/UniGetUI" @@ -90,16 +76,38 @@ public void FindAppBundle_ReturnsNullOutsideABundle(string executable) Assert.Null(CoreTools.FindAppBundle(executable)); } - // The real helper: a sleeping child stands in for the exiting UniGetUI, and the "executable" is - // a script that leaves a marker. The marker must not appear while the child is alive. [Fact] - public async Task RelaunchHelper_StartsTheExecutableOnlyAfterTheProcessExits() + public void TryStartRelaunchHelper_ReturnsFalseWhenTheHelperCannotStart() { - if (OperatingSystem.IsWindows()) + var startInfo = new ProcessStartInfo( + Path.Combine(Path.GetTempPath(), "unigetui-missing-" + Path.GetRandomFileName()) + ) { - return; - } + UseShellExecute = false, + }; + + Assert.False(CoreTools.TryStartRelaunchHelper(startInfo)); + } + + // The real helper: a sleeping child stands in for the exiting UniGetUI, and the "executable" is + // a script that leaves a marker. The marker must not appear while the child is alive. + [UnixFact] + [UnsupportedOSPlatform("windows")] + public Task RelaunchHelper_StartsTheExecutableOnlyAfterTheProcessExits() => + AssertHelperRelaunchesAfterExitAsync(bundle: null); + + // A bundle open refuses (here one that does not exist; on Linux there is no /usr/bin/open at + // all) must fall back to starting the executable instead of leaving nothing running. + [UnixFact] + [UnsupportedOSPlatform("windows")] + public Task RelaunchHelper_StartsTheExecutableWhenOpenRejectsTheBundle() => + AssertHelperRelaunchesAfterExitAsync( + bundle: Path.Combine(Path.GetTempPath(), "unigetui-missing-" + Path.GetRandomFileName(), "UniGetUI.app") + ); + [UnsupportedOSPlatform("windows")] + private static async Task AssertHelperRelaunchesAfterExitAsync(string? bundle) + { string directory = Path.Combine(Path.GetTempPath(), "unigetui-relaunch-" + Path.GetRandomFileName()); Directory.CreateDirectory(directory); string marker = Path.Combine(directory, "relaunched"); @@ -110,7 +118,13 @@ public async Task RelaunchHelper_StartsTheExecutableOnlyAfterTheProcessExits() using var standIn = Process.Start(new ProcessStartInfo("sleep", "1.5") { UseShellExecute = false })!; try { - using var helper = Process.Start(CoreTools.CreateRelaunchStartInfo(standIn.Id, target)); + ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo(standIn.Id, target); + if (bundle is not null) + { + startInfo.ArgumentList[5] = bundle; + } + + using var helper = Process.Start(startInfo); Assert.NotNull(helper); await Task.Delay(500); diff --git a/src/UniGetUI.Core.Tools/Tools.cs b/src/UniGetUI.Core.Tools/Tools.cs index 030fca80db..5d2d540926 100644 --- a/src/UniGetUI.Core.Tools/Tools.cs +++ b/src/UniGetUI.Core.Tools/Tools.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Collections; using System.Collections.Concurrent; +using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Net; @@ -157,17 +158,40 @@ public static string AutoTranslated(string text) public static void RelaunchProcess() { Logger.Debug("Launching process: " + CoreData.UniGetUIExecutableFile); - ScheduleRelaunchAfterExit(); + if (!TryScheduleRelaunchAfterExit()) + { + return; + } + Logger.Warn("About to kill process"); Environment.Exit(0); } - public static void ScheduleRelaunchAfterExit(string? executablePath = null) + /// + /// Starts the helper that relaunches UniGetUI once this process exits. Returns false, and + /// logs why, when the helper could not be started; the caller should then keep running. + /// + public static bool TryScheduleRelaunchAfterExit(string? executablePath = null) { executablePath ??= CoreData.UniGetUIExecutableFile; ProcessStartInfo startInfo = CreateRelaunchStartInfo(Environment.ProcessId, executablePath); Logger.Debug($"Scheduling a relaunch of {executablePath} through {startInfo.FileName}"); - using var process = Process.Start(startInfo); + return TryStartRelaunchHelper(startInfo); + } + + internal static bool TryStartRelaunchHelper(ProcessStartInfo startInfo) + { + try + { + using var process = Process.Start(startInfo); + return process is not null; + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or PlatformNotSupportedException) + { + Logger.Error($"Could not start the relaunch helper {startInfo.FileName}:"); + Logger.Error(ex); + return false; + } } /// @@ -193,13 +217,14 @@ internal static ProcessStartInfo CreateRelaunchStartInfo(int currentProcessId, s } // Positional arguments ($1=pid, $2=executable, $3=bundle or empty) keep the paths out of - // the script text. The wait gives up after 30 seconds, like the self-updater's helper. + // the script text. The wait has no cap, like Wait-Process on Windows: launching while the + // old process is alive would hand off to it through the single-instance guard and exit. + // If open rejects the bundle, the executable is started directly. const string script = """ pid="$1"; exe="$2"; bundle="$3" - i=0 - while kill -0 "$pid" 2>/dev/null && [ "$i" -lt 150 ]; do sleep 0.2; i=$((i+1)); done + while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done if [ -n "$bundle" ]; then - /usr/bin/open -na "$bundle" + /usr/bin/open -na "$bundle" || "$exe" >/dev/null 2>&1 & else "$exe" >/dev/null 2>&1 & fi