| File: TerminalHostForwarder.cs | Web Access |
| Project: src\src\Aspire.Managed\Aspire.Managed.csproj (aspire-managed) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Globalization; using System.Net.Sockets; using System.Runtime.InteropServices; using Aspire.Hosting; using Aspire.Shared; using StreamJsonRpc; internal static partial class TerminalHostForwarder { // kill(2) takes the native SIGTERM number (15 on Linux/macOS), not the // symbolic value of the .NET PosixSignal.SIGTERM enum. private const int SigTerm = 15; // Match ParentProcessWatchdog's conventional "terminated by timeout" exit code. private const int ShutdownTimeoutExitCode = 124; internal static ProcessStartInfo CreateStartInfo(string managedDirectory, string[] args) { var directory = Path.GetFullPath(Path.Combine(managedDirectory, "..", BundleDiscovery.TerminalHostDirectoryName)); var startInfo = new ProcessStartInfo(Path.Combine(directory, BundleDiscovery.GetExecutableFileName(BundleDiscovery.TerminalHostExecutableName))) { WorkingDirectory = directory, UseShellExecute = false }; foreach (var argument in args) { startInfo.ArgumentList.Add(argument); } // The shim watches the original AppHost; its child watches the shim so even an // uncatchable termination of the forwarding process cannot orphan the native relay. startInfo.Environment[KnownConfigNames.TerminalHostParentProcessId] = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); startInfo.Environment[KnownConfigNames.TerminalHostParentProcessStartedStable] = ProcessStartTimeHelper.GetCurrentProcessStartTimeUnixMilliseconds().ToString(CultureInfo.InvariantCulture); return startInfo; } public static async Task<int> RunAsync(string[] args) { var startInfo = CreateStartInfo(AppContext.BaseDirectory, args); if (!File.Exists(startInfo.FileName)) { Console.Error.WriteLine($"Terminal host executable was not found at '{startInfo.FileName}'. Reinstall or rebuild the Aspire bundle."); return 1; } using var shutdownCts = new CancellationTokenSource(); var watchdog = ParentProcessWatchdog.Start(shutdownCts, KnownConfigNames.TerminalHostParentProcessId, KnownConfigNames.TerminalHostParentProcessStartedStable, legacyStartVariable: null); using var process = new Process { StartInfo = startInfo }; try { process.Start(); using var sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, HandleSignal); using var sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, HandleSignal); using var sigQuit = OperatingSystem.IsWindows() ? PosixSignalRegistration.Create(PosixSignal.SIGQUIT, HandleSignal) : null; try { await process.WaitForExitAsync(shutdownCts.Token).ConfigureAwait(false); return process.ExitCode; } catch (OperationCanceledException) when (shutdownCts.IsCancellationRequested) { if (!process.HasExited) { if (OperatingSystem.IsWindows()) { // TerminateProcess cannot run the child's socket cleanup. Ask its // control RPC to stop first, retaining the bounded kill fallback. using var shutdownTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); try { await RequestShutdownAsync(args, shutdownTimeout.Token).ConfigureAwait(false); } catch (Exception ex) when (ex is SocketException or IOException or ArgumentException or OperationCanceledException or ConnectionLostException) { Console.Error.WriteLine($"Failed to request terminal host shutdown: {ex.Message}"); if (!process.HasExited) { process.Kill(entireProcessTree: true); } } } else if (SendSignal(process.Id, SigTerm) != 0 && !process.HasExited) { throw new System.ComponentModel.Win32Exception(Marshal.GetLastPInvokeError()); } } try { await process.WaitForExitAsync().WaitAsync(ParentProcessWatchdog.ForceExitGracePeriod).ConfigureAwait(false); } catch (TimeoutException) { Console.Error.WriteLine("Terminal host did not stop within the shutdown grace period."); process.Kill(entireProcessTree: true); await process.WaitForExitAsync().ConfigureAwait(false); return ShutdownTimeoutExitCode; } return process.ExitCode; } void HandleSignal(PosixSignalContext context) { context.Cancel = !shutdownCts.IsCancellationRequested; shutdownCts.Cancel(); } } finally { if (watchdog is not null) { await watchdog.DisposeAsync().ConfigureAwait(false); } } } internal static async Task RequestShutdownAsync(string[] args, CancellationToken cancellationToken) { // TerminalHost receives the control socket as two arguments: --control-uds <path>. var controlPathIndex = Array.IndexOf(args, "--control-uds"); if (controlPathIndex < 0 || controlPathIndex + 1 >= args.Length) { throw new ArgumentException("A terminal host control socket path is required for graceful shutdown.", nameof(args)); } using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); await socket.ConnectAsync(new UnixDomainSocketEndPoint(args[controlPathIndex + 1]), cancellationToken).ConfigureAwait(false); using var stream = new NetworkStream(socket); using var rpc = new JsonRpc(stream); rpc.StartListening(); await rpc.NotifyAsync("shutdown").WaitAsync(cancellationToken).ConfigureAwait(false); } [LibraryImport("libc", EntryPoint = "kill", SetLastError = true)] private static partial int SendSignal(int processId, int signal); }