Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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/`
Expand Down
73 changes: 73 additions & 0 deletions TrayApp.Test/ShutdownSignalsTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for how a tray run answers Ctrl+C and <c>SIGTERM</c>.
/// </summary>
/// <remarks>
/// 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. <c>scripts/check-tray-signals.sh</c> covers the real thing.
/// </remarks>
[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());
}
}
70 changes: 70 additions & 0 deletions TrayApp/Tray/ShutdownSignals.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TrayApp.Tray;

using System;
using System.Runtime.InteropServices;

/// <summary>
/// Turns Ctrl+C and <c>SIGTERM</c> into the shutdown the tray's Quit item asks for, for as long as it is held.
/// </summary>
/// <remarks>
/// Avalonia handles neither signal, so without this a <c>kill</c>, a <c>systemctl stop</c> 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.
/// </remarks>
internal sealed class ShutdownSignals : IDisposable
{
private readonly Func<TrayApplication?> host;
private readonly PosixSignalRegistration termination;

/// <summary>
/// Initializes a new instance of the <see cref="ShutdownSignals"/> class, taking both signals.
/// </summary>
/// <param name="host">Reads the tray host, which does not exist until Avalonia has called back for it.</param>
internal ShutdownSignals(Func<TrayApplication?> host)
{
Ensure.NotNull(host);
this.host = host;

Console.CancelKeyPress += OnCancelKeyPress;

// SIGTERM does not go through CancelKeyPress.
termination = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnTermination);
}

/// <summary>
/// Releases both signals back to their default handling.
/// </summary>
public void Dispose()
{
Console.CancelKeyPress -= OnCancelKeyPress;
termination.Dispose();
}

/// <summary>
/// Handles <c>SIGTERM</c>, keeping the process alive only when there is a tray to shut down instead.
/// </summary>
/// <param name="context">The signal being delivered.</param>
internal void OnTermination(PosixSignalContext context) => context.Cancel = RequestShutdown();

/// <summary>
/// Asks the tray to shut down, if there is one yet.
/// </summary>
/// <returns>
/// <see langword="true"/> when a tray took the request, so the signal's default handling should be cancelled.
/// </returns>
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();
}
24 changes: 24 additions & 0 deletions TrayApp/Tray/TrayApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,6 +41,7 @@ internal sealed class TrayApplication : Application, IDisposable
private TrayIcon? trayIcon;
private DispatcherTimer? expiryTimer;
private bool disposed;
private int shutdownRequested;

/// <summary>
/// Initializes a new instance of the <see cref="TrayApplication"/> class.
Expand Down Expand Up @@ -239,6 +241,28 @@ internal void OnTrayIconClicked(object? sender, EventArgs e)

private void OnQuitRequested(object? sender, EventArgs e) => Shutdown();

/// <summary>
/// Ends the run the way the Quit item does, from any thread.
/// </summary>
/// <remarks>
/// 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
/// <see cref="OnExit"/> and <see cref="Dispose"/>, 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.
/// </remarks>
/// <returns><see langword="true"/> when this call posted the shutdown, <see langword="false"/> when an earlier one had.</returns>
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();

/// <summary>
Expand Down
14 changes: 9 additions & 5 deletions TrayApp/TrayAppBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
85 changes: 85 additions & 0 deletions scripts/check-tray-signals.sh
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 20 in scripts/check-tray-signals.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TrayApp&issues=AaF30Hr4YN6Ak495tNiE&open=AaF30Hr4YN6Ak495tNiE&pullRequest=52
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

Check failure on line 43 in scripts/check-tray-signals.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TrayApp&issues=AaF30Hr4YN6Ak495tNiF&open=AaF30Hr4YN6Ak495tNiF&pullRequest=52
sleep 0.5
done

if [ -z "$item" ]; then

Check failure on line 47 in scripts/check-tray-signals.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TrayApp&issues=AaF30Hr4YN6Ak495tNiG&open=AaF30Hr4YN6Ak495tNiG&pullRequest=52
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

Check failure on line 72 in scripts/check-tray-signals.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TrayApp&issues=AaF30Hr4YN6Ak495tNiH&open=AaF30Hr4YN6Ak495tNiH&pullRequest=52
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
Loading