Skip to content

Commit

Permalink
Merge pull request #290 from tmds/cancel_key
Browse files Browse the repository at this point in the history
Support Ctrl+C handling
  • Loading branch information
jonsequitur authored Dec 5, 2018
2 parents 1a03bc0 + 6116ef9 commit aa631e3
Show file tree
Hide file tree
Showing 18 changed files with 653 additions and 20 deletions.
2 changes: 1 addition & 1 deletion samples/DragonFruit/DragonFruit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.0</TargetFramework>
<TargetFramework>netcoreapp2.1</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
<TargetFramework>netcoreapp2.1</TargetFramework>
<StartupObject>AutoGeneratedProgram</StartupObject>
<!-- Ensure that an XML doc file is emitted to supply command line help -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
Expand Down
87 changes: 87 additions & 0 deletions src/System.CommandLine.Tests/CancelOnProcessTerminationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;

namespace System.CommandLine.Tests
{
public class CancelOnProcessTerminationTests
{
private const int SIGINT = 2;
private const int SIGTERM = 15;

[LinuxOnlyTheory]
[InlineData(SIGINT)] // Console.CancelKeyPress
[InlineData(SIGTERM)] // AppDomain.CurrentDomain.ProcessExit
public async Task CancelOnProcessTermination_cancels_on_process_termination(int signo)
{
const string ChildProcessWaiting = "Waiting for the command to be cancelled";
const int CancelledExitCode = 42;

Func<string[], Task<int>> childProgram = (string[] args) =>
new CommandLineBuilder()
.AddCommand(new Command("the-command",
handler: CommandHandler.Create<CancellationToken>(async ct =>
{
try
{
Console.WriteLine(ChildProcessWaiting);
await Task.Delay(int.MaxValue, ct);
}
catch (OperationCanceledException)
{
// For Process.Exit handling the event must remain blocked as long as the
// command is executed.
// We are currently blocking that event because CancellationTokenSource.Cancel
// is called from the event handler.
// We'll do an async Yield now. This means the Cancel call will return
// and we're no longer actively blocking the event.
// The event handler is responsible to continue blocking until the command
// has finished executing. If it doesn't we won't get the CancelledExitCode.
await Task.Yield();
return CancelledExitCode;
}
return 1;
})))
.CancelOnProcessTermination()
.Build()
.InvokeAsync("the-command");

using (RemoteExecution program = RemoteExecutor.Execute(childProgram, psi: new ProcessStartInfo { RedirectStandardOutput = true }))
{
System.Diagnostics.Process process = program.Process;

// Wait for the child to be in the command handler.
string childState = await process.StandardOutput.ReadLineAsync();
childState.Should().Be(ChildProcessWaiting);

// Request termination
kill(process.Id, signo).Should().Be(0);

// Verify the process terminates timely
bool processExited = process.WaitForExit(10000);
if (!processExited)
{
process.Kill();
process.WaitForExit();
}
processExited.Should().Be(true);

// Verify the process exit code
process.ExitCode.Should().Be(CancelledExitCode);
}
}

[DllImport("libc", SetLastError = true)]
private static extern int kill(int pid, int sig);
}
}
18 changes: 18 additions & 0 deletions src/System.CommandLine.Tests/CollectionHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Generic;

namespace System.CommandLine.Tests
{
public static class CollectionHelpers
{
public static void AddRange<T>(this ICollection<T> destination, IEnumerable<T> source)
{
foreach (T item in source)
{
destination.Add(item);
}
}
}
}
5 changes: 5 additions & 0 deletions src/System.CommandLine.Tests/InvocationExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public async Task RootCommand_InvokeAsync_returns_1_when_handler_throws()
{
wasCalled = true;
throw new Exception("oops!");
// Help the compiler pick a CommandHandler.Create overload.
#pragma warning disable CS0162 // Unreachable code detected
return 0;
#pragma warning restore CS0162
});

var resultCode = await rootCommand.InvokeAsync("");
Expand Down
13 changes: 10 additions & 3 deletions src/System.CommandLine.Tests/InvocationPipelineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using FluentAssertions;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Xunit;

Expand Down Expand Up @@ -73,7 +74,14 @@ public void When_middleware_throws_then_InvokeAsync_does_not_handle_the_exceptio
public void When_command_handler_throws_then_InvokeAsync_does_not_handle_the_exception()
{
var command = new Command("the-command");
command.Handler = CommandHandler.Create(() => throw new Exception("oops!"));
command.Handler = CommandHandler.Create(() =>
{
throw new Exception("oops!");
// Help the compiler pick a CommandHandler.Create overload.
#pragma warning disable CS0162 // Unreachable code detected
return 0;
#pragma warning restore CS0162
});

var parser = new CommandLineBuilder()
.AddCommand(command)
Expand All @@ -82,9 +90,8 @@ public void When_command_handler_throws_then_InvokeAsync_does_not_handle_the_exc
Func<Task> invoke = async () => await parser.InvokeAsync("the-command", _console);

invoke.Should()
.Throw<TargetInvocationException>()
.Throw<Exception>()
.Which
.InnerException
.Message
.Should()
.Be("oops!");
Expand Down
20 changes: 20 additions & 0 deletions src/System.CommandLine.Tests/LinuxOnlyTheory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Runtime.InteropServices;
using Microsoft.DotNet.PlatformAbstractions;
using Xunit;

namespace System.CommandLine.Tests
{
public class LinuxOnlyTheory : TheoryAttribute
{
public LinuxOnlyTheory()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
this.Skip = "This test requires Linux to run";
}
}
}
}
85 changes: 85 additions & 0 deletions src/System.CommandLine.Tests/RemoteExecution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Diagnostics;
using System.IO;
using Xunit;
using Xunit.Sdk;

namespace System.CommandLine.Tests
{
public class RemoteExecution : IDisposable
{
private const int FailWaitTimeoutMilliseconds = 60 * 1000;
private string _exceptionFile;

public RemoteExecution(Process process, string className, string methodName, string exceptionFile)
{
Process = process;
ClassName = className;
MethodName = methodName;
_exceptionFile = exceptionFile;
}

public Process Process { get; private set; }
public string ClassName { get; private set; }
public string MethodName { get; private set; }

public void Dispose()
{
GC.SuppressFinalize(this); // before Dispose(true) in case the Dispose call throws
Dispose(disposing: true);
}

private void Dispose(bool disposing)
{
Assert.True(disposing, $"A test {ClassName}.{MethodName} forgot to Dispose() the result of RemoteInvoke()");

if (Process != null)
{
Assert.True(Process.WaitForExit(FailWaitTimeoutMilliseconds),
$"Timed out after {FailWaitTimeoutMilliseconds}ms waiting for remote process {Process.Id}");

// A bit unorthodox to do throwing operations in a Dispose, but by doing it here we avoid
// needing to do this in every derived test and keep each test much simpler.
try
{
if (File.Exists(_exceptionFile))
{
throw new RemoteExecutionException(File.ReadAllText(_exceptionFile));
}
}
finally
{
if (File.Exists(_exceptionFile))
{
File.Delete(_exceptionFile);
}

// Cleanup
try { Process.Kill(); }
catch { } // ignore all cleanup errors
}

Process.Dispose();
Process = null;
}
}

private sealed class RemoteExecutionException : Exception
{
private readonly string _stackTrace;

internal RemoteExecutionException(string stackTrace)
: base("Remote process failed with an unhandled exception.")
{
_stackTrace = stackTrace;
}

public override string StackTrace
{
get => _stackTrace ?? base.StackTrace;
}
}
}
}
Loading

0 comments on commit aa631e3

Please sign in to comment.