Skip to content

Commit 7f36552

Browse files
committed
Add new HostingAction that uses .NET Generic Host Hosted Service
1 parent 6a562ce commit 7f36552

8 files changed

+748
-4
lines changed

Directory.Packages.props

+8-2
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@
1414
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" />
1515
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.0.1" />
1616
<!-- Runtime dependencies -->
17-
<PackageVersion Include="Microsoft.Extensions.Configuration.CommandLine" Version="6.0.0" />
17+
<PackageVersion Include="Microsoft.Extensions.Configuration.CommandLine">
18+
<Version>6.0.0</Version>
19+
<Version Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">8.0.0</Version>
20+
</PackageVersion>
1821
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
19-
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="6.0.0" />
22+
<PackageVersion Include="Microsoft.Extensions.Hosting">
23+
<Version>6.0.0</Version>
24+
<Version Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">8.0.0</Version>
25+
</PackageVersion>
2026
<!-- external dependencies -->
2127
<PackageVersion Include="ApprovalTests" Version="7.0.0-beta.3" />
2228
<PackageVersion Include="BenchmarkDotNet" Version="0.13.1" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#if NET8_0_OR_GREATER
2+
using System.Collections.Generic;
3+
using Microsoft.Extensions.Configuration;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Hosting;
6+
7+
namespace System.CommandLine.Hosting;
8+
9+
public class HostApplicationBuilderAction() : HostingAction()
10+
{
11+
private new readonly Func<string[], HostApplicationBuilder>? _createHostBuilder;
12+
public new Action<HostApplicationBuilder>? ConfigureHost { get; set; }
13+
14+
protected override IHostBuilder CreateHostBuiderCore(string[] args)
15+
{
16+
var hostBuilder = _createHostBuilder?.Invoke(args) ??
17+
new HostApplicationBuilder(args);
18+
return new HostApplicationBuilderWrapper(hostBuilder);
19+
}
20+
21+
protected override void ConfigureHostBuilder(IHostBuilder hostBuilder)
22+
{
23+
base.ConfigureHostBuilder(hostBuilder);
24+
ConfigureHost?.Invoke(GetHostApplicationBuilder(hostBuilder));
25+
}
26+
27+
private static HostApplicationBuilder GetHostApplicationBuilder(
28+
IHostBuilder hostBuilder
29+
)
30+
{
31+
return (HostApplicationBuilder)hostBuilder
32+
.Properties[typeof(HostApplicationBuilder)];
33+
}
34+
35+
private class HostApplicationBuilderWrapper(
36+
HostApplicationBuilder hostApplicationBuilder
37+
) : IHostBuilder
38+
{
39+
private Action? _useServiceProviderFactoryAction;
40+
private object? _configureServiceProviderBuilderAction;
41+
42+
public HostBuilderContext Context { get; } = new(
43+
((IHostApplicationBuilder)hostApplicationBuilder).Properties
44+
)
45+
{
46+
Configuration = hostApplicationBuilder.Configuration,
47+
HostingEnvironment = hostApplicationBuilder.Environment,
48+
Properties =
49+
{ { typeof(HostApplicationBuilder), hostApplicationBuilder } }
50+
};
51+
52+
public IDictionary<object, object> Properties =>
53+
((IHostApplicationBuilder)hostApplicationBuilder).Properties;
54+
55+
public IHost Build()
56+
{
57+
_useServiceProviderFactoryAction?.Invoke();
58+
return hostApplicationBuilder.Build();
59+
}
60+
61+
public IHostBuilder ConfigureHostConfiguration(
62+
Action<IConfigurationBuilder> configureDelegate
63+
)
64+
{
65+
configureDelegate?.Invoke(hostApplicationBuilder.Configuration);
66+
return this;
67+
}
68+
69+
public IHostBuilder ConfigureAppConfiguration(
70+
Action<HostBuilderContext, IConfigurationBuilder> configureDelegate
71+
)
72+
{
73+
SynchronizeContext();
74+
configureDelegate?.Invoke(
75+
Context,
76+
hostApplicationBuilder.Configuration
77+
);
78+
SynchronizeContext();
79+
return this;
80+
}
81+
82+
public IHostBuilder ConfigureServices(
83+
Action<HostBuilderContext, IServiceCollection> configureDelegate
84+
)
85+
{
86+
SynchronizeContext();
87+
configureDelegate?.Invoke(Context, hostApplicationBuilder.Services);
88+
SynchronizeContext();
89+
return this;
90+
}
91+
92+
IHostBuilder IHostBuilder.UseServiceProviderFactory<TContainerBuilder>(
93+
IServiceProviderFactory<TContainerBuilder> factory
94+
)
95+
{
96+
_useServiceProviderFactoryAction = () =>
97+
{
98+
Action<TContainerBuilder>? configureDelegate = null;
99+
if (_configureServiceProviderBuilderAction is Action<HostBuilderContext, TContainerBuilder> configureDelegateWithContext)
100+
{
101+
configureDelegate = builder =>
102+
{
103+
SynchronizeContext();
104+
configureDelegateWithContext(Context, builder);
105+
SynchronizeContext();
106+
};
107+
}
108+
hostApplicationBuilder.ConfigureContainer(factory, configureDelegate);
109+
};
110+
return this;
111+
}
112+
113+
IHostBuilder IHostBuilder.UseServiceProviderFactory<TContainerBuilder>(
114+
Func<HostBuilderContext, IServiceProviderFactory<TContainerBuilder>> factory
115+
)
116+
{
117+
_useServiceProviderFactoryAction = () =>
118+
{
119+
Action<TContainerBuilder>? configureDelegate = null;
120+
if (_configureServiceProviderBuilderAction is Action<HostBuilderContext, TContainerBuilder> configureDelegateWithContext)
121+
{
122+
configureDelegate = builder =>
123+
{
124+
SynchronizeContext();
125+
configureDelegateWithContext(Context, builder);
126+
SynchronizeContext();
127+
};
128+
}
129+
var factoryInstance = factory(Context);
130+
hostApplicationBuilder.ConfigureContainer(factoryInstance, configureDelegate);
131+
};
132+
return this;
133+
}
134+
135+
IHostBuilder IHostBuilder.ConfigureContainer<TContainerBuilder>(
136+
Action<HostBuilderContext, TContainerBuilder> configureDelegate
137+
)
138+
{
139+
_configureServiceProviderBuilderAction = configureDelegate;
140+
return this;
141+
}
142+
143+
private void SynchronizeContext()
144+
{
145+
Context.Configuration = hostApplicationBuilder.Configuration;
146+
Context.HostingEnvironment = hostApplicationBuilder.Environment;
147+
}
148+
}
149+
}
150+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.Hosting;
6+
7+
using System.CommandLine.Parsing;
8+
9+
namespace System.CommandLine.Hosting;
10+
11+
public class HostConfigurationDirective() : Directive(Name)
12+
{
13+
public new const string Name = "config";
14+
15+
internal static void ConfigureHostBuilder(IHostBuilder hostBuilder)
16+
{
17+
var parseResult = hostBuilder.GetParseResult();
18+
if (parseResult.Configuration.RootCommand is RootCommand rootCommand &&
19+
rootCommand.Directives.FirstOrDefault(IsConfigDirective)
20+
is Directive configDirective &&
21+
parseResult.GetResult(configDirective)
22+
is DirectiveResult configResult
23+
)
24+
{
25+
var configKvps = configResult.Values.Select(GetKeyValuePair)
26+
.ToList();
27+
hostBuilder.ConfigureHostConfiguration(
28+
(config) => config.AddInMemoryCollection(configKvps)
29+
);
30+
}
31+
32+
static bool IsConfigDirective(Directive directive) =>
33+
string.Equals(directive.Name, Name, StringComparison.OrdinalIgnoreCase);
34+
35+
[Diagnostics.CodeAnalysis.SuppressMessage(
36+
"Style",
37+
"IDE0057: Use range operator",
38+
Justification = ".NET Standard 2.0"
39+
)]
40+
static KeyValuePair<string, string?> GetKeyValuePair(string configDirective)
41+
{
42+
ReadOnlySpan<char> kvpSpan = configDirective.AsSpan();
43+
int eqlIdx = kvpSpan.IndexOf('=');
44+
string key;
45+
string? value = default;
46+
if (eqlIdx < 0)
47+
key = kvpSpan.Trim().ToString();
48+
else
49+
{
50+
key = kvpSpan.Slice(0, eqlIdx).Trim().ToString();
51+
value = kvpSpan.Slice(eqlIdx + 1).Trim().ToString();
52+
}
53+
return new KeyValuePair<string, string?>(key, value);
54+
}
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.Linq;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Hosting;
7+
8+
using System.CommandLine.Invocation;
9+
10+
namespace System.CommandLine.Hosting;
11+
12+
public class HostingAction() : AsynchronousCommandLineAction()
13+
{
14+
protected readonly Func<string[], IHostBuilder>? _createHostBuilder;
15+
internal Action<IHostBuilder>? ConfigureHost { get; set; }
16+
public Action<HostBuilderContext, IServiceCollection>? ConfigureServices { get; set; }
17+
18+
protected virtual IHostBuilder CreateHostBuiderCore(string[] args)
19+
{
20+
var hostBuilder = _createHostBuilder?.Invoke(args) ??
21+
new HostBuilder();
22+
return hostBuilder;
23+
}
24+
25+
protected virtual void ConfigureHostBuilder(IHostBuilder hostBuilder)
26+
{
27+
ConfigureHost?.Invoke(hostBuilder);
28+
if (ConfigureServices is not null)
29+
{
30+
hostBuilder.ConfigureServices(ConfigureServices);
31+
}
32+
}
33+
34+
public override async Task<int> InvokeAsync(
35+
ParseResult parseResult,
36+
CancellationToken cancellationToken = default
37+
)
38+
{
39+
#if NET6_0_OR_GREATER
40+
ArgumentNullException.ThrowIfNull(parseResult);
41+
#else
42+
_ = parseResult ?? throw new ArgumentNullException(nameof(parseResult));
43+
#endif
44+
45+
string[] unmatchedTokens = parseResult.UnmatchedTokens?.ToArray() ?? [];
46+
IHostBuilder hostBuilder = CreateHostBuiderCore(unmatchedTokens);
47+
hostBuilder.Properties[typeof(ParseResult)] = parseResult;
48+
49+
// As long as done before first await
50+
// ProcessTerminationTimeout can be set to null
51+
// so that .NET Generic Host can control console lifetime instead.
52+
parseResult.Configuration.ProcessTerminationTimeout = null;
53+
hostBuilder.UseConsoleLifetime();
54+
55+
hostBuilder.ConfigureServices(static (context, services) =>
56+
{
57+
var parseResult = context.GetParseResult();
58+
var hostingAction = parseResult.GetHostingAction();
59+
services.AddSingleton(parseResult);
60+
services.AddSingleton(parseResult.Configuration);
61+
services.AddHostedService<HostingActionService>();
62+
// TODO: add IHostingActionInvocation singleton
63+
});
64+
65+
ConfigureHostBuilder(hostBuilder);
66+
67+
using var host = hostBuilder.Build();
68+
await host.StartAsync(cancellationToken)
69+
.ConfigureAwait(continueOnCapturedContext: false);
70+
71+
var appRunningTask = host.WaitForShutdownAsync(cancellationToken);
72+
73+
// TODO: Retrieve ExecuteTask from HostingActionService to get result
74+
Task<int> invocationTask = Task.FromResult(0);
75+
76+
await appRunningTask.ConfigureAwait(continueOnCapturedContext: false);
77+
78+
return await invocationTask
79+
.ConfigureAwait(continueOnCapturedContext: false);
80+
}
81+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using System.Threading;
2+
using System.Threading.Tasks;
3+
using Microsoft.Extensions.Hosting;
4+
5+
namespace System.CommandLine.Hosting;
6+
7+
internal class HostingActionService(
8+
IHostApplicationLifetime lifetime,
9+
IHostingActionInvocation invocation
10+
) : BackgroundService()
11+
{
12+
public new Task<int>? ExecuteTask => base.ExecuteTask as Task<int>;
13+
14+
protected override Task ExecuteAsync(CancellationToken stoppingToken)
15+
{
16+
return WaitForStartAndInvokeAsync(stoppingToken);
17+
}
18+
19+
private async Task WaitForApplicationStarted(CancellationToken cancelToken)
20+
{
21+
TaskCompletionSource<object?> appStarted = new();
22+
using var startedReg = lifetime.ApplicationStarted
23+
.Register(SetTaskComplete, appStarted);
24+
using var preStartCancelReg = cancelToken
25+
.Register(SetTaskCanceled, appStarted);
26+
27+
await appStarted.Task
28+
.ConfigureAwait(continueOnCapturedContext: false);
29+
30+
static void SetTaskComplete(object? state)
31+
{
32+
var tcs = (TaskCompletionSource<object?>)state!;
33+
tcs.TrySetResult(default);
34+
}
35+
36+
static void SetTaskCanceled(object? state)
37+
{
38+
var tcs = (TaskCompletionSource<object?>)state!;
39+
tcs.TrySetCanceled(
40+
CancellationToken.None
41+
);
42+
}
43+
}
44+
45+
private async Task<int> WaitForStartAndInvokeAsync(CancellationToken cancelToken)
46+
{
47+
await WaitForApplicationStarted(cancelToken)
48+
.ConfigureAwait(continueOnCapturedContext: false);
49+
try
50+
{
51+
int result = await invocation.InvokeAsync(cancelToken)
52+
.ConfigureAwait(continueOnCapturedContext: false);
53+
return result;
54+
}
55+
finally
56+
{
57+
// If the application is not already shut down or shutting down,
58+
// make sure that application is shutting down now.
59+
if (!lifetime.ApplicationStopping.IsCancellationRequested)
60+
{
61+
lifetime.StopApplication();
62+
}
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)