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
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -173,17 +173,17 @@ 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
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

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

/// <summary>
/// Tests for how a tray run answers Ctrl+C and <c>SIGTERM</c>.
/// Tests for how a tray run answers Ctrl+C, <c>SIGTERM</c> and <c>SIGHUP</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.
/// Most of these call the handlers directly, so they show what each one decides without a display or a process
/// to kill. The <c>SIGHUP</c> case sends the real signal, because what it checks is that the signal is taken at
/// all. <c>scripts/check-tray-signals.sh</c> covers the whole run.
/// </remarks>
[TestClass]
[DoNotParallelize]
Expand Down Expand Up @@ -51,6 +55,51 @@
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");

Check warning on line 85 in TrayApp.Test/ShutdownSignalsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TrayApp&issues=AaGJKa9ChVvDSLZmHgxB&open=AaGJKa9ChVvDSLZmHgxB&pullRequest=55
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()
{
Expand Down
21 changes: 13 additions & 8 deletions TrayApp/Tray/ShutdownSignals.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,24 @@ namespace ktsu.TrayApp.Tray;
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.
/// Turns Ctrl+C, <c>SIGTERM</c> and <c>SIGHUP</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
/// Avalonia handles none of these signals, so without this a <c>kill</c>, a <c>systemctl stop</c>, 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.
/// </remarks>
internal sealed class ShutdownSignals : IDisposable
{
private readonly Func<TrayApplication?> host;
private readonly PosixSignalRegistration termination;
private readonly PosixSignalRegistration hangup;

/// <summary>
/// Initializes a new instance of the <see cref="ShutdownSignals"/> class, taking both signals.
/// Initializes a new instance of the <see cref="ShutdownSignals"/> class, taking all three 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)
Expand All @@ -30,21 +33,23 @@ internal ShutdownSignals(Func<TrayApplication?> 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);
}

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

/// <summary>
/// Handles <c>SIGTERM</c>, keeping the process alive only when there is a tray to shut down instead.
/// Handles <c>SIGTERM</c> and <c>SIGHUP</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();
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-tray-signals.sh
Original file line number Diff line number Diff line change
@@ -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`.
#
Expand Down Expand Up @@ -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
Loading