Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/ModularPipelines/Context/Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -301,15 +301,25 @@ private async Task<CommandResult> Of(CliWrap.Command command,

var inputToLog = options.InputLoggingManipulator == null ? command.ToString() : options.InputLoggingManipulator(command.ToString());

// Only create timeout token if ExecutionTimeout is specified to avoid unnecessary allocations
using var timeoutCancellationToken = options.ExecutionTimeout.HasValue
? new CancellationTokenSource(options.ExecutionTimeout.Value)
: null;

// Link the timeout token with the passed cancellation token, or just wrap the original if no timeout
using var linkedCancellationToken = timeoutCancellationToken != null
? CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationToken.Token, cancellationToken)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

using var forcefulCancellationToken = new CancellationTokenSource();

await using var registration = cancellationToken.Register(() =>
await using var registration = linkedCancellationToken.Token.Register(() =>
{
try
{
if (forcefulCancellationToken.Token.CanBeCanceled)
{
forcefulCancellationToken.CancelAfter(TimeSpan.FromSeconds(30));
forcefulCancellationToken.CancelAfter(options.GracefulShutdownTimeout);
}
}
catch (ObjectDisposedException)
Expand All @@ -324,7 +334,7 @@ private async Task<CommandResult> Of(CliWrap.Command command,
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(standardOutputStringBuilder))
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(standardErrorStringBuilder))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(forcefulCancellationToken.Token, cancellationToken).ConfigureAwait(false);
.ExecuteAsync(forcefulCancellationToken.Token, linkedCancellationToken.Token).ConfigureAwait(false);

standardOutput = options.OutputLoggingManipulator == null ? standardOutputStringBuilder.ToString() : options.OutputLoggingManipulator(standardOutputStringBuilder.ToString());
standardError = options.OutputLoggingManipulator == null ? standardErrorStringBuilder.ToString() : options.OutputLoggingManipulator(standardErrorStringBuilder.ToString());
Expand Down
21 changes: 21 additions & 0 deletions src/ModularPipelines/Options/CommandLineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,26 @@ public record CommandLineOptions
/// </summary>
public bool ThrowOnNonZeroExitCode { get; init; } = true;

/// <summary>
/// Gets or sets the maximum time allowed for the command to complete.
/// </summary>
/// <remarks>
/// <para>When set, the command will be cancelled if it exceeds this duration.</para>
/// <para>If the command does not complete within the timeout, a <see cref="System.OperationCanceledException"/> will be thrown.</para>
/// <para>If not set (null), the command will run until completion or until the passed cancellation token is cancelled.</para>
/// </remarks>
public TimeSpan? ExecutionTimeout { get; init; }

/// <summary>
/// Gets or sets the time to wait for graceful shutdown before forcefully terminating the process.
/// </summary>
/// <remarks>
/// <para>When a command is cancelled (either via <see cref="ExecutionTimeout"/> or an external cancellation token),
/// the process is first asked to terminate gracefully. If it does not terminate within this duration,
/// it will be forcefully killed.</para>
/// <para>Default is 30 seconds.</para>
/// </remarks>
public TimeSpan GracefulShutdownTimeout { get; init; } = TimeSpan.FromSeconds(30);

internal bool InternalDryRun { get; set; }
}
Loading