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