mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-08-19 00:33:24 +08:00
feat: implement AirAppRuntimeBridge for managing runtime lifecycle and add corresponding unit tests for launcher window and runtime orchestration
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
using System.Diagnostics;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
@@ -7,31 +6,47 @@ namespace LanMountainDesktop.Launcher.Shell;
|
||||
internal sealed class AirAppRuntimeBridge
|
||||
{
|
||||
private const int ConnectAttempts = 8;
|
||||
private const int AttachAttempts = 4;
|
||||
|
||||
private readonly string _appRoot;
|
||||
private readonly string? _dataRoot;
|
||||
private readonly IAirAppRuntimeBridgeBackend _backend;
|
||||
|
||||
public AirAppRuntimeBridge(string appRoot, string? dataRoot)
|
||||
: this(appRoot, dataRoot, new AirAppRuntimeBridgeBackend())
|
||||
{
|
||||
}
|
||||
|
||||
internal AirAppRuntimeBridge(
|
||||
string appRoot,
|
||||
string? dataRoot,
|
||||
IAirAppRuntimeBridgeBackend backend)
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
_dataRoot = dataRoot;
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
public async Task EnsureStartedAsync()
|
||||
public async Task<AirAppRuntimeAvailabilityResult> EnsureStartedAsync()
|
||||
{
|
||||
Logger.Info($"AIRAPP: Checking if AirApp Runtime is available. AppRoot='{_appRoot}'");
|
||||
|
||||
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||
var status = await TryGetStatusAsync().ConfigureAwait(false);
|
||||
if (status is not null)
|
||||
{
|
||||
Logger.Info("AIRAPP: AirApp Runtime is already available.");
|
||||
return;
|
||||
return new AirAppRuntimeAvailabilityResult(
|
||||
true,
|
||||
"already_available",
|
||||
"AirApp Runtime is already available.",
|
||||
status);
|
||||
}
|
||||
|
||||
Logger.Info("AIRAPP: Starting AirApp Runtime...");
|
||||
Process? process;
|
||||
int? processId;
|
||||
try
|
||||
{
|
||||
process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
|
||||
processId = _backend.Start(new AirAppRuntimeStartRequest(
|
||||
_appRoot,
|
||||
Environment.ProcessId,
|
||||
0,
|
||||
@@ -40,72 +55,160 @@ internal sealed class AirAppRuntimeBridge
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"AIRAPP: AirApp Runtime start request failed. AppRoot='{_appRoot}'; Error='{ex.Message}'");
|
||||
return;
|
||||
return new AirAppRuntimeAvailabilityResult(
|
||||
false,
|
||||
"start_failed",
|
||||
$"AirApp Runtime start request failed: {ex.Message}",
|
||||
null);
|
||||
}
|
||||
|
||||
Logger.Info($"AIRAPP: AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
|
||||
Logger.Info($"AIRAPP: AirApp Runtime start requested. Pid={processId ?? -1}; AppRoot='{_appRoot}'.");
|
||||
if (processId is null)
|
||||
{
|
||||
Logger.Warn("AIRAPP: AirApp Runtime process was not created; Host fallback remains available.");
|
||||
return new AirAppRuntimeAvailabilityResult(
|
||||
false,
|
||||
"process_not_created",
|
||||
"AirApp Runtime process was not created.",
|
||||
null);
|
||||
}
|
||||
|
||||
for (var attempt = 1; attempt <= ConnectAttempts; attempt++)
|
||||
{
|
||||
Logger.Info($"AIRAPP: Attempt {attempt}/{ConnectAttempts} - Checking IPC connection...");
|
||||
|
||||
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||
status = await TryGetStatusAsync().ConfigureAwait(false);
|
||||
if (status is not null)
|
||||
{
|
||||
Logger.Info("AIRAPP: AirApp Runtime IPC is ready.");
|
||||
return;
|
||||
return new AirAppRuntimeAvailabilityResult(
|
||||
true,
|
||||
"started",
|
||||
"AirApp Runtime IPC is ready.",
|
||||
status);
|
||||
}
|
||||
|
||||
var delayMs = 250 * attempt;
|
||||
Logger.Info($"AIRAPP: IPC not ready, waiting {delayMs}ms before retry...");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(delayMs)).ConfigureAwait(false);
|
||||
if (attempt < ConnectAttempts)
|
||||
{
|
||||
var delay = TimeSpan.FromMilliseconds(250 * attempt);
|
||||
Logger.Info($"AIRAPP: IPC not ready, waiting {delay.TotalMilliseconds:0}ms before retry...");
|
||||
await _backend.DelayAsync(delay).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Warn("AIRAPP: AirApp Runtime did not become ready after pre-start; Host fallback remains available.");
|
||||
return new AirAppRuntimeAvailabilityResult(
|
||||
false,
|
||||
"runtime_unavailable",
|
||||
"AirApp Runtime did not become ready after pre-start.",
|
||||
null);
|
||||
}
|
||||
|
||||
public async Task AttachHostAsync(int hostProcessId)
|
||||
public async Task<AirAppRuntimeHandoffResult> AttachHostAsync(int hostProcessId)
|
||||
{
|
||||
if (hostProcessId <= 0)
|
||||
{
|
||||
return;
|
||||
Logger.Warn($"AIRAPP: Cannot hand off runtime ownership because HostPid={hostProcessId} is invalid.");
|
||||
return new AirAppRuntimeHandoffResult(
|
||||
false,
|
||||
"invalid_host_pid",
|
||||
"Host process id must be positive.",
|
||||
hostProcessId,
|
||||
0,
|
||||
null);
|
||||
}
|
||||
|
||||
try
|
||||
AirAppRuntimeControlResult? lastControlResult = null;
|
||||
Exception? lastException = null;
|
||||
var attemptsCompleted = 0;
|
||||
|
||||
for (var attempt = 1; attempt <= AttachAttempts; attempt++)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
// Re-check availability before every attempt. If a pre-started runtime exits
|
||||
// between discovery and attach, this starts a replacement while Launcher is alive.
|
||||
var availability = await EnsureStartedAsync().ConfigureAwait(false);
|
||||
if (!availability.Available)
|
||||
{
|
||||
return new AirAppRuntimeHandoffResult(
|
||||
false,
|
||||
availability.Code,
|
||||
availability.Message,
|
||||
hostProcessId,
|
||||
attemptsCompleted,
|
||||
availability.Status);
|
||||
}
|
||||
|
||||
var connectTask = client.ConnectAsync(IpcConstants.AirAppRuntimePipeName);
|
||||
await connectTask.WaitAsync(TimeSpan.FromSeconds(3), cts.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
attemptsCompleted = attempt;
|
||||
lastControlResult = await _backend.AttachHostAsync(hostProcessId).ConfigureAwait(false);
|
||||
if (IsConfirmedHostAttach(lastControlResult, hostProcessId))
|
||||
{
|
||||
Logger.Info(
|
||||
$"AIRAPP: Runtime ownership handed off to Host. HostPid={hostProcessId}; " +
|
||||
$"RuntimePid={lastControlResult.Status.ProcessId}; Attempts={attemptsCompleted}.");
|
||||
return new AirAppRuntimeHandoffResult(
|
||||
true,
|
||||
"host_attached",
|
||||
"AirApp Runtime confirmed the live Host attachment.",
|
||||
hostProcessId,
|
||||
attemptsCompleted,
|
||||
lastControlResult.Status);
|
||||
}
|
||||
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
var attachTask = proxy.AttachHostAsync(hostProcessId);
|
||||
var result = await attachTask.WaitAsync(TimeSpan.FromSeconds(3), cts.Token).ConfigureAwait(false);
|
||||
Logger.Info($"AirApp Runtime host attach completed. Accepted={result.Accepted}; Code='{result.Code}'; HostPid={hostProcessId}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to attach Host to AirApp Runtime: {ex.Message}");
|
||||
Logger.Warn(
|
||||
$"AIRAPP: Runtime Host attach was not confirmed. Attempt={attempt}/{AttachAttempts}; " +
|
||||
$"Accepted={lastControlResult.Accepted}; Code='{lastControlResult.Code}'; " +
|
||||
$"ReturnedHostPid={lastControlResult.Status.HostProcessId}; " +
|
||||
$"HostAlive={lastControlResult.Status.HostProcessAlive}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attemptsCompleted = attempt;
|
||||
lastException = ex;
|
||||
Logger.Warn(
|
||||
$"AIRAPP: Runtime Host attach attempt failed. Attempt={attempt}/{AttachAttempts}; " +
|
||||
$"HostPid={hostProcessId}; Error='{ex.Message}'.");
|
||||
}
|
||||
|
||||
if (attempt < AttachAttempts)
|
||||
{
|
||||
await _backend.DelayAsync(TimeSpan.FromMilliseconds(250 * attempt)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var code = lastControlResult is null ? "host_attach_failed" : "host_attach_unconfirmed";
|
||||
var message = lastControlResult is null
|
||||
? $"AirApp Runtime Host attach failed: {lastException?.Message ?? "unknown error"}"
|
||||
: $"AirApp Runtime did not confirm Host attachment. LastCode='{lastControlResult.Code}'.";
|
||||
Logger.Warn(
|
||||
$"AIRAPP: Runtime ownership handoff failed; Host fallback remains available. " +
|
||||
$"HostPid={hostProcessId}; Attempts={attemptsCompleted}; Code='{code}'.");
|
||||
return new AirAppRuntimeHandoffResult(
|
||||
false,
|
||||
code,
|
||||
message,
|
||||
hostProcessId,
|
||||
attemptsCompleted,
|
||||
lastControlResult?.Status);
|
||||
}
|
||||
|
||||
private static async Task<AirAppRuntimeStatus?> TryGetStatusAsync()
|
||||
private static bool IsConfirmedHostAttach(AirAppRuntimeControlResult result, int hostProcessId)
|
||||
{
|
||||
return result.Accepted &&
|
||||
result.Status.HostProcessId == hostProcessId &&
|
||||
result.Status.HostProcessAlive;
|
||||
}
|
||||
|
||||
private async Task<AirAppRuntimeStatus?> TryGetStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
|
||||
var connectTask = client.ConnectAsync(IpcConstants.AirAppRuntimePipeName);
|
||||
await connectTask.WaitAsync(TimeSpan.FromSeconds(2), cts.Token).ConfigureAwait(false);
|
||||
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
var statusTask = proxy.GetStatusAsync();
|
||||
return await statusTask.WaitAsync(TimeSpan.FromSeconds(2), cts.Token).ConfigureAwait(false);
|
||||
return await _backend.GetStatusAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Logger.Info("AIRAPP: TryGetStatusAsync timed out (2s).");
|
||||
Logger.Info("AIRAPP: TryGetStatusAsync timed out.");
|
||||
return null;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -120,3 +223,65 @@ internal sealed class AirAppRuntimeBridge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AirAppRuntimeAvailabilityResult(
|
||||
bool Available,
|
||||
string Code,
|
||||
string Message,
|
||||
AirAppRuntimeStatus? Status);
|
||||
|
||||
internal sealed record AirAppRuntimeHandoffResult(
|
||||
bool Accepted,
|
||||
string Code,
|
||||
string Message,
|
||||
int HostProcessId,
|
||||
int Attempts,
|
||||
AirAppRuntimeStatus? Status);
|
||||
|
||||
internal interface IAirAppRuntimeBridgeBackend
|
||||
{
|
||||
int? Start(AirAppRuntimeStartRequest request);
|
||||
|
||||
Task<AirAppRuntimeStatus> GetStatusAsync();
|
||||
|
||||
Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId);
|
||||
|
||||
Task DelayAsync(TimeSpan delay);
|
||||
}
|
||||
|
||||
internal sealed class AirAppRuntimeBridgeBackend : IAirAppRuntimeBridgeBackend
|
||||
{
|
||||
public int? Start(AirAppRuntimeStartRequest request)
|
||||
{
|
||||
using var process = AirAppRuntimeProcessStarter.Start(request);
|
||||
return process?.Id;
|
||||
}
|
||||
|
||||
public async Task<AirAppRuntimeStatus> GetStatusAsync()
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName)
|
||||
.WaitAsync(TimeSpan.FromSeconds(2))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
return await proxy.GetStatusAsync()
|
||||
.WaitAsync(TimeSpan.FromSeconds(2))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId)
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName)
|
||||
.WaitAsync(TimeSpan.FromSeconds(3))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
return await proxy.AttachHostAsync(hostProcessId)
|
||||
.WaitAsync(TimeSpan.FromSeconds(3))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task DelayAsync(TimeSpan delay) => Task.Delay(delay);
|
||||
}
|
||||
|
||||
@@ -22,21 +22,35 @@ internal static class LaunchUiPresenter
|
||||
{
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync());
|
||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to dismiss splash window.", ex);
|
||||
await ForceHideAndCloseSplashAsync(splashWindow).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (loadingDetailsWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
loadingDetailsWindow.IsHitTestVisible = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
|
||||
{
|
||||
loadingDetailsWindow.Close();
|
||||
}
|
||||
loadingDetailsWindow.Hide();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to hide loading details window.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
loadingDetailsWindow.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -45,6 +59,39 @@ internal static class LaunchUiPresenter
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task ForceHideAndCloseSplashAsync(SplashWindow splashWindow)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
splashWindow.IsHitTestVisible = false;
|
||||
|
||||
try
|
||||
{
|
||||
splashWindow.Hide();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to force-hide splash window.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to force-close splash window.", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to dispatch forced splash cleanup.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
|
||||
@@ -116,17 +116,31 @@ internal static class LauncherGuiCoordinator
|
||||
}
|
||||
|
||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
|
||||
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||
var handoff = await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
|
||||
RecordAirAppRuntimeHandoff(result, handoff);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn(
|
||||
$"AIRAPP: Unexpected runtime ownership handoff failure; Host fallback remains available. " +
|
||||
$"HostPid={hostPid}; Error='{ex.Message}'.");
|
||||
result.Details["airAppRuntimeHandoffAccepted"] = bool.FalseString;
|
||||
result.Details["airAppRuntimeHandoffCode"] = "handoff_exception";
|
||||
result.Details["airAppRuntimeHandoffMessage"] = ex.Message;
|
||||
result.Details["airAppRuntimeHostPid"] = hostPid.ToString();
|
||||
result.Details["airAppRuntimeAttachAttempts"] = "0";
|
||||
}
|
||||
}
|
||||
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
Logger.Info("Launcher coordination is complete; shutting down without extending the Host process lifetime.");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
@@ -168,15 +182,16 @@ internal static class LauncherGuiCoordinator
|
||||
return fallbackHostPid;
|
||||
}
|
||||
|
||||
private static async Task WaitForHostProcessToExitAsync(int hostPid)
|
||||
private static void RecordAirAppRuntimeHandoff(
|
||||
LauncherResult result,
|
||||
AirAppRuntimeHandoffResult handoff)
|
||||
{
|
||||
Logger.Info($"Launcher entering host background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Launcher host background lifetime completed; host process is gone.");
|
||||
result.Details["airAppRuntimeHandoffAccepted"] = handoff.Accepted.ToString();
|
||||
result.Details["airAppRuntimeHandoffCode"] = handoff.Code;
|
||||
result.Details["airAppRuntimeHandoffMessage"] = handoff.Message;
|
||||
result.Details["airAppRuntimeHostPid"] = handoff.HostProcessId.ToString();
|
||||
result.Details["airAppRuntimeAttachAttempts"] = handoff.Attempts.ToString();
|
||||
result.Details["airAppRuntimeProcessId"] = handoff.Status?.ProcessId.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
|
||||
Reference in New Issue
Block a user