Skip to content

After clicking Quit or the --for expiry, a Ctrl+C/SIGTERM during a hung OnStop is still swallowed: only RequestShutdown marks the shutdown as under way #80

Open

Description

@matt-edmondson

What's wrong

Only TrayApplication.RequestShutdown (TrayApp/Tray/TrayApplication.cs:265-274) sets shutdownRequested. Two other paths end the tray run by calling Shutdown() directly:

  • the Quit item: OnQuitRequested => Shutdown() (L252)
  • the --for expiry timer's Tick (L320-324)

Neither sets the flag. After either one, the first Ctrl+C or SIGTERM still finds shutdownRequested == 0. RequestShutdown returns true, ShutdownSignals sets Cancel = true (TrayApp/Tray/ShutdownSignals.cs:217), and a second Shutdown is posted to a UI thread that is already blocked.

The remarks on RequestShutdown (L258-263) and #63/#66 promise the opposite: a signal that arrives while a stop is already under way gets its default handling. The console runner keeps that promise, because stopping.Close() (ConsoleRunner.cs:83) marks the run as stopping whatever ended the wait, including --for.

The test that is supposed to cover this, ShutdownSignalsTests.ASignalAfterQuit_IsNotCancelled (TrayApp.Test/ShutdownSignalsTests.cs:133-145), fakes Quit by calling host.RequestShutdown(). The real Quit item never makes that call, so the test passes on a path the product doesn't take.

Failure scenario

The tool's OnStop hangs, for example an inhibitor release blocked on D-Bus.

  • Quit: the user clicks Quit. desktop.Shutdown() leads to OnExit, then Dispose, then OnStop, which hangs. The user presses Ctrl+C and nothing happens, because the signal is cancelled. Only a second Ctrl+C ends the process.
  • --for under systemd: the window expires and OnStop hangs. systemctl stop sends SIGTERM, which is cancelled, so the unit sits out TimeoutStopSec and is SIGKILLed. A --no-tray run in the same state exits on the first SIGTERM.

Suggested fix / acceptance criteria

  • Claim the flag on every path that ends the run. For example, do Interlocked.Exchange(ref shutdownRequested, 1) inside Shutdown() itself and keep RequestShutdown's check-then-post, or send Quit and the expiry through one helper.
  • After Quit, or after the expiry fires, the next termination signal leaves Cancel == false.
  • ASignalAfterQuit_IsNotCancelled drives the Quit item (OnQuitRequested), not RequestShutdown. Add an equivalent test for the expiry.

Activity

  1. matt-edmondson commented on Nov 5, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    Category: Bug
    Priority: Medium. It only shows when OnStop hangs, but the consequences in that case are serious: the first Ctrl+C is ignored, and under systemd a --for run waits out TimeoutStopSec and gets SIGKILLed. The existing test passes because it calls RequestShutdown instead of driving the real Quit path, so CI cannot catch this.

    Suggested area / assignment: Shutdown signalling in TrayApp/Tray/TrayApplication.cs (Shutdown, RequestShutdown L258-274, the Quit handler L252, the --for timer L320-324) and TrayApp.Test/ShutdownSignalsTests.cs.

    Duplicates / in progress: Not a duplicate. It is the follow-up to #63/#66, which added the "a signal during a stop gets its default handling" promise that these two paths miss. Related to #30 (console mode signal handling around OnStart/SIGHUP), but that covers the console runner, not the tray. No open PR covers it.

    Next step: Claim shutdownRequested inside Shutdown() itself (or route Quit and the expiry through one helper). Change ASignalAfterQuit_IsNotCancelled to drive OnQuitRequested, and add an equivalent test for the expiry.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions