diff --git a/CliWrap.Benchmarks/CliWrap.Benchmarks.csproj b/CliWrap.Benchmarks/CliWrap.Benchmarks.csproj index 7188739b..ede15a1f 100644 --- a/CliWrap.Benchmarks/CliWrap.Benchmarks.csproj +++ b/CliWrap.Benchmarks/CliWrap.Benchmarks.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/CliWrap.Signaler/CliWrap.Signaler.csproj b/CliWrap.Signaler/CliWrap.Signaler.csproj index 6a2a5e0d..9410585d 100644 --- a/CliWrap.Signaler/CliWrap.Signaler.csproj +++ b/CliWrap.Signaler/CliWrap.Signaler.csproj @@ -6,7 +6,7 @@ - + \ No newline at end of file diff --git a/CliWrap.Tests.Dummy/CliWrap.Tests.Dummy.csproj b/CliWrap.Tests.Dummy/CliWrap.Tests.Dummy.csproj index f0f641cc..7ad3d443 100644 --- a/CliWrap.Tests.Dummy/CliWrap.Tests.Dummy.csproj +++ b/CliWrap.Tests.Dummy/CliWrap.Tests.Dummy.csproj @@ -8,7 +8,7 @@ - + diff --git a/CliWrap.Tests/CliWrap.Tests.csproj b/CliWrap.Tests/CliWrap.Tests.csproj index 9ce9607b..c6d4adba 100644 --- a/CliWrap.Tests/CliWrap.Tests.csproj +++ b/CliWrap.Tests/CliWrap.Tests.csproj @@ -10,15 +10,15 @@ - - - + + + - - + + diff --git a/CliWrap.Tests/ConfigurationSpecs.cs b/CliWrap.Tests/ConfigurationSpecs.cs index a02db08a..33a403d0 100644 --- a/CliWrap.Tests/ConfigurationSpecs.cs +++ b/CliWrap.Tests/ConfigurationSpecs.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using CliWrap.Builders; using FluentAssertions; using Xunit; @@ -17,6 +19,7 @@ public void I_can_create_a_command_with_the_default_configuration() cmd.TargetFilePath.Should().Be("foo"); cmd.Arguments.Should().BeEmpty(); cmd.WorkingDirPath.Should().Be(Directory.GetCurrentDirectory()); + cmd.ResourcePolicy.Should().Be(ResourcePolicy.Default); cmd.Credentials.Should().BeEquivalentTo(Credentials.Default); cmd.EnvironmentVariables.Should().BeEmpty(); cmd.Validation.Should().Be(CommandResultValidation.ZeroExitCode); @@ -109,6 +112,47 @@ public void I_can_configure_the_working_directory() modified.WorkingDirPath.Should().Be("new"); } + [Fact] + public void I_can_configure_the_resource_policy() + { + // Arrange + var original = Cli.Wrap("foo").WithResourcePolicy(ResourcePolicy.Default); + + // Act + var modified = original.WithResourcePolicy( + new ResourcePolicy(ProcessPriorityClass.High, 0x1, 1024, 2048) + ); + + // Assert + original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.ResourcePolicy)); + original.ResourcePolicy.Should().NotBe(modified.ResourcePolicy); + modified + .ResourcePolicy.Should() + .BeEquivalentTo(new ResourcePolicy(ProcessPriorityClass.High, 0x1, 1024, 2048)); + } + + [Fact] + public void I_can_configure_the_resource_policy_using_a_builder() + { + // Arrange + var original = Cli.Wrap("foo").WithResourcePolicy(ResourcePolicy.Default); + + // Act + var modified = original.WithResourcePolicy(b => + b.SetPriority(ProcessPriorityClass.High) + .SetAffinity(0x1) + .SetMinWorkingSet(1024) + .SetMaxWorkingSet(2048) + ); + + // Assert + original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.ResourcePolicy)); + original.ResourcePolicy.Should().NotBe(modified.ResourcePolicy); + modified + .ResourcePolicy.Should() + .BeEquivalentTo(new ResourcePolicy(ProcessPriorityClass.High, 0x1, 1024, 2048)); + } + [Fact] public void I_can_configure_the_user_credentials() { diff --git a/CliWrap.Tests/CredentialsSpecs.cs b/CliWrap.Tests/CredentialsSpecs.cs index ebc14d46..8fd0b9e5 100644 --- a/CliWrap.Tests/CredentialsSpecs.cs +++ b/CliWrap.Tests/CredentialsSpecs.cs @@ -58,7 +58,7 @@ public async Task I_can_try_to_execute_a_command_as_a_different_user_and_get_an_ { Skip.If( RuntimeInformation.IsOSPlatform(OSPlatform.Windows), - "Starting a process as another user is only supported on Windows." + "Starting a process as another user is fully supported on Windows." ); // Arrange diff --git a/CliWrap.Tests/PipingSpecs.cs b/CliWrap.Tests/PipingSpecs.cs index 0f39dd48..81be5e57 100644 --- a/CliWrap.Tests/PipingSpecs.cs +++ b/CliWrap.Tests/PipingSpecs.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Text; @@ -138,6 +139,30 @@ public async Task I_can_execute_a_command_and_pipe_the_stdin_from_another_comman result.StandardOutput.Trim().Should().Be("100000"); } + [Fact(Timeout = 15000)] + public async Task I_can_execute_a_command_and_pipe_the_stdin_from_another_command_with_a_transform() + { + // Arrange + var cmd = + PipeSource.FromCommand( + Cli.Wrap(Dummy.Program.FilePath) + .WithArguments(["generate binary", "--length", "100000"]), + // Transform: take the first 5000 bytes and discard the rest + async (source, destination, cancellationToken) => + { + using var buffer = MemoryPool.Shared.Rent(5000); + await source.ReadAtLeastAsync(buffer.Memory, 5000, false, cancellationToken); + await destination.WriteAsync(buffer.Memory[..5000], cancellationToken); + } + ) | Cli.Wrap(Dummy.Program.FilePath).WithArguments("length stdin"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + result.StandardOutput.Trim().Should().Be("5000"); + } + [Fact(Timeout = 15000)] public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_chain_of_commands() { diff --git a/CliWrap.Tests/ResourcePolicySpecs.cs b/CliWrap.Tests/ResourcePolicySpecs.cs new file mode 100644 index 00000000..7b537f24 --- /dev/null +++ b/CliWrap.Tests/ResourcePolicySpecs.cs @@ -0,0 +1,89 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace CliWrap.Tests; + +public class ResourcePolicySpecs +{ + [SkippableFact(Timeout = 15000)] + public async Task I_can_execute_a_command_with_a_custom_process_priority() + { + // Process priority is supported on other platforms, but setting it requires elevated permissions, + // which we cannot guarantee in a CI environment. Therefore, we only test this on Windows. + Skip.IfNot( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Starting a process with a custom priority is only supported on Windows." + ); + + // Arrange + var cmd = Cli.Wrap(Dummy.Program.FilePath) + .WithResourcePolicy(p => p.SetPriority(ProcessPriorityClass.High)); + + // Act + var result = await cmd.ExecuteAsync(); + + // Assert + result.ExitCode.Should().Be(0); + } + + [SkippableFact(Timeout = 15000)] + public async Task I_can_execute_a_command_with_a_custom_core_affinity() + { + Skip.IfNot( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Starting a process with a custom core affinity is only supported on Windows and Linux." + ); + + // Arrange + var cmd = Cli.Wrap(Dummy.Program.FilePath).WithResourcePolicy(p => p.SetAffinity(0b1010)); // Cores 1 and 3 + + // Act + var result = await cmd.ExecuteAsync(); + + // Assert + result.ExitCode.Should().Be(0); + } + + [SkippableFact(Timeout = 15000)] + public async Task I_can_execute_a_command_with_a_custom_working_set_limit() + { + Skip.IfNot( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Starting a process with a custom working set limit is only supported on Windows." + ); + + // Arrange + var cmd = Cli.Wrap(Dummy.Program.FilePath) + .WithResourcePolicy(p => + p.SetMinWorkingSet(1024 * 1024) // 1 MB + .SetMaxWorkingSet(1024 * 1024 * 10) // 10 MB + ); + + // Act + var result = await cmd.ExecuteAsync(); + + // Assert + result.ExitCode.Should().Be(0); + } + + [SkippableFact(Timeout = 15000)] + public async Task I_can_try_to_execute_a_command_with_a_custom_resource_policy_and_get_an_error_if_the_operating_system_does_not_support_it() + { + Skip.If( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Starting a process with a custom resource policy is fully supported on Windows." + ); + + // Arrange + var cmd = Cli.Wrap(Dummy.Program.FilePath) + .WithResourcePolicy(p => p.SetMinWorkingSet(1024 * 1024)); + + // Act & assert + await Assert.ThrowsAsync(() => cmd.ExecuteAsync()); + } +} diff --git a/CliWrap/Builders/ResourcePolicyBuilder.cs b/CliWrap/Builders/ResourcePolicyBuilder.cs new file mode 100644 index 00000000..8ee91807 --- /dev/null +++ b/CliWrap/Builders/ResourcePolicyBuilder.cs @@ -0,0 +1,56 @@ +using System.Diagnostics; + +namespace CliWrap.Builders; + +/// +/// Builder that helps configure resource policy. +/// +public class ResourcePolicyBuilder +{ + private ProcessPriorityClass _priority = ProcessPriorityClass.Normal; + private nint? _affinity; + private nint? _minWorkingSet; + private nint? _maxWorkingSet; + + /// + /// Sets the priority class of the process. + /// + public ResourcePolicyBuilder SetPriority(ProcessPriorityClass priority) + { + _priority = priority; + return this; + } + + /// + /// Sets the processor core affinity mask of the process. + /// For example, to set the affinity to cores 1 and 3 out of 4, pass 0b1010. + /// + public ResourcePolicyBuilder SetAffinity(nint? affinity) + { + _affinity = affinity; + return this; + } + + /// + /// Sets the minimum working set size of the process. + /// + public ResourcePolicyBuilder SetMinWorkingSet(nint? minWorkingSet) + { + _minWorkingSet = minWorkingSet; + return this; + } + + /// + /// Sets the maximum working set size of the process. + /// + public ResourcePolicyBuilder SetMaxWorkingSet(nint? maxWorkingSet) + { + _maxWorkingSet = maxWorkingSet; + return this; + } + + /// + /// Builds the resulting resource policy. + /// + public ResourcePolicy Build() => new(_priority, _affinity, _minWorkingSet, _maxWorkingSet); +} diff --git a/CliWrap/CliWrap.csproj b/CliWrap/CliWrap.csproj index 23397bca..feb4e464 100644 --- a/CliWrap/CliWrap.csproj +++ b/CliWrap/CliWrap.csproj @@ -23,12 +23,12 @@ - - + + - + diff --git a/CliWrap/Command.Execution.cs b/CliWrap/Command.Execution.cs index 35e31930..2c2ca5f5 100644 --- a/CliWrap/Command.Execution.cs +++ b/CliWrap/Command.Execution.cs @@ -115,7 +115,7 @@ private ProcessStartInfo CreateStartInfo() { throw new NotSupportedException( "Cannot start a process using the provided credentials. " - + "Setting custom domain, password, or loading user profile is only supported on Windows.", + + "Setting custom domain, username, password, and/or loading the user profile is not supported on this platform.", ex ); } @@ -308,10 +308,36 @@ CancellationToken gracefulCancellationToken { var process = new ProcessEx(CreateStartInfo()); - // This method may fail and we want to propagate the exceptions immediately instead + // This method may fail, and we want to propagate the exceptions immediately instead // of wrapping them in a task, so it needs to be executed in a synchronous context. // https://github.com/Tyrrrz/CliWrap/issues/139 - process.Start(); + process.Start(p => + { + try + { + // Disable CA1416 because we're handling an exception that is thrown by the property setters +#pragma warning disable CA1416 + p.PriorityClass = ResourcePolicy.Priority; + + if (ResourcePolicy.Affinity is not null) + p.ProcessorAffinity = ResourcePolicy.Affinity.Value; + + if (ResourcePolicy.MinWorkingSet is not null) + p.MinWorkingSet = ResourcePolicy.MinWorkingSet.Value; + + if (ResourcePolicy.MaxWorkingSet is not null) + p.MaxWorkingSet = ResourcePolicy.MaxWorkingSet.Value; +#pragma warning restore CA1416 + } + catch (NotSupportedException ex) + { + throw new NotSupportedException( + "Cannot set resource policy for the process. " + + "Setting custom priority, affinity, and/or working set limits is not supported on this platform.", + ex + ); + } + }); // Extract the process ID before calling ExecuteAsync(), because the process may // already be disposed by then. diff --git a/CliWrap/Command.cs b/CliWrap/Command.cs index 27bed170..07524a20 100644 --- a/CliWrap/Command.cs +++ b/CliWrap/Command.cs @@ -14,6 +14,7 @@ public partial class Command( string targetFilePath, string arguments, string workingDirPath, + ResourcePolicy resourcePolicy, Credentials credentials, IReadOnlyDictionary environmentVariables, CommandResultValidation validation, @@ -30,6 +31,7 @@ public Command(string targetFilePath) targetFilePath, string.Empty, Directory.GetCurrentDirectory(), + ResourcePolicy.Default, Credentials.Default, new Dictionary(), CommandResultValidation.ZeroExitCode, @@ -47,6 +49,9 @@ public Command(string targetFilePath) /// public string WorkingDirPath { get; } = workingDirPath; + /// + public ResourcePolicy ResourcePolicy { get; } = resourcePolicy; + /// public Credentials Credentials { get; } = credentials; @@ -75,6 +80,7 @@ public Command WithTargetFile(string targetFilePath) => targetFilePath, Arguments, WorkingDirPath, + ResourcePolicy, Credentials, EnvironmentVariables, Validation, @@ -96,6 +102,7 @@ public Command WithArguments(string arguments) => TargetFilePath, arguments, WorkingDirPath, + ResourcePolicy, Credentials, EnvironmentVariables, Validation, @@ -142,6 +149,25 @@ public Command WithWorkingDirectory(string workingDirPath) => TargetFilePath, Arguments, workingDirPath, + ResourcePolicy, + Credentials, + EnvironmentVariables, + Validation, + StandardInputPipe, + StandardOutputPipe, + StandardErrorPipe + ); + + /// + /// Creates a copy of this command, setting the resource policy to the specified value. + /// + [Pure] + public Command WithResourcePolicy(ResourcePolicy resourcePolicy) => + new( + TargetFilePath, + Arguments, + WorkingDirPath, + resourcePolicy, Credentials, EnvironmentVariables, Validation, @@ -150,6 +176,19 @@ public Command WithWorkingDirectory(string workingDirPath) => StandardErrorPipe ); + /// + /// Creates a copy of this command, setting the resource policy to the value + /// configured by the specified delegate. + /// + [Pure] + public Command WithResourcePolicy(Action configure) + { + var builder = new ResourcePolicyBuilder(); + configure(builder); + + return WithResourcePolicy(builder.Build()); + } + /// /// Creates a copy of this command, setting the user credentials to the specified value. /// @@ -159,6 +198,7 @@ public Command WithCredentials(Credentials credentials) => TargetFilePath, Arguments, WorkingDirPath, + ResourcePolicy, credentials, EnvironmentVariables, Validation, @@ -191,6 +231,7 @@ public Command WithEnvironmentVariables( TargetFilePath, Arguments, WorkingDirPath, + ResourcePolicy, Credentials, environmentVariables, Validation, @@ -221,6 +262,7 @@ public Command WithValidation(CommandResultValidation validation) => TargetFilePath, Arguments, WorkingDirPath, + ResourcePolicy, Credentials, EnvironmentVariables, validation, @@ -238,6 +280,7 @@ public Command WithStandardInputPipe(PipeSource source) => TargetFilePath, Arguments, WorkingDirPath, + ResourcePolicy, Credentials, EnvironmentVariables, Validation, @@ -255,6 +298,7 @@ public Command WithStandardOutputPipe(PipeTarget target) => TargetFilePath, Arguments, WorkingDirPath, + ResourcePolicy, Credentials, EnvironmentVariables, Validation, @@ -272,6 +316,7 @@ public Command WithStandardErrorPipe(PipeTarget target) => TargetFilePath, Arguments, WorkingDirPath, + ResourcePolicy, Credentials, EnvironmentVariables, Validation, diff --git a/CliWrap/ICommandConfiguration.cs b/CliWrap/ICommandConfiguration.cs index 5a101d0b..0f72fc8e 100644 --- a/CliWrap/ICommandConfiguration.cs +++ b/CliWrap/ICommandConfiguration.cs @@ -22,6 +22,11 @@ public interface ICommandConfiguration /// string WorkingDirPath { get; } + /// + /// Resource policy set for the underlying process. + /// + ResourcePolicy ResourcePolicy { get; } + /// /// User credentials set for the underlying process. /// diff --git a/CliWrap/PipeSource.cs b/CliWrap/PipeSource.cs index 7a8faca4..3bb679a5 100644 --- a/CliWrap/PipeSource.cs +++ b/CliWrap/PipeSource.cs @@ -132,12 +132,34 @@ public static PipeSource FromString(string str, Encoding encoding) => /// /// Creates a pipe source that reads from the standard output of the specified command. /// - public static PipeSource FromCommand(Command command) => + public static PipeSource FromCommand( + Command command, + Func copyStreamAsync + ) => + // cmdA | | cmdB Create( - async (destination, cancellationToken) => + // Destination -> cmdB's standard input + async (destination, destinationCancellationToken) => await command - .WithStandardOutputPipe(PipeTarget.ToStream(destination)) - .ExecuteAsync(cancellationToken) + .WithStandardOutputPipe( + PipeTarget.Create( + // Source -> cmdA's standard output + async (source, sourceCancellationToken) => + await copyStreamAsync(source, destination, sourceCancellationToken) + .ConfigureAwait(false) + ) + ) + .ExecuteAsync(destinationCancellationToken) .ConfigureAwait(false) ); + + /// + /// Creates a pipe source that reads from the standard output of the specified command. + /// + public static PipeSource FromCommand(Command command) => + FromCommand( + command, + async (source, destination, cancellationToken) => + await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false) + ); } diff --git a/CliWrap/ResourcePolicy.cs b/CliWrap/ResourcePolicy.cs new file mode 100644 index 00000000..943824b9 --- /dev/null +++ b/CliWrap/ResourcePolicy.cs @@ -0,0 +1,42 @@ +using System.Diagnostics; + +namespace CliWrap; + +/// +/// Resource policy assigned to a process. +/// +public partial class ResourcePolicy( + ProcessPriorityClass priority = ProcessPriorityClass.Normal, + nint? affinity = null, + nint? minWorkingSet = null, + nint? maxWorkingSet = null +) +{ + /// + /// Priority class of the process. + /// + public ProcessPriorityClass Priority { get; } = priority; + + /// + /// Processor core affinity mask of the process. + /// + public nint? Affinity { get; } = affinity; + + /// + /// Minimum working set size of the process. + /// + public nint? MinWorkingSet { get; } = minWorkingSet; + + /// + /// Maximum working set size of the process. + /// + public nint? MaxWorkingSet { get; } = maxWorkingSet; +} + +public partial class ResourcePolicy +{ + /// + /// Default resource policy. + /// + public static ResourcePolicy Default { get; } = new(); +} diff --git a/CliWrap/Utils/ProcessEx.cs b/CliWrap/Utils/ProcessEx.cs index 8fd53594..91ccc93a 100644 --- a/CliWrap/Utils/ProcessEx.cs +++ b/CliWrap/Utils/ProcessEx.cs @@ -42,7 +42,7 @@ internal class ProcessEx(ProcessStartInfo startInfo) : IDisposable public int ExitCode => _nativeProcess.ExitCode; - public void Start() + public void Start(Action? configureProcess = null) { // Hook up events _nativeProcess.EnableRaisingEvents = true; @@ -64,6 +64,9 @@ public void Start() } StartTime = DateTimeOffset.Now; + + // Apply custom configurations + configureProcess?.Invoke(_nativeProcess); } catch (Win32Exception ex) { diff --git a/Readme.md b/Readme.md index 1f1f59a2..4b5ec50c 100644 --- a/Readme.md +++ b/Readme.md @@ -269,6 +269,41 @@ var cmd = Cli.Wrap("git") > Environment variables configured using `WithEnvironmentVariables(...)` are applied on top of those inherited from the parent process. > If you need to remove an inherited variable, set the corresponding value to `null`. +#### `WithResourcePolicy(...)` + +Sets the system resource management policy for the child process. + +**Default**: default policy. + +**Examples**: + +- Set resource policy using a builder: + +```csharp +var cmd = Cli.Wrap("git") + .WithResourcePolicy(policy => policy + .SetPriority(ProcessPriorityClass.High) + .SetAffinity(0b1010) + .SetMinWorkingSet(1024) + .SetMaxWorkingSet(4096) + ); +``` + +- Set resource policy directly: + +```csharp +var cmd = Cli.Wrap("git") + .WithResourcePolicy(new ResourcePolicy( + priority: ProcessPriorityClass.High, + affinity: 0b1010, + minWorkingSet: 1024, + maxWorkingSet: 4096 + )); +``` + +> **Warning**: +> Resource policy options have varying support across different platforms. + #### `WithCredentials(...)` Sets domain, name and password of the user, under whom the child process should be started.