Skip to content

Commit a45177d

Browse files
TheConstructornatemcmaster
authored andcommitted
Add IUnhandledExceptionHandler and a default implementation in Hosting.CLI (#192)
1 parent 8647c18 commit a45177d

File tree

6 files changed

+149
-10
lines changed

6 files changed

+149
-10
lines changed

src/CommandLineUtils/Conventions/ExecuteMethodConvention.cs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Reflection;
6+
using System.Runtime.ExceptionServices;
67
using System.Threading.Tasks;
78

89
namespace McMaster.Extensions.CommandLineUtils.Conventions
@@ -71,22 +72,37 @@ private async Task<int> OnExecute(ConventionContext context)
7172

7273
private async Task<int> InvokeAsync(MethodInfo method, object instance, object[] arguments)
7374
{
74-
var result = (Task)method.Invoke(instance, arguments);
75-
if (result is Task<int> intResult)
75+
try
76+
{
77+
var result = (Task) method.Invoke(instance, arguments);
78+
if (result is Task<int> intResult)
79+
{
80+
return await intResult;
81+
}
82+
83+
await result;
84+
}
85+
catch (TargetInvocationException e)
7686
{
77-
return await intResult;
87+
ExceptionDispatchInfo.Capture(e.InnerException).Throw();
7888
}
7989

80-
await result;
8190
return 0;
8291
}
8392

8493
private int Invoke(MethodInfo method, object instance, object[] arguments)
8594
{
86-
var result = method.Invoke(instance, arguments);
87-
if (method.ReturnType == typeof(int))
95+
try
96+
{
97+
var result = method.Invoke(instance, arguments);
98+
if (method.ReturnType == typeof(int))
99+
{
100+
return (int) result;
101+
}
102+
}
103+
catch (TargetInvocationException e)
88104
{
89-
return (int)result;
105+
ExceptionDispatchInfo.Capture(e.InnerException).Throw();
90106
}
91107

92108
return 0;

src/Hosting.CommandLine/HostBuilderExtensions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// Copyright (c) Nate McMaster.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
using System.Runtime.ExceptionServices;
45
using System.Threading;
56
using System.Threading.Tasks;
67
using McMaster.Extensions.CommandLineUtils;
78
using McMaster.Extensions.CommandLineUtils.Abstractions;
9+
using McMaster.Extensions.Hosting.CommandLine;
810
using McMaster.Extensions.Hosting.CommandLine.Internal;
911
using Microsoft.Extensions.DependencyInjection;
1012
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -31,11 +33,14 @@ public static async Task<int> RunCommandLineApplicationAsync<TApp>(
3133
this IHostBuilder hostBuilder, string[] args, CancellationToken cancellationToken = default)
3234
where TApp : class
3335
{
36+
var exceptionHandler = new StoreExceptionHandler();
3437
var state = new CommandLineState(args);
3538
hostBuilder.ConfigureServices(
3639
(context, services)
3740
=>
3841
{
42+
services
43+
.TryAddSingleton<IUnhandledExceptionHandler>(exceptionHandler);
3944
services
4045
.AddSingleton<IHostLifetime, CommandLineLifetime>()
4146
.TryAddSingleton(PhysicalConsole.Singleton);
@@ -53,6 +58,11 @@ public static async Task<int> RunCommandLineApplicationAsync<TApp>(
5358
{
5459
await host.RunAsync(cancellationToken);
5560

61+
if (exceptionHandler.StoredException != null)
62+
{
63+
ExceptionDispatchInfo.Capture(exceptionHandler.StoredException).Throw();
64+
}
65+
5666
return state.ExitCode;
5767
}
5868
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
using McMaster.Extensions.CommandLineUtils;
3+
using McMaster.Extensions.Hosting.CommandLine.Internal;
4+
5+
namespace McMaster.Extensions.Hosting.CommandLine
6+
{
7+
/// <summary>
8+
/// Used by <see cref="CommandLineLifetime"/> to handle exceptions that are emitted from the
9+
/// <see cref="CommandLineApplication{TModel}"/> e.g. during parsing or execution
10+
/// </summary>
11+
public interface IUnhandledExceptionHandler
12+
{
13+
/// <summary>
14+
/// Handle otherwise uncaught exception. You are free to log, rethrow, … the exception
15+
/// </summary>
16+
/// <param name="e">An otherwise uncaught exception</param>
17+
void HandleException(Exception e);
18+
}
19+
}

src/Hosting.CommandLine/Internal/CommandLineLifetime.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Runtime.ExceptionServices;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using McMaster.Extensions.CommandLineUtils;
@@ -18,18 +19,21 @@ internal class CommandLineLifetime : IHostLifetime, IDisposable
1819
private readonly IApplicationLifetime _applicationLifetime;
1920
private readonly ICommandLineService _cliService;
2021
private readonly IConsole _console;
22+
private readonly IUnhandledExceptionHandler _unhandledExceptionHandler;
2123
private readonly ManualResetEvent _disposeComplete = new ManualResetEvent(false);
2224

2325
/// <summary>
2426
/// Creates a new instance.
2527
/// </summary>
2628
public CommandLineLifetime(IApplicationLifetime applicationLifetime,
2729
ICommandLineService cliService,
28-
IConsole console)
30+
IConsole console,
31+
IUnhandledExceptionHandler unhandledExceptionHandler)
2932
{
3033
_applicationLifetime = applicationLifetime;
3134
_cliService = cliService;
3235
_console = console;
36+
_unhandledExceptionHandler = unhandledExceptionHandler;
3337
}
3438

3539
/// <summary>The exit code returned by the command line application</summary>
@@ -56,7 +60,22 @@ public Task WaitForStartAsync(CancellationToken cancellationToken)
5660
{
5761
_applicationLifetime.ApplicationStarted.Register(async () =>
5862
{
59-
ExitCode = await _cliService.RunAsync(cancellationToken).ConfigureAwait(false);
63+
try
64+
{
65+
ExitCode = await _cliService.RunAsync(cancellationToken).ConfigureAwait(false);
66+
}
67+
catch (Exception e)
68+
{
69+
if (_unhandledExceptionHandler != null)
70+
{
71+
_unhandledExceptionHandler.HandleException(e);
72+
}
73+
else
74+
{
75+
ExceptionDispatchInfo.Capture(e).Throw();
76+
}
77+
}
78+
6079
_applicationLifetime.StopApplication();
6180
});
6281

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
3+
namespace McMaster.Extensions.Hosting.CommandLine.Internal
4+
{
5+
/// <summary>
6+
/// Implementation of <see cref="IUnhandledExceptionHandler"/> that stores an unhandled exception so it can later be
7+
/// rethrown by <see cref="CommandLineService{T}"/>.
8+
/// </summary>
9+
internal class StoreExceptionHandler : IUnhandledExceptionHandler
10+
{
11+
/// <summary>
12+
/// The captured exception, if any
13+
/// </summary>
14+
public Exception StoredException { get; private set; }
15+
16+
/// <summary>
17+
/// This will store the first unhandled exception and throw an <see cref="AggregateException"/> if called a
18+
/// second time.
19+
/// </summary>
20+
/// <param name="e">The unhandled exception to store</param>
21+
/// <exception cref="AggregateException">If called a second time an <see cref="AggregateException"/> containing
22+
/// both exceptions is raised</exception>
23+
public void HandleException(Exception e)
24+
{
25+
if (StoredException != null)
26+
{
27+
throw new AggregateException("Second exception received!", StoredException, e);
28+
}
29+
30+
StoredException = e;
31+
}
32+
}
33+
}

test/Hosting.CommandLine.Tests/HostBuilderExtensionsTests.cs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.IO;
1+
using System;
2+
using System.IO;
23
using McMaster.Extensions.CommandLineUtils;
34
using McMaster.Extensions.CommandLineUtils.Conventions;
45
using McMaster.Extensions.Hosting.CommandLine.Tests.Utilities;
@@ -61,6 +62,31 @@ public async void TestConventionInjection()
6162
Mock.Verify(convention);
6263
}
6364

65+
[Fact]
66+
public void ItThrowsOnUnknownSubCommand()
67+
{
68+
var ex = Assert.Throws<UnrecognizedCommandParsingException>(
69+
() => new HostBuilder()
70+
.ConfigureServices(collection => collection.AddSingleton<IConsole>(new TestConsole(_output)))
71+
.RunCommandLineApplicationAsync<ParentCommand>(new string[] {"return41"})
72+
.GetAwaiter()
73+
.GetResult());
74+
Assert.Equal(new string[] {"return42"}, ex.NearestMatches);
75+
}
76+
77+
[Fact]
78+
public void ItRethrowsThrownExceptions()
79+
{
80+
var ex = Assert.Throws<InvalidOperationException>(
81+
() => new HostBuilder()
82+
.ConfigureServices(collection => collection.AddSingleton<IConsole>(new TestConsole(_output)))
83+
.RunCommandLineApplicationAsync<ThrowsExceptionCommand>(new string[0])
84+
.GetAwaiter()
85+
.GetResult());
86+
Assert.Equal("A test", ex.Message);
87+
}
88+
89+
[Command]
6490
public class Return42Command
6591
{
6692
private int OnExecute()
@@ -69,6 +95,7 @@ private int OnExecute()
6995
}
7096
}
7197

98+
[Command]
7299
public class Write42Command
73100
{
74101
private void OnExecute(CommandLineApplication<Write42Command> app)
@@ -102,5 +129,20 @@ private void OnExecute(CommandLineApplication<CaptureRemainingArgsCommand> app)
102129
{
103130
}
104131
}
132+
133+
[Command]
134+
[Subcommand(typeof(Return42Command))]
135+
class ParentCommand
136+
{
137+
}
138+
139+
[Command]
140+
class ThrowsExceptionCommand
141+
{
142+
private int OnExecute()
143+
{
144+
throw new InvalidOperationException("A test");
145+
}
146+
}
105147
}
106148
}

0 commit comments

Comments
 (0)