mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
fix(launcher): extract startup subsystem and harden IPC detection
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<PublicShellStatus?> 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<IPublicShellControlService>();
|
||||||
|
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<ExistingHostBehaviorResult> ApplyExistingHostBehaviorAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
MultiInstanceLaunchBehavior behavior,
|
||||||
|
PublicShellStatus status)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
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<ExistingHostBehaviorResult> 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<ExistingHostBehaviorResult> 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<ExistingHostBehaviorResult> 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<PublicShellActivationResult?> 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<IPublicShellControlService>();
|
||||||
|
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<StartupSuccessState?> 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<PublicShellStatus?> TryGetPublicShellStatusAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
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<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
Process hostProcess,
|
||||||
|
Task<StartupSuccessState> successTask,
|
||||||
|
StartupSuccessTracker startupSuccessTracker)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
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<string, string> BuildAttemptDetails(
|
||||||
|
StartupAttemptRecord? trackedAttempt,
|
||||||
|
bool attachedToExistingAttempt,
|
||||||
|
bool ipcConnected,
|
||||||
|
bool hostProcessAlive,
|
||||||
|
StartupStage lastStage,
|
||||||
|
string lastStageMessage,
|
||||||
|
string? activationFailureReason,
|
||||||
|
bool softTimeoutShown,
|
||||||
|
bool recoveryActivationAttempted)
|
||||||
|
{
|
||||||
|
var details = new Dictionary<string, string>(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<string, string> 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<string, string> details) =>
|
||||||
|
new(result, process, null, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HostLaunchOutcome> 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<HostLaunchOutcome> 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<HostLaunchOutcome> 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() ?? "<none>"}'.");
|
||||||
|
|
||||||
|
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<HostStartAttempt> 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 ?? "<none>"}'; 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<string, string> BuildResolutionDetails(
|
||||||
|
HostResolutionResult resolution,
|
||||||
|
HostStartAttempt? firstAttempt,
|
||||||
|
HostStartAttempt? secondAttempt,
|
||||||
|
string? failureStage)
|
||||||
|
{
|
||||||
|
var details = new Dictionary<string, string>(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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MigrationResult> 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<MultiInstancePromptResult> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
50
LanMountainDesktop.Launcher/Startup/HostActivationPolicy.cs
Normal file
50
LanMountainDesktop.Launcher/Startup/HostActivationPolicy.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
511
LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs
Normal file
511
LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs
Normal file
@@ -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<string, string> LaunchDetails,
|
||||||
|
TaskCompletionSource<StartupSuccessState> SuccessTcs,
|
||||||
|
TaskCompletionSource<string> ActivationFailedTcs,
|
||||||
|
ISplashStageReporter Reporter,
|
||||||
|
LoadingDetailsWindow? LoadingDetailsWindow,
|
||||||
|
LoadingStateMessage LoadingState,
|
||||||
|
StartupStage LastStage,
|
||||||
|
string LastStageMessage,
|
||||||
|
bool IpcConnected,
|
||||||
|
string ActivationFailureReason,
|
||||||
|
bool SoftTimeoutShown,
|
||||||
|
Action<bool?, bool, bool> PublishCoordinatorStatus,
|
||||||
|
Func<bool, bool, bool, Dictionary<string, string>> ComposeLaunchDetails);
|
||||||
|
|
||||||
|
internal sealed record Outcome(
|
||||||
|
bool Success,
|
||||||
|
string Code,
|
||||||
|
string Message,
|
||||||
|
bool RecoveryActivationAttempted,
|
||||||
|
Dictionary<string, string> Details);
|
||||||
|
|
||||||
|
public async Task<Outcome> 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<StartupSuccessState?> 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<StartupSuccessState?> 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<PublicShellStatus?> TryGetPublicShellStatusAsync(LanMountainDesktopIpcClient ipcClient)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
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<PublicShellActivationResult?> 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<IPublicShellControlService>();
|
||||||
|
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<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
Process hostProcess,
|
||||||
|
Task<StartupSuccessState> successTask,
|
||||||
|
StartupSuccessTracker startupSuccessTracker)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||||
|
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<string, string> MergeExitCodeDetails(Dictionary<string, string> details, int exitCode)
|
||||||
|
{
|
||||||
|
details["exitCode"] = exitCode.ToString();
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
LanMountainDesktop.Launcher/Startup/PublicIpcConnection.cs
Normal file
57
LanMountainDesktop.Launcher/Startup/PublicIpcConnection.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Startup;
|
||||||
|
|
||||||
|
internal static class PublicIpcConnection
|
||||||
|
{
|
||||||
|
public static async Task<bool> 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<bool> TryConnectWithBackoffAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
IReadOnlyList<TimeSpan> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
LanMountainDesktop.Launcher/Startup/StartupDiagnostics.cs
Normal file
72
LanMountainDesktop.Launcher/Startup/StartupDiagnostics.cs
Normal file
@@ -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<string, string?> fields)
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, string?>(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<string, string?>(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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
138
LanMountainDesktop.Launcher/Startup/StartupSuccessTracker.cs
Normal file
138
LanMountainDesktop.Launcher/Startup/StartupSuccessTracker.cs
Normal file
@@ -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.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
23
LanMountainDesktop.Launcher/Startup/StartupTimeoutPolicy.cs
Normal file
23
LanMountainDesktop.Launcher/Startup/StartupTimeoutPolicy.cs
Normal file
@@ -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);
|
||||||
|
|
||||||
|
/// <summary>Initial Public IPC connect attempt (AOT cold start may be slower).</summary>
|
||||||
|
public static readonly TimeSpan InitialIpcConnectTimeout = TimeSpan.FromMilliseconds(1200);
|
||||||
|
|
||||||
|
/// <summary>Subsequent reconnect attempts use increasing per-try timeouts.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
46
LanMountainDesktop.Tests/HostActivationPolicyTests.cs
Normal file
46
LanMountainDesktop.Tests/HostActivationPolicyTests.cs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using LanMountainDesktop.Launcher;
|
using LanMountainDesktop.Launcher;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Startup;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
@@ -22,7 +22,7 @@ public sealed class LauncherMultiInstancePolicyTests
|
|||||||
{
|
{
|
||||||
var context = CommandContext.FromArgs(["launch"]);
|
var context = CommandContext.FromArgs(["launch"]);
|
||||||
|
|
||||||
Assert.True(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context));
|
Assert.True(HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -33,16 +33,16 @@ public sealed class LauncherMultiInstancePolicyTests
|
|||||||
$"--{LauncherIpcConstants.LaunchSourceOptionName}=restart"
|
$"--{LauncherIpcConstants.LaunchSourceOptionName}=restart"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.False(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context));
|
Assert.False(HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ActivationExitCodes_AreClassifiedSeparatelyFromEarlyHostExit()
|
public void ActivationExitCodes_AreClassifiedSeparatelyFromEarlyHostExit()
|
||||||
{
|
{
|
||||||
Assert.True(LauncherFlowCoordinator.IsSuccessfulActivationExitCode(HostExitCodes.SecondaryActivationSucceeded));
|
Assert.True(HostActivationPolicy.IsSuccessfulActivationExitCode(HostExitCodes.SecondaryActivationSucceeded));
|
||||||
Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.SecondaryActivationFailed));
|
Assert.True(HostActivationPolicy.IsFailedActivationExitCode(HostExitCodes.SecondaryActivationFailed));
|
||||||
Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.RestartLockNotAcquired));
|
Assert.True(HostActivationPolicy.IsFailedActivationExitCode(HostExitCodes.RestartLockNotAcquired));
|
||||||
Assert.False(LauncherFlowCoordinator.IsFailedActivationExitCode(1));
|
Assert.False(HostActivationPolicy.IsFailedActivationExitCode(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -57,7 +57,7 @@ public sealed class LauncherMultiInstancePolicyTests
|
|||||||
mainWindowOpened: false,
|
mainWindowOpened: false,
|
||||||
desktopVisible: false));
|
desktopVisible: false));
|
||||||
|
|
||||||
Assert.True(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation));
|
Assert.True(HostActivationPolicy.IsRecoverableActivationFailure(activation));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -72,18 +72,18 @@ public sealed class LauncherMultiInstancePolicyTests
|
|||||||
mainWindowOpened: false,
|
mainWindowOpened: false,
|
||||||
desktopVisible: false));
|
desktopVisible: false));
|
||||||
|
|
||||||
Assert.False(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation));
|
Assert.False(HostActivationPolicy.IsRecoverableActivationFailure(activation));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsExistingHostReadyForLauncherDecision_RequiresPublicIpcReady()
|
public void IsExistingHostReadyForLauncherDecision_RequiresPublicIpcReady()
|
||||||
{
|
{
|
||||||
Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(null));
|
Assert.False(HostActivationPolicy.IsExistingHostReadyForLauncherDecision(null));
|
||||||
Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
|
Assert.False(HostActivationPolicy.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
|
||||||
publicIpcReady: false,
|
publicIpcReady: false,
|
||||||
mainWindowOpened: true,
|
mainWindowOpened: true,
|
||||||
desktopVisible: true)));
|
desktopVisible: true)));
|
||||||
Assert.True(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
|
Assert.True(HostActivationPolicy.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
|
||||||
publicIpcReady: true,
|
publicIpcReady: true,
|
||||||
mainWindowOpened: true,
|
mainWindowOpened: true,
|
||||||
desktopVisible: true)));
|
desktopVisible: true)));
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ public sealed class LauncherStartupTimeoutPolicyTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void LauncherStartupTimeouts_MatchSlowStartupContract()
|
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("SoftTimeout = TimeSpan.FromSeconds(30)", source);
|
||||||
Assert.Contains("StartupHardTimeout = TimeSpan.FromSeconds(120)", source);
|
Assert.Contains("HardTimeout = TimeSpan.FromSeconds(120)", source);
|
||||||
Assert.DoesNotContain("StartupHardTimeout = TimeSpan.FromSeconds(30)", source);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ReadRepositoryFile(params string[] pathParts)
|
private static string ReadRepositoryFile(params string[] pathParts)
|
||||||
|
|||||||
56
LanMountainDesktop.Tests/StartupSuccessTrackerTests.cs
Normal file
56
LanMountainDesktop.Tests/StartupSuccessTrackerTests.cs
Normal file
@@ -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<string> { "launch", "--launch-source", launchSource };
|
||||||
|
args.AddRange(extraArgs);
|
||||||
|
return CommandContext.FromArgs(args.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user