Add HostShutdownGate and shutdown handling

Introduce HostShutdownGate to serialize and record the first host shutdown request (Restart preferred over later Exit). Add tests (HostShutdownGateTests) and a tray-menu spec describing shutdown requirements. Integrate the gate into App: expose IsShutdownInProgress, ignore tray/settings/component-library actions during shutdown, reuse/track the fused component library window, ensure edit-mode exit on failures, and close the library during shutdown. Add TrySubmitShutdown to commit shutdown intent, schedule forced termination, perform exit cleanup, and invoke desktop lifetime shutdown. Update HostApplicationLifecycleService to use the new TrySubmitShutdown flow for Exit/Restart. Harden DesktopTrayService.Dispose to clear icons and dispose the tray icon safely. These changes ensure irreversible shutdown commits, prevent UI reopening during shutdown, preserve restart intent, and avoid duplicate or conflicting shutdown actions.
This commit is contained in:
lincube
2026-04-23 14:18:09 +08:00
parent 927dc8d1fd
commit 2d9391f930
6 changed files with 344 additions and 25 deletions

View File

@@ -0,0 +1,17 @@
# Tray Menu Shutdown Addendum
## Requirements
- Tray menu `Exit App` must commit an irreversible host shutdown request.
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline.
- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit.
- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it.
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
## Acceptance
- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
- Repeated tray clicks during shutdown are ignored and logged.
- Repeated component-library clicks focus the existing window instead of opening duplicates.

View File

@@ -0,0 +1,48 @@
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class HostShutdownGateTests
{
[Fact]
public void Submit_WhenFirstExitRequest_AcceptsAndRecordsExit()
{
var gate = new HostShutdownGate();
var submission = gate.Submit(HostShutdownMode.Exit);
Assert.True(submission.Accepted);
Assert.True(submission.IsFirstSubmission);
Assert.Equal(HostShutdownMode.Exit, submission.EffectiveMode);
Assert.True(gate.IsShutdownRequested);
Assert.Equal(HostShutdownMode.Exit, gate.EffectiveMode);
}
[Fact]
public void Submit_WhenDuplicateSameMode_AcceptsButDoesNotExecuteAgain()
{
var gate = new HostShutdownGate();
gate.Submit(HostShutdownMode.Exit);
var duplicate = gate.Submit(HostShutdownMode.Exit);
Assert.True(duplicate.Accepted);
Assert.False(duplicate.IsFirstSubmission);
Assert.Equal(HostShutdownMode.Exit, duplicate.EffectiveMode);
}
[Fact]
public void Submit_WhenExitArrivesAfterRestart_DoesNotOverwriteRestart()
{
var gate = new HostShutdownGate();
gate.Submit(HostShutdownMode.Restart);
var conflictingExit = gate.Submit(HostShutdownMode.Exit);
Assert.False(conflictingExit.Accepted);
Assert.False(conflictingExit.IsFirstSubmission);
Assert.Equal(HostShutdownMode.Restart, conflictingExit.EffectiveMode);
Assert.Equal(HostShutdownMode.Restart, gate.EffectiveMode);
}
}

View File

