Skip to content

Commit 680d466

Browse files
committed
Add support for timeout in process termination handling
1 parent 209b724 commit 680d466

File tree

3 files changed

+120
-12
lines changed

3 files changed

+120
-12
lines changed

src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ System.CommandLine.Builder
240240
public static class CommandLineBuilderExtensions
241241
public static CommandLineBuilder AddMiddleware(this CommandLineBuilder builder, System.CommandLine.Invocation.InvocationMiddleware middleware, System.CommandLine.Invocation.MiddlewareOrder order = Default)
242242
public static CommandLineBuilder AddMiddleware(this CommandLineBuilder builder, System.Action<System.CommandLine.Invocation.InvocationContext> onInvoke, System.CommandLine.Invocation.MiddlewareOrder order = Default)
243-
public static CommandLineBuilder CancelOnProcessTermination(this CommandLineBuilder builder)
243+
public static CommandLineBuilder CancelOnProcessTermination(this CommandLineBuilder builder, System.Nullable<System.TimeSpan> cancelationProcessingTimeout = null)
244244
public static CommandLineBuilder EnableDirectives(this CommandLineBuilder builder, System.Boolean value = True)
245245
public static CommandLineBuilder EnableLegacyDoubleDashBehavior(this CommandLineBuilder builder, System.Boolean value = True)
246246
public static CommandLineBuilder EnablePosixBundling(this CommandLineBuilder builder, System.Boolean value = True)

src/System.CommandLine.Tests/Invocation/CancelOnProcessTerminationTests.cs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public class CancelOnProcessTerminationTests
2020
private const int SIGTERM = 15;
2121

2222
[LinuxOnlyTheory]
23-
[InlineData(SIGINT, Skip = "https://github.com/dotnet/command-line-api/issues/1206")] // Console.CancelKeyPress
23+
[InlineData(SIGINT/*, Skip = "https://github.com/dotnet/command-line-api/issues/1206"*/)] // Console.CancelKeyPress
2424
[InlineData(SIGTERM)] // AppDomain.CurrentDomain.ProcessExit
2525
public async Task CancelOnProcessTermination_cancels_on_process_termination(int signo)
2626
{
@@ -91,6 +91,83 @@ public async Task CancelOnProcessTermination_cancels_on_process_termination(int
9191
process.ExitCode.Should().Be(CancelledExitCode);
9292
}
9393

94+
[LinuxOnlyTheory]
95+
[InlineData(null, SIGINT)]
96+
[InlineData(100, SIGINT)]
97+
[InlineData(null, SIGTERM)]
98+
[InlineData(100, SIGTERM)]
99+
public async Task CancelOnProcessTermination_timeout_on_cancel_processing(int? timeOutMs, int signo)
100+
{
101+
TimeSpan? timeOut = timeOutMs.HasValue ? TimeSpan.FromMilliseconds(timeOutMs.Value) : null;
102+
103+
const string ChildProcessWaiting = "Waiting for the command to be cancelled";
104+
const int CancelledExitCode = 42;
105+
const int ForceTerminationCode = 130;
106+
107+
Func<string[], Task<int>> childProgram = (string[] args) =>
108+
{
109+
var command = new Command("the-command");
110+
111+
command.SetHandler(async context =>
112+
{
113+
var cancellationToken = context.GetCancellationToken();
114+
115+
try
116+
{
117+
context.Console.WriteLine(ChildProcessWaiting);
118+
await Task.Delay(int.MaxValue, cancellationToken);
119+
context.ExitCode = 1;
120+
}
121+
catch (OperationCanceledException)
122+
{
123+
// For Process.Exit handling the event must remain blocked as long as the
124+
// command is executed.
125+
// We are currently blocking that event because CancellationTokenSource.Cancel
126+
// is called from the event handler.
127+
// We'll do an async Task.Delay now. This means the Cancel call will return
128+
// and we're no longer actively blocking the event.
129+
// The event handler is responsible to continue blocking until the command
130+
// has finished executing. If it doesn't we won't get the CancelledExitCode.
131+
await Task.Delay(TimeSpan.FromMilliseconds(2000));
132+
133+
context.ExitCode = CancelledExitCode;
134+
}
135+
136+
});
137+
138+
return new CommandLineBuilder(new RootCommand
139+
{
140+
command
141+
})
142+
.CancelOnProcessTermination(timeOut)
143+
.Build()
144+
.InvokeAsync("the-command");
145+
};
146+
147+
using RemoteExecution program = RemoteExecutor.Execute(childProgram, psi: new ProcessStartInfo { RedirectStandardOutput = true });
148+
149+
Process process = program.Process;
150+
151+
// Wait for the child to be in the command handler.
152+
string childState = await process.StandardOutput.ReadLineAsync();
153+
childState.Should().Be(ChildProcessWaiting);
154+
155+
// Request termination
156+
kill(process.Id, signo).Should().Be(0);
157+
158+
// Verify the process terminates timely
159+
bool processExited = process.WaitForExit(10000);
160+
if (!processExited)
161+
{
162+
process.Kill();
163+
process.WaitForExit();
164+
}
165+
processExited.Should().Be(true);
166+
167+
// Verify the process exit code
168+
process.ExitCode.Should().Be(timeOutMs.HasValue ? ForceTerminationCode : CancelledExitCode);
169+
}
170+
94171
[DllImport("libc", SetLastError = true)]
95172
private static extern int kill(int pid, int sig);
96173
}

