Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for resource policies #276

Merged
merged 12 commits into from
Feb 9, 2025
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
44 changes: 44 additions & 0 deletions CliWrap.Tests/ConfigurationSpecs.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using CliWrap.Builders;
using FluentAssertions;
using Xunit;

Expand All @@ -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);
Expand Down Expand Up @@ -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()
{
Expand Down
2 changes: 1 addition & 1 deletion CliWrap.Tests/CredentialsSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions CliWrap.Tests/ResourcePolicySpecs.cs
Original file line number Diff line number Diff line change
@@ -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<NotSupportedException>(() => cmd.ExecuteAsync());
}
}
56 changes: 56 additions & 0 deletions CliWrap/Builders/ResourcePolicyBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Diagnostics;

namespace CliWrap.Builders;

/// <summary>
/// Builder that helps configure resource policy.
/// </summary>
public class ResourcePolicyBuilder
{
private ProcessPriorityClass _priority = ProcessPriorityClass.Normal;
private nint? _affinity;
private nint? _minWorkingSet;
private nint? _maxWorkingSet;

/// <summary>
/// Sets the priority class of the process.
/// </summary>
public ResourcePolicyBuilder SetPriority(ProcessPriorityClass priority)
{
_priority = priority;
return this;
}

/// <summary>
/// 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.
/// </summary>
public ResourcePolicyBuilder SetAffinity(nint? affinity)
{
_affinity = affinity;
return this;
}

/// <summary>
/// Sets the minimum working set size of the process.
/// </summary>
public ResourcePolicyBuilder SetMinWorkingSet(nint? minWorkingSet)
{
_minWorkingSet = minWorkingSet;
return this;
}

/// <summary>
/// Sets the maximum working set size of the process.
/// </summary>
public ResourcePolicyBuilder SetMaxWorkingSet(nint? maxWorkingSet)
{
_maxWorkingSet = maxWorkingSet;
return this;
}

/// <summary>
/// Builds the resulting resource policy.
/// </summary>
public ResourcePolicy Build() => new(_priority, _affinity, _minWorkingSet, _maxWorkingSet);
}
32 changes: 29 additions & 3 deletions CliWrap/Command.Execution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Expand Down Expand Up @@ -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.
Expand Down
Loading