@@ -56,6 +56,7 @@ public partial class App : Application
private readonly LocalizationService _localizationService = new();
private readonly FontFamilyService _fontFamilyService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly HostShutdownGate _shutdownGate = new();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
@@ -75,6 +76,7 @@ public partial class App : Application
private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow;
private TransparentOverlayWindow? _transparentOverlayWindow;
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
private bool _mainWindowClosed;
private bool _uiUnhandledExceptionHooked;
private DesktopShellHost? _desktopShellHost;
@@ -107,6 +109,7 @@ public partial class App : Application
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
internal INotificationService? NotificationService => _notificationService;
internal bool IsShutdownInProgress => _shutdownGate.IsShutdownRequested || _shutdownIntent != ShutdownIntent.None;
internal RestartPresentationMode GetCurrentRestartPresentationMode()
{
return _desktopShellState switch
@@ -119,6 +122,14 @@ public partial class App : Application
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
{
if (IsShutdownInProgress)
{
AppLogger.Info(
"SettingsFacade",
$"Settings open ignored because shutdown is in progress. Source='{source}'; PageTag='{pageTag ?? "<default>"}'.");
return;
}
EnsureSettingsWindowService();
AppLogger.Info(
"SettingsFacade",
@@ -348,11 +359,23 @@ public partial class App : Application
private void OnTrayShowDesktopClick(object? sender, EventArgs e)
{
if (IsShutdownInProgress)
{
AppLogger.Info("DesktopShell", "Tray Open Desktop ignored because shutdown is in progress.");
return;
}
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu");
}
private void OnTrayRestartClick(object? sender, EventArgs e)
{
if (IsShutdownInProgress)
{
AppLogger.Info("HostLifecycle", "Tray Restart ignored because shutdown is already in progress.");
return;
}
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
Reason: "User selected Restart App from the tray menu."));
@@ -362,6 +385,13 @@ public partial class App : Application
{
_ = sender;
_ = e;
if (IsShutdownInProgress)
{
AppLogger.Info("SettingsFacade", "Tray Settings ignored because shutdown is in progress.");
return;
}
OpenIndependentSettingsModule("TrayMenu");
}
@@ -369,28 +399,52 @@ public partial class App : Application
{
_ = sender;
_ = e;
if (IsShutdownInProgress)
{
AppLogger.Info("FusedDesktop", "Tray Component Library ignored because shutdown is in progress.");
return;
}
if (!OperatingSystem.IsWindows())
{
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
return;
}
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
// 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず
EnsureTransparentOverlayWindow();
Dispatcher.UIThread.Post(() =>
{
if (IsShutdownInProgress)
{
AppLogger.Info("FusedDesktop", "Deferred Component Library open ignored because shutdown is in progress.");
return;
}
try
{
if (_fusedComponentLibraryWindow is { } existingWindow)
{
if (!existingWindow.IsVisible)
{
existingWindow.Show();
}
existingWindow.Activate();
return;
}
var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate();
fusedDesktopManager.EnterEditMode();
// 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず
EnsureTransparentOverlayWindow();
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Show();
}
var window = new FusedDesktopComponentLibraryWindow();
_fusedComponentLibraryWindow = window;
if (_transparentOverlayWindow is not null)
{
@@ -406,7 +460,11 @@ public partial class App : Application
}
// 璁╃鐞嗗櫒鏍规嵁宸插瓨鍌ㄧ殑鏈€鏂板揩鐓ч噸寤虹敓鎴愭墍鏈夊疄浣撳皬缁勪欢
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
fusedDesktopManager.ExitEditMode();
if (ReferenceEquals(_fusedComponentLibraryWindow, s))
{
_fusedComponentLibraryWindow = null;
}
};
window.Show();
@@ -415,6 +473,25 @@ public partial class App : Application
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
try
{
_transparentOverlayWindow?.SaveLayoutAndHide();
}
catch (Exception overlayEx)
{
AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay after library open failure.", overlayEx);
}
try
{
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
}
catch (Exception exitEx)
{
AppLogger.Warn("FusedDesktop", "Failed to exit edit mode after library open failure.", exitEx);
}
_fusedComponentLibraryWindow = null;
}
}, DispatcherPriority.Send);
}
@@ -752,6 +829,12 @@ public partial class App : Application
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
{
if (IsShutdownInProgress)
{
AppLogger.Info("DesktopShell", $"Restore ignored because shutdown is in progress. Source='{source}'.");
return;
}
Dispatcher.UIThread.Post(() =>
{
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
@@ -760,6 +843,12 @@ public partial class App : Application
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
{
if (IsShutdownInProgress)
{
AppLogger.Info("DesktopShell", $"Restore skipped because shutdown is in progress. Source='{source}'.");
return false;
}
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'.");
@@ -838,6 +927,62 @@ public partial class App : Application
}
}
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn(
"HostLifecycle",
$"Shutdown request ignored because desktop lifetime is unavailable. Mode='{mode}'; Source='{request?.Source ?? "Unknown"}'.");
return false;
}
return Dispatcher.UIThread.CheckAccess()
? TrySubmitShutdownCore(mode, request, desktop)
: Dispatcher.UIThread.InvokeAsync(
() => TrySubmitShutdownCore(mode, request, desktop),
DispatcherPriority.Send).GetAwaiter().GetResult();
}
private bool TrySubmitShutdownCore(
HostShutdownMode mode,
HostApplicationLifecycleRequest? request,
IClassicDesktopStyleApplicationLifetime desktop)
{
var source = request?.Source ?? "Unknown";
var submission = _shutdownGate.Submit(mode);
if (!submission.IsFirstSubmission)
{
AppLogger.Warn(
"HostLifecycle",
$"Shutdown request ignored because shutdown is already in progress. Requested='{submission.RequestedMode}'; Effective='{submission.EffectiveMode}'; Source='{source}'.");
return submission.Accepted;
}
_shutdownIntent = mode == HostShutdownMode.Restart
? ShutdownIntent.RestartRequested
: ShutdownIntent.ExitRequested;
AppLogger.Info(
"DesktopShell",
$"Shutdown committed. Intent='{_shutdownIntent}'; Source='{source}'; Reason='{request?.Reason ?? string.Empty}'; CurrentShellState='{_desktopShellState}'.");
ScheduleForcedProcessTermination($"ShutdownRequest:{source}");
StopShellRecoveryWatchdog();
PerformExitCleanup();
ReleaseSingleInstanceAfterExit($"ShutdownRequest:{source}");
try
{
desktop.Shutdown();
return true;
}
catch (Exception ex)
{
AppLogger.Warn("HostLifecycle", $"Desktop lifetime shutdown failed. Source='{source}'.", ex);
return true;
}
}
internal void PrepareForShutdown(bool isRestart, string source)
{
void Mark()
@@ -1123,6 +1268,30 @@ public partial class App : Application
disposableRegistry.Dispose();
}
if (_fusedComponentLibraryWindow is not null)
{
try
{
_fusedComponentLibraryWindow.Close();
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to close fused desktop component library during shutdown.", ex);
}
finally
{
_fusedComponentLibraryWindow = null;
try
{
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode during shutdown.", ex);
}
}
}
if (_transparentOverlayWindow is not null)
{
try
@@ -1487,6 +1656,12 @@ public partial class App : Application
private bool EnsureTaskbarEntry(string source)
{
if (IsShutdownInProgress)
{
AppLogger.Info("DesktopShell", $"Taskbar repair skipped because shutdown is in progress. Source='{source}'.");
return false;
}
if (!ShouldShowMainWindowInTaskbar())
{
return false;

View File

@@ -128,6 +128,27 @@ internal sealed class DesktopTrayService : IDisposable
{
}
try
{
TrayIcon.SetIcons(_application, []);
}
catch
{
}
try
{
if (_trayIcon is IDisposable disposable)
{
disposable.Dispose();
}
}
catch
{
}
_trayIcon = null;
SetState(TrayAvailabilityState.Unavailable, "Dispose");
}

View File

@@ -23,23 +23,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
app = Application.Current as App;
if (app?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
if (app is null || app.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
{
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
return false;
}
app.PrepareForShutdown(isRestart: false, request?.Source ?? "Unknown");
if (Dispatcher.UIThread.CheckAccess())
{
desktop.Shutdown();
}
else
{
Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send);
}
return true;
return app.TrySubmitShutdown(HostShutdownMode.Exit, request);
}
catch (Exception ex)
{
@@ -55,6 +45,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
try
{
app = Application.Current as App;
if (app?.IsShutdownInProgress == true)
{
AppLogger.Warn(
"HostLifecycle",
$"Restart request ignored because shutdown is already in progress. Source='{request?.Source ?? "Unknown"}'.");
return false;
}
if (HasPendingPluginUpgrades())
{
@@ -123,10 +120,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
Process.Start(helperStartInfo);
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
return TryExit(request);
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
}
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
@@ -143,8 +137,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
}
Process.Start(startInfo);
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
var exitRequest = request is null
var shutdownRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
{
@@ -153,7 +146,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
: request.Reason
};
return TryExit(exitRequest);
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
}
private static string ResolveUpgradeHelperPath()

View File

@@ -0,0 +1,65 @@
namespace LanMountainDesktop.Services;
internal enum HostShutdownMode
{
Exit = 0,
Restart = 1
}
internal readonly record struct HostShutdownSubmission(
bool Accepted,
bool IsFirstSubmission,
HostShutdownMode EffectiveMode,
HostShutdownMode RequestedMode);
internal sealed class HostShutdownGate
{
private readonly object _gate = new();
private bool _submitted;
private HostShutdownMode _mode;
public bool IsShutdownRequested
{
get
{
lock (_gate)
{
return _submitted;
}
}
}
public HostShutdownMode? EffectiveMode
{
get
{
lock (_gate)
{
return _submitted ? _mode : null;
}
}
}
public HostShutdownSubmission Submit(HostShutdownMode requestedMode)
{
lock (_gate)
{
if (!_submitted)
{
_submitted = true;
_mode = requestedMode;
return new HostShutdownSubmission(
Accepted: true,
IsFirstSubmission: true,
EffectiveMode: requestedMode,
RequestedMode: requestedMode);
}
return new HostShutdownSubmission(
Accepted: _mode == requestedMode,
IsFirstSubmission: false,
EffectiveMode: _mode,
RequestedMode: requestedMode);
}
}
}