src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using System.Linq;
1111
using System.Reflection;
1212
using System.Threading;
13+
using System.Threading.Tasks;
1314
using static System.Environment;
1415
using Process = System.CommandLine.Invocation.Process;
1516

@@ -42,9 +43,21 @@ public static class CommandLineBuilderExtensions
4243
/// Enables signaling and handling of process termination via a <see cref="CancellationToken"/> that can be passed to a <see cref="ICommandHandler"/> during invocation.
4344
/// </summary>
4445
/// <param name="builder">A command line builder.</param>
46+
/// <param name="cancelationProcessingTimeout">
47+
/// Optional timeout for the command to process the exit cancellation.
48+
/// If not passed, or passed null or non-positive timeout (including <see cref="Timeout.InfiniteTimeSpan"/>), no timeout is enforced.
49+
/// If positive value is passed - command is forcefully terminated after the timeout with exit code 130 (as if <see cref="CancelOnProcessTermination"/> was not called).
50+
/// Host enforced timeout for ProcessExit event cannot be extended - default is 2 seconds: https://docs.microsoft.com/en-us/dotnet/api/system.appdomain.processexit?view=net-6.0.
51+
/// </param>
4552
/// <returns>The same instance of <see cref="CommandLineBuilder"/>.</returns>
46-
public static CommandLineBuilder CancelOnProcessTermination(this CommandLineBuilder builder)
53+
public static CommandLineBuilder CancelOnProcessTermination(
54+
this CommandLineBuilder builder,
55+
TimeSpan? cancelationProcessingTimeout = null)
4756
{
57+
cancelationProcessingTimeout ??= Timeout.InfiniteTimeSpan;
58+
// https://tldp.org/LDP/abs/html/exitcodes.html - 130 - script terminated by ctrl-c
59+
const int SIGINT_EXIT_CODE = 130;
60+
4861
builder.AddMiddleware(async (context, next) =>
4962
{
5063
bool cancellationHandlingAdded = false;
@@ -56,24 +69,42 @@ public static CommandLineBuilder CancelOnProcessTermination(this CommandLineBuil
5669
{
5770
blockProcessExit = new ManualResetEventSlim(initialState: false);
5871
cancellationHandlingAdded = true;
59-
consoleHandler = (_, args) =>
60-
{
61-
cts.Cancel();
62-
// Stop the process from terminating.
63-
// Since the context was cancelled, the invocation should
64-
// finish and Main will return.
65-
args.Cancel = true;
66-
};
72+
// Default limit for ProcesExit handler is 2 seconds
73+
// https://docs.microsoft.com/en-us/dotnet/api/system.appdomain.processexit?view=net-6.0
6774
processExitHandler = (_, _) =>
6875
{
6976
cts.Cancel();
7077
// The process exits as soon as the event handler returns.
7178
// We provide a return value using Environment.ExitCode
7279
// because Main will not finish executing.
7380
// Wait for the invocation to finish.
74-
blockProcessExit.Wait();
81+
if (!blockProcessExit.Wait(cancelationProcessingTimeout > TimeSpan.Zero
82+
? cancelationProcessingTimeout.Value
83+
: Timeout.InfiniteTimeSpan))
84+
{
85+
context.ExitCode = SIGINT_EXIT_CODE;
86+
}
7587
ExitCode = context.ExitCode;
7688
};
89+
consoleHandler = (_, args) =>
90+
{
91+
cts.Cancel();
92+
// Stop the process from terminating.
93+
// Since the context was cancelled, the invocation should
94+
// finish and Main will return.
95+
args.Cancel = true;
96+
if (cancelationProcessingTimeout! > TimeSpan.Zero)
97+
{
98+
Task
99+
.Delay(cancelationProcessingTimeout.Value, default)
100+
.ContinueWith(t =>
101+
{
102+
// Prevent our ProcessExit from intervene and block the exit
103+
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
104+
Environment.Exit(SIGINT_EXIT_CODE);
105+
}, (CancellationToken)default);
106+
}
107+
};
77108
Console.CancelKeyPress += consoleHandler;
78109
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
79110
};

0 commit comments

Comments
 (0)