diff --git a/CLAUDE.md b/CLAUDE.md index ce5935e..867d5e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ menu model and its own platform layer, and the library brings everything else. T - `TrayApp/Tray/TrayApplication.cs` - The Avalonia host: projects the menu model onto native menu items and owns `HasStarted` - `TrayApp/ConsoleRunner.cs` - The headless mode, including Ctrl+C and `SIGTERM` -- `TrayApp/Tray/ShutdownSignals.cs` - The tray mode's Ctrl+C and `SIGTERM`, turned into the Quit item's shutdown +- `TrayApp/Tray/ShutdownSignals.cs` - The tray mode's Ctrl+C, `SIGTERM` and `SIGHUP`, turned into the Quit item's shutdown - `TrayApp/TrayIconSet.cs` - Loads the tray PNGs by embedded resource name, and says what the assembly does have when a name misses - `TrayApp/DesktopSession.cs` - Whether this process has a desktop session to put a tray icon in @@ -69,7 +69,7 @@ menu model and its own platform layer, and the library brings everything else. T without a panel - `scripts/click-tray-menu.py` - Clicks an entry in a running tool's tray menu over D-Bus, and prints the menu either side of it -- `scripts/check-tray-signals.sh` - Checks that `SIGTERM` and `SIGINT` end a tray run through `OnStop` +- `scripts/check-tray-signals.sh` - Checks that `SIGTERM`, `SIGINT` and `SIGHUP` end a tray run through `OnStop` and the preference flush ### Dependencies @@ -173,7 +173,7 @@ persisted toggle - the preference file lands under `~/.config`. Assertions on `T this is what covers the projection onto native menu items. `scripts/check-tray-signals.sh` covers the way out that does not go through the menu. It runs the demo in -the tray, clicks the remembered toggle, and sends `SIGTERM`, then `SIGINT`, inside the preference store's +the tray, clicks the remembered toggle, and sends `SIGTERM`, then `SIGINT`, then `SIGHUP`, inside the preference store's 500 ms debounce window: ```bash @@ -181,9 +181,9 @@ dotnet build examples/TrayApp.Demo dbus-run-session -- scripts/check-tray-signals.sh ``` -Each run has to exit 0 and leave the toggle saved. An exit of 143 or 130 means the signal killed the +Each run has to exit 0 and leave the toggle saved. An exit of 143, 130 or 129 means the signal killed the process outright, so the tool's `OnStop` never ran and the pending preference write was lost. Avalonia -handles neither signal, which is why `RunTray` holds a `ShutdownSignals` for the length of the run. +handles none of these signals, which is why `RunTray` holds a `ShutdownSignals` for the length of the run. ## Packaging diff --git a/TrayApp.Test/ShutdownSignalsTests.cs b/TrayApp.Test/ShutdownSignalsTests.cs index 61c1108..4141ef4 100644 --- a/TrayApp.Test/ShutdownSignalsTests.cs +++ b/TrayApp.Test/ShutdownSignalsTests.cs @@ -2,18 +2,22 @@ namespace ktsu.TrayApp.Test; +using System; +using System.Diagnostics; using System.Runtime.InteropServices; +using System.Threading; using ktsu.TrayApp; using ktsu.TrayApp.Menu; using ktsu.TrayApp.Tray; using Microsoft.VisualStudio.TestTools.UnitTesting; /// -/// Tests for how a tray run answers Ctrl+C and SIGTERM. +/// Tests for how a tray run answers Ctrl+C, SIGTERM and SIGHUP. /// /// -/// The signals themselves are not sent: the handlers are called directly, so the tests show what each one -/// decides without a display or a process to kill. scripts/check-tray-signals.sh covers the real thing. +/// Most of these call the handlers directly, so they show what each one decides without a display or a process +/// to kill. The SIGHUP case sends the real signal, because what it checks is that the signal is taken at +/// all. scripts/check-tray-signals.sh covers the whole run. /// [TestClass] [DoNotParallelize] @@ -51,6 +55,51 @@ public void TerminationOnceTheTrayExists_IsTakenAndShutsTheTrayDown() Assert.IsFalse(host.RequestShutdown()); } + [TestMethod] + [OSCondition(OperatingSystems.Linux | OperatingSystems.OSX)] + public void HangupOnceTheTrayExists_IsTakenAndShutsTheTrayDown() + { + using TrayApplication host = Host(); + int asked = 0; + using ShutdownSignals signals = new(() => + { + Interlocked.Increment(ref asked); + return host; + }); + + // Keeps the test host alive whatever ShutdownSignals does: without it, a SIGHUP nothing takes ends the + // process, and the failure would show up as a crashed run instead of this assertion. + using ManualResetEventSlim delivered = new(); + using PosixSignalRegistration guard = PosixSignalRegistration.Create(PosixSignal.SIGHUP, context => + { + context.Cancel = true; + delivered.Set(); + }); + + using (Process kill = Process.Start("kill", ["-HUP", Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)])) + { + kill.WaitForExit(); + Assert.AreEqual(0, kill.ExitCode); + } + + Assert.IsTrue(delivered.Wait(TimeSpan.FromSeconds(10)), "SIGHUP was never delivered"); + Assert.IsTrue(SpinWait.SpinUntil(() => Volatile.Read(ref asked) > 0, TimeSpan.FromSeconds(10)), "SIGHUP did not reach the tray"); + + // The signal already asked, so nothing is left for a second request to post. + Assert.IsFalse(host.RequestShutdown()); + } + + [TestMethod] + public void HangupBeforeTheTrayExists_IsLeftToEndTheProcess() + { + using ShutdownSignals signals = new(() => null); + PosixSignalContext context = new(PosixSignal.SIGHUP); + + signals.OnTermination(context); + + Assert.IsFalse(context.Cancel); + } + [TestMethod] public void ARepeatedSignal_IsStillTakenButPostsNoSecondShutdown() { diff --git a/TrayApp/Tray/ShutdownSignals.cs b/TrayApp/Tray/ShutdownSignals.cs index 7eefef0..bae88ec 100644 --- a/TrayApp/Tray/ShutdownSignals.cs +++ b/TrayApp/Tray/ShutdownSignals.cs @@ -6,21 +6,24 @@ namespace ktsu.TrayApp.Tray; using System.Runtime.InteropServices; /// -/// Turns Ctrl+C and SIGTERM into the shutdown the tray's Quit item asks for, for as long as it is held. +/// Turns Ctrl+C, SIGTERM and SIGHUP into the shutdown the tray's Quit item asks for, for as long as +/// it is held. /// /// -/// Avalonia handles neither signal, so without this a kill, a systemctl stop or a logout ended a -/// tray run through the runtime's default handler: the tool's stop action never ran, and a toggle clicked inside -/// the preference store's debounce window was never saved. Before the tray exists there is nothing to shut +/// Avalonia handles none of these signals, so without this a kill, a systemctl stop, a logout or +/// closing the terminal the tool was started from ended a tray run through the runtime's default handler: the +/// tool's stop action never ran, and a toggle clicked inside the preference store's debounce window was never +/// saved. Before the tray exists there is nothing to shut /// down, so the signal is left to end the process as it would have. /// internal sealed class ShutdownSignals : IDisposable { private readonly Func host; private readonly PosixSignalRegistration termination; + private readonly PosixSignalRegistration hangup; /// - /// Initializes a new instance of the class, taking both signals. + /// Initializes a new instance of the class, taking all three signals. /// /// Reads the tray host, which does not exist until Avalonia has called back for it. internal ShutdownSignals(Func host) @@ -30,21 +33,23 @@ internal ShutdownSignals(Func host) Console.CancelKeyPress += OnCancelKeyPress; - // SIGTERM does not go through CancelKeyPress. + // SIGTERM and SIGHUP do not go through CancelKeyPress. termination = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnTermination); + hangup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, OnTermination); } /// - /// Releases both signals back to their default handling. + /// Releases all three signals back to their default handling. /// public void Dispose() { Console.CancelKeyPress -= OnCancelKeyPress; termination.Dispose(); + hangup.Dispose(); } /// - /// Handles SIGTERM, keeping the process alive only when there is a tray to shut down instead. + /// Handles SIGTERM and SIGHUP, keeping the process alive only when there is a tray to shut down instead. /// /// The signal being delivered. internal void OnTermination(PosixSignalContext context) => context.Cancel = RequestShutdown(); diff --git a/scripts/check-tray-signals.sh b/scripts/check-tray-signals.sh index a8f4d02..fddb503 100755 --- a/scripts/check-tray-signals.sh +++ b/scripts/check-tray-signals.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Copyright (c) 2023-2026 ktsu-dev contributors # -# Checks that a tray run ends through the tool's own shutdown when it is sent SIGTERM or SIGINT: the +# Checks that a tray run ends through the tool's own shutdown when it is sent SIGTERM, SIGINT or SIGHUP: the # process exits 0 rather than dying to the signal, and a toggle clicked inside the preference store's # 500 ms debounce window is still saved. Needs Xvfb, dbus-run-session and `pip install dbus-next`. # @@ -82,4 +82,4 @@ check() { echo "SIG$signal: shut down cleanly and saved the toggle" } -check TERM && check INT +check TERM && check INT && check HUP