diff --git a/examples/ExampleHostedAdapter/Program.cs b/examples/ExampleHostedAdapter/Program.cs index 4254daec..e58f927c 100644 --- a/examples/ExampleHostedAdapter/Program.cs +++ b/examples/ExampleHostedAdapter/Program.cs @@ -16,6 +16,12 @@ builder.Configuration .AddJsonFile(Constants.AdapterSettingsFilePath, false, true); +// Parent PID. If specified, we will gracefully shut down if the parent process exits. +var pid = builder.Configuration.GetValue("AppStoreConnect:Adapter:Host:ParentPid"); +if (pid > 0) { + builder.Services.AddDependentProcessWatcher(pid); +} + // Host instance ID. var instanceId = builder.Configuration.GetValue("AppStoreConnect:Adapter:Host:InstanceId"); if (string.IsNullOrWhiteSpace(instanceId)) { diff --git a/examples/MinimalApiExample/Program.cs b/examples/MinimalApiExample/Program.cs index 26e749c7..40bb04cf 100644 --- a/examples/MinimalApiExample/Program.cs +++ b/examples/MinimalApiExample/Program.cs @@ -11,6 +11,12 @@ var builder = WebApplication.CreateBuilder(args); +// Parent PID. If specified, we will gracefully shut down if the parent process exits. +var pid = builder.Configuration.GetValue("AppStoreConnect:Adapter:Host:ParentPid"); +if (pid > 0) { + builder.Services.AddDependentProcessWatcher(pid); +} + builder.Services .AddLocalization() .AddProblemDetails(); diff --git a/src/DataCore.Adapter.AspNetCore.Common/Configuration/CommonAdapterConfigurationExtensions.cs b/src/DataCore.Adapter.AspNetCore.Common/Configuration/CommonAdapterConfigurationExtensions.cs index fd5be754..c22ab1c2 100644 --- a/src/DataCore.Adapter.AspNetCore.Common/Configuration/CommonAdapterConfigurationExtensions.cs +++ b/src/DataCore.Adapter.AspNetCore.Common/Configuration/CommonAdapterConfigurationExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Numerics; using System.Reflection; using DataCore.Adapter; @@ -63,7 +62,7 @@ public static IAdapterConfigurationBuilder AddDataCoreAdapterAspNetCoreServices( /// is . /// public static IAdapterConfigurationBuilder AddHostInfo( - this IAdapterConfigurationBuilder builder, + this IAdapterConfigurationBuilder builder, HostInfo hostInfo ) { if (builder == null) { @@ -310,7 +309,7 @@ public static IAdapterConfigurationBuilder AddHostInfo(this IAdapterConfiguratio .WithName(entryAssembly?.GetName()?.FullName) .WithVersion(entryAssembly?.GetInformationalVersion()) .WithVendor(sp.GetService() ?? entryAssembly?.GetCustomAttribute()?.CreateVendorInfo()); - + AddOperatingSystemHostInfoProperty(hostInfoBuilder); AddContainerHostInfoProperty(hostInfoBuilder); @@ -507,6 +506,70 @@ public static IAdapterConfigurationBuilder AddDefaultAspNetCoreServices(this IAd return builder; } + + /// + /// Registers an that will request a graceful application + /// shutdown when any of the specified processes exit. + /// + /// + /// The . + /// + /// + /// The PID of the first process to watch. + /// + /// + /// The PID of any additional processes to watch. + /// + /// + /// The . + /// + /// + /// is . + /// + /// + /// This method is intended to allow adapter host applications that are started by an + /// external application such as App Store Connect to exit if the external application + /// exits without stopping the adapter host. + /// + public static IServiceCollection AddDependentProcessWatcher(this IServiceCollection services, int pid, params int[] additionalPids) => AddDependentProcessWatcher(services, new[] { pid }.Concat(additionalPids)); + + + /// + /// Registers an that will request a graceful application + /// shutdown when any of the specified processes exit. + /// + /// + /// The . + /// + /// + /// The PIDs of the processes to watch. + /// + /// + /// The . + /// + /// + /// is . + /// + /// + /// is . + /// + /// + /// This method is intended to allow adapter host applications that are started by an + /// external application such as App Store Connect to exit if the external application + /// exits without stopping the adapter host. + /// + public static IServiceCollection AddDependentProcessWatcher(this IServiceCollection services, IEnumerable pids) { + if (services == null) { + throw new ArgumentNullException(nameof(services)); + } + if (pids == null) { + throw new ArgumentNullException(nameof(pids)); + } + + services.AddHostedService(sp => ActivatorUtilities.CreateInstance(sp, pids)); + return services; + } + } } diff --git a/src/DataCore.Adapter.AspNetCore.Common/Internal/DependentProcessWatcher.cs b/src/DataCore.Adapter.AspNetCore.Common/Internal/DependentProcessWatcher.cs new file mode 100644 index 00000000..befc8f48 --- /dev/null +++ b/src/DataCore.Adapter.AspNetCore.Common/Internal/DependentProcessWatcher.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace DataCore.Adapter.AspNetCore.Internal { + + /// + /// that watches a set of dependent processes and will request + /// that the application gracefully exits if any of the dependent processes exit. + /// + internal sealed partial class DependentProcessWatcher : BackgroundService { + + /// + /// The that is used to request graceful shutdown + /// if required. + /// + private readonly IHostApplicationLifetime _hostApplicationLifetime; + + /// + /// Logging. + /// + private readonly ILogger _logger; + + /// + /// The process IDs to watch. + /// + private readonly int[] _pids; + + + /// + /// Creates a new instance. + /// + /// + /// The process IDs to watch. Note that specifying a PID that does not exist will result + /// in immediate shutdown of the host application when is called. + /// + /// + /// The . + /// + /// + /// The . + /// + /// + /// is . + /// + /// + /// Specifying a PID that does not exist will result in immediate shutdown of the host + /// application when is called. + /// + public DependentProcessWatcher(IEnumerable pids, IHostApplicationLifetime hostApplicationLifetime, ILogger logger) { + _logger = logger; + _hostApplicationLifetime = hostApplicationLifetime; + _pids = pids?.ToArray() ?? throw new ArgumentNullException(nameof(pids)); + } + + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) { + foreach (var pid in _pids) { + var process = Process.GetProcessById(pid); + if (process == null) { + LogProcessNotFound(pid); + _hostApplicationLifetime.StopApplication(); + break; + } + + var name = process.ProcessName; + + LogWatchingProcess(pid, name); + + process.EnableRaisingEvents = true; + process.Exited += (sender, args) => { + if (!stoppingToken.IsCancellationRequested) { + LogProcessExited(pid, name); + _hostApplicationLifetime.StopApplication(); + } + }; + } + + return Task.CompletedTask; + } + + + [LoggerMessage(0, LogLevel.Information, "Watching dependent process '{name}' (PID: {pid}).")] + partial void LogWatchingProcess(int pid, string name); + + [LoggerMessage(1, LogLevel.Warning, "Dependent process '{name}' (PID: {pid}) has exited.")] + partial void LogProcessExited(int pid, string name); + + [LoggerMessage(2, LogLevel.Warning, "Dependent process {pid} does not exist or has already exited.")] + partial void LogProcessNotFound(int pid); + + } +} diff --git a/src/DataCore.Adapter.AspNetCore.Common/PublicAPI.Unshipped.txt b/src/DataCore.Adapter.AspNetCore.Common/PublicAPI.Unshipped.txt index aea6684b..30fa33f0 100644 --- a/src/DataCore.Adapter.AspNetCore.Common/PublicAPI.Unshipped.txt +++ b/src/DataCore.Adapter.AspNetCore.Common/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +static Microsoft.Extensions.DependencyInjection.CommonAdapterConfigurationExtensions.AddDependentProcessWatcher(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, int pid, params int[]! additionalPids) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.CommonAdapterConfigurationExtensions.AddDependentProcessWatcher(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Collections.Generic.IEnumerable! pids) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.CommonAdapterConfigurationExtensions.AddHostInfo(this DataCore.Adapter.DependencyInjection.IAdapterConfigurationBuilder! builder, System.Action! configure) -> DataCore.Adapter.DependencyInjection.IAdapterConfigurationBuilder! static Microsoft.Extensions.DependencyInjection.CommonAdapterConfigurationExtensions.AddHostInfo(this DataCore.Adapter.DependencyInjection.IAdapterConfigurationBuilder! builder, System.Action! configure) -> DataCore.Adapter.DependencyInjection.IAdapterConfigurationBuilder! static Microsoft.Extensions.DependencyInjection.CommonAdapterConfigurationExtensions.WithInstanceId(this DataCore.Adapter.Common.HostInfoBuilder! builder, string! instanceId) -> DataCore.Adapter.Common.HostInfoBuilder! diff --git a/src/DataCore.Adapter.Templates/src/aschostedadapter/Program.cs b/src/DataCore.Adapter.Templates/src/aschostedadapter/Program.cs index f259be00..85ea05f6 100644 --- a/src/DataCore.Adapter.Templates/src/aschostedadapter/Program.cs +++ b/src/DataCore.Adapter.Templates/src/aschostedadapter/Program.cs @@ -15,6 +15,12 @@ // Our adapter settings are stored in adaptersettings.json. builder.Configuration.AddJsonFile(Constants.AdapterSettingsFilePath, false, true); +// Parent PID. If specified, we will gracefully shut down if the parent process exits. +var pid = builder.Configuration.GetValue("AppStoreConnect:Adapter:Host:ParentPid"); +if (pid > 0) { + builder.Services.AddDependentProcessWatcher(pid); +} + // Host instance ID. var instanceId = builder.Configuration.GetValue("AppStoreConnect:Adapter:Host:InstanceId"); if (string.IsNullOrWhiteSpace(instanceId)) {