From ef78e44db2013493f23dc007d3b9ae66caf414c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:34:02 +0000 Subject: [PATCH 1/2] [patch] Shut a tray run down through OnStop on SIGTERM and Ctrl+C Only the console path installed signal handlers, and Avalonia handles neither signal, so a tray run sent SIGTERM (kill, systemctl stop, logout) or SIGINT died to the runtime's default handler: the tool's OnStop never ran, and a toggle clicked inside the 500 ms debounce window was never saved. RunTray now takes both, as ConsoleRunner does, and posts the same lifetime shutdown the Quit item asks for, so the run ends through OnExit, Dispose, OnStop and the preference flush and exits 0. scripts/check-tray-signals.sh drives this under Xvfb with a status-notifier watcher: it clicks the remembered toggle, signals inside the debounce window, and fails on a non-zero exit or a lost toggle. Fixes #21 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017UuQscGFrjteUf9Ui3jWdd --- CLAUDE.md | 15 ++++++ TrayApp/Tray/TrayApplication.cs | 20 ++++++++ TrayApp/TrayAppBuilder.cs | 44 +++++++++++++++-- scripts/check-tray-signals.sh | 85 +++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 5 deletions(-) create mode 100755 scripts/check-tray-signals.sh diff --git a/CLAUDE.md b/CLAUDE.md index 465ac50..30ac903 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,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 +171,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` installs its own handlers. + ## Packaging `TrayApp/msbuild/ktsu.TrayApp.props` and `TrayApp/msbuild/ktsu.TrayApp.targets` are packed to `build/` diff --git a/TrayApp/Tray/TrayApplication.cs b/TrayApp/Tray/TrayApplication.cs index f40f6ee..8f0b89a 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,24 @@ 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. + /// + internal void RequestShutdown() + { + if (Interlocked.Exchange(ref shutdownRequested, 1) == 0) + { + Dispatcher.UIThread.Post(Shutdown); + } + } + private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) => Dispose(); /// diff --git a/TrayApp/TrayAppBuilder.cs b/TrayApp/TrayAppBuilder.cs index 43e77a0..207d492 100644 --- a/TrayApp/TrayAppBuilder.cs +++ b/TrayApp/TrayAppBuilder.cs @@ -7,6 +7,7 @@ namespace ktsu.TrayApp; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using System.Threading.Tasks; using Avalonia; using ktsu.Essentials; @@ -593,13 +594,46 @@ void Remember(TrayToggleItem toggle) } } + // Avalonia handles neither Ctrl+C nor SIGTERM, so without these a `kill`, a `systemctl stop` or a logout + // ended the process through the runtime's default handler: the tool's stop action never ran, and a + // toggle clicked inside the debounce window was never saved. Both are taken and turned into the same + // shutdown the Quit item asks for. Before the tray exists there is nothing to shut down, so the signal + // is left to end the process as it would have. + bool RequestShutdown() + { + if (host is not TrayApplication running) + { + return false; + } + + running.RequestShutdown(); + return true; + } + + void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs eventArgs) => eventArgs.Cancel = RequestShutdown(); + 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); + Console.CancelKeyPress += OnCancelKeyPress; + + // SIGTERM does not go through CancelKeyPress. + using PosixSignalRegistration termination = PosixSignalRegistration.Create( + PosixSignal.SIGTERM, + context => context.Cancel = RequestShutdown()); + + 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); + } + finally + { + // Unhooked before any fallback to the console, which installs handlers of its own. + Console.CancelKeyPress -= OnCancelKeyPress; + } } 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 From f6247ebcbf7336a6e3bbac0f5eb59be1650fc70e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:47:20 +0000 Subject: [PATCH 2/2] Move the tray's signal handling into ShutdownSignals so it is unit tested RunTray only runs with a real tray, so the handlers written inline there were covered by nothing but the Xvfb script. ShutdownSignals now holds the CancelKeyPress hook and the SIGTERM registration, and decides from the host whether to take the signal; TrayApplication.RequestShutdown reports whether it posted the shutdown. Both are exercised directly in ShutdownSignalsTests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017UuQscGFrjteUf9Ui3jWdd --- CLAUDE.md | 3 +- TrayApp.Test/ShutdownSignalsTests.cs | 73 ++++++++++++++++++++++++++++ TrayApp/Tray/ShutdownSignals.cs | 70 ++++++++++++++++++++++++++ TrayApp/Tray/TrayApplication.cs | 10 ++-- TrayApp/TrayAppBuilder.cs | 34 +------------ 5 files changed, 154 insertions(+), 36 deletions(-) create mode 100644 TrayApp.Test/ShutdownSignalsTests.cs create mode 100644 TrayApp/Tray/ShutdownSignals.cs diff --git a/CLAUDE.md b/CLAUDE.md index 30ac903..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 @@ -182,7 +183,7 @@ 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` installs its own handlers. +handles neither signal, 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 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 8f0b89a..ef7ce5c 100644 --- a/TrayApp/Tray/TrayApplication.cs +++ b/TrayApp/Tray/TrayApplication.cs @@ -251,12 +251,16 @@ internal void OnTrayIconClicked(object? sender, EventArgs e) /// 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. /// - internal void RequestShutdown() + /// when this call posted the shutdown, when an earlier one had. + internal bool RequestShutdown() { - if (Interlocked.Exchange(ref shutdownRequested, 1) == 0) + if (Interlocked.Exchange(ref shutdownRequested, 1) != 0) { - Dispatcher.UIThread.Post(Shutdown); + 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 207d492..73cf5ca 100644 --- a/TrayApp/TrayAppBuilder.cs +++ b/TrayApp/TrayAppBuilder.cs @@ -7,7 +7,6 @@ namespace ktsu.TrayApp; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; -using System.Runtime.InteropServices; using System.Threading.Tasks; using Avalonia; using ktsu.Essentials; @@ -594,34 +593,10 @@ void Remember(TrayToggleItem toggle) } } - // Avalonia handles neither Ctrl+C nor SIGTERM, so without these a `kill`, a `systemctl stop` or a logout - // ended the process through the runtime's default handler: the tool's stop action never ran, and a - // toggle clicked inside the debounce window was never saved. Both are taken and turned into the same - // shutdown the Quit item asks for. Before the tray exists there is nothing to shut down, so the signal - // is left to end the process as it would have. - bool RequestShutdown() - { - if (host is not TrayApplication running) - { - return false; - } - - running.RequestShutdown(); - return true; - } - - void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs eventArgs) => eventArgs.Cancel = RequestShutdown(); - try { - Console.CancelKeyPress += OnCancelKeyPress; - - // SIGTERM does not go through CancelKeyPress. - using PosixSignalRegistration termination = PosixSignalRegistration.Create( - PosixSignal.SIGTERM, - context => context.Cancel = RequestShutdown()); - - try + // 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)) @@ -629,11 +604,6 @@ bool RequestShutdown() .With(new MacOSPlatformOptions { ShowInDock = false }) .StartWithClassicDesktopLifetime([], Avalonia.Controls.ShutdownMode.OnExplicitShutdown); } - finally - { - // Unhooked before any fallback to the console, which installs handlers of its own. - Console.CancelKeyPress -= OnCancelKeyPress; - } } catch (Exception ex) {