mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00: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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user