diff --git a/CLAUDE.md b/CLAUDE.md index 465ac50..ce5935e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +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/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 @@ -68,6 +69,8 @@ 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` + and the preference flush ### Dependencies @@ -169,6 +172,19 @@ checked state flips, the disabled status line re-reads itself from the tool's ge persisted toggle - the preference file lands under `~/.config`. Assertions on `TrayMenu` cover the model; 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 +500 ms debounce window: + +```bash +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 +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. + ## Packaging `TrayApp/msbuild/ktsu.TrayApp.props` and `TrayApp/msbuild/ktsu.TrayApp.targets` are packed to `build/` diff --git a/TrayApp.Test/ShutdownSignalsTests.cs b/TrayApp.Test/ShutdownSignalsTests.cs new file mode 100644 index 0000000..61c1108 --- /dev/null +++ b/TrayApp.Test/ShutdownSignalsTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.TrayApp.Test; + +using System.Runtime.InteropServices; +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. +/// +/// +/// 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. +/// +[TestClass] +[DoNotParallelize] +public class ShutdownSignalsTests +{ + private static TrayApplication Host() + { + TrayAppDefinition app = TrayAppBuilder.Create("demo").Toggle("One", () => true, _ => { }).Build(); + return new TrayApplication(app, new TrayMenu(app.DisplayName, app.Status, app.Items), duration: null, onToggled: null); + } + + [TestMethod] + public void TerminationBeforeTheTrayExists_IsLeftToEndTheProcess() + { + using ShutdownSignals signals = new(() => null); + PosixSignalContext context = new(PosixSignal.SIGTERM); + + signals.OnTermination(context); + + Assert.IsFalse(context.Cancel); + } + + [TestMethod] + public void TerminationOnceTheTrayExists_IsTakenAndShutsTheTrayDown() + { + using TrayApplication host = Host(); + using ShutdownSignals signals = new(() => host); + PosixSignalContext context = new(PosixSignal.SIGTERM); + + signals.OnTermination(context); + + Assert.IsTrue(context.Cancel); + + // The signal already asked, so nothing is left for a second request to post. + Assert.IsFalse(host.RequestShutdown()); + } + + [TestMethod] + public void ARepeatedSignal_IsStillTakenButPostsNoSecondShutdown() + { + using TrayApplication host = Host(); + using ShutdownSignals signals = new(() => host); + + Assert.IsTrue(signals.RequestShutdown()); + Assert.IsTrue(signals.RequestShutdown()); + Assert.IsFalse(host.RequestShutdown()); + } + + [TestMethod] + public void RequestShutdown_PostsOnlyOnce() + { + using TrayApplication host = Host(); + + Assert.IsTrue(host.RequestShutdown()); + Assert.IsFalse(host.RequestShutdown()); + } +} diff --git a/TrayApp/Tray/ShutdownSignals.cs b/TrayApp/Tray/ShutdownSignals.cs new file mode 100644 index 0000000..7eefef0 --- /dev/null +++ b/TrayApp/Tray/ShutdownSignals.cs @@ -0,0 +1,70 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.TrayApp.Tray; + +using System; +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. +/// +/// +/// 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 +/// 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; + + /// + /// Initializes a new instance of the class, taking both signals. + /// + /// Reads the tray host, which does not exist until Avalonia has called back for it. + internal ShutdownSignals(Func host) + { + Ensure.NotNull(host); + this.host = host; + + Console.CancelKeyPress += OnCancelKeyPress; + + // SIGTERM does not go through CancelKeyPress. + termination = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnTermination); + } + + /// + /// Releases both signals back to their default handling. + /// + public void Dispose() + { + Console.CancelKeyPress -= OnCancelKeyPress; + termination.Dispose(); + } + + /// + /// Handles SIGTERM, 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(); + + /// + /// Asks the tray to shut down, if there is one yet. + /// + /// + /// when a tray took the request, so the signal's default handling should be cancelled. + /// + internal bool RequestShutdown() + { + if (host() is not TrayApplication running) + { + return false; + } + + _ = running.RequestShutdown(); + return true; + } + + private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs eventArgs) => eventArgs.Cancel = RequestShutdown(); +} diff --git a/TrayApp/Tray/TrayApplication.cs b/TrayApp/Tray/TrayApplication.cs index f40f6ee..ef7ce5c 100644 --- a/TrayApp/Tray/TrayApplication.cs +++ b/TrayApp/Tray/TrayApplication.cs @@ -4,6 +4,7 @@ namespace ktsu.TrayApp.Tray; using System; using System.Collections.Generic; +using System.Threading; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -40,6 +41,7 @@ internal sealed class TrayApplication : Application, IDisposable private TrayIcon? trayIcon; private DispatcherTimer? expiryTimer; private bool disposed; + private int shutdownRequested; /// /// Initializes a new instance of the class. @@ -239,6 +241,28 @@ internal void OnTrayIconClicked(object? sender, EventArgs e) private void OnQuitRequested(object? sender, EventArgs e) => Shutdown(); + /// + /// Ends the run the way the Quit item does, from any thread. + /// + /// + /// This is what a signal handler calls, so it runs on whichever thread delivered the signal. The shutdown + /// itself is posted to the UI thread, where the lifetime expects it, and from there the run ends through + /// and , so the tool's stop action runs and the caller gets to + /// flush its preferences. Only the first request posts anything: Ctrl+C pressed twice, or a signal + /// arriving while the user is quitting, must not shut down a lifetime that is already shutting down. + /// + /// when this call posted the shutdown, when an earlier one had. + internal bool RequestShutdown() + { + if (Interlocked.Exchange(ref shutdownRequested, 1) != 0) + { + return false; + } + + Dispatcher.UIThread.Post(Shutdown); + return true; + } + private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) => Dispose(); /// diff --git a/TrayApp/TrayAppBuilder.cs b/TrayApp/TrayAppBuilder.cs index 43e77a0..73cf5ca 100644 --- a/TrayApp/TrayAppBuilder.cs +++ b/TrayApp/TrayAppBuilder.cs @@ -595,11 +595,15 @@ void Remember(TrayToggleItem toggle) try { - // StartWithClassicDesktopLifetime blocks until the tray quits. - return AppBuilder.Configure(() => host = new TrayApplication(app, menu, options.Duration, Remember)) - .UsePlatformDetect() - .With(new MacOSPlatformOptions { ShowInDock = false }) - .StartWithClassicDesktopLifetime([], Avalonia.Controls.ShutdownMode.OnExplicitShutdown); + // Released before any fallback to the console, which takes the same signals itself. + using (new ShutdownSignals(() => host)) + { + // StartWithClassicDesktopLifetime blocks until the tray quits. + return AppBuilder.Configure(() => host = new TrayApplication(app, menu, options.Duration, Remember)) + .UsePlatformDetect() + .With(new MacOSPlatformOptions { ShowInDock = false }) + .StartWithClassicDesktopLifetime([], Avalonia.Controls.ShutdownMode.OnExplicitShutdown); + } } catch (Exception ex) { diff --git a/scripts/check-tray-signals.sh b/scripts/check-tray-signals.sh new file mode 100755 index 0000000..a8f4d02 --- /dev/null +++ b/scripts/check-tray-signals.sh @@ -0,0 +1,85 @@ +#!/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 +# 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`. +# +# dotnet build examples/TrayApp.Demo +# dbus-run-session -- scripts/check-tray-signals.sh +# +# Exits non-zero, naming the signal, on the first run that does not shut down cleanly. + +set -u + +root="$(cd "$(dirname "$0")/.." && pwd)" +demo="$root/examples/TrayApp.Demo/bin/Debug/net10.0/ktsu.TrayApp.Demo.dll" +work="$(mktemp -d)" +trap 'kill $(jobs -p) 2>/dev/null; rm -rf "$work"' EXIT + +if [ ! -f "$demo" ]; then + echo "Build the demo first: dotnet build examples/TrayApp.Demo" >&2 + exit 2 +fi + +check() { + local signal="$1" + local home="$work/$signal" + mkdir -p "$home" + + python3 -u "$root/scripts/status-notifier-watcher.py" > "$home/watcher.log" 2>&1 & + local watcher=$! + + # A non-interactive shell starts background jobs with SIGINT ignored, and an ignored signal stays + # ignored across exec, so it is put back to the default for the run to receive it at all. + HOME="$home" XDG_CONFIG_HOME="$home/.config" xvfb-run -a \ + env --default-signal=INT DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS" \ + dotnet "$demo" --tray > "$home/demo.log" 2>&1 & + local run=$! + + local item="" + for _ in $(seq 1 60); do + item="$(grep -o 'org.kde.StatusNotifierItem-[0-9-]*' "$home/watcher.log" | head -1)" + [ -n "$item" ] && break + sleep 0.5 + done + + if [ -z "$item" ]; then + echo "SIG$signal: the tray icon never registered" >&2 + cat "$home/demo.log" >&2 + return 1 + fi + + local pid + pid="$(pgrep -f "^dotnet $demo" | head -1)" + + # Flip the remembered toggle, then send the signal well inside the debounce window. + python3 -u "$root/scripts/click-tray-menu.py" "$item" Running > "$home/click.log" 2>&1 & + for _ in $(seq 1 100); do + grep -q '^clicking' "$home/click.log" && break + sleep 0.02 + done + sleep 0.1 + kill "-$signal" "$pid" + + wait "$run" + local status=$? + kill "$watcher" 2>/dev/null + + local saved + saved="$(cat "$home/.config/trayapp-demo/tray.json" 2>/dev/null)" + + if [ "$status" -ne 0 ]; then + echo "SIG$signal: exited $status instead of shutting down" >&2 + return 1 + fi + + if ! grep -q '"running": false' <<< "$saved"; then + echo "SIG$signal: the toggle clicked before the signal was not saved: ${saved:-no preference file}" >&2 + return 1 + fi + + echo "SIG$signal: shut down cleanly and saved the toggle" +} + +check TERM && check INT