From 3d756e3c516de58184c0095d1d3926a7117d3f01 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:26:55 +0000 Subject: [PATCH 1/2] Keep the restore error and stop the tool once when tray start-up fails A successful OnStart cleared the preference-restore error seeded into the menu, so the status line looked healthy although the remembered setting was not applied. The start sequence now re-seeds it after a successful start; a failed start still reports its own error. When start-up threw after OnStart had run, RunTray saw HasStarted false, skipped OnStop and fell back to the console, which called OnStart again. TrayApplication now records ToolStarted separately: Dispose stops the tool on it, and RunTray reports the failure and exits 1 instead of restarting the tool in the terminal. Fixes #12 Fixes #13 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011tqaKZaJ9nxFMskVoZgreK --- README.md | 2 + TrayApp.Test/TrayApplicationTests.cs | 119 +++++++++++++++++++++++++++ TrayApp/Tray/TrayApplication.cs | 45 ++++++++-- TrayApp/TrayAppBuilder.cs | 70 +++++++++++----- 4 files changed, 210 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 08a464e..0abeaf1 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,8 @@ These are the parts that look like they could be simplified away and cannot. | Behaviour | What breaks without it | |-----------|------------------------| | Catching **all** exceptions out of the Avalonia run call, and using `HasStarted` to tell "the tray ran and quit" from "the tray never came up" | Avalonia's `DBusTrayIconImpl.WatchAsync` is `async void` with an exception filter that stops applying once the icon is disposed, so an ordinary Linux shutdown throws `TaskCanceledException` *after* the tray has done its job. Without the flag, a clean quit restarts in the console. | +| Tracking `ToolStarted` separately from `HasStarted`, and never falling back to the console once it is set | Start-up can still throw after the start action has run (a status getter, a missing icon resource). Falling back then starts the tool a second time and never stops the first start. | +| Re-seeding a failed preference restore after a successful start action | `TrayMenu.Run` clears `LastError` on success, so the restore failure would be wiped before the menu was ever painted. | | Falling back to the console when the windowing stack refuses after `DesktopSession` said yes | A `DISPLAY` pointing at nothing, a missing libX11, or no status-notifier host kills a process someone started to hold a resource. | | Constructing menu items eagerly and the `TrayIcon` lazily | `TrayIcon`'s constructor reaches for a platform handle that does not exist until Avalonia has started. | | Keeping a failed action's message in a field rather than writing it into the menu item | The refresh that runs immediately after every action overwrites anything written directly. | diff --git a/TrayApp.Test/TrayApplicationTests.cs b/TrayApp.Test/TrayApplicationTests.cs index bfe0e62..e049f81 100644 --- a/TrayApp.Test/TrayApplicationTests.cs +++ b/TrayApp.Test/TrayApplicationTests.cs @@ -5,6 +5,7 @@ namespace ktsu.TrayApp.Test; using System.Linq; using Avalonia.Controls; using ktsu.TrayApp; +using ktsu.TrayApp.Cli; using ktsu.TrayApp.Menu; using ktsu.TrayApp.Tray; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -237,4 +238,122 @@ public void TrayIconClick_WithNoToggleAtAll_DoesNothing() // of all. Assert.AreEqual(0, commandCalls); } + + [TestMethod] + public void StartTool_WhenTheStartActionSucceeds_KeepsTheSeededRestoreError() + { + TrayAppDefinition app = TrayAppBuilder.Create("demo") + .DisplayName("Demo") + .Status(() => "idle") + .Toggle("One", () => false, _ => { }) + .OnStart(() => { }) + .Build(); + + using TrayApplication host = Host(app, out TrayMenu menu); + menu.SeedError("no inhibitor"); + + host.StartTool(); + + // The remembered setting was not applied, and a successful start does not change that. Clearing the + // message here would put a healthy status line in front of the user before the menu is ever painted. + Assert.AreEqual("no inhibitor", menu.LastError); + Assert.AreEqual("Demo failed: no inhibitor", menu.StatusItem!.Header); + } + + [TestMethod] + public void StartTool_WhenTheStartActionFails_ReportsItsOwnError() + { + TrayAppDefinition app = TrayAppBuilder.Create("demo") + .DisplayName("Demo") + .Status(() => "idle") + .Toggle("One", () => false, _ => { }) + .OnStart(() => throw new InvalidOperationException("device busy")) + .Build(); + + using TrayApplication host = Host(app, out TrayMenu menu); + menu.SeedError("no inhibitor"); + + host.StartTool(); + + // The start refusal is the newer and more pressing failure, so it wins the one status line. + Assert.AreEqual("device busy", menu.LastError); + } + + [TestMethod] + public void Dispose_AfterTheToolStartedButBeforeTheTrayFinishedStarting_StopsTheTool() + { + int stops = 0; + + TrayAppDefinition app = TrayAppBuilder.Create("demo") + .DisplayName("Demo") + .Toggle("One", () => false, _ => { }) + .OnStart(() => { }) + .OnStop(() => stops++) + .Build(); + + TrayApplication host = Host(app, out _); + host.StartTool(); + host.Dispose(); + + // Whatever threw between the start action and the end of start-up, the tool is holding what it took, + // and nothing else will release it. + Assert.IsFalse(host.HasStarted); + Assert.AreEqual(1, stops); + } + + [TestMethod] + public void RecoverFromTrayFailure_AfterTheToolStarted_DoesNotStartItAgain() + { + int starts = 0; + int stops = 0; + + TrayAppDefinition app = TrayAppBuilder.Create("demo") + .DisplayName("Demo") + .Toggle("One", () => false, _ => { }) + .OnStart(() => starts++) + .OnStop(() => stops++) + .Build(); + + using TrayApplication host = Host(app, out _); + host.StartTool(); + + // What a missing icon resource or a throwing status getter looks like from RunTray: start-up threw + // after the start action had run, so HasStarted is still false. + int exitCode = TrayAppBuilder.RecoverFromTrayFailure( + app, + new CommandLineOptions { Duration = TimeSpan.Zero }, + host, + new InvalidOperationException("The tray icon 'nope-idle.png' is missing")); + + Assert.AreEqual(1, starts); + Assert.AreEqual(1, stops); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + public void RecoverFromTrayFailure_BeforeTheToolStarted_FallsBackToTheConsole() + { + int starts = 0; + int stops = 0; + + TrayAppDefinition app = TrayAppBuilder.Create("demo") + .DisplayName("Demo") + .Toggle("One", () => false, _ => { }) + .OnStart(() => starts++) + .OnStop(() => stops++) + .Build(); + + using TrayApplication host = Host(app, out _); + + int exitCode = TrayAppBuilder.RecoverFromTrayFailure( + app, + new CommandLineOptions { Duration = TimeSpan.Zero }, + host, + new InvalidOperationException("XOpenDisplay failed")); + + // The windowing stack refused before anything was started, so the terminal run is the only start. + Assert.AreEqual(1, starts); + Assert.AreEqual(1, stops); + Assert.AreEqual(0, exitCode); + } } diff --git a/TrayApp/Tray/TrayApplication.cs b/TrayApp/Tray/TrayApplication.cs index e0b315d..f40f6ee 100644 --- a/TrayApp/Tray/TrayApplication.cs +++ b/TrayApp/Tray/TrayApplication.cs @@ -81,6 +81,16 @@ internal TrayApplication(TrayAppDefinition app, TrayMenu menu, TimeSpan? duratio /// internal bool HasStarted { get; private set; } + /// + /// Gets a value indicating whether the tool's start action has run. + /// + /// + /// Set before , and not the same thing: start-up can still throw after the start + /// action - a status getter, a subscription, a missing icon resource - and by then the tool is holding + /// whatever it took. stops it on this, and the caller must not start it again. + /// + internal bool ToolStarted { get; private set; } + /// public override void Initialize() => Name = app.DisplayName; @@ -102,10 +112,7 @@ public override void OnFrameworkInitializationCompleted() // Started here rather than before the run so that a tool whose start action throws still gets a tray // icon, with the refusal in its status line, instead of a process that exits before the user sees // anything. Run() puts the message where RefreshMenu will pick it up. - if (app.OnStart is not null) - { - menu.Run(app.OnStart); - } + StartTool(); // The tool's own state can change with nobody having clicked anything - a timer expiring, a device // going away - and the menu only ever reads through getters, so all it needs is a nudge. @@ -124,6 +131,34 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + /// + /// Runs the tool's start action, putting a refusal in the status line. + /// + /// + /// Internal so that the start sequence can be driven without a platform: everything here reads and + /// writes menu state, and none of it needs the tray icon. + /// + internal void StartTool() + { + ToolStarted = true; + + if (app.OnStart is null) + { + return; + } + + // A failed preference restore was seeded before the menu existed, and a successful start does not + // make the remembered setting any more applied. Run() clears the error on success, so put it back; + // a failed start reports its own error instead, which is the more pressing of the two. + string? seeded = menu.LastError; + + if (menu.Run(app.OnStart) && seeded is not null) + { + menu.SeedError(seeded); + menu.Refresh(); + } + } + /// /// Stops the tray icon and runs the tool's stop action. /// @@ -139,7 +174,7 @@ public void Dispose() expiryTimer?.Stop(); menu.QuitRequested -= OnQuitRequested; - if (HasStarted) + if (ToolStarted) { app.OnStop?.Invoke(); } diff --git a/TrayApp/TrayAppBuilder.cs b/TrayApp/TrayAppBuilder.cs index 0fa5684..85299cf 100644 --- a/TrayApp/TrayAppBuilder.cs +++ b/TrayApp/TrayAppBuilder.cs @@ -43,6 +43,7 @@ namespace ktsu.TrayApp; public sealed class TrayAppBuilder { private const int ExitSuccess = 0; + private const int ExitFailure = 1; private const int ExitUsage = 2; /// Set to 1 to print the full exception behind a tray failure. @@ -568,30 +569,57 @@ void Remember(TrayToggleItem toggle) } catch (Exception ex) { - if (Environment.GetEnvironmentVariable(DebugEnvironmentVariable) == "1") - { - Console.Error.WriteLine(ex.ToString()); - } + return RecoverFromTrayFailure(app, options, host, ex); + } + } - // The tray already ran and quit; this is Avalonia's teardown throwing after the fact (see - // TrayApplication.HasStarted). Restarting in the terminal here would leave a tool the user just - // quit still holding whatever it holds. - if (host?.HasStarted == true) - { - host.Dispose(); - return ExitSuccess; - } + /// + /// Decides what a tray run that threw ends as. + /// + /// The tool being run. + /// The options the run was started with. + /// The tray host, or if Avalonia never constructed it. + /// What the run threw. + /// The process exit code. + internal static int RecoverFromTrayFailure( + TrayAppDefinition app, + CommandLineOptions options, + TrayApplication? host, + Exception ex) + { + if (Environment.GetEnvironmentVariable(DebugEnvironmentVariable) == "1") + { + Console.Error.WriteLine(ex.ToString()); + } - // Otherwise the windowing stack refused after the session check passed - a DISPLAY pointing at - // nothing, a missing libX11, no status-notifier host on the bus - and it reports those as plain - // exceptions of whatever type the backend felt like (X11 throws Exception("XOpenDisplay failed")). - // There is no useful list to filter on, and every one of them means the same thing here: no tray, - // so use the terminal. Letting it escape instead would kill a process the user asked to keep - // running. - host?.Dispose(); - Console.Error.WriteLine($"{app.Name}: could not show a tray icon ({ex.Message}). Staying in the terminal instead."); - return ConsoleRunner.Run(app, options); + // The tray already ran and quit; this is Avalonia's teardown throwing after the fact (see + // TrayApplication.HasStarted). Restarting in the terminal here would leave a tool the user just + // quit still holding whatever it holds. + if (host?.HasStarted == true) + { + host.Dispose(); + return ExitSuccess; } + + // The icon came up and the tool was started, then something later in start-up threw - a status + // getter, a subscription, a missing icon resource. Disposing stops the tool; falling back to the + // terminal would start it a second time, and say the icon could not be shown when it was. + if (host?.ToolStarted == true) + { + host.Dispose(); + Console.Error.WriteLine($"{app.Name}: the tray failed after starting ({ex.Message})."); + return ExitFailure; + } + + // Otherwise the windowing stack refused after the session check passed - a DISPLAY pointing at + // nothing, a missing libX11, no status-notifier host on the bus - and it reports those as plain + // exceptions of whatever type the backend felt like (X11 throws Exception("XOpenDisplay failed")). + // There is no useful list to filter on, and every one of them means the same thing here: no tray, + // so use the terminal. Letting it escape instead would kill a process the user asked to keep + // running. + host?.Dispose(); + Console.Error.WriteLine($"{app.Name}: could not show a tray icon ({ex.Message}). Staying in the terminal instead."); + return ConsoleRunner.Run(app, options); } /// From 94f69364b6d5f1f9dfffb6693ea7bb78c9a72e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:43:45 +0000 Subject: [PATCH 2/2] Cover the no-start-action, no-host and debug-output recovery paths Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011tqaKZaJ9nxFMskVoZgreK --- TrayApp.Test/TrayApplicationTests.cs | 72 ++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/TrayApp.Test/TrayApplicationTests.cs b/TrayApp.Test/TrayApplicationTests.cs index e049f81..056bf29 100644 --- a/TrayApp.Test/TrayApplicationTests.cs +++ b/TrayApp.Test/TrayApplicationTests.cs @@ -356,4 +356,76 @@ public void RecoverFromTrayFailure_BeforeTheToolStarted_FallsBackToTheConsole() Assert.AreEqual(1, stops); Assert.AreEqual(0, exitCode); } + + [TestMethod] + public void StartTool_WithNoStartAction_KeepsTheSeededErrorAndRecordsTheStart() + { + using TrayApplication host = Host(Definition(), out TrayMenu menu); + menu.SeedError("no inhibitor"); + + host.StartTool(); + + // There is no start action to succeed, so nothing clears the restore error; the tool still counts as + // started, so a later failure stops it rather than starting it again in the terminal. + Assert.IsTrue(host.ToolStarted); + Assert.AreEqual("no inhibitor", menu.LastError); + } + + [TestMethod] + public void RecoverFromTrayFailure_WithNoHost_FallsBackToTheConsole() + { + int starts = 0; + + TrayAppDefinition app = TrayAppBuilder.Create("demo") + .DisplayName("Demo") + .Toggle("One", () => false, _ => { }) + .OnStart(() => starts++) + .Build(); + + // Avalonia can refuse before it ever asks for the application, leaving no host to dispose. + int exitCode = TrayAppBuilder.RecoverFromTrayFailure( + app, + new CommandLineOptions { Duration = TimeSpan.Zero }, + host: null, + new InvalidOperationException("XOpenDisplay failed")); + + Assert.AreEqual(1, starts); + Assert.AreEqual(0, exitCode); + } + + [TestMethod] + public void RecoverFromTrayFailure_WithDebugOutputOn_StillStopsTheToolOnce() + { + int stops = 0; + + TrayAppDefinition app = TrayAppBuilder.Create("demo") + .DisplayName("Demo") + .Toggle("One", () => false, _ => { }) + .OnStop(() => stops++) + .Build(); + + using TrayApplication host = Host(app, out _); + host.StartTool(); + + string? previous = Environment.GetEnvironmentVariable("TRAYAPP_DEBUG"); + + try + { + Environment.SetEnvironmentVariable("TRAYAPP_DEBUG", "1"); + + int exitCode = TrayAppBuilder.RecoverFromTrayFailure( + app, + new CommandLineOptions { Duration = TimeSpan.Zero }, + host, + new InvalidOperationException("status getter threw")); + + // Printing the full exception is diagnostic only; it must not change what the run ends as. + Assert.AreEqual(1, exitCode); + Assert.AreEqual(1, stops); + } + finally + { + Environment.SetEnvironmentVariable("TRAYAPP_DEBUG", previous); + } + } }