diff --git a/src/ModularPipelines/Context/Command.cs b/src/ModularPipelines/Context/Command.cs index 3d78b44a3d..c2c8b52f1d 100644 --- a/src/ModularPipelines/Context/Command.cs +++ b/src/ModularPipelines/Context/Command.cs @@ -301,15 +301,25 @@ private async Task 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) @@ -324,7 +334,7 @@ private async Task 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()); diff --git a/src/ModularPipelines/Options/CommandLineOptions.cs b/src/ModularPipelines/Options/CommandLineOptions.cs index 8e2911e35d..2c6275ce58 100644 --- a/src/ModularPipelines/Options/CommandLineOptions.cs +++ b/src/ModularPipelines/Options/CommandLineOptions.cs @@ -60,5 +60,26 @@ public record CommandLineOptions /// public bool ThrowOnNonZeroExitCode { get; init; } = true; + /// + /// Gets or sets the maximum time allowed for the command to complete. + /// + /// + /// When set, the command will be cancelled if it exceeds this duration. + /// If the command does not complete within the timeout, a will be thrown. + /// If not set (null), the command will run until completion or until the passed cancellation token is cancelled. + /// + public TimeSpan? ExecutionTimeout { get; init; } + + /// + /// Gets or sets the time to wait for graceful shutdown before forcefully terminating the process. + /// + /// + /// When a command is cancelled (either via 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. + /// Default is 30 seconds. + /// + public TimeSpan GracefulShutdownTimeout { get; init; } = TimeSpan.FromSeconds(30); + internal bool InternalDryRun { get; set; } } \ No newline at end of file