From ebe35d6f91b844dcdf3729d0d894875804749e9a Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 28 May 2026 10:27:33 +0800 Subject: [PATCH] fix(launcher): extract startup subsystem and harden IPC detection Co-authored-by: Cursor --- ...ncherFlowCoordinator.HostStartupMonitor.cs | 438 +++++ ...ncherFlowCoordinator.LaunchOrchestrator.cs | 316 ++++ .../LauncherFlowCoordinator.UiPresenter.cs | 175 ++ .../Services/LauncherFlowCoordinator.cs | 1492 +---------------- .../Startup/HostActivationPolicy.cs | 50 + .../Startup/HostStartupMonitor.cs | 511 ++++++ .../Startup/PublicIpcConnection.cs | 57 + .../Startup/StartupDiagnostics.cs | 72 + .../Startup/StartupSuccessTracker.cs | 138 ++ .../Startup/StartupTimeoutPolicy.cs | 23 + .../HostActivationPolicyTests.cs | 46 + .../LauncherMultiInstancePolicyTests.cs | 24 +- .../LauncherStartupTimeoutPolicyTests.cs | 7 +- .../StartupSuccessTrackerTests.cs | 56 + 14 files changed, 1930 insertions(+), 1475 deletions(-) create mode 100644 LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.HostStartupMonitor.cs create mode 100644 LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.LaunchOrchestrator.cs create mode 100644 LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.UiPresenter.cs create mode 100644 LanMountainDesktop.Launcher/Startup/HostActivationPolicy.cs create mode 100644 LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs create mode 100644 LanMountainDesktop.Launcher/Startup/PublicIpcConnection.cs create mode 100644 LanMountainDesktop.Launcher/Startup/StartupDiagnostics.cs create mode 100644 LanMountainDesktop.Launcher/Startup/StartupSuccessTracker.cs create mode 100644 LanMountainDesktop.Launcher/Startup/StartupTimeoutPolicy.cs create mode 100644 LanMountainDesktop.Tests/HostActivationPolicyTests.cs create mode 100644 LanMountainDesktop.Tests/StartupSuccessTrackerTests.cs diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.HostStartupMonitor.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.HostStartupMonitor.cs new file mode 100644 index 0000000..cd252b5 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.HostStartupMonitor.cs @@ -0,0 +1,438 @@ +using System.Diagnostics; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Resources; +using LanMountainDesktop.Launcher.Services.Ipc; +using LanMountainDesktop.Launcher.Startup; +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed partial class LauncherFlowCoordinator +{ + private MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior() + { + try + { + var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(_dataLocationResolver.ResolveDataRoot()); + return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath); + } + catch (Exception ex) + { + Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}"); + return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop; + } + } + + private static async Task TryGetExistingHostStatusAsync( + LanMountainDesktopIpcClient ipcClient, + TimeSpan timeout) + { + try + { + var connected = ipcClient.IsConnected || + await PublicIpcConnection.TryConnectAsync(ipcClient, timeout).ConfigureAwait(false); + if (!connected) + { + return null; + } + + var shellProxy = ipcClient.CreateProxy(); + var status = await shellProxy.GetShellStatusAsync().ConfigureAwait(false); + StartupDiagnostics.TraceShellStatus("existing_host_probe", status); + return status; + } + catch (Exception ex) + { + Logger.Info($"Existing host status probe did not complete: {ex.Message}"); + return null; + } + } + + private static async Task ApplyExistingHostBehaviorAsync( + LanMountainDesktopIpcClient ipcClient, + MultiInstanceLaunchBehavior behavior, + PublicShellStatus status) + { + try + { + var shellProxy = ipcClient.CreateProxy(); + return behavior switch + { + MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync( + shellProxy, + showLauncherNotice: false, + successCode: "existing_host_activated", + successMessage: "Launcher activated the existing desktop instance.", + failureCode: "existing_host_activation_failed").ConfigureAwait(false), + + MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync( + shellProxy, + showLauncherNotice: true, + successCode: "existing_host_activated_with_notice", + successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.", + failureCode: "existing_host_activation_failed").ConfigureAwait(false), + + MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync( + shellProxy, + status).ConfigureAwait(false), + + MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false), + + _ => await ActivateExistingHostForBehaviorAsync( + shellProxy, + showLauncherNotice: true, + successCode: "existing_host_activated_with_notice", + successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.", + failureCode: "existing_host_activation_failed").ConfigureAwait(false) + }; + } + catch (Exception ex) + { + Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}"); + return new ExistingHostBehaviorResult( + false, + "multi_instance_behavior_failed", + $"Failed to apply multi-instance behavior '{behavior}': {ex.Message}", + null); + } + } + + private static async Task ActivateExistingHostForBehaviorAsync( + IPublicShellControlService shellProxy, + bool showLauncherNotice, + string successCode, + string successMessage, + string failureCode) + { + var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + var success = activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation); + if (showLauncherNotice && success) + { + var promptResult = await ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false); + if (promptResult == MultiInstancePromptResult.OpenDesktop) + { + activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + } + } + + return new ExistingHostBehaviorResult( + success, + activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode, + activation.Accepted ? successMessage : activation.Message, + activation); + } + + private static async Task RestartExistingHostAsync( + IPublicShellControlService shellProxy) + { + var accepted = await shellProxy.RestartAsync().ConfigureAwait(false); + return new ExistingHostBehaviorResult( + accepted, + accepted ? "existing_host_restart_requested" : "existing_host_restart_failed", + accepted + ? "Launcher requested the existing desktop instance to restart." + : "Launcher could not request restart from the existing desktop instance.", + null); + } + + private static async Task ShowPromptOnlyExistingHostAsync( + IPublicShellControlService shellProxy, + PublicShellStatus status) + { + var promptResult = await ShowMultiInstancePromptAsync(status).ConfigureAwait(false); + + if (promptResult == MultiInstancePromptResult.OpenDesktop) + { + return await ActivateExistingHostForBehaviorAsync( + shellProxy, + showLauncherNotice: false, + successCode: "existing_host_activated_from_prompt", + successMessage: "Launcher activated the existing desktop instance from the prompt.", + failureCode: "existing_host_activation_failed").ConfigureAwait(false); + } + + return new ExistingHostBehaviorResult( + true, + "existing_host_prompt_only", + "Launcher showed the repeated-launch prompt and did not open the desktop automatically.", + null); + } + + private static async Task TryActivateExistingHostWithStatusAsync( + LanMountainDesktopIpcClient ipcClient, + TimeSpan timeout) + { + try + { + var connected = ipcClient.IsConnected || + await PublicIpcConnection.TryConnectAsync(ipcClient, timeout).ConfigureAwait(false); + if (!connected) + { + return null; + } + + var shellProxy = ipcClient.CreateProxy(); + return await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Info($"Existing host activation probe did not complete: {ex.Message}"); + return null; + } + } + + private static async Task TryRecoverActivationThroughExistingHostAsync( + LanMountainDesktopIpcClient ipcClient, + StartupSuccessTracker startupSuccessTracker, + TimeSpan timeout) + { + var activation = await TryActivateExistingHostWithStatusAsync(ipcClient, timeout).ConfigureAwait(false); + if (activation is null) + { + return null; + } + + if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess)) + { + return shellSuccess; + } + + if (activation.Accepted) + { + return startupSuccessTracker.BuildRecoverySuccessState(); + } + + return HostActivationPolicy.IsRecoverableActivationFailure(activation) + ? new StartupSuccessState( + StartupStage.Ready, + "startup_pending", + activation.Message) + : null; + } + + private static async Task TryGetPublicShellStatusAsync( + LanMountainDesktopIpcClient ipcClient) + { + try + { + var shellProxy = ipcClient.CreateProxy(); + return await shellProxy.GetShellStatusAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to query public shell status: {ex.Message}"); + return null; + } + } + + private static async Task TryRecoverWithPublicActivationAsync( + LanMountainDesktopIpcClient ipcClient, + Process hostProcess, + Task successTask, + StartupSuccessTracker startupSuccessTracker) + { + try + { + var shellProxy = ipcClient.CreateProxy(); + var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + StartupDiagnostics.TraceShellStatus("recovery_activation", activation.Status); + if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess)) + { + return shellSuccess; + } + + var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false); + if (completedTask == successTask) + { + return await successTask.ConfigureAwait(false); + } + + if (!hostProcess.HasExited && (activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation))) + { + return startupSuccessTracker.BuildRecoverySuccessState(); + } + } + catch (Exception ex) + { + Logger.Warn($"Public activation recovery failed: {ex.Message}"); + } + + 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["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O"); + details["successPolicy"] = trackedAttempt.SuccessPolicy; + details["hostPid"] = trackedAttempt.HostPid.ToString(); + details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString(); + details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName; + details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString(); + details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString(); + details["shellStatus"] = trackedAttempt.ShellStatus; + } + + 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, + Direct + } + + private sealed record HostStartAttempt( + HostStartMode StartMode, + bool ProcessCreated, + Process? Process, + bool ExitedEarly, + int? ExitCode, + string? FailureReason, + string? PackageRoot, + string? WorkingDirectory, + string? Arguments) + { + public int? ProcessId => Process?.Id; + + public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) => + new( + startMode, + true, + process, + false, + null, + null, + plan.PackageRoot, + plan.WorkingDirectory, + HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); + + public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) => + new( + startMode, + true, + process, + true, + exitCode, + null, + plan.PackageRoot, + plan.WorkingDirectory, + HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); + + public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) => + new( + startMode, + false, + null, + false, + null, + failureReason, + plan?.PackageRoot, + plan?.WorkingDirectory, + plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); + } + + private sealed record ExistingHostBehaviorResult( + bool Success, + string Code, + string Message, + PublicShellActivationResult? ActivationResult); + + private sealed record HostLaunchOutcome( + LauncherResult Result, + Process? Process, + LauncherResult? ImmediateResult, + Dictionary Details) + { + public static HostLaunchOutcome FromResult(LauncherResult result) => + new(result, null, result.Success ? result : null, result.Details); + + public static HostLaunchOutcome FromImmediateResult(LauncherResult result) => + new(result, null, result, result.Details); + + public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary details) => + new(result, process, null, details); + } +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.LaunchOrchestrator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.LaunchOrchestrator.cs new file mode 100644 index 0000000..5ab0eb5 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.LaunchOrchestrator.cs @@ -0,0 +1,316 @@ +using System.Diagnostics; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Resources; +using LanMountainDesktop.Launcher.Startup; +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed partial class LauncherFlowCoordinator +{ + private async Task LaunchHostWithIpcAsync(bool forceDirectMode = false, string? retryTag = null) + { + var resolution = _deploymentLocator.ResolveHostExecutable(_context); + if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath)) + { + var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync().ConfigureAwait(false); + if (errorResult == ErrorWindowResult.Retry) + { + if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath)) + { + return await LaunchHostWithExplicitPathAsync(selectedPath, forceDirectMode, retryTag).ConfigureAwait(false); + } + + return await LaunchHostWithIpcAsync(forceDirectMode, retryTag).ConfigureAwait(false); + } + + return HostLaunchOutcome.FromResult(BuildResult( + success: false, + stage: "launchHost", + code: "host_not_found", + message: "LanMountainDesktop host executable was not found.", + details: BuildResolutionDetails(resolution, null, null, "resolve"))); + } + + return await LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag).ConfigureAwait(false); + } + + private Task LaunchHostWithExplicitPathAsync(string hostPath, bool forceDirectMode, string? retryTag) + { + var resolution = new HostResolutionResult + { + Success = true, + ResolvedHostPath = Path.GetFullPath(hostPath), + ResolutionSource = "user_selected_path", + AppRoot = _deploymentLocator.GetAppRoot(), + ExplicitAppRoot = Path.GetDirectoryName(hostPath), + SearchedPaths = [Path.GetFullPath(hostPath)] + }; + + return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag); + } + + internal static LauncherResult? ValidateDotNetRuntimePrerequisite( + HostLaunchPlan plan, + HostResolutionResult resolution, + DotNetRuntimeProbeOptions? probeOptions = null) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(resolution); + + if (!DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(plan.HostPath)) + { + return null; + } + + var runtime = DotNetRuntimeProbe.Probe(probeOptions); + Logger.Info( + $"Runtime prerequisite check completed. Available={runtime.IsAvailable}; " + + $"Architecture={runtime.Architecture}; Message='{runtime.Message}'."); + + if (runtime.IsAvailable) + { + return null; + } + + var details = BuildResolutionDetails(resolution, null, null, "runtime"); + foreach (var pair in runtime.ToDetails()) + { + details[pair.Key] = pair.Value; + } + + return BuildResult( + success: false, + stage: "launchHost", + code: "dotnet_runtime_missing", + message: ".NET 10 Desktop Runtime is required before LanMountainDesktop can start.", + details: details, + errorMessage: runtime.Message); + } + + private async Task LaunchHostWithResolvedPathAsync( + HostResolutionResult resolution, + bool forceDirectMode, + string? retryTag) + { + var dataRoot = _dataLocationResolver.ResolveDataRoot(); + var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot); + var prerequisiteFailure = ValidateDotNetRuntimePrerequisite(plan, resolution); + if (prerequisiteFailure is not null) + { + return HostLaunchOutcome.FromResult(prerequisiteFailure); + } + + var hostPath = plan.HostPath; + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + EnsureExecutable(hostPath); + } + + var primaryMode = HostStartMode.Direct; + var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows() + ? HostStartMode.ShellExecute + : (HostStartMode?)null; + + var firstAttempt = await StartHostProcessAsync(plan, primaryMode, retryTag).ConfigureAwait(false); + if (firstAttempt.ProcessCreated && firstAttempt.Process is not null) + { + var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null); + return HostLaunchOutcome.FromProcess( + firstAttempt.Process, + BuildResult(true, "launchHost", "ok", "Host launched.", firstDetails), + firstDetails); + } + + if (fallbackMode is null) + { + return BuildOutcomeFromAttempt(resolution, firstAttempt, null); + } + + Logger.Warn( + $"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " + + $"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? ""}'."); + + var secondAttempt = await StartHostProcessAsync(plan, fallbackMode.Value, retryTag).ConfigureAwait(false); + if (secondAttempt.ProcessCreated && secondAttempt.Process is not null) + { + var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null); + return HostLaunchOutcome.FromProcess( + secondAttempt.Process, + BuildResult(true, "launchHost", "ok", "Host launched.", details), + details); + } + + return BuildOutcomeFromAttempt(resolution, secondAttempt, firstAttempt); + } + + private static HostLaunchOutcome BuildOutcomeFromAttempt( + HostResolutionResult resolution, + HostStartAttempt finalAttempt, + HostStartAttempt? previousAttempt) + { + var details = BuildResolutionDetails( + resolution, + previousAttempt ?? finalAttempt, + previousAttempt is null ? null : finalAttempt, + !finalAttempt.ProcessCreated + ? "start" + : finalAttempt.ExitCode is int finalExitCode && HostActivationPolicy.IsFailedActivationExitCode(finalExitCode) + ? "activation" + : "early-exit"); + + if (!finalAttempt.ProcessCreated) + { + return HostLaunchOutcome.FromResult(BuildResult( + false, + "launchHost", + "host_start_failed", + $"Failed to start host using start mode '{finalAttempt.StartMode}'.", + details)); + } + + if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value)) + { + return HostLaunchOutcome.FromImmediateResult(BuildResult( + true, + "launch", + "activation_redirected", + "Launcher activation was redirected to the existing desktop instance.", + details)); + } + + if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsFailedActivationExitCode(finalAttempt.ExitCode.Value)) + { + return HostLaunchOutcome.FromResult(BuildResult( + false, + "launch", + "activation_failed", + $"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.", + details)); + } + + return HostLaunchOutcome.FromResult(BuildResult( + false, + "launchHost", + "host_exited_early", + $"Host exited early using start mode '{finalAttempt.StartMode}'.", + details)); + } + + private async Task StartHostProcessAsync( + HostLaunchPlan plan, + HostStartMode startMode, + string? retryTag) + { + var startInfo = new ProcessStartInfo + { + FileName = plan.HostPath, + WorkingDirectory = plan.WorkingDirectory, + UseShellExecute = startMode == HostStartMode.ShellExecute + }; + + if (startMode == HostStartMode.Direct) + { + foreach (var argument in plan.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (var pair in plan.EnvironmentVariables) + { + startInfo.EnvironmentVariables[pair.Key] = pair.Value; + } + } + else + { + startInfo.Arguments = HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments); + } + + try + { + var process = Process.Start(startInfo); + Logger.Info( + $"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? ""}'; Path='{plan.HostPath}'; " + + $"PackageRoot='{plan.PackageRoot}'; WorkingDir='{plan.WorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; " + + $"Args='{HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}'."); + + if (process is null) + { + return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan); + } + + await Task.Yield(); + return HostStartAttempt.Started(startMode, process, plan); + } + catch (Exception ex) + { + Logger.Error($"Host start failed. Mode='{startMode}'.", ex); + return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan); + } + } + + private static Dictionary BuildResolutionDetails( + HostResolutionResult resolution, + HostStartAttempt? firstAttempt, + HostStartAttempt? secondAttempt, + string? failureStage) + { + var details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["resolvedAppRoot"] = resolution.AppRoot, + ["explicitAppRoot"] = resolution.ExplicitAppRoot ?? string.Empty, + ["resolvedHostPath"] = resolution.ResolvedHostPath ?? string.Empty, + ["resolutionSource"] = resolution.ResolutionSource ?? string.Empty, + ["devModeConfigIgnored"] = resolution.DevModeConfigIgnored.ToString(), + ["searchedPaths"] = string.Join(" | ", resolution.SearchedPaths), + ["failureStage"] = failureStage ?? string.Empty + }; + + if (firstAttempt is not null) + { + details["startMode"] = firstAttempt.StartMode.ToString(); + details["processCreated"] = firstAttempt.ProcessCreated.ToString(); + details["hostPid"] = firstAttempt.ProcessId?.ToString() ?? string.Empty; + details["packageRoot"] = firstAttempt.PackageRoot ?? string.Empty; + details["workingDirectory"] = firstAttempt.WorkingDirectory ?? string.Empty; + details["arguments"] = firstAttempt.Arguments ?? string.Empty; + details["firstAttemptFailureReason"] = firstAttempt.FailureReason ?? string.Empty; + details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty; + } + + if (secondAttempt is not null) + { + details["fallbackStartMode"] = secondAttempt.StartMode.ToString(); + details["fallbackProcessCreated"] = secondAttempt.ProcessCreated.ToString(); + details["fallbackHostPid"] = secondAttempt.ProcessId?.ToString() ?? string.Empty; + details["fallbackPackageRoot"] = secondAttempt.PackageRoot ?? string.Empty; + details["fallbackWorkingDirectory"] = secondAttempt.WorkingDirectory ?? string.Empty; + details["fallbackArguments"] = secondAttempt.Arguments ?? string.Empty; + details["fallbackFailureReason"] = secondAttempt.FailureReason ?? string.Empty; + details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty; + } + + return details; + } + private static void EnsureExecutable(string path) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + try + { + var mode = File.GetUnixFileMode(path); + mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + File.SetUnixFileMode(path, mode); + } + catch + { + } + } +} \ No newline at end of file diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.UiPresenter.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.UiPresenter.cs new file mode 100644 index 0000000..0dc9a11 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.UiPresenter.cs @@ -0,0 +1,175 @@ +using System.Diagnostics; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Resources; +using LanMountainDesktop.Launcher.Services.Ipc; +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed partial class LauncherFlowCoordinator +{ + private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow) + { + try + { + await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync()); + } + catch (Exception ex) + { + Logger.Error("Failed to dismiss splash window.", ex); + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible) + { + loadingDetailsWindow.Close(); + } + } + catch (Exception ex) + { + Logger.Error("Failed to close loading details window.", ex); + } + }); + } + private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync() + { + ErrorWindow? errorWindow = null; + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + errorWindow = new ErrorWindow(); + errorWindow.ConfigureForHostNotFound(); + errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found."); + errorWindow.Show(); + Logger.Warn("Host not found. Showing error window."); + } + catch (Exception ex) + { + Logger.Error("Failed to show host-not-found error window.", ex); + } + }); + + if (errorWindow is null) + { + return (ErrorWindowResult.Exit, null); + } + + ErrorWindowResult result; + string? customPath; + try + { + result = await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); + customPath = errorWindow.GetCustomHostPath(); + Logger.Info($"Host-not-found window result='{result}'; HasCustomPath={!string.IsNullOrWhiteSpace(customPath)}."); + } + catch (Exception ex) + { + Logger.Error("Failed while waiting for host-not-found window result.", ex); + result = ErrorWindowResult.Exit; + customPath = null; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + if (errorWindow.IsVisible && errorWindow.IsLoaded) + { + errorWindow.Close(); + } + } + catch (Exception ex) + { + Logger.Error("Failed to close host-not-found error window.", ex); + } + }); + + return (result, customPath); + } + + private async Task ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo) + { + MigrationPromptWindow? migrationWindow = null; + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + migrationWindow = new MigrationPromptWindow(); + migrationWindow.SetLegacyInfo(legacyInfo); + migrationWindow.Show(); + } + catch (Exception ex) + { + Logger.Error("Failed to show migration prompt window.", ex); + } + }); + + if (migrationWindow is null) + { + return MigrationResult.Skipped; + } + + MigrationResult result; + try + { + result = await migrationWindow.WaitForChoiceAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Failed while waiting for migration prompt result.", ex); + result = MigrationResult.Skipped; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + if (migrationWindow.IsVisible && migrationWindow.IsLoaded) + { + migrationWindow.Close(); + } + } + catch (Exception ex) + { + Logger.Error("Failed to close migration prompt window.", ex); + } + }); + + return result; + } + private static string MapStartupStageToSplashStage(StartupStage stage) => stage switch + { + StartupStage.Initializing => "initializing", + StartupStage.LoadingSettings => "settings", + StartupStage.LoadingPlugins => "plugins", + StartupStage.TrayReady => "shell", + StartupStage.InitializingUI => "ui", + StartupStage.ShellInitialized => "shell", + StartupStage.BackgroundReady => "ready", + StartupStage.DesktopVisible => "ready", + StartupStage.ActivationRedirected => "activation", + StartupStage.ActivationFailed => "error", + StartupStage.Ready => "ready", + _ => "launch" + }; + private static async Task ShowMultiInstancePromptAsync(PublicShellStatus status) + { + return await Dispatcher.UIThread.InvokeAsync(async () => + { + var prompt = new MultiInstancePromptWindow(); + prompt.SetDetails(status.ProcessId, status.ShellState); + prompt.Show(); + return await prompt.WaitForChoiceAsync().ConfigureAwait(true); + }); + } +} \ No newline at end of file diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index e1eb620..d3fbd25 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -3,6 +3,7 @@ using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Resources; using LanMountainDesktop.Launcher.Services.Ipc; +using LanMountainDesktop.Launcher.Startup; using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.IPC; @@ -10,10 +11,8 @@ using LanMountainDesktop.Shared.IPC.Abstractions.Services; namespace LanMountainDesktop.Launcher.Services; -internal sealed class LauncherFlowCoordinator +internal sealed partial class LauncherFlowCoordinator { - private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(120); private static readonly string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage; private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage; @@ -21,7 +20,6 @@ internal sealed class LauncherFlowCoordinator private readonly DeploymentLocator _deploymentLocator; private readonly OobeStateService _oobeStateService; private readonly UpdateEngineService _updateEngine; - private readonly PluginInstallerService _pluginInstallerService; private readonly StartupAttemptRegistry _startupAttemptRegistry; private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer; private readonly DataLocationResolver _dataLocationResolver; @@ -32,7 +30,6 @@ internal sealed class LauncherFlowCoordinator DeploymentLocator deploymentLocator, OobeStateService oobeStateService, UpdateEngineService updateEngine, - PluginInstallerService pluginInstallerService, StartupAttemptRegistry? startupAttemptRegistry = null, LauncherCoordinatorIpcServer? coordinatorIpcServer = null) { @@ -40,7 +37,6 @@ internal sealed class LauncherFlowCoordinator _deploymentLocator = deploymentLocator; _oobeStateService = oobeStateService; _updateEngine = updateEngine; - _pluginInstallerService = pluginInstallerService; _startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry(); _coordinatorIpcServer = coordinatorIpcServer; _dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot()); @@ -217,12 +213,12 @@ internal sealed class LauncherFlowCoordinator try { - if (ShouldProbeExistingHostBeforeLaunch(_context)) + if (HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(_context)) { var multiInstanceBehavior = LoadMultiInstanceLaunchBehavior(); - var existingShellStatus = await TryGetExistingHostStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900)) + var existingShellStatus = await TryGetExistingHostStatusAsync(ipcClient, StartupTimeoutPolicy.ExistingHostProbeTimeout) .ConfigureAwait(false); - if (IsExistingHostReadyForLauncherDecision(existingShellStatus)) + if (HostActivationPolicy.IsExistingHostReadyForLauncherDecision(existingShellStatus)) { ipcConnected = true; shellStatus = existingShellStatus; @@ -233,7 +229,7 @@ internal sealed class LauncherFlowCoordinator .ConfigureAwait(false); shellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus; var recoverableActivationFailure = decisionResult.ActivationResult is not null && - IsRecoverableActivationFailure(decisionResult.ActivationResult); + HostActivationPolicy.IsRecoverableActivationFailure(decisionResult.ActivationResult); lastStage = decisionResult.Success || recoverableActivationFailure ? StartupStage.ActivationRedirected : StartupStage.ActivationFailed; @@ -288,15 +284,6 @@ internal sealed class LauncherFlowCoordinator // Continue to launch existing version instead of aborting } - reporter.Report("plugins", "Applying plugin upgrades..."); - var pluginsDir = _context.GetOption("plugins-dir") ?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins"); - var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir); - if (!queueResult.Success) - { - Logger.Warn($"Plugin upgrade failed, continuing startup. Error='{queueResult.Message}'."); - reporter.Report("plugins", "Plugin upgrade failed, continuing..."); - } - if (oobeDecision.ShouldShowOobe) { await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide()); @@ -463,363 +450,37 @@ internal sealed class LauncherFlowCoordinator recoveryActivationAttempted))); } - async Task RefreshShellStatusAsync(string waitingMessage) - { - if (!ipcClient.IsConnected) - { - return null; - } - - ipcConnected = true; - _startupAttemptRegistry.MarkOwnedIpcConnected(); - shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); - if (startupSuccessTracker.TryResolve(shellStatus, out var successState)) - { - return successState; - } - - if (shellStatus is { DesktopVisible: false }) - { - _startupAttemptRegistry.MarkOwnedWaitingForShell(waitingMessage); - } - - PublishCoordinatorStatus(hostProcessAliveOverride: true); - return null; - } - - var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(1200)).ConfigureAwait(false); - if (!connected) - { - Logger.Info("Host public IPC is not ready yet. Launcher will keep monitoring the host process and retry."); - } - else - { - var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.") - .ConfigureAwait(false); - if (shellSuccess is not null) - { - successTcs.TrySetResult(shellSuccess); - } - } - - var processExitTask = launchOutcome.Process.WaitForExitAsync(); - var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow; - var softTimeoutAt = startedAt + StartupSoftTimeout; - var hardTimeoutAt = startedAt + StartupHardTimeout; - var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2); - var nextShellStatusPollAt = DateTimeOffset.UtcNow.AddSeconds(1); - var activationRetryAttempted = false; - - while (true) - { - if (successTcs.Task.IsCompleted) - { - var successState = await successTcs.Task.ConfigureAwait(false); - windowsClosingByCoordinator = true; - _startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message); - PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: successState.Code, - message: successState.Message, - details: ComposeLaunchDetails(!launchOutcome.Process.HasExited)); - } - - if (activationFailedTcs.Task.IsCompleted && !activationRetryAttempted) - { - activationRetryAttempted = true; - activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false); - Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'."); - var activationRecovery = await TryRecoverActivationThroughExistingHostAsync( - ipcClient, - startupSuccessTracker, - TimeSpan.FromSeconds(1)).ConfigureAwait(false); - if (activationRecovery is not null) - { - windowsClosingByCoordinator = true; - _startupAttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message); - PublishCoordinatorStatus( - hostProcessAliveOverride: !launchOutcome.Process.HasExited, - completed: true, - succeeded: true); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: activationRecovery.Code, - message: activationRecovery.Message, - details: ComposeLaunchDetails( - !launchOutcome.Process.HasExited, - recoveryActivationAttempted: true)); - } - - Logger.Info("Activation failure did not recover through public IPC yet. Launcher will keep monitoring the current host attempt."); - } - - if (processExitTask.IsCompleted) - { - var exitCode = launchOutcome.Process.ExitCode; - Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}."); - - windowsClosingByCoordinator = true; - if (IsSuccessfulActivationExitCode(exitCode)) - { - _startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance."); - PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true); - 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() - })); - } - - if (!activationRetryAttempted && - IsFailedActivationExitCode(exitCode)) - { - activationRetryAttempted = true; - var activationRecovery = await TryRecoverActivationThroughExistingHostAsync( - ipcClient, - startupSuccessTracker, - TimeSpan.FromSeconds(2)).ConfigureAwait(false); - if (activationRecovery is not null) - { - _startupAttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message); - PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: activationRecovery.Code, - message: activationRecovery.Message, - details: MergeDetails( - ComposeLaunchDetails(hostProcessAlive: true, recoveryActivationAttempted: true), - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["exitCode"] = exitCode.ToString() - })); - } - - Logger.Info("Activation exit code did not recover through public IPC. Launcher will report the activation failure without launching another host."); - } - - _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); - PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: false, - stage: "launch", - code: IsFailedActivationExitCode(exitCode) - ? "activation_failed" - : "host_exited_early", - message: IsFailedActivationExitCode(exitCode) - ? $"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 >= nextShellStatusPollAt) - { - var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.") - .ConfigureAwait(false); - if (shellSuccess is not null) - { - successTcs.TrySetResult(shellSuccess); - continue; - } - - nextShellStatusPollAt = DateTimeOffset.UtcNow.AddSeconds(1); - } - - if (!ipcConnected && - !launchOutcome.Process.HasExited && - now >= nextReconnectAttemptAt) - { - connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false); - if (connected) - { - var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.") - .ConfigureAwait(false); - if (shellSuccess is not null) - { - successTcs.TrySetResult(shellSuccess); - continue; - } - } - - nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2); - } - - 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); - PublishCoordinatorStatus(hostProcessAliveOverride: !launchOutcome.Process.HasExited); - } - - 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); - } - - var recoveryActivationAttempted = false; - if (!connected && !launchOutcome.Process.HasExited) - { - connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false); - if (connected) - { - var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.") - .ConfigureAwait(false); - if (shellSuccess is not null) - { - windowsClosingByCoordinator = true; - _startupAttemptRegistry.MarkOwnedSucceeded(shellSuccess.Stage, shellSuccess.Message); - PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: shellSuccess.Code, - message: shellSuccess.Message, - details: ComposeLaunchDetails(hostProcessAlive: true)); - } - } - } - - if (connected && !launchOutcome.Process.HasExited) - { - recoveryActivationAttempted = true; - var recoveryOutcome = await TryRecoverWithPublicActivationAsync( - ipcClient, - launchOutcome.Process, - successTcs.Task, - startupSuccessTracker).ConfigureAwait(false); - if (recoveryOutcome is not null) - { - windowsClosingByCoordinator = true; - _startupAttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message); - shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); - PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: recoveryOutcome.Code, - message: recoveryOutcome.Message, - details: ComposeLaunchDetails( - !launchOutcome.Process.HasExited, - recoveryActivationAttempted: true)); - } - } - - if (connected && !launchOutcome.Process.HasExited) - { - windowsClosingByCoordinator = true; - _startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window."); - shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); - if (startupSuccessTracker.TryResolve(shellStatus, out var finalShellSuccess)) - { - _startupAttemptRegistry.MarkOwnedSucceeded(finalShellSuccess.Stage, finalShellSuccess.Message); - PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: finalShellSuccess.Code, - message: finalShellSuccess.Message, - details: ComposeLaunchDetails( - hostProcessAlive: true, - recoveryActivationAttempted)); - } - - PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: false); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: false, - stage: "launch", - code: "shell_not_ready", - message: "Host public IPC is connected, but the desktop shell did not create or show the main window in time.", - details: ComposeLaunchDetails( - hostProcessAlive: true, - recoveryActivationAttempted)); - } - - if (!connected && !launchOutcome.Process.HasExited) - { - windowsClosingByCoordinator = true; - _startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running, but public IPC is not ready yet."); - PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: false, succeeded: true); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: "startup_pending", - message: "Host process is still running; Launcher will not start another process while public IPC finishes startup.", - details: ComposeLaunchDetails( - hostProcessAlive: true, - recoveryActivationAttempted)); - } + var monitor = new HostStartupMonitor(); + var monitorOutcome = await monitor.MonitorUntilCompleteAsync(new HostStartupMonitor.Request( + launchOutcome.Process, + ipcClient, + startupSuccessTracker, + _startupAttemptRegistry, + trackedAttempt, + attachedToExistingAttempt, + launcherContextDetails, + successTcs, + activationFailedTcs, + reporter, + loadingDetailsWindow, + loadingState, + lastStage, + lastStageMessage, + ipcConnected, + activationFailureReason, + softTimeoutShown, + (hostProcessAliveOverride, completed, succeeded) => + PublishCoordinatorStatus(hostProcessAliveOverride, completed, succeeded), + ComposeLaunchDetails)).ConfigureAwait(false); windowsClosingByCoordinator = true; - _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); - PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( - success: false, + success: monitorOutcome.Success, stage: "launch", - code: "desktop_not_visible", - message: $"Host process started, but it never reached the required startup state within {StartupHardTimeout.TotalSeconds:0} seconds.", - details: ComposeLaunchDetails( - !launchOutcome.Process.HasExited, - recoveryActivationAttempted)); + code: monitorOutcome.Code, + message: monitorOutcome.Message, + details: monitorOutcome.Details); } finally { @@ -861,399 +522,9 @@ internal sealed class LauncherFlowCoordinator } } - private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow) - { - try - { - await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync()); - } - catch (Exception ex) - { - Logger.Error("Failed to dismiss splash window.", ex); - } - await Dispatcher.UIThread.InvokeAsync(() => - { - try - { - if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible) - { - loadingDetailsWindow.Close(); - } - } - catch (Exception ex) - { - Logger.Error("Failed to close loading details window.", ex); - } - }); - } - private async Task LaunchHostWithIpcAsync(bool forceDirectMode = false, string? retryTag = null) - { - var resolution = _deploymentLocator.ResolveHostExecutable(_context); - if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath)) - { - var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync().ConfigureAwait(false); - if (errorResult == ErrorWindowResult.Retry) - { - if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath)) - { - return await LaunchHostWithExplicitPathAsync(selectedPath, forceDirectMode, retryTag).ConfigureAwait(false); - } - return await LaunchHostWithIpcAsync(forceDirectMode, retryTag).ConfigureAwait(false); - } - - return HostLaunchOutcome.FromResult(BuildResult( - success: false, - stage: "launchHost", - code: "host_not_found", - message: "LanMountainDesktop host executable was not found.", - details: BuildResolutionDetails(resolution, null, null, "resolve"))); - } - - return await LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag).ConfigureAwait(false); - } - - private Task LaunchHostWithExplicitPathAsync(string hostPath, bool forceDirectMode, string? retryTag) - { - var resolution = new HostResolutionResult - { - Success = true, - ResolvedHostPath = Path.GetFullPath(hostPath), - ResolutionSource = "user_selected_path", - AppRoot = _deploymentLocator.GetAppRoot(), - ExplicitAppRoot = Path.GetDirectoryName(hostPath), - SearchedPaths = [Path.GetFullPath(hostPath)] - }; - - return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag); - } - - internal static LauncherResult? ValidateDotNetRuntimePrerequisite( - HostLaunchPlan plan, - HostResolutionResult resolution, - DotNetRuntimeProbeOptions? probeOptions = null) - { - ArgumentNullException.ThrowIfNull(plan); - ArgumentNullException.ThrowIfNull(resolution); - - if (!DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(plan.HostPath)) - { - return null; - } - - var runtime = DotNetRuntimeProbe.Probe(probeOptions); - Logger.Info( - $"Runtime prerequisite check completed. Available={runtime.IsAvailable}; " + - $"Architecture={runtime.Architecture}; Message='{runtime.Message}'."); - - if (runtime.IsAvailable) - { - return null; - } - - var details = BuildResolutionDetails(resolution, null, null, "runtime"); - foreach (var pair in runtime.ToDetails()) - { - details[pair.Key] = pair.Value; - } - - return BuildResult( - success: false, - stage: "launchHost", - code: "dotnet_runtime_missing", - message: ".NET 10 Desktop Runtime is required before LanMountainDesktop can start.", - details: details, - errorMessage: runtime.Message); - } - - private async Task LaunchHostWithResolvedPathAsync( - HostResolutionResult resolution, - bool forceDirectMode, - string? retryTag) - { - var dataRoot = _dataLocationResolver.ResolveDataRoot(); - var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot); - var prerequisiteFailure = ValidateDotNetRuntimePrerequisite(plan, resolution); - if (prerequisiteFailure is not null) - { - return HostLaunchOutcome.FromResult(prerequisiteFailure); - } - - var hostPath = plan.HostPath; - if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) - { - EnsureExecutable(hostPath); - } - - var primaryMode = HostStartMode.Direct; - var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows() - ? HostStartMode.ShellExecute - : (HostStartMode?)null; - - var firstAttempt = await StartHostProcessAsync(plan, primaryMode, retryTag).ConfigureAwait(false); - if (firstAttempt.ProcessCreated && firstAttempt.Process is not null) - { - var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null); - return HostLaunchOutcome.FromProcess( - firstAttempt.Process, - BuildResult(true, "launchHost", "ok", "Host launched.", firstDetails), - firstDetails); - } - - if (fallbackMode is null) - { - return BuildOutcomeFromAttempt(resolution, firstAttempt, null); - } - - Logger.Warn( - $"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " + - $"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? ""}'."); - - var secondAttempt = await StartHostProcessAsync(plan, fallbackMode.Value, retryTag).ConfigureAwait(false); - if (secondAttempt.ProcessCreated && secondAttempt.Process is not null) - { - var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null); - return HostLaunchOutcome.FromProcess( - secondAttempt.Process, - BuildResult(true, "launchHost", "ok", "Host launched.", details), - details); - } - - return BuildOutcomeFromAttempt(resolution, secondAttempt, firstAttempt); - } - - private static HostLaunchOutcome BuildOutcomeFromAttempt( - HostResolutionResult resolution, - HostStartAttempt finalAttempt, - HostStartAttempt? previousAttempt) - { - var details = BuildResolutionDetails( - resolution, - previousAttempt ?? finalAttempt, - previousAttempt is null ? null : finalAttempt, - !finalAttempt.ProcessCreated - ? "start" - : finalAttempt.ExitCode is int finalExitCode && IsFailedActivationExitCode(finalExitCode) - ? "activation" - : "early-exit"); - - if (!finalAttempt.ProcessCreated) - { - return HostLaunchOutcome.FromResult(BuildResult( - false, - "launchHost", - "host_start_failed", - $"Failed to start host using start mode '{finalAttempt.StartMode}'.", - details)); - } - - if (finalAttempt.ExitCode is not null && IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value)) - { - return HostLaunchOutcome.FromImmediateResult(BuildResult( - true, - "launch", - "activation_redirected", - "Launcher activation was redirected to the existing desktop instance.", - details)); - } - - if (finalAttempt.ExitCode is not null && IsFailedActivationExitCode(finalAttempt.ExitCode.Value)) - { - return HostLaunchOutcome.FromResult(BuildResult( - false, - "launch", - "activation_failed", - $"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.", - details)); - } - - return HostLaunchOutcome.FromResult(BuildResult( - false, - "launchHost", - "host_exited_early", - $"Host exited early using start mode '{finalAttempt.StartMode}'.", - details)); - } - - private async Task StartHostProcessAsync( - HostLaunchPlan plan, - HostStartMode startMode, - string? retryTag) - { - var startInfo = new ProcessStartInfo - { - FileName = plan.HostPath, - WorkingDirectory = plan.WorkingDirectory, - UseShellExecute = startMode == HostStartMode.ShellExecute - }; - - if (startMode == HostStartMode.Direct) - { - foreach (var argument in plan.Arguments) - { - startInfo.ArgumentList.Add(argument); - } - - foreach (var pair in plan.EnvironmentVariables) - { - startInfo.EnvironmentVariables[pair.Key] = pair.Value; - } - } - else - { - startInfo.Arguments = HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments); - } - - try - { - var process = Process.Start(startInfo); - Logger.Info( - $"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? ""}'; Path='{plan.HostPath}'; " + - $"PackageRoot='{plan.PackageRoot}'; WorkingDir='{plan.WorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; " + - $"Args='{HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}'."); - - if (process is null) - { - return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan); - } - - await Task.Yield(); - return HostStartAttempt.Started(startMode, process, plan); - } - catch (Exception ex) - { - Logger.Error($"Host start failed. Mode='{startMode}'.", ex); - return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan); - } - } - - private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync() - { - ErrorWindow? errorWindow = null; - - await Dispatcher.UIThread.InvokeAsync(() => - { - try - { - errorWindow = new ErrorWindow(); - errorWindow.ConfigureForHostNotFound(); - errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found."); - errorWindow.Show(); - Logger.Warn("Host not found. Showing error window."); - } - catch (Exception ex) - { - Logger.Error("Failed to show host-not-found error window.", ex); - } - }); - - if (errorWindow is null) - { - return (ErrorWindowResult.Exit, null); - } - - ErrorWindowResult result; - string? customPath; - try - { - result = await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); - customPath = errorWindow.GetCustomHostPath(); - Logger.Info($"Host-not-found window result='{result}'; HasCustomPath={!string.IsNullOrWhiteSpace(customPath)}."); - } - catch (Exception ex) - { - Logger.Error("Failed while waiting for host-not-found window result.", ex); - result = ErrorWindowResult.Exit; - customPath = null; - } - - await Dispatcher.UIThread.InvokeAsync(() => - { - try - { - if (errorWindow.IsVisible && errorWindow.IsLoaded) - { - errorWindow.Close(); - } - } - catch (Exception ex) - { - Logger.Error("Failed to close host-not-found error window.", ex); - } - }); - - return (result, customPath); - } - - private async Task ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo) - { - MigrationPromptWindow? migrationWindow = null; - - await Dispatcher.UIThread.InvokeAsync(() => - { - try - { - migrationWindow = new MigrationPromptWindow(); - migrationWindow.SetLegacyInfo(legacyInfo); - migrationWindow.Show(); - } - catch (Exception ex) - { - Logger.Error("Failed to show migration prompt window.", ex); - } - }); - - if (migrationWindow is null) - { - return MigrationResult.Skipped; - } - - MigrationResult result; - try - { - result = await migrationWindow.WaitForChoiceAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error("Failed while waiting for migration prompt result.", ex); - result = MigrationResult.Skipped; - } - - await Dispatcher.UIThread.InvokeAsync(() => - { - try - { - if (migrationWindow.IsVisible && migrationWindow.IsLoaded) - { - migrationWindow.Close(); - } - } - catch (Exception ex) - { - Logger.Error("Failed to close migration prompt window.", ex); - } - }); - - return result; - } - - private static string MapStartupStageToSplashStage(StartupStage stage) => stage switch - { - StartupStage.Initializing => "initializing", - StartupStage.LoadingSettings => "settings", - StartupStage.LoadingPlugins => "plugins", - StartupStage.TrayReady => "shell", - StartupStage.InitializingUI => "ui", - StartupStage.ShellInitialized => "shell", - StartupStage.BackgroundReady => "ready", - StartupStage.DesktopVisible => "ready", - StartupStage.ActivationRedirected => "activation", - StartupStage.ActivationFailed => "error", - StartupStage.Ready => "ready", - _ => "launch" - }; private static LauncherResult BuildResult( bool success, @@ -1319,49 +590,6 @@ internal sealed class LauncherFlowCoordinator }; } - private static Dictionary BuildResolutionDetails( - HostResolutionResult resolution, - HostStartAttempt? firstAttempt, - HostStartAttempt? secondAttempt, - string? failureStage) - { - var details = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["resolvedAppRoot"] = resolution.AppRoot, - ["explicitAppRoot"] = resolution.ExplicitAppRoot ?? string.Empty, - ["resolvedHostPath"] = resolution.ResolvedHostPath ?? string.Empty, - ["resolutionSource"] = resolution.ResolutionSource ?? string.Empty, - ["devModeConfigIgnored"] = resolution.DevModeConfigIgnored.ToString(), - ["searchedPaths"] = string.Join(" | ", resolution.SearchedPaths), - ["failureStage"] = failureStage ?? string.Empty - }; - - if (firstAttempt is not null) - { - details["startMode"] = firstAttempt.StartMode.ToString(); - details["processCreated"] = firstAttempt.ProcessCreated.ToString(); - details["hostPid"] = firstAttempt.ProcessId?.ToString() ?? string.Empty; - details["packageRoot"] = firstAttempt.PackageRoot ?? string.Empty; - details["workingDirectory"] = firstAttempt.WorkingDirectory ?? string.Empty; - details["arguments"] = firstAttempt.Arguments ?? string.Empty; - details["firstAttemptFailureReason"] = firstAttempt.FailureReason ?? string.Empty; - details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty; - } - - if (secondAttempt is not null) - { - details["fallbackStartMode"] = secondAttempt.StartMode.ToString(); - details["fallbackProcessCreated"] = secondAttempt.ProcessCreated.ToString(); - details["fallbackHostPid"] = secondAttempt.ProcessId?.ToString() ?? string.Empty; - details["fallbackPackageRoot"] = secondAttempt.PackageRoot ?? string.Empty; - details["fallbackWorkingDirectory"] = secondAttempt.WorkingDirectory ?? string.Empty; - details["fallbackArguments"] = secondAttempt.Arguments ?? string.Empty; - details["fallbackFailureReason"] = secondAttempt.FailureReason ?? string.Empty; - details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty; - } - - return details; - } private static Dictionary MergeDetails( Dictionary left, @@ -1375,659 +603,5 @@ internal sealed class LauncherFlowCoordinator return merged; } - - private static void EnsureExecutable(string path) - { - if (OperatingSystem.IsWindows()) - { - return; - } - - try - { - var mode = File.GetUnixFileMode(path); - mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; - File.SetUnixFileMode(path, mode); - } - catch - { - } - } - - private static async Task TryConnectToPublicIpcAsync( - LanMountainDesktopIpcClient ipcClient, - TimeSpan timeout) - { - if (ipcClient.IsConnected) - { - return true; - } - - try - { - var connectTask = ipcClient.ConnectAsync(); - var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false); - if (completedTask != connectTask) - { - return false; - } - - await connectTask.ConfigureAwait(false); - return ipcClient.IsConnected; - } - catch (Exception ex) - { - Logger.Info($"Public IPC is not ready yet: {ex.Message}"); - return false; - } - } - - internal static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context) - { - if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (context.IsPreviewCommand || context.IsMaintenanceCommand) - { - return false; - } - - return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase); - } - - private MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior() - { - try - { - var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(_dataLocationResolver.ResolveDataRoot()); - return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath); - } - catch (Exception ex) - { - Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}"); - return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop; - } - } - - internal static bool IsExistingHostReadyForLauncherDecision(PublicShellStatus? status) - { - return status is { PublicIpcReady: true, ProcessId: > 0 }; - } - - private static async Task TryGetExistingHostStatusAsync( - LanMountainDesktopIpcClient ipcClient, - TimeSpan timeout) - { - try - { - var connected = ipcClient.IsConnected || - await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false); - if (!connected) - { - return null; - } - - var shellProxy = ipcClient.CreateProxy(); - return await shellProxy.GetShellStatusAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Info($"Existing host status probe did not complete: {ex.Message}"); - return null; - } - } - - private static async Task ApplyExistingHostBehaviorAsync( - LanMountainDesktopIpcClient ipcClient, - MultiInstanceLaunchBehavior behavior, - PublicShellStatus status) - { - try - { - var shellProxy = ipcClient.CreateProxy(); - return behavior switch - { - MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync( - shellProxy, - showLauncherNotice: false, - successCode: "existing_host_activated", - successMessage: "Launcher activated the existing desktop instance.", - failureCode: "existing_host_activation_failed").ConfigureAwait(false), - - MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync( - shellProxy, - showLauncherNotice: true, - successCode: "existing_host_activated_with_notice", - successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.", - failureCode: "existing_host_activation_failed").ConfigureAwait(false), - - MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync( - shellProxy, - status).ConfigureAwait(false), - - MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false), - - _ => await ActivateExistingHostForBehaviorAsync( - shellProxy, - showLauncherNotice: true, - successCode: "existing_host_activated_with_notice", - successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.", - failureCode: "existing_host_activation_failed").ConfigureAwait(false) - }; - } - catch (Exception ex) - { - Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}"); - return new ExistingHostBehaviorResult( - false, - "multi_instance_behavior_failed", - $"Failed to apply multi-instance behavior '{behavior}': {ex.Message}", - null); - } - } - - private static async Task ActivateExistingHostForBehaviorAsync( - IPublicShellControlService shellProxy, - bool showLauncherNotice, - string successCode, - string successMessage, - string failureCode) - { - var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); - var success = activation.Accepted || IsRecoverableActivationFailure(activation); - if (showLauncherNotice && success) - { - var promptResult = await ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false); - if (promptResult == MultiInstancePromptResult.OpenDesktop) - { - activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); - } - } - - return new ExistingHostBehaviorResult( - success, - activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode, - activation.Accepted ? successMessage : activation.Message, - activation); - } - - private static async Task RestartExistingHostAsync( - IPublicShellControlService shellProxy) - { - var accepted = await shellProxy.RestartAsync().ConfigureAwait(false); - return new ExistingHostBehaviorResult( - accepted, - accepted ? "existing_host_restart_requested" : "existing_host_restart_failed", - accepted - ? "Launcher requested the existing desktop instance to restart." - : "Launcher could not request restart from the existing desktop instance.", - null); - } - - private static async Task ShowPromptOnlyExistingHostAsync( - IPublicShellControlService shellProxy, - PublicShellStatus status) - { - var promptResult = await ShowMultiInstancePromptAsync(status).ConfigureAwait(false); - - if (promptResult == MultiInstancePromptResult.OpenDesktop) - { - return await ActivateExistingHostForBehaviorAsync( - shellProxy, - showLauncherNotice: false, - successCode: "existing_host_activated_from_prompt", - successMessage: "Launcher activated the existing desktop instance from the prompt.", - failureCode: "existing_host_activation_failed").ConfigureAwait(false); - } - - return new ExistingHostBehaviorResult( - true, - "existing_host_prompt_only", - "Launcher showed the repeated-launch prompt and did not open the desktop automatically.", - null); - } - - private static async Task ShowMultiInstancePromptAsync(PublicShellStatus status) - { - return await Dispatcher.UIThread.InvokeAsync(async () => - { - var prompt = new MultiInstancePromptWindow(); - prompt.SetDetails(status.ProcessId, status.ShellState); - prompt.Show(); - return await prompt.WaitForChoiceAsync().ConfigureAwait(true); - }); - } - - private static async Task TryActivateExistingHostWithStatusAsync( - LanMountainDesktopIpcClient ipcClient, - TimeSpan timeout) - { - try - { - var connected = ipcClient.IsConnected || - await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false); - if (!connected) - { - return null; - } - - var shellProxy = ipcClient.CreateProxy(); - return await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Info($"Existing host activation probe did not complete: {ex.Message}"); - return null; - } - } - - private static async Task TryRecoverActivationThroughExistingHostAsync( - LanMountainDesktopIpcClient ipcClient, - StartupSuccessTracker startupSuccessTracker, - TimeSpan timeout) - { - var activation = await TryActivateExistingHostWithStatusAsync(ipcClient, timeout).ConfigureAwait(false); - if (activation is null) - { - return null; - } - - if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess)) - { - return shellSuccess; - } - - if (activation.Accepted) - { - return startupSuccessTracker.BuildRecoverySuccessState(); - } - - return IsRecoverableActivationFailure(activation) - ? new StartupSuccessState( - StartupStage.Ready, - "startup_pending", - activation.Message) - : null; - } - - internal static bool IsRecoverableActivationFailure(PublicShellActivationResult activation) - { - if (activation.Accepted) - { - return false; - } - - if (string.Equals(activation.Code, "shutdown_in_progress", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return activation.Status.PublicIpcReady && - (!activation.Status.MainWindowOpened || - !activation.Status.DesktopVisible || - string.Equals(activation.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) || - string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase)); - } - - internal static bool IsSuccessfulActivationExitCode(int exitCode) => - exitCode == HostExitCodes.SecondaryActivationSucceeded; - - internal static bool IsFailedActivationExitCode(int exitCode) => - exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired; - - private static async Task TryGetPublicShellStatusAsync( - LanMountainDesktopIpcClient ipcClient) - { - try - { - var shellProxy = ipcClient.CreateProxy(); - return await shellProxy.GetShellStatusAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Warn($"Failed to query public shell status: {ex.Message}"); - return null; - } - } - - private static async Task TryRecoverWithPublicActivationAsync( - LanMountainDesktopIpcClient ipcClient, - Process hostProcess, - Task successTask, - StartupSuccessTracker startupSuccessTracker) - { - try - { - var shellProxy = ipcClient.CreateProxy(); - var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); - if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess)) - { - return shellSuccess; - } - - var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false); - if (completedTask == successTask) - { - return await successTask.ConfigureAwait(false); - } - - if (!hostProcess.HasExited && (activation.Accepted || IsRecoverableActivationFailure(activation))) - { - return startupSuccessTracker.BuildRecoverySuccessState(); - } - } - catch (Exception ex) - { - Logger.Warn($"Public activation recovery failed: {ex.Message}"); - } - - 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["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O"); - details["successPolicy"] = trackedAttempt.SuccessPolicy; - details["hostPid"] = trackedAttempt.HostPid.ToString(); - details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString(); - details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName; - details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString(); - details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString(); - details["shellStatus"] = trackedAttempt.ShellStatus; - } - - 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, - Direct - } - - private sealed record HostStartAttempt( - HostStartMode StartMode, - bool ProcessCreated, - Process? Process, - bool ExitedEarly, - int? ExitCode, - string? FailureReason, - string? PackageRoot, - string? WorkingDirectory, - string? Arguments) - { - public int? ProcessId => Process?.Id; - - public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) => - new( - startMode, - true, - process, - false, - null, - null, - plan.PackageRoot, - plan.WorkingDirectory, - HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); - - public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) => - new( - startMode, - true, - process, - true, - exitCode, - null, - plan.PackageRoot, - plan.WorkingDirectory, - HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); - - public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) => - new( - startMode, - false, - null, - false, - null, - failureReason, - plan?.PackageRoot, - plan?.WorkingDirectory, - plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); - } - - private sealed record ExistingHostBehaviorResult( - bool Success, - string Code, - string Message, - PublicShellActivationResult? ActivationResult); - - private sealed record HostLaunchOutcome( - LauncherResult Result, - Process? Process, - LauncherResult? ImmediateResult, - Dictionary Details) - { - public static HostLaunchOutcome FromResult(LauncherResult result) => - new(result, null, result.Success ? result : null, result.Details); - - public static HostLaunchOutcome FromImmediateResult(LauncherResult result) => - new(result, null, result, result.Details); - - public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary details) => - new(result, process, null, details); - } - - private sealed class StartupSuccessTracker - { - private readonly LaunchSuccessPolicy _policy; - private bool _trayReady; - private bool _backgroundReady; - - public string PolicyKey => _policy.ToString(); - - public StartupSuccessTracker(CommandContext context) - { - var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs); - var isRestartLaunch = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase); - - _policy = !isRestartLaunch - ? LaunchSuccessPolicy.Foreground - : restartPresentation switch - { - RestartPresentationMode.Tray => LaunchSuccessPolicy.RestartTray, - RestartPresentationMode.Minimized => LaunchSuccessPolicy.RestartBackground, - _ => LaunchSuccessPolicy.Foreground - }; - } - - public bool TryResolve(StartupStage stage, out StartupSuccessState successState) - { - switch (stage) - { - case StartupStage.ActivationRedirected: - successState = new StartupSuccessState( - stage, - "activation_redirected", - "Launcher activation was redirected to the existing desktop instance."); - return true; - - case StartupStage.DesktopVisible: - successState = new StartupSuccessState( - stage, - _policy == LaunchSuccessPolicy.Foreground ? "ok" : "desktop_visible_fallback", - _policy == LaunchSuccessPolicy.Foreground - ? "Desktop is visible and ready." - : "Desktop recovered in a visible state."); - return true; - - case StartupStage.Ready: - successState = new StartupSuccessState( - stage, - _policy == LaunchSuccessPolicy.Foreground ? "ready" : "background_ready", - "Desktop reported that startup is ready."); - return true; - - case StartupStage.TrayReady: - _trayReady = true; - break; - - case StartupStage.BackgroundReady: - _backgroundReady = true; - break; - } - - if (_policy == LaunchSuccessPolicy.RestartBackground && _backgroundReady) - { - successState = new StartupSuccessState( - StartupStage.BackgroundReady, - "background_ready", - "Desktop restart completed in the background."); - return true; - } - - if (_policy == LaunchSuccessPolicy.RestartTray && _trayReady && _backgroundReady) - { - successState = new StartupSuccessState( - StartupStage.BackgroundReady, - "background_ready", - "Desktop restart completed with tray recovery ready."); - return true; - } - - successState = default!; - return false; - } - - public bool TryResolve(PublicShellStatus? status, out StartupSuccessState successState) - { - if (status is not null && - (status.DesktopVisible || status.MainWindowVisible || status.MainWindowOpened)) - { - successState = new StartupSuccessState( - status.DesktopVisible || status.MainWindowVisible - ? StartupStage.DesktopVisible - : StartupStage.Ready, - _policy == LaunchSuccessPolicy.Foreground ? "ok" : "background_ready", - status.DesktopVisible || status.MainWindowVisible - ? "Desktop shell is visible and ready." - : "Desktop shell window has opened."); - return true; - } - - successState = default!; - return false; - } - - public StartupSuccessState BuildRecoverySuccessState() - { - return _policy switch - { - LaunchSuccessPolicy.RestartTray => new StartupSuccessState( - StartupStage.DesktopVisible, - "recovery_activation_requested", - "Launcher requested a visible recovery because the background restart never confirmed tray readiness."), - LaunchSuccessPolicy.RestartBackground => new StartupSuccessState( - StartupStage.DesktopVisible, - "recovery_activation_requested", - "Launcher requested a visible recovery because the background restart never confirmed readiness."), - _ => new StartupSuccessState( - StartupStage.DesktopVisible, - "recovery_activation_requested", - "Launcher requested a visible recovery from the running desktop instance.") - }; - } - } - - private sealed record StartupSuccessState( - StartupStage Stage, - string Code, - string Message); - - private enum LaunchSuccessPolicy - { - Foreground, - RestartBackground, - RestartTray - } } + diff --git a/LanMountainDesktop.Launcher/Startup/HostActivationPolicy.cs b/LanMountainDesktop.Launcher/Startup/HostActivationPolicy.cs new file mode 100644 index 0000000..1e29cf2 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/HostActivationPolicy.cs @@ -0,0 +1,50 @@ +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Startup; + +internal static class HostActivationPolicy +{ + internal static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context) + { + if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (context.IsPreviewCommand || context.IsMaintenanceCommand) + { + return false; + } + + return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase); + } + + internal static bool IsExistingHostReadyForLauncherDecision(PublicShellStatus? status) => + status is { PublicIpcReady: true, ProcessId: > 0 }; + + internal static bool IsRecoverableActivationFailure(PublicShellActivationResult activation) + { + if (activation.Accepted) + { + return false; + } + + if (string.Equals(activation.Code, "shutdown_in_progress", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return activation.Status.PublicIpcReady && + (!activation.Status.MainWindowOpened || + !activation.Status.DesktopVisible || + string.Equals(activation.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) || + string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase)); + } + + internal static bool IsSuccessfulActivationExitCode(int exitCode) => + exitCode == HostExitCodes.SecondaryActivationSucceeded; + + internal static bool IsFailedActivationExitCode(int exitCode) => + exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired; +} diff --git a/LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs b/LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs new file mode 100644 index 0000000..3b4e5a0 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs @@ -0,0 +1,511 @@ +using System.Diagnostics; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Resources; +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.Startup; + +internal sealed class HostStartupMonitor +{ + private static readonly string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage; + private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage; + + internal sealed record Request( + Process HostProcess, + LanMountainDesktopIpcClient IpcClient, + StartupSuccessTracker SuccessTracker, + StartupAttemptRegistry AttemptRegistry, + StartupAttemptRecord? TrackedAttempt, + bool AttachedToExistingAttempt, + Dictionary LaunchDetails, + TaskCompletionSource SuccessTcs, + TaskCompletionSource ActivationFailedTcs, + ISplashStageReporter Reporter, + LoadingDetailsWindow? LoadingDetailsWindow, + LoadingStateMessage LoadingState, + StartupStage LastStage, + string LastStageMessage, + bool IpcConnected, + string ActivationFailureReason, + bool SoftTimeoutShown, + Action PublishCoordinatorStatus, + Func> ComposeLaunchDetails); + + internal sealed record Outcome( + bool Success, + string Code, + string Message, + bool RecoveryActivationAttempted, + Dictionary Details); + + public async Task MonitorUntilCompleteAsync(Request request) + { + var ipcConnected = request.IpcConnected; + var softTimeoutShown = request.SoftTimeoutShown; + var lastStage = request.LastStage; + var lastStageMessage = request.LastStageMessage; + var activationFailureReason = request.ActivationFailureReason; + var loadingState = request.LoadingState; + PublicShellStatus? shellStatus = null; + var trackedAttempt = request.TrackedAttempt; + + async Task RefreshShellStatusAsync(string waitingMessage) + { + if (!request.IpcClient.IsConnected) + { + return null; + } + + ipcConnected = true; + request.AttemptRegistry.MarkOwnedIpcConnected(); + shellStatus = await TryGetPublicShellStatusAsync(request.IpcClient).ConfigureAwait(false); + StartupDiagnostics.TraceShellStatus("refresh", shellStatus, lastStage); + if (request.SuccessTracker.TryResolve(shellStatus, out var successState)) + { + return successState; + } + + if (shellStatus is not null && !shellStatus.MainWindowOpened && !shellStatus.DesktopVisible) + { + request.AttemptRegistry.MarkOwnedWaitingForShell(waitingMessage); + } + + request.PublishCoordinatorStatus(true, false, false); + return null; + } + + var connected = await PublicIpcConnection.TryConnectWithBackoffAsync( + request.IpcClient, + [ + StartupTimeoutPolicy.InitialIpcConnectTimeout, + TimeSpan.FromMilliseconds(3000), + TimeSpan.FromMilliseconds(5000) + ]).ConfigureAwait(false); + if (!connected) + { + Logger.Info("Host public IPC is not ready yet. Launcher will keep monitoring the host process and retry."); + } + else + { + var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.") + .ConfigureAwait(false); + if (shellSuccess is not null) + { + request.SuccessTcs.TrySetResult(shellSuccess); + } + } + + var processExitTask = request.HostProcess.WaitForExitAsync(); + var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow; + var softTimeoutAt = startedAt + StartupTimeoutPolicy.SoftTimeout; + var hardTimeoutAt = startedAt + StartupTimeoutPolicy.HardTimeout; + var nextReconnectAttemptAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.IpcReconnectInterval; + var nextShellStatusPollAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.ShellStatusPollInterval; + var ipcReconnectAttemptIndex = 0; + var activationRetryAttempted = false; + + while (true) + { + if (request.SuccessTcs.Task.IsCompleted) + { + var successState = await request.SuccessTcs.Task.ConfigureAwait(false); + request.AttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message); + request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, true); + return new Outcome( + true, + successState.Code, + successState.Message, + false, + request.ComposeLaunchDetails(!request.HostProcess.HasExited, false)); + } + + if (request.ActivationFailedTcs.Task.IsCompleted && !activationRetryAttempted) + { + activationRetryAttempted = true; + activationFailureReason = await request.ActivationFailedTcs.Task.ConfigureAwait(false); + Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'."); + var activationRecovery = await TryRecoverActivationThroughExistingHostAsync( + request.IpcClient, + request.SuccessTracker, + TimeSpan.FromSeconds(1)).ConfigureAwait(false); + if (activationRecovery is not null) + { + request.AttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message); + request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, true); + return new Outcome( + true, + activationRecovery.Code, + activationRecovery.Message, + true, + request.ComposeLaunchDetails(!request.HostProcess.HasExited, true)); + } + + Logger.Info("Activation failure did not recover through public IPC yet. Launcher will keep monitoring the current host attempt."); + } + + if (processExitTask.IsCompleted) + { + var exitCode = request.HostProcess.ExitCode; + Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}."); + + if (HostActivationPolicy.IsSuccessfulActivationExitCode(exitCode)) + { + request.AttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance."); + request.PublishCoordinatorStatus(false, true, true); + return new Outcome( + true, + "activation_redirected", + "Host redirected activation to the existing desktop instance.", + false, + MergeExitCodeDetails(request.ComposeLaunchDetails(false, false), exitCode)); + } + + if (!activationRetryAttempted && HostActivationPolicy.IsFailedActivationExitCode(exitCode)) + { + activationRetryAttempted = true; + var activationRecovery = await TryRecoverActivationThroughExistingHostAsync( + request.IpcClient, + request.SuccessTracker, + TimeSpan.FromSeconds(2)).ConfigureAwait(false); + if (activationRecovery is not null) + { + request.AttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message); + request.PublishCoordinatorStatus(true, true, true); + return new Outcome( + true, + activationRecovery.Code, + activationRecovery.Message, + true, + MergeExitCodeDetails(request.ComposeLaunchDetails(true, true), exitCode)); + } + + Logger.Info("Activation exit code did not recover through public IPC. Launcher will report the activation failure without launching another host."); + } + + request.AttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); + request.PublishCoordinatorStatus(false, true, false); + return new Outcome( + false, + HostActivationPolicy.IsFailedActivationExitCode(exitCode) ? "activation_failed" : "host_exited_early", + HostActivationPolicy.IsFailedActivationExitCode(exitCode) + ? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}." + : $"Host exited before the required startup state was reported. ExitCode={exitCode}.", + false, + MergeExitCodeDetails(request.ComposeLaunchDetails(false, false), exitCode)); + } + + var now = DateTimeOffset.UtcNow; + if (ipcConnected && + !request.HostProcess.HasExited && + now >= nextShellStatusPollAt) + { + var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.") + .ConfigureAwait(false); + if (shellSuccess is not null) + { + request.SuccessTcs.TrySetResult(shellSuccess); + continue; + } + + nextShellStatusPollAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.ShellStatusPollInterval; + } + + if (!ipcConnected && + !request.HostProcess.HasExited && + now >= nextReconnectAttemptAt) + { + var reconnectTimeout = StartupTimeoutPolicy.IpcReconnectAttemptTimeouts[ + Math.Min(ipcReconnectAttemptIndex, StartupTimeoutPolicy.IpcReconnectAttemptTimeouts.Length - 1)]; + ipcReconnectAttemptIndex++; + connected = await PublicIpcConnection.TryConnectAsync(request.IpcClient, reconnectTimeout).ConfigureAwait(false); + if (connected) + { + ipcConnected = true; + var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.") + .ConfigureAwait(false); + if (shellSuccess is not null) + { + request.SuccessTcs.TrySetResult(shellSuccess); + continue; + } + } + + nextReconnectAttemptAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.IpcReconnectInterval; + } + + if (!softTimeoutShown && + now >= softTimeoutAt && + (!request.HostProcess.HasExited || ipcConnected)) + { + softTimeoutShown = true; + request.AttemptRegistry.MarkOwnedSoftTimeout(SoftTimeoutStatusMessage); + request.Reporter.Report("delayed", SoftTimeoutStatusMessage); + loadingState = BuildDelayedLoadingState( + loadingState, + SoftTimeoutStatusMessage, + SoftTimeoutDetailsMessage, + trackedAttempt?.StartedAtUtc ?? startedAt); + request.LoadingDetailsWindow?.UpdateLoadingState(loadingState); + request.PublishCoordinatorStatus(!request.HostProcess.HasExited, false, false); + } + + 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( + request.SuccessTcs.Task, + request.ActivationFailedTcs.Task, + processExitTask, + Task.Delay(delay)).ConfigureAwait(false); + } + + var recoveryActivationAttempted = false; + if (!connected && !request.HostProcess.HasExited) + { + connected = await PublicIpcConnection.TryConnectAsync(request.IpcClient, TimeSpan.FromSeconds(3)).ConfigureAwait(false); + if (connected) + { + var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.") + .ConfigureAwait(false); + if (shellSuccess is not null) + { + request.AttemptRegistry.MarkOwnedSucceeded(shellSuccess.Stage, shellSuccess.Message); + request.PublishCoordinatorStatus(true, true, true); + return new Outcome( + true, + shellSuccess.Code, + shellSuccess.Message, + false, + request.ComposeLaunchDetails(true, false)); + } + } + } + + if (connected && !request.HostProcess.HasExited) + { + recoveryActivationAttempted = true; + var recoveryOutcome = await TryRecoverWithPublicActivationAsync( + request.IpcClient, + request.HostProcess, + request.SuccessTcs.Task, + request.SuccessTracker).ConfigureAwait(false); + if (recoveryOutcome is not null) + { + request.AttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message); + request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, true); + return new Outcome( + true, + recoveryOutcome.Code, + recoveryOutcome.Message, + true, + request.ComposeLaunchDetails(!request.HostProcess.HasExited, true)); + } + } + + if (connected && !request.HostProcess.HasExited) + { + request.AttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window."); + shellStatus = await TryGetPublicShellStatusAsync(request.IpcClient).ConfigureAwait(false); + if (request.SuccessTracker.TryResolve(shellStatus, out var finalShellSuccess)) + { + request.AttemptRegistry.MarkOwnedSucceeded(finalShellSuccess.Stage, finalShellSuccess.Message); + request.PublishCoordinatorStatus(true, true, true); + return new Outcome( + true, + finalShellSuccess.Code, + finalShellSuccess.Message, + recoveryActivationAttempted, + request.ComposeLaunchDetails(true, recoveryActivationAttempted)); + } + + request.PublishCoordinatorStatus(true, true, false); + return new Outcome( + false, + "shell_not_ready", + "Host public IPC is connected, but the desktop shell did not create or show the main window in time.", + recoveryActivationAttempted, + request.ComposeLaunchDetails(true, recoveryActivationAttempted)); + } + + if (!connected && !request.HostProcess.HasExited) + { + request.AttemptRegistry.MarkOwnedWaitingForShell("Host process is still running, but public IPC is not ready yet."); + request.PublishCoordinatorStatus(true, false, true); + return new Outcome( + true, + "startup_pending", + "Host process is still running; Launcher will not start another process while public IPC finishes startup.", + recoveryActivationAttempted, + request.ComposeLaunchDetails(true, recoveryActivationAttempted)); + } + + request.AttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); + request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, false); + return new Outcome( + false, + "desktop_not_visible", + $"Host process started, but it never reached the required startup state within {StartupTimeoutPolicy.HardTimeout.TotalSeconds:0} seconds.", + recoveryActivationAttempted, + request.ComposeLaunchDetails(!request.HostProcess.HasExited, recoveryActivationAttempted)); + } + + internal static async Task TryRecoverActivationThroughExistingHostAsync( + LanMountainDesktopIpcClient ipcClient, + StartupSuccessTracker startupSuccessTracker, + TimeSpan timeout) + { + var activation = await TryActivateExistingHostWithStatusAsync(ipcClient, timeout).ConfigureAwait(false); + if (activation is null) + { + return null; + } + + if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess)) + { + return shellSuccess; + } + + if (activation.Accepted) + { + return startupSuccessTracker.BuildRecoverySuccessState(); + } + + return HostActivationPolicy.IsRecoverableActivationFailure(activation) + ? new StartupSuccessState( + StartupStage.Ready, + "startup_pending", + activation.Message) + : null; + } + + internal static async Task TryGetPublicShellStatusAsync(LanMountainDesktopIpcClient ipcClient) + { + try + { + var shellProxy = ipcClient.CreateProxy(); + return await shellProxy.GetShellStatusAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to query public shell status: {ex.Message}"); + return null; + } + } + + private static async Task TryActivateExistingHostWithStatusAsync( + LanMountainDesktopIpcClient ipcClient, + TimeSpan timeout) + { + try + { + var connected = ipcClient.IsConnected || + await PublicIpcConnection.TryConnectAsync(ipcClient, timeout).ConfigureAwait(false); + if (!connected) + { + return null; + } + + var shellProxy = ipcClient.CreateProxy(); + return await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Info($"Existing host activation probe did not complete: {ex.Message}"); + return null; + } + } + + private static async Task TryRecoverWithPublicActivationAsync( + LanMountainDesktopIpcClient ipcClient, + Process hostProcess, + Task successTask, + StartupSuccessTracker startupSuccessTracker) + { + try + { + var shellProxy = ipcClient.CreateProxy(); + var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + StartupDiagnostics.TraceShellStatus("recovery_activation", activation.Status); + if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess)) + { + return shellSuccess; + } + + var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false); + if (completedTask == successTask) + { + return await successTask.ConfigureAwait(false); + } + + if (!hostProcess.HasExited && (activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation))) + { + return startupSuccessTracker.BuildRecoverySuccessState(); + } + } + catch (Exception ex) + { + Logger.Warn($"Public activation recovery failed: {ex.Message}"); + } + + return null; + } + + internal 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 MergeExitCodeDetails(Dictionary details, int exitCode) + { + details["exitCode"] = exitCode.ToString(); + return details; + } +} diff --git a/LanMountainDesktop.Launcher/Startup/PublicIpcConnection.cs b/LanMountainDesktop.Launcher/Startup/PublicIpcConnection.cs new file mode 100644 index 0000000..9c9a3aa --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/PublicIpcConnection.cs @@ -0,0 +1,57 @@ +using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Shared.IPC; + +namespace LanMountainDesktop.Launcher.Startup; + +internal static class PublicIpcConnection +{ + public static async Task TryConnectAsync( + LanMountainDesktopIpcClient ipcClient, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (ipcClient.IsConnected) + { + return true; + } + + try + { + var connectTask = ipcClient.ConnectAsync(); + var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false); + if (completedTask != connectTask) + { + return false; + } + + await connectTask.ConfigureAwait(false); + return ipcClient.IsConnected; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.Info($"Public IPC is not ready yet: {ex.Message}"); + return false; + } + } + + public static async Task TryConnectWithBackoffAsync( + LanMountainDesktopIpcClient ipcClient, + IReadOnlyList attemptTimeouts, + CancellationToken cancellationToken = default) + { + if (ipcClient.IsConnected) + { + return true; + } + + foreach (var timeout in attemptTimeouts) + { + if (await TryConnectAsync(ipcClient, timeout, cancellationToken).ConfigureAwait(false)) + { + return true; + } + } + + return ipcClient.IsConnected; + } +} diff --git a/LanMountainDesktop.Launcher/Startup/StartupDiagnostics.cs b/LanMountainDesktop.Launcher/Startup/StartupDiagnostics.cs new file mode 100644 index 0000000..1ce6fb4 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/StartupDiagnostics.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Startup; + +internal static class StartupDiagnostics +{ + private static readonly bool Enabled = + string.Equals( + Environment.GetEnvironmentVariable("LMD_LAUNCHER_STARTUP_DIAG"), + "1", + StringComparison.OrdinalIgnoreCase); + + public static bool IsEnabled => Enabled; + + public static void Trace(string eventName, IReadOnlyDictionary fields) + { + if (!Enabled) + { + return; + } + + var payload = new Dictionary(fields, StringComparer.OrdinalIgnoreCase) + { + ["event"] = eventName, + ["timestampUtc"] = DateTimeOffset.UtcNow.ToString("O") + }; + + Logger.Info($"[startup-diag] {eventName}: {string.Join("; ", payload.Select(static kv => $"{kv.Key}={kv.Value}"))}"); + + try + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + ".launcher", + "diag"); + Directory.CreateDirectory(directory); + var filePath = Path.Combine(directory, $"startup-{DateTime.UtcNow:yyyyMMdd}.jsonl"); + var line = JsonSerializer.Serialize(payload); + File.AppendAllText(filePath, line + Environment.NewLine); + } + catch (Exception ex) + { + Logger.Warn($"Failed to write startup diagnostic bundle: {ex.Message}"); + } + } + + public static void TraceShellStatus(string source, PublicShellStatus? status, StartupStage? stage = null) + { + if (!Enabled) + { + return; + } + + Trace( + "shell_status", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["source"] = source, + ["stage"] = stage?.ToString(), + ["processId"] = status?.ProcessId.ToString(), + ["publicIpcReady"] = status?.PublicIpcReady.ToString(), + ["desktopVisible"] = status?.DesktopVisible.ToString(), + ["mainWindowVisible"] = status?.MainWindowVisible.ToString(), + ["mainWindowOpened"] = status?.MainWindowOpened.ToString(), + ["shellState"] = status?.ShellState + }); + } +} diff --git a/LanMountainDesktop.Launcher/Startup/StartupSuccessTracker.cs b/LanMountainDesktop.Launcher/Startup/StartupSuccessTracker.cs new file mode 100644 index 0000000..9d4ef41 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/StartupSuccessTracker.cs @@ -0,0 +1,138 @@ +using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Startup; + +internal enum LaunchSuccessPolicy +{ + Foreground, + RestartBackground, + RestartTray +} + +internal sealed record StartupSuccessState( + StartupStage Stage, + string Code, + string Message); + +internal sealed class StartupSuccessTracker +{ + private readonly LaunchSuccessPolicy _policy; + private bool _trayReady; + private bool _backgroundReady; + + public string PolicyKey => _policy.ToString(); + + public StartupSuccessTracker(CommandContext context) + { + var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs); + var isRestartLaunch = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase); + + _policy = !isRestartLaunch + ? LaunchSuccessPolicy.Foreground + : restartPresentation switch + { + RestartPresentationMode.Tray => LaunchSuccessPolicy.RestartTray, + RestartPresentationMode.Minimized => LaunchSuccessPolicy.RestartBackground, + _ => LaunchSuccessPolicy.Foreground + }; + } + + public bool TryResolve(StartupStage stage, out StartupSuccessState successState) + { + switch (stage) + { + case StartupStage.ActivationRedirected: + successState = new StartupSuccessState( + stage, + "activation_redirected", + "Launcher activation was redirected to the existing desktop instance."); + return true; + + case StartupStage.DesktopVisible: + successState = new StartupSuccessState( + stage, + _policy == LaunchSuccessPolicy.Foreground ? "ok" : "desktop_visible_fallback", + _policy == LaunchSuccessPolicy.Foreground + ? "Desktop is visible and ready." + : "Desktop recovered in a visible state."); + return true; + + case StartupStage.Ready: + successState = new StartupSuccessState( + stage, + _policy == LaunchSuccessPolicy.Foreground ? "ready" : "background_ready", + "Desktop reported that startup is ready."); + return true; + + case StartupStage.TrayReady: + _trayReady = true; + break; + + case StartupStage.BackgroundReady: + _backgroundReady = true; + break; + } + + if (_policy == LaunchSuccessPolicy.RestartBackground && _backgroundReady) + { + successState = new StartupSuccessState( + StartupStage.BackgroundReady, + "background_ready", + "Desktop restart completed in the background."); + return true; + } + + if (_policy == LaunchSuccessPolicy.RestartTray && _trayReady && _backgroundReady) + { + successState = new StartupSuccessState( + StartupStage.BackgroundReady, + "background_ready", + "Desktop restart completed with tray recovery ready."); + return true; + } + + successState = default!; + return false; + } + + public bool TryResolve(PublicShellStatus? status, out StartupSuccessState successState) + { + if (status is not null && + (status.DesktopVisible || status.MainWindowVisible || status.MainWindowOpened)) + { + successState = new StartupSuccessState( + status.DesktopVisible || status.MainWindowVisible + ? StartupStage.DesktopVisible + : StartupStage.Ready, + _policy == LaunchSuccessPolicy.Foreground ? "ok" : "background_ready", + status.DesktopVisible || status.MainWindowVisible + ? "Desktop shell is visible and ready." + : "Desktop shell window has opened."); + return true; + } + + successState = default!; + return false; + } + + public StartupSuccessState BuildRecoverySuccessState() + { + return _policy switch + { + LaunchSuccessPolicy.RestartTray => new StartupSuccessState( + StartupStage.DesktopVisible, + "recovery_activation_requested", + "Launcher requested a visible recovery because the background restart never confirmed tray readiness."), + LaunchSuccessPolicy.RestartBackground => new StartupSuccessState( + StartupStage.DesktopVisible, + "recovery_activation_requested", + "Launcher requested a visible recovery because the background restart never confirmed readiness."), + _ => new StartupSuccessState( + StartupStage.DesktopVisible, + "recovery_activation_requested", + "Launcher requested a visible recovery from the running desktop instance.") + }; + } +} diff --git a/LanMountainDesktop.Launcher/Startup/StartupTimeoutPolicy.cs b/LanMountainDesktop.Launcher/Startup/StartupTimeoutPolicy.cs new file mode 100644 index 0000000..cad7844 --- /dev/null +++ b/LanMountainDesktop.Launcher/Startup/StartupTimeoutPolicy.cs @@ -0,0 +1,23 @@ +namespace LanMountainDesktop.Launcher.Startup; + +internal static class StartupTimeoutPolicy +{ + public static readonly TimeSpan SoftTimeout = TimeSpan.FromSeconds(30); + public static readonly TimeSpan HardTimeout = TimeSpan.FromSeconds(120); + + /// Initial Public IPC connect attempt (AOT cold start may be slower). + public static readonly TimeSpan InitialIpcConnectTimeout = TimeSpan.FromMilliseconds(1200); + + /// Subsequent reconnect attempts use increasing per-try timeouts. + public static readonly TimeSpan[] IpcReconnectAttemptTimeouts = + [ + TimeSpan.FromMilliseconds(800), + TimeSpan.FromMilliseconds(1500), + TimeSpan.FromMilliseconds(3000), + TimeSpan.FromMilliseconds(5000) + ]; + + public static readonly TimeSpan ExistingHostProbeTimeout = TimeSpan.FromMilliseconds(900); + public static readonly TimeSpan ShellStatusPollInterval = TimeSpan.FromSeconds(1); + public static readonly TimeSpan IpcReconnectInterval = TimeSpan.FromSeconds(2); +} diff --git a/LanMountainDesktop.Tests/HostActivationPolicyTests.cs b/LanMountainDesktop.Tests/HostActivationPolicyTests.cs new file mode 100644 index 0000000..38bb494 --- /dev/null +++ b/LanMountainDesktop.Tests/HostActivationPolicyTests.cs @@ -0,0 +1,46 @@ +using LanMountainDesktop.Launcher; +using LanMountainDesktop.Launcher.Startup; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class HostActivationPolicyTests +{ + [Theory] + [InlineData("launch", "normal", true)] + [InlineData("launch", "restart", false)] + [InlineData("apply-update", "normal", false)] + public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource( + string command, + string launchSource, + bool expected) + { + var context = CommandContext.FromArgs([command, "--launch-source", launchSource]); + Assert.Equal(expected, HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context)); + } + + [Fact] + public void IsRecoverableActivationFailure_AllowsStartupPendingWhenIpcReady() + { + var activation = new PublicShellActivationResult( + false, + "startup_pending", + "pending", + new PublicShellStatus( + ProcessId: 1, + StartedAtUtc: DateTimeOffset.UtcNow, + LaunchSource: "normal", + ShellState: "initializing", + MainWindowCreated: false, + MainWindowVisible: false, + MainWindowOpened: false, + DesktopVisible: false, + PublicIpcReady: true, + Tray: new PublicTrayStatus("Unavailable", false, false, false, false, 0), + Taskbar: new PublicTaskbarStatus(false, false, false, false, false, false))); + + Assert.True(HostActivationPolicy.IsRecoverableActivationFailure(activation)); + } +} diff --git a/LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs b/LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs index dd3df24..530b9a9 100644 --- a/LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs +++ b/LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs @@ -1,5 +1,5 @@ using LanMountainDesktop.Launcher; -using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Startup; using LanMountainDesktop.Models; using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.IPC.Abstractions.Services; @@ -22,7 +22,7 @@ public sealed class LauncherMultiInstancePolicyTests { var context = CommandContext.FromArgs(["launch"]); - Assert.True(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context)); + Assert.True(HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context)); } [Fact] @@ -33,16 +33,16 @@ public sealed class LauncherMultiInstancePolicyTests $"--{LauncherIpcConstants.LaunchSourceOptionName}=restart" ]); - Assert.False(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context)); + Assert.False(HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context)); } [Fact] public void ActivationExitCodes_AreClassifiedSeparatelyFromEarlyHostExit() { - Assert.True(LauncherFlowCoordinator.IsSuccessfulActivationExitCode(HostExitCodes.SecondaryActivationSucceeded)); - Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.SecondaryActivationFailed)); - Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.RestartLockNotAcquired)); - Assert.False(LauncherFlowCoordinator.IsFailedActivationExitCode(1)); + Assert.True(HostActivationPolicy.IsSuccessfulActivationExitCode(HostExitCodes.SecondaryActivationSucceeded)); + Assert.True(HostActivationPolicy.IsFailedActivationExitCode(HostExitCodes.SecondaryActivationFailed)); + Assert.True(HostActivationPolicy.IsFailedActivationExitCode(HostExitCodes.RestartLockNotAcquired)); + Assert.False(HostActivationPolicy.IsFailedActivationExitCode(1)); } [Fact] @@ -57,7 +57,7 @@ public sealed class LauncherMultiInstancePolicyTests mainWindowOpened: false, desktopVisible: false)); - Assert.True(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation)); + Assert.True(HostActivationPolicy.IsRecoverableActivationFailure(activation)); } [Fact] @@ -72,18 +72,18 @@ public sealed class LauncherMultiInstancePolicyTests mainWindowOpened: false, desktopVisible: false)); - Assert.False(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation)); + Assert.False(HostActivationPolicy.IsRecoverableActivationFailure(activation)); } [Fact] public void IsExistingHostReadyForLauncherDecision_RequiresPublicIpcReady() { - Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(null)); - Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus( + Assert.False(HostActivationPolicy.IsExistingHostReadyForLauncherDecision(null)); + Assert.False(HostActivationPolicy.IsExistingHostReadyForLauncherDecision(CreateShellStatus( publicIpcReady: false, mainWindowOpened: true, desktopVisible: true))); - Assert.True(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus( + Assert.True(HostActivationPolicy.IsExistingHostReadyForLauncherDecision(CreateShellStatus( publicIpcReady: true, mainWindowOpened: true, desktopVisible: true))); diff --git a/LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs b/LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs index 0239980..41338d5 100644 --- a/LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs +++ b/LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs @@ -7,11 +7,10 @@ public sealed class LauncherStartupTimeoutPolicyTests [Fact] public void LauncherStartupTimeouts_MatchSlowStartupContract() { - var source = ReadRepositoryFile("LanMountainDesktop.Launcher", "Services", "LauncherFlowCoordinator.cs"); + var source = ReadRepositoryFile("LanMountainDesktop.Launcher", "Startup", "StartupTimeoutPolicy.cs"); - Assert.Contains("StartupSoftTimeout = TimeSpan.FromSeconds(30)", source); - Assert.Contains("StartupHardTimeout = TimeSpan.FromSeconds(120)", source); - Assert.DoesNotContain("StartupHardTimeout = TimeSpan.FromSeconds(30)", source); + Assert.Contains("SoftTimeout = TimeSpan.FromSeconds(30)", source); + Assert.Contains("HardTimeout = TimeSpan.FromSeconds(120)", source); } private static string ReadRepositoryFile(params string[] pathParts) diff --git a/LanMountainDesktop.Tests/StartupSuccessTrackerTests.cs b/LanMountainDesktop.Tests/StartupSuccessTrackerTests.cs new file mode 100644 index 0000000..ea70f24 --- /dev/null +++ b/LanMountainDesktop.Tests/StartupSuccessTrackerTests.cs @@ -0,0 +1,56 @@ +using LanMountainDesktop.Launcher; +using LanMountainDesktop.Launcher.Startup; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class StartupSuccessTrackerTests +{ + [Fact] + public void TryResolve_DesktopVisibleStage_SucceedsForForegroundLaunch() + { + var tracker = new StartupSuccessTracker(CreateContext("normal")); + Assert.True(tracker.TryResolve(StartupStage.DesktopVisible, out var state)); + Assert.Equal("ok", state.Code); + } + + [Fact] + public void TryResolve_ShellStatusWithMainWindowOpened_Succeeds() + { + var tracker = new StartupSuccessTracker(CreateContext("normal")); + var status = new PublicShellStatus( + ProcessId: 1234, + StartedAtUtc: DateTimeOffset.UtcNow, + LaunchSource: "normal", + ShellState: "opened", + MainWindowCreated: true, + MainWindowVisible: false, + MainWindowOpened: true, + DesktopVisible: false, + PublicIpcReady: true, + Tray: new PublicTrayStatus("Unavailable", false, false, false, false, 0), + Taskbar: new PublicTaskbarStatus(false, true, false, false, false, true)); + + Assert.True(tracker.TryResolve(status, out var state)); + Assert.Equal(StartupStage.Ready, state.Stage); + } + + [Fact] + public void TryResolve_RestartTrayPolicy_RequiresTrayAndBackground() + { + var tracker = new StartupSuccessTracker(CreateContext("restart", "--restart-presentation", "tray")); + Assert.False(tracker.TryResolve(StartupStage.TrayReady, out _)); + Assert.True(tracker.TryResolve(StartupStage.BackgroundReady, out _)); + Assert.True(tracker.TryResolve(StartupStage.TrayReady, out var final)); + Assert.Equal("background_ready", final.Code); + } + + private static CommandContext CreateContext(string launchSource, params string[] extraArgs) + { + var args = new List { "launch", "--launch-source", launchSource }; + args.AddRange(extraArgs); + return CommandContext.FromArgs(args.ToArray()); + } +}