diff --git a/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md b/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md new file mode 100644 index 0000000..241e786 --- /dev/null +++ b/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md @@ -0,0 +1,29 @@ +# Launcher Slow-Startup And Startup Visual Addendum + +## New startup timing contract + +- `30s` is a soft timeout, not a failure threshold. +- After `30s`, if the desktop process is still alive or Public IPC is connected, Launcher must stay in a waiting state and must not start another host process. +- `120s` is the hard timeout. +- Before returning `desktop_not_visible`, Launcher must attempt one foreground recovery through `ActivateMainWindowAsync()`. + +## Startup attempt de-duplication + +- Launcher persists the current startup attempt in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json`. +- A second Launcher process must attach to a live pending attempt instead of calling `Process.Start()` again. +- Closing the splash window does not cancel startup; it transitions the attempt into detached waiting and preserves recovery state for the next Launcher run. + +## Startup visual modes + +- `EnableSlideTransition = true` forces `StartupVisualMode.SlideSplash` and automatically disables fade. +- `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`. +- `EnableSlideTransition = false && EnableFadeTransition = true` resolves to `StartupVisualMode.Fade`. + +## UX safeguards + +- If the host process is still alive at failure time, the failure dialog must prefer: + - `Activate` + - `Wait` + - `Open Logs` + - `Exit` +- Retry is only valid when Launcher is not about to create a duplicate desktop process. diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index d18571e..c004f89 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -6,6 +6,9 @@ using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; namespace LanMountainDesktop.Launcher; @@ -52,7 +55,7 @@ public partial class App : Application } else { - var splashWindow = new SplashWindow(); + var splashWindow = CreateSplashWindow(); splashWindow.Show(); _ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow); } @@ -68,7 +71,7 @@ public partial class App : Application case "preview-splash": { Logger.Info("Preview command: splash."); - var splashWindow = new SplashWindow(); + var splashWindow = CreateSplashWindow(); splashWindow.SetDebugMode(true); splashWindow.Show(); _ = SimulateSplashPreviewAsync(desktop, splashWindow); @@ -112,6 +115,12 @@ public partial class App : Application } } + private static SplashWindow CreateSplashWindow() + { + var preferences = StartupVisualPreferencesResolver.Resolve(); + return new SplashWindow(preferences.Mode); + } + private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window) { var stages = new[] { "initializing", "update", "plugins", "launch", "ready" }; @@ -172,49 +181,76 @@ public partial class App : Application SplashWindow splashWindow) { LauncherResult result; + SplashWindow? currentSplashWindow = splashWindow; + var appRoot = Commands.ResolveAppRoot(context); - try + while (true) { - var appRoot = Commands.ResolveAppRoot(context); - Logger.Info( - $"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " + - $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + - $"ResultPath='{context.GetOption("result") ?? ""}'."); - - var deploymentLocator = new DeploymentLocator(appRoot); - var coordinator = new LauncherFlowCoordinator( - context, - deploymentLocator, - new OobeStateService(appRoot), - new UpdateEngineService(deploymentLocator), - new PluginInstallerService()); - - result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error("Coordinator threw an unhandled exception.", ex); - result = new LauncherResult + try { - Success = false, - Stage = "launch", - Code = "exception", - Message = $"Launcher failed: {ex.Message}", - ErrorMessage = ex.ToString() - }; + Logger.Info( + $"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " + + $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + + $"ResultPath='{context.GetOption("result") ?? ""}'."); + + var deploymentLocator = new DeploymentLocator(appRoot); + var coordinator = new LauncherFlowCoordinator( + context, + deploymentLocator, + new OobeStateService(appRoot), + new UpdateEngineService(deploymentLocator), + new PluginInstallerService()); + + result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Coordinator threw an unhandled exception.", ex); + result = new LauncherResult + { + Success = false, + Stage = "launch", + Code = "exception", + Message = $"Launcher failed: {ex.Message}", + ErrorMessage = ex.ToString() + }; + } + + if (result.Success || + result.Code == "host_not_found" || + (!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) && + !string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase))) + { + break; + } + + var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false); + if (failureAction == ErrorWindowResult.Exit) + { + break; + } + + if (failureAction == ErrorWindowResult.ActivateExisting && + await TryActivateExistingInstanceAsync().ConfigureAwait(false)) + { + result = new LauncherResult + { + Success = true, + Stage = "launch", + Code = "activation_requested", + Message = "Launcher activated the existing desktop instance.", + Details = result.Details + }; + break; + } + + currentSplashWindow = CreateSplashWindow(); + currentSplashWindow.Show(); } Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'."); await WriteLauncherResultAsync(context, result).ConfigureAwait(false); - if (!result.Success && - result.Code is not "host_not_found" && - (string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) || - string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase))) - { - await ShowFailureWindowAsync(result).ConfigureAwait(false); - } - Environment.ExitCode = result.Success ? 0 : 1; await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); } @@ -238,15 +274,31 @@ public partial class App : Application } } - private static async Task ShowFailureWindowAsync(LauncherResult result) + private static async Task ShowFailureWindowAsync(LauncherResult result) { ErrorWindow? errorWindow = null; + var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) && + bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) && + hostProcessAliveValue; + var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) && + int.TryParse(hostPidText, out var parsedPid) + ? parsedPid + : (int?)null; await Dispatcher.UIThread.InvokeAsync(() => { try { errorWindow = new ErrorWindow(); + if (hostProcessAlive) + { + errorWindow.ConfigureForRunningHostFailure(hostPid); + } + else + { + errorWindow.ConfigureForGenericFailure(allowRetry: true); + } + errorWindow.SetErrorMessage( $"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}"); errorWindow.Show(); @@ -259,16 +311,38 @@ public partial class App : Application if (errorWindow is null) { - return; + return ErrorWindowResult.Exit; } try { - await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); + return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); } catch (Exception ex) { Logger.Error("Failure window closed unexpectedly.", ex); + return ErrorWindowResult.Exit; + } + } + + private static async Task TryActivateExistingInstanceAsync() + { + try + { + using var ipcClient = new LanMountainDesktopIpcClient(); + await ipcClient.ConnectAsync().ConfigureAwait(false); + if (!ipcClient.IsConnected) + { + return false; + } + + var shellProxy = ipcClient.CreateProxy(); + return await shellProxy.ActivateMainWindowAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}"); + return false; } } diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj index d22016c..de158af 100644 --- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj +++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj @@ -35,6 +35,7 @@ + diff --git a/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs b/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs new file mode 100644 index 0000000..1105324 --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Models; + +internal enum StartupAttemptState +{ + Pending, + SoftTimeout, + DetachedWaiting, + Succeeded, + Failed +} + +internal sealed class StartupAttemptRecord +{ + [JsonPropertyName("attemptId")] + public string AttemptId { get; set; } = Guid.NewGuid().ToString("N"); + + [JsonPropertyName("hostPid")] + public int HostPid { get; set; } + + [JsonPropertyName("startedAtUtc")] + public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow; + + [JsonPropertyName("updatedAtUtc")] + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + + [JsonPropertyName("launchSource")] + public string LaunchSource { get; set; } = string.Empty; + + [JsonPropertyName("successPolicy")] + public string SuccessPolicy { get; set; } = string.Empty; + + [JsonPropertyName("lastObservedStage")] + public StartupStage LastObservedStage { get; set; } = StartupStage.Initializing; + + [JsonPropertyName("lastObservedMessage")] + public string LastObservedMessage { get; set; } = string.Empty; + + [JsonPropertyName("ipcConnected")] + public bool IpcConnected { get; set; } + + [JsonPropertyName("state")] + public StartupAttemptState State { get; set; } = StartupAttemptState.Pending; +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index 085e1ae..c1eb343 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -10,6 +10,11 @@ namespace LanMountainDesktop.Launcher.Services; internal sealed class LauncherFlowCoordinator { + private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(120); + private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。"; + private const string SoftTimeoutDetailsMessage = "桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。"; + private static readonly string[] LauncherOnlyOptions = [ "debug", "show-loading-details", "plugins-dir", "source", "result", @@ -25,6 +30,7 @@ internal sealed class LauncherFlowCoordinator private readonly OobeStateService _oobeStateService; private readonly UpdateEngineService _updateEngine; private readonly PluginInstallerService _pluginInstallerService; + private readonly StartupAttemptRegistry _startupAttemptRegistry; private readonly IReadOnlyList _oobeSteps; public LauncherFlowCoordinator( @@ -39,6 +45,7 @@ internal sealed class LauncherFlowCoordinator _oobeStateService = oobeStateService; _updateEngine = updateEngine; _pluginInstallerService = pluginInstallerService; + _startupAttemptRegistry = new StartupAttemptRegistry(); _oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)]; } @@ -66,6 +73,7 @@ internal sealed class LauncherFlowCoordinator window.Show(); return window; }); + var windowsClosingByCoordinator = false; var versionInfo = _deploymentLocator.GetVersionInfo(); splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename); var reporter = (ISplashStageReporter)splashWindow; @@ -85,8 +93,25 @@ internal sealed class LauncherFlowCoordinator var lastStage = StartupStage.Initializing; var lastStageMessage = "launcher-started"; var startupSuccessTracker = new StartupSuccessTracker(_context); + var activationFailureReason = string.Empty; + var ipcConnected = false; + var softTimeoutShown = false; + var attachedToExistingAttempt = false; + StartupAttemptRecord? trackedAttempt = null; var loadingState = new LoadingStateMessage(); + EventHandler? splashClosedHandler = null; + splashClosedHandler = (_, _) => + { + if (windowsClosingByCoordinator) + { + return; + } + + _startupAttemptRegistry.MarkOwnedDetachedWaiting(); + Logger.Warn("Splash window was closed manually. Launcher will continue monitoring the current startup attempt."); + }; + splashWindow.Closed += splashClosedHandler; using var ipcClient = new LanMountainDesktopIpcClient(); ipcClient.RegisterNotifyHandler(IpcRoutedNotifyIds.LauncherStartupProgress, message => { @@ -94,8 +119,9 @@ internal sealed class LauncherFlowCoordinator { try { + ipcConnected = true; lastStage = message.Stage; - lastStageMessage = message.Message ?? string.Empty; + lastStageMessage = message.Message ?? message.Stage.ToString(); Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'."); loadingState = loadingState with @@ -108,6 +134,7 @@ internal sealed class LauncherFlowCoordinator reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString()); loadingDetailsWindow?.UpdateLoadingState(loadingState); + _startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true); if (startupSuccessTracker.TryResolve(message.Stage, out var successState)) { @@ -116,6 +143,7 @@ internal sealed class LauncherFlowCoordinator if (message.Stage == StartupStage.ActivationFailed) { + activationFailureReason = message.Message ?? "activation_failed"; activationFailedTcs.TrySetResult(message.Message ?? "activation_failed"); } } @@ -170,7 +198,90 @@ internal sealed class LauncherFlowCoordinator } reporter.Report("launch", "Launching desktop..."); - var launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false); + var launchOutcome = default(HostLaunchOutcome); + var attachableAttempt = _startupAttemptRegistry.TryGetAttachableAttempt(_context.LaunchSource, startupSuccessTracker.PolicyKey); + if (attachableAttempt is not null && + _startupAttemptRegistry.AdoptAttempt(attachableAttempt.AttemptId) && + TryGetLiveProcess(attachableAttempt.HostPid, out var attachedProcess)) + { + trackedAttempt = attachableAttempt; + attachedToExistingAttempt = true; + ipcConnected = attachableAttempt.IpcConnected; + lastStage = attachableAttempt.LastObservedStage; + lastStageMessage = string.IsNullOrWhiteSpace(attachableAttempt.LastObservedMessage) + ? "Attached to the existing startup attempt." + : attachableAttempt.LastObservedMessage; + reporter.Report(MapStartupStageToSplashStage(lastStage), lastStageMessage); + + if (startupSuccessTracker.TryResolve(lastStage, out var attachedSuccessState)) + { + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedSucceeded(attachedSuccessState.Stage, attachedSuccessState.Message); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: true, + stage: "launch", + code: attachedSuccessState.Code, + message: attachedSuccessState.Message, + details: MergeDetails( + launcherContextDetails, + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive: true, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown: false, + recoveryActivationAttempted: false))); + } + + if (attachableAttempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting) + { + softTimeoutShown = true; + reporter.Report("delayed", SoftTimeoutStatusMessage); + loadingState = BuildDelayedLoadingState( + loadingState, + SoftTimeoutStatusMessage, + SoftTimeoutDetailsMessage, + trackedAttempt.StartedAtUtc); + loadingDetailsWindow?.UpdateLoadingState(loadingState); + } + + launchOutcome = HostLaunchOutcome.FromProcess( + attachedProcess!, + BuildResult( + true, + "launchHost", + "attached_attempt", + "Attached to an existing startup attempt.", + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive: true, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown, + recoveryActivationAttempted: false)), + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive: true, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown, + recoveryActivationAttempted: false)); + } + else + { + launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false); + } + if (!launchOutcome.Result.Success) { return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails); @@ -189,7 +300,30 @@ internal sealed class LauncherFlowCoordinator stage: "launch", code: "host_start_failed", message: "Host launch did not create a process.", - details: MergeDetails(launcherContextDetails, launchOutcome.Details)); + details: MergeDetails( + launcherContextDetails, + MergeDetails( + launchOutcome.Details, + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive: false, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown, + recoveryActivationAttempted: false)))); + } + + if (!attachedToExistingAttempt) + { + trackedAttempt = _startupAttemptRegistry.StartOwnedAttempt( + launchOutcome.Process.Id, + _context.LaunchSource, + startupSuccessTracker.PolicyKey, + lastStage, + lastStageMessage); } var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false); @@ -197,68 +331,172 @@ internal sealed class LauncherFlowCoordinator { Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications."); } + else + { + ipcConnected = true; + _startupAttemptRegistry.MarkOwnedIpcConnected(); + } + + Dictionary ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false) + { + return MergeDetails( + launcherContextDetails, + MergeDetails( + launchOutcome.Details, + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown, + recoveryActivationAttempted))); + } var processExitTask = launchOutcome.Process.WaitForExitAsync(); - var completedTask = await Task.WhenAny( - successTcs.Task, - activationFailedTcs.Task, - processExitTask, - Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false); + var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow; + var softTimeoutAt = startedAt + StartupSoftTimeout; + var hardTimeoutAt = startedAt + StartupHardTimeout; + var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5); - if (completedTask == successTcs.Task) + while (true) { - var successState = await successTcs.Task.ConfigureAwait(false); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: successState.Code, - message: successState.Message, - details: MergeDetails(launcherContextDetails, launchOutcome.Details)); - } - - if (completedTask == activationFailedTcs.Task) - { - Logger.Warn($"Activation failure received before desktop visibility. Reason='{await activationFailedTcs.Task.ConfigureAwait(false)}'."); - var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false); - if (retryOutcome is not null) + if (successTcs.Task.IsCompleted) { + var successState = await successTcs.Task.ConfigureAwait(false); + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return WithAdditionalDetails(retryOutcome, launcherContextDetails); + return BuildResult( + success: true, + stage: "launch", + code: successState.Code, + message: successState.Message, + details: ComposeLaunchDetails(!launchOutcome.Process.HasExited)); } + + if (activationFailedTcs.Task.IsCompleted && string.IsNullOrWhiteSpace(activationFailureReason)) + { + activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false); + Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'."); + } + + if (processExitTask.IsCompleted) + { + var exitCode = launchOutcome.Process.ExitCode; + Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}."); + + windowsClosingByCoordinator = true; + if (exitCode == HostExitCodes.SecondaryActivationSucceeded) + { + _startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance."); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: true, + stage: "launch", + code: "activation_redirected", + message: "Host redirected activation to the existing desktop instance.", + details: MergeDetails( + ComposeLaunchDetails(hostProcessAlive: false), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["exitCode"] = exitCode.ToString() + })); + } + + _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: false, + stage: "launch", + code: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired + ? "activation_failed" + : "host_exited_early", + message: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired + ? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}." + : $"Host exited before the required startup state was reported. ExitCode={exitCode}.", + details: MergeDetails( + ComposeLaunchDetails(hostProcessAlive: false), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["exitCode"] = exitCode.ToString() + })); + } + + var now = DateTimeOffset.UtcNow; + if (!ipcConnected && + !launchOutcome.Process.HasExited && + now >= nextReconnectAttemptAt) + { + connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false); + if (connected) + { + ipcConnected = true; + _startupAttemptRegistry.MarkOwnedIpcConnected(); + } + + nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5); + } + + if (!softTimeoutShown && + now >= softTimeoutAt && + (!launchOutcome.Process.HasExited || ipcConnected)) + { + softTimeoutShown = true; + _startupAttemptRegistry.MarkOwnedSoftTimeout(SoftTimeoutStatusMessage); + reporter.Report("delayed", SoftTimeoutStatusMessage); + loadingState = BuildDelayedLoadingState( + loadingState, + SoftTimeoutStatusMessage, + SoftTimeoutDetailsMessage, + trackedAttempt?.StartedAtUtc ?? startedAt); + loadingDetailsWindow?.UpdateLoadingState(loadingState); + } + + if (now >= hardTimeoutAt) + { + break; + } + + var nextCheckpointAt = hardTimeoutAt; + if (!softTimeoutShown && softTimeoutAt < nextCheckpointAt) + { + nextCheckpointAt = softTimeoutAt; + } + + var delay = nextCheckpointAt - now; + if (delay > TimeSpan.FromSeconds(1)) + { + delay = TimeSpan.FromSeconds(1); + } + else if (delay < TimeSpan.FromMilliseconds(100)) + { + delay = TimeSpan.FromMilliseconds(100); + } + + await Task.WhenAny( + successTcs.Task, + activationFailedTcs.Task, + processExitTask, + Task.Delay(delay)).ConfigureAwait(false); } - if (completedTask == processExitTask) + var recoveryActivationAttempted = false; + if (!connected && !launchOutcome.Process.HasExited) { - var exitCode = launchOutcome.Process.ExitCode; - Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}."); - - if (exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired) + connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false); + if (connected) { - var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false); - if (retryOutcome is not null) - { - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return WithAdditionalDetails(retryOutcome, launcherContextDetails); - } + ipcConnected = true; + _startupAttemptRegistry.MarkOwnedIpcConnected(); } - - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: false, - stage: "launch", - code: exitCode == HostExitCodes.SecondaryActivationSucceeded ? "activation_redirected" : "host_exited_early", - message: exitCode == HostExitCodes.SecondaryActivationSucceeded - ? "Host redirected activation to the existing desktop instance." - : $"Host exited before the required startup state was reported. ExitCode={exitCode}.", - details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary - { - ["exitCode"] = exitCode.ToString() - }))); } if (connected && !launchOutcome.Process.HasExited) { + recoveryActivationAttempted = true; var recoveryOutcome = await TryRecoverWithPublicActivationAsync( ipcClient, launchOutcome.Process, @@ -266,48 +504,57 @@ internal sealed class LauncherFlowCoordinator startupSuccessTracker).ConfigureAwait(false); if (recoveryOutcome is not null) { + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( success: true, stage: "launch", code: recoveryOutcome.Code, message: recoveryOutcome.Message, - details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary - { - ["recoveryActivationAttempted"] = bool.TrueString - }))); + details: ComposeLaunchDetails( + !launchOutcome.Process.HasExited, + recoveryActivationAttempted: true)); } } + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( success: false, stage: "launch", code: "desktop_not_visible", - message: "Host process started, but it never reached the required startup state within 30 seconds.", - details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary - { - ["ipcStage"] = lastStage.ToString(), - ["ipcMessage"] = lastStageMessage - }))); + message: "Host process started, but it never reached the required startup state within 120 seconds.", + details: ComposeLaunchDetails( + !launchOutcome.Process.HasExited, + recoveryActivationAttempted)); } finally { - await Dispatcher.UIThread.InvokeAsync(() => + if (splashClosedHandler is not null) { - try + splashWindow.Closed -= splashClosedHandler; + } + + if (!windowsClosingByCoordinator) + { + await Dispatcher.UIThread.InvokeAsync(() => { - if (splashWindow.IsVisible && splashWindow.IsLoaded) + try { - splashWindow.Close(); - Logger.Info("Splash window closed in coordinator cleanup."); + if (splashWindow.IsVisible && splashWindow.IsLoaded) + { + splashWindow.Close(); + Logger.Info("Splash window closed in coordinator cleanup."); + } } - } - catch (Exception ex) - { - Logger.Error("Failed to close splash window during coordinator cleanup.", ex); - } - }); + catch (Exception ex) + { + Logger.Error("Failed to close splash window during coordinator cleanup.", ex); + } + }); + } } } catch (Exception ex) @@ -373,20 +620,17 @@ internal sealed class LauncherFlowCoordinator private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow) { + try + { + await splashWindow.DismissAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Failed to dismiss splash window.", ex); + } + await Dispatcher.UIThread.InvokeAsync(() => { - try - { - if (splashWindow.IsVisible && splashWindow.IsLoaded) - { - splashWindow.Close(); - } - } - catch (Exception ex) - { - Logger.Error("Failed to close splash window.", ex); - } - try { if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible) @@ -672,6 +916,7 @@ internal sealed class LauncherFlowCoordinator try { errorWindow = new ErrorWindow(); + errorWindow.ConfigureForHostNotFound(); errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found."); errorWindow.Show(); Logger.Warn("Host not found. Showing error window."); @@ -1000,6 +1245,94 @@ internal sealed class LauncherFlowCoordinator return null; } + private static LoadingStateMessage BuildDelayedLoadingState( + LoadingStateMessage loadingState, + string summaryMessage, + string detailMessage, + DateTimeOffset startedAtUtc) + { + var delayedItems = loadingState.ActiveItems + .Where(item => !string.Equals(item.Id, "launcher-soft-timeout", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + delayedItems.Insert(0, new LoadingItem + { + Id = "launcher-soft-timeout", + Type = LoadingItemType.System, + Name = "Startup still in progress", + Description = detailMessage, + State = LoadingState.Delayed, + ProgressPercent = Math.Max(loadingState.OverallProgressPercent, 1), + Message = detailMessage, + StartTime = startedAtUtc + }); + + return loadingState with + { + ActiveItems = delayedItems, + Message = summaryMessage, + Timestamp = DateTimeOffset.UtcNow, + TotalCount = Math.Max(loadingState.TotalCount, delayedItems.Count) + }; + } + + private static Dictionary BuildAttemptDetails( + StartupAttemptRecord? trackedAttempt, + bool attachedToExistingAttempt, + bool ipcConnected, + bool hostProcessAlive, + StartupStage lastStage, + string lastStageMessage, + string? activationFailureReason, + bool softTimeoutShown, + bool recoveryActivationAttempted) + { + var details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["hostProcessAlive"] = hostProcessAlive.ToString(), + ["attachedToExistingAttempt"] = attachedToExistingAttempt.ToString(), + ["ipcConnected"] = ipcConnected.ToString(), + ["ipcStage"] = lastStage.ToString(), + ["ipcMessage"] = lastStageMessage, + ["activationFailureReason"] = activationFailureReason ?? string.Empty, + ["softTimeoutShown"] = softTimeoutShown.ToString(), + ["recoveryActivationAttempted"] = recoveryActivationAttempted.ToString() + }; + + if (trackedAttempt is not null) + { + details["startupAttemptId"] = trackedAttempt.AttemptId; + details["startupAttemptState"] = trackedAttempt.State.ToString(); + details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O"); + details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O"); + details["successPolicy"] = trackedAttempt.SuccessPolicy; + details["hostPid"] = trackedAttempt.HostPid.ToString(); + } + + return details; + } + + private static bool TryGetLiveProcess(int processId, out Process? process) + { + process = null; + if (processId <= 0) + { + return false; + } + + try + { + process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { + process?.Dispose(); + process = null; + return false; + } + } + private enum HostStartMode { ShellExecute, @@ -1048,6 +1381,8 @@ internal sealed class LauncherFlowCoordinator private bool _trayReady; private bool _backgroundReady; + public string PolicyKey => _policy.ToString(); + public StartupSuccessTracker(CommandContext context) { var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs); diff --git a/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs new file mode 100644 index 0000000..ffd08d0 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs @@ -0,0 +1,313 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class StartupAttemptRegistry +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true + }; + + private readonly string _statePath; + private readonly string _mutexName; + private string? _ownedAttemptId; + + public StartupAttemptRegistry() + : this(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + ".launcher", + "state", + "startup-attempt.json")) + { + } + + internal StartupAttemptRegistry(string statePath) + { + _statePath = statePath; + _mutexName = $"LanMountainDesktop.Launcher.StartupAttempt.{ComputePathHash(statePath)}"; + } + + public StartupAttemptRecord StartOwnedAttempt( + int hostPid, + string launchSource, + string successPolicy, + StartupStage stage, + string? message) + { + var record = new StartupAttemptRecord + { + AttemptId = Guid.NewGuid().ToString("N"), + HostPid = hostPid, + LaunchSource = launchSource, + SuccessPolicy = successPolicy, + LastObservedStage = stage, + LastObservedMessage = message ?? string.Empty, + StartedAtUtc = DateTimeOffset.UtcNow, + UpdatedAtUtc = DateTimeOffset.UtcNow, + State = StartupAttemptState.Pending + }; + + ExecuteWithLock(() => + { + SaveUnsafe(record); + _ownedAttemptId = record.AttemptId; + }); + + return Clone(record); + } + + public bool AdoptAttempt(string attemptId) + { + if (string.IsNullOrWhiteSpace(attemptId)) + { + return false; + } + + var adopted = false; + ExecuteWithLock(() => + { + var record = LoadUnsafe(); + if (record is null || !string.Equals(record.AttemptId, attemptId, StringComparison.Ordinal)) + { + return; + } + + if (!IsAttachable(record)) + { + return; + } + + _ownedAttemptId = record.AttemptId; + if (record.State == StartupAttemptState.DetachedWaiting) + { + record.State = StartupAttemptState.SoftTimeout; + } + + record.UpdatedAtUtc = DateTimeOffset.UtcNow; + SaveUnsafe(record); + adopted = true; + }); + + return adopted; + } + + public StartupAttemptRecord? TryGetAttachableAttempt(string launchSource, string successPolicy) + { + StartupAttemptRecord? result = null; + ExecuteWithLock(() => + { + var record = LoadUnsafe(); + if (record is null || + !IsAttachable(record) || + !string.Equals(record.LaunchSource, launchSource, StringComparison.OrdinalIgnoreCase) || + !string.Equals(record.SuccessPolicy, successPolicy, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + result = Clone(record); + }); + + return result; + } + + public void MarkOwnedIpcConnected() + { + UpdateOwned(record => record.IpcConnected = true); + } + + public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected) + { + UpdateOwned(record => + { + record.LastObservedStage = stage; + record.LastObservedMessage = message ?? string.Empty; + if (ipcConnected) + { + record.IpcConnected = true; + } + }); + } + + public void MarkOwnedSoftTimeout(string? message) + { + UpdateOwned(record => + { + record.State = StartupAttemptState.SoftTimeout; + record.LastObservedMessage = message ?? record.LastObservedMessage; + }); + } + + public void MarkOwnedDetachedWaiting() + { + UpdateOwned(record => + { + if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout) + { + record.State = StartupAttemptState.DetachedWaiting; + } + }); + } + + public void MarkOwnedSucceeded(StartupStage stage, string? message) + { + UpdateOwned(record => + { + record.State = StartupAttemptState.Succeeded; + record.LastObservedStage = stage; + record.LastObservedMessage = message ?? record.LastObservedMessage; + }); + } + + public void MarkOwnedFailed(StartupStage stage, string? message) + { + UpdateOwned(record => + { + record.State = StartupAttemptState.Failed; + record.LastObservedStage = stage; + record.LastObservedMessage = message ?? record.LastObservedMessage; + }); + } + + private void UpdateOwned(Action update) + { + if (string.IsNullOrWhiteSpace(_ownedAttemptId)) + { + return; + } + + ExecuteWithLock(() => + { + var record = LoadUnsafe(); + if (record is null || !string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal)) + { + return; + } + + update(record); + record.UpdatedAtUtc = DateTimeOffset.UtcNow; + SaveUnsafe(record); + }); + } + + private void ExecuteWithLock(Action action) + { + using var mutex = new Mutex(false, _mutexName); + var hasHandle = false; + try + { + try + { + hasHandle = mutex.WaitOne(TimeSpan.FromSeconds(2)); + } + catch (AbandonedMutexException) + { + hasHandle = true; + } + + if (!hasHandle) + { + return; + } + + action(); + } + finally + { + if (hasHandle) + { + mutex.ReleaseMutex(); + } + } + } + + private StartupAttemptRecord? LoadUnsafe() + { + if (!File.Exists(_statePath)) + { + return null; + } + + try + { + var json = File.ReadAllText(_statePath); + return JsonSerializer.Deserialize(json, SerializerOptions); + } + catch + { + return null; + } + } + + private void SaveUnsafe(StartupAttemptRecord record) + { + var directory = Path.GetDirectoryName(_statePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(_statePath, JsonSerializer.Serialize(record, SerializerOptions)); + } + + private static bool IsAttachable(StartupAttemptRecord record) + { + if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)) + { + return false; + } + + return TryGetLiveProcess(record.HostPid, out _); + } + + private static bool TryGetLiveProcess(int processId, out Process? process) + { + process = null; + if (processId <= 0) + { + return false; + } + + try + { + process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { + process?.Dispose(); + process = null; + return false; + } + } + + private static string ComputePathHash(string statePath) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(statePath.ToLowerInvariant())); + return Convert.ToHexString(bytes[..8]); + } + + private static StartupAttemptRecord Clone(StartupAttemptRecord record) + { + return new StartupAttemptRecord + { + AttemptId = record.AttemptId, + HostPid = record.HostPid, + StartedAtUtc = record.StartedAtUtc, + UpdatedAtUtc = record.UpdatedAtUtc, + LaunchSource = record.LaunchSource, + SuccessPolicy = record.SuccessPolicy, + LastObservedStage = record.LastObservedStage, + LastObservedMessage = record.LastObservedMessage, + IpcConnected = record.IpcConnected, + State = record.State + }; + } +} diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml index 3e27087..3bfb78b 100644 --- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml +++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml @@ -3,102 +3,96 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views" - xmlns:ui="using:FluentAvalonia.UI.Controls" mc:Ignorable="d" - d:DesignWidth="520" - d:DesignHeight="280" x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow" x:DataType="views:ErrorWindow" - Title="阑山桌面" - Width="520" - Height="280" + Title="LanMountain Desktop" + Width="560" + Height="320" CanResize="False" WindowStartupLocation="CenterScreen" - Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" + Background="#111318" TransparencyLevelHint="None" Icon="/Assets/logo.ico"> - - - - - + - + VerticalAlignment="Center" /> - - - - + + - - + Foreground="#F6F7FB" + TextWrapping="Wrap" /> + - - + LineHeight="22" /> + + LineHeight="20" /> - - + Padding="24,16" + Background="#171A21"> +