build: initial project release
All checks were successful
release / dotnet-release-workflow (push) Successful in 1m23s

This commit is contained in:
Louis Seubert 2026-01-20 22:41:16 +01:00
commit 48c483c568
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
62 changed files with 4957 additions and 0 deletions

View file

@ -0,0 +1,6 @@
[*.{cs,vb}]
# disable IDE0060: Remove unused parameter
dotnet_diagnostic.IDE0060.severity = none
# disable IDE0005: Unnecessary using directive
dotnet_diagnostic.IDE0005.severity = none

View file

@ -0,0 +1,13 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal abstract class AsyncOutputCommand<T> : AsyncCommand<T> where T : OutputCommandSettings
{
}
internal abstract class OutputCommandSettings : CommandSettings
{
[CommandOption("--target")] public OutputTarget Target { get; init; } = OutputTarget.StdOut;
}

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<PropertyGroup>
<RootNamespace>Geekeey.Process</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" PrivateAssets="compile" />
<PackageReference Include="Spectre.Console.Cli" PrivateAssets="compile" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,42 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
internal sealed class Output : IDisposable
{
private readonly CancellationTokenSource _cts = new();
public Output()
{
Console.CancelKeyPress += Cancel;
}
public StreamReader Stdin { get; } = new(Console.OpenStandardInput(), leaveOpen: false);
public StreamWriter Stdout { get; } = new(Console.OpenStandardOutput(), leaveOpen: false);
public StreamWriter Stderr { get; } = new(Console.OpenStandardError(), leaveOpen: false);
public CancellationToken CancellationToken => _cts.Token;
public static Output Connect()
{
return new Output();
}
private void Cancel(object? sender, ConsoleCancelEventArgs args)
{
args.Cancel = true;
_cts.Cancel();
}
public void Dispose()
{
Stdout.BaseStream.Flush();
Stdout.Dispose();
Stderr.BaseStream.Flush();
Stderr.Dispose();
Stdin.Dispose();
Console.CancelKeyPress -= Cancel;
_cts.Dispose();
}
}

View file

@ -0,0 +1,26 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
[Flags]
internal enum OutputTarget
{
StdOut = 1,
StdErr = 2,
All = StdOut | StdErr
}
internal static class OutputTargetExtensions
{
public static IEnumerable<StreamWriter> GetWriters(this Output output, OutputTarget target)
{
if (target.HasFlag(OutputTarget.StdOut))
{
yield return output.Stdout;
}
if (target.HasFlag(OutputTarget.StdErr))
{
yield return output.Stderr;
}
}
}

View file

@ -0,0 +1,45 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Reflection;
using System.Runtime.InteropServices;
using Spectre.Console.Cli;
namespace Geekeey.Process.Testing.Fixture;
public static class Program
{
private static readonly string? FileExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "exe" : null;
#pragma warning disable IL3000 // only for testing where we don't run in single files!
private static readonly string AssemblyPath = Assembly.GetExecutingAssembly().Location;
#pragma warning restore IL3000
public static string FilePath { get; } = Path.ChangeExtension(AssemblyPath, FileExtension);
private static Task<int> Main(string[] args)
{
Environment.SetEnvironmentVariable("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "false");
var app = new CommandApp();
app.Configure(Configuration);
return app.RunAsync(args);
static void Configuration(IConfigurator configuration)
{
configuration.AddCommand<EchoCommand>("echo");
configuration.AddCommand<EchoStdinCommand>("echo-stdin");
configuration.AddCommand<EnvironmentCommand>("env");
configuration.AddCommand<WorkingDirectoryCommand>("cwd");
configuration.AddCommand<WorkingDirectoryCommand>("cwd");
configuration.AddCommand<ExitCommand>("exit");
configuration.AddCommand<LengthCommand>("length");
configuration.AddCommand<SleepCommand>("sleep");
configuration.AddBranch("generate", static generate =>
{
generate.AddCommand<GenerateBlobCommand>("blob");
generate.AddCommand<GenerateClobCommand>("clob");
});
}
}
}

View file

@ -0,0 +1,25 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class EchoCommand : AsyncOutputCommand<EchoCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--separator <char>")] public string Separator { get; init; } = " ";
[CommandArgument(0, "[line]")] public string[] Items { get; init; } = [];
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(string.Join(settings.Separator, settings.Items));
}
return 0;
}
}

View file

@ -0,0 +1,41 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using Spectre.Console.Cli;
internal sealed class EchoStdinCommand : AsyncOutputCommand<EchoStdinCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--length")] public long Length { get; init; } = long.MaxValue;
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
using var buffer = MemoryPool<byte>.Shared.Rent(81920);
var count = 0L;
while (count < settings.Length)
{
var bytesWanted = (int)Math.Min(buffer.Memory.Length, settings.Length - count);
var bytesRead = await output.Stdin.BaseStream.ReadAsync(buffer.Memory[..bytesWanted], cancellationToken);
if (bytesRead <= 0)
{
break;
}
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.BaseStream.WriteAsync(buffer.Memory[..bytesRead], cancellationToken);
}
count += bytesRead;
}
return 0;
}
}

View file

@ -0,0 +1,29 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class EnvironmentCommand : AsyncOutputCommand<EnvironmentCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
[CommandArgument(0, "<ARGUMENT>")] public string[] Variables { get; init; } = [];
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
foreach (var name in settings.Variables)
{
var value = Environment.GetEnvironmentVariable(name) ?? string.Empty;
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(value);
}
}
return 0;
}
}

View file

@ -0,0 +1,21 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class ExitCommand : AsyncCommand<ExitCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandArgument(1, "<code>")] public int Code { get; init; }
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
await output.Stderr.WriteLineAsync($"Exit code set to {settings.Code}");
return settings.Code;
}
}

View file

@ -0,0 +1,40 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using Spectre.Console.Cli;
internal sealed class GenerateBlobCommand : AsyncOutputCommand<GenerateBlobCommand.Settings>
{
private readonly Random _random = new(1234567);
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--length")] public long Length { get; init; } = 100_000;
[CommandOption("--buffer")] public int BufferSize { get; init; } = 1024;
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
using var bytes = MemoryPool<byte>.Shared.Rent(settings.BufferSize);
var total = 0L;
while (total < settings.Length)
{
_random.NextBytes(bytes.Memory.Span);
var count = (int)Math.Min(bytes.Memory.Length, settings.Length - total);
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.BaseStream.WriteAsync(bytes.Memory[..count], cancellationToken);
}
total += count;
}
return 0;
}
}

View file

@ -0,0 +1,42 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
using Spectre.Console.Cli;
internal sealed class GenerateClobCommand : AsyncOutputCommand<GenerateClobCommand.Settings>
{
private readonly Random _random = new(1234567);
private readonly char[] _chars = [.. Enumerable.Range(32, 94).Select(i => (char)i)];
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--length")] public int Length { get; init; } = 100_000;
[CommandOption("--lines")] public int LinesCount { get; init; } = 1;
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
var buffer = new StringBuilder(settings.Length);
for (var line = 0; line < settings.LinesCount; line++)
{
buffer.Clear();
for (var i = 0; i < settings.Length; i++)
{
buffer.Append(_chars[_random.Next(0, _chars.Length)]);
}
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(buffer.ToString());
}
}
return 0;
}
}

View file

@ -0,0 +1,40 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using System.Globalization;
using Spectre.Console.Cli;
internal sealed class LengthCommand : AsyncOutputCommand<LengthCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
using var buffer = MemoryPool<byte>.Shared.Rent(81920);
var count = 0L;
while (true)
{
var bytesRead = await output.Stdin.BaseStream.ReadAsync(buffer.Memory, cancellationToken);
if (bytesRead <= 0)
{
break;
}
count += bytesRead;
}
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(count.ToString(CultureInfo.InvariantCulture));
}
return 0;
}
}

View file

@ -0,0 +1,34 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class SleepCommand : AsyncCommand<SleepCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandArgument(0, "[duration]")] public TimeSpan Duration { get; init; } = TimeSpan.FromSeconds(1);
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
try
{
await Console.Out.WriteLineAsync($"Sleeping for {settings.Duration}...");
await Console.Out.FlushAsync(CancellationToken.None);
await Task.Delay(settings.Duration, output.CancellationToken);
}
catch (OperationCanceledException)
{
await Console.Out.WriteLineAsync("Canceled.");
await Console.Out.FlushAsync(CancellationToken.None);
}
await Console.Out.WriteLineAsync("Done.");
await Console.Out.FlushAsync(CancellationToken.None);
return 0;
}
}

View file

@ -0,0 +1,23 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class WorkingDirectoryCommand : AsyncOutputCommand<WorkingDirectoryCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(Directory.GetCurrentDirectory());
}
return 0;
}
}

View file

@ -0,0 +1,6 @@
[*.{cs,vb}]
# disable IDE0060: Remove unused parameter
dotnet_diagnostic.IDE0060.severity = none
# disable IDE0005: Unnecessary using directive
dotnet_diagnostic.IDE0005.severity = none

View file

@ -0,0 +1,181 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
using Geekeey.Process.Buffered;
namespace Geekeey.Process.Tests;
internal sealed class CancellationTests
{
private static Action<string> NotifyOnStart(out TaskCompletionSource tcs)
{
// run the continuation async on the thread pool to allow the io reader to complete
var source = tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
return line =>
{
if (line.Contains("Sleeping for", StringComparison.OrdinalIgnoreCase))
{
source.TrySetResult();
}
};
}
[Test]
public async Task I_can_execute_a_command_and_cancel_it_immediately()
{
// Arrange
using var cts = new CancellationTokenSource();
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteAsync(cts.Token);
await tcs.Task;
await cts.CancelAsync();
// Assert
await Assert.That(async () => await task).Throws<OperationCanceledException>();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).DoesNotContain("Done.");
}
}
[Test]
public async Task I_can_execute_a_command_and_kill_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteAsync();
await tcs.Task;
task.Kill();
// Assert
await Assert.That(async () => await task).Throws<CommandExecutionException>();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).DoesNotContain("Done.");
}
}
[Test]
public async Task I_can_execute_a_command_with_buffering_and_kill_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteBufferedAsync();
await tcs.Task;
task.Kill();
// Assert
await Assert.That(async () => await task).Throws<CommandExecutionException>();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).DoesNotContain("Done.");
}
}
[Test]
public async Task I_can_execute_a_command_and_interrupt_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteAsync();
await tcs.Task;
task.Interrupt();
// Assert
await Assert.That(async () => await task).ThrowsNothing();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).Contains("Done.");
}
}
[Test]
public async Task I_can_execute_a_command_with_buffering_and_interrupt_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteBufferedAsync();
await tcs.Task;
task.Interrupt();
// Assert
await Assert.That(async () => await task).ThrowsNothing();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).Contains("Done.");
}
}
}

View file

@ -0,0 +1,273 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class CommandTests
{
[Test]
public async Task I_can_create_a_command_with_the_default_configuration()
{
var cmd = new Command("foo");
using (Assert.Multiple())
{
await Assert.That(cmd.TargetFilePath).IsEqualTo("foo");
await Assert.That(cmd.Arguments).IsEmpty();
await Assert.That(cmd.WorkingDirPath).IsEqualTo(Directory.GetCurrentDirectory());
await Assert.That(cmd.Environment).IsEmpty();
await Assert.That(cmd.Validation).HasFlag(ValidationMode.ZeroExitCode);
await Assert.That(cmd.StandardInputPipe).IsEqualTo(PipeSource.Null);
await Assert.That(cmd.StandardOutputPipe).IsEqualTo(PipeTarget.Null);
await Assert.That(cmd.StandardErrorPipe).IsEqualTo(PipeTarget.Null);
}
}
[Test]
public async Task I_can_configure_the_target_file()
{
var cmd = new Command("foo");
var modified = cmd.WithTargetFile("bar");
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo("bar");
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.TargetFilePath).IsNotEqualTo("bar");
}
}
[Test]
public async Task I_can_configure_the_command_line_arguments()
{
var cmd = new Command("foo").WithArguments("xxx");
var modified = cmd.WithArguments("abc def");
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo("abc def");
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Arguments).IsNotEqualTo("abc def");
}
}
[Test]
public async Task I_can_configure_the_command_line_arguments_by_passing_an_array()
{
var cmd = new Command("foo").WithArguments("xxx");
var modified = cmd.WithArguments(["abc", "def"]);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo("abc def");
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Arguments).IsNotEqualTo("abc def");
}
}
[Test]
public async Task I_can_configure_the_command_line_arguments_using_a_builder()
{
var cmd = new Command("foo").WithArguments("xxx");
var modified = cmd.WithArguments(args => args
.Add("-a")
.Add("foo bar")
.Add("\"foo\\\\bar\"")
.Add(3.14)
.Add(["foo", "bar"])
.Add([-10, 12.12]));
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12");
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Arguments).IsNotEqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12");
}
}
[Test]
public async Task I_can_configure_the_working_directory()
{
var cmd = new Command("foo").WithWorkingDirectory("xxx");
var modified = cmd.WithWorkingDirectory("new");
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo("new");
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.WorkingDirPath).IsNotEqualTo("new");
}
}
[Test]
public async Task I_can_configure_the_environment_variables()
{
var cmd = new Command("foo").WithEnvironment(e => e.Set("xxx", "xxx"));
var vars = new Dictionary<string, string?>
{
["name"] = "value",
["key"] = "door",
};
var modified = cmd.WithEnvironment(vars);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(vars);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Environment).IsNotEqualTo(vars);
}
}
[Test]
public async Task I_can_configure_the_environment_variables_using_a_builder()
{
var cmd = new Command("foo").WithEnvironment(e => e.Set("xxx", "xxx"));
var modified = cmd.WithEnvironment(env => env
.Set("name", "value")
.Set("key", "door")
.Set(new Dictionary<string, string?>
{
["zzz"] = "yyy",
["aaa"] = "bbb",
}));
using (Assert.Multiple())
{
var vars = new Dictionary<string, string?>
{
["name"] = "value",
["key"] = "door",
["zzz"] = "yyy",
["aaa"] = "bbb",
};
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEquivalentTo(vars);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Environment).IsNotEqualTo(vars);
}
}
[Test]
public async Task I_can_configure_the_result_validation_strategy()
{
var cmd = new Command("foo").WithExitValidation(ValidationMode.ZeroExitCode);
var modified = cmd.WithExitValidation(ValidationMode.None);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(ValidationMode.None);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Validation).IsNotEqualTo(ValidationMode.None);
}
}
[Test]
public async Task I_can_configure_the_stdin_pipe()
{
var cmd = new Command("foo").WithStandardInputPipe(PipeSource.Null);
var pipeSource = PipeSource.FromStream(Stream.Null);
var modified = cmd.WithStandardInputPipe(pipeSource);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(pipeSource);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.StandardInputPipe).IsNotEqualTo(pipeSource);
}
}
[Test]
public async Task I_can_configure_the_stdout_pipe()
{
var cmd = new Command("foo").WithStandardOutputPipe(PipeTarget.Null);
var pipeTarget = PipeTarget.ToStream(Stream.Null);
var modified = cmd.WithStandardOutputPipe(pipeTarget);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(pipeTarget);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.StandardOutputPipe).IsNotEqualTo(pipeTarget);
}
}
[Test]
public async Task I_can_configure_the_stderr_pipe()
{
var cmd = new Command("foo").WithStandardErrorPipe(PipeTarget.Null);
var pipeTarget = PipeTarget.ToStream(Stream.Null);
var modified = cmd.WithStandardErrorPipe(pipeTarget);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(pipeTarget);
await Assert.That(cmd.StandardErrorPipe).IsNotEqualTo(pipeTarget);
}
}
}

View file

@ -0,0 +1,139 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Geekeey.Process.Buffered;
namespace Geekeey.Process.Tests;
internal sealed class ExecuteTests
{
[Test]
public async Task I_can_execute_a_command_and_get_the_exit_code_and_execution_time()
{
// Arrange
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["echo"]);
// Act
var result = await cmd.ExecuteAsync();
await Assert.That(result.ExitCode).IsZero();
// Assert
using (Assert.Multiple())
{
await Assert.That(result.ExitCode).IsZero();
await Assert.That(result.IsSuccess).IsTrue();
await Assert.That(result.RunTime).IsGreaterThan(TimeSpan.Zero);
}
}
[Test]
public async Task I_can_execute_a_command_and_get_the_associated_process_id()
{
// Arrange
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["echo"]);
// Act
var task = cmd.ExecuteAsync();
// Assert
await Assert.That(task.ProcessId).IsNotZero();
await task;
}
[Test]
public async Task I_can_execute_a_command_with_a_configured_awaiter()
{
// Arrange
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["echo"]);
// Act + Assert
await cmd.ExecuteAsync().ConfigureAwait(false);
}
[Test]
public async Task I_can_try_to_execute_a_command_and_get_an_error_if_the_target_file_does_not_exist()
{
// Arrange
var cmd = new Command("some_exe_with_does_not_exits");
// Act + Assert
await Assert.That(() => cmd.ExecuteAsync()).Throws<InvalidOperationException>()
.WithInnerException();
}
[Test]
public async Task I_can_execute_a_command_with_a_custom_working_directory()
{
// Arrange
using var dir = TestTempDirectory.Create();
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments("cwd")
.WithWorkingDirectory(dir.Path);
// Act
var result = await cmd.ExecuteBufferedAsync();
await Assert.That(result.ExitCode).IsZero();
// Assert
var lines = result.StandardOutput.Split(Environment.NewLine);
await Assert.That(lines).Contains(dir.Path);
}
[Test]
public async Task I_can_execute_a_command_with_additional_environment_variables()
{
// Arrange
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["env", "foo", "bar"])
.WithEnvironment(env => env
.Set("foo", "hello")
.Set("bar", "world"));
// Act
var result = await cmd.ExecuteBufferedAsync();
await Assert.That(result.ExitCode).IsZero();
// Assert
var lines = result.StandardOutput.Split(Environment.NewLine);
await Assert.That(lines).Contains("hello");
await Assert.That(lines).Contains("world");
}
[Test]
public async Task I_can_execute_a_command_with_some_environment_variables_overwritten()
{
// Arrange
var key = Guid.NewGuid();
var variableToKeep = $"GKY_TEST_KEEP_{key}";
var variableToOverwrite = $"GKY_TEST_OVERWRITE_{key}";
var variableToUnset = $"GKY_TEST_UNSET_{key}";
using var a = TestEnvironment.Create(variableToKeep, "keep");
using var b = TestEnvironment.Create(variableToOverwrite, "overwrite");
using var c = TestEnvironment.Create(variableToUnset, "unset");
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["env", variableToKeep, variableToOverwrite, variableToUnset])
.WithEnvironment(env => env
.Set(variableToOverwrite, "overwritten")
.Set(variableToUnset, null));
// Act
var result = await cmd.ExecuteBufferedAsync();
await Assert.That(result.ExitCode).IsZero();
// Assert
var lines = result.StandardOutput.Split(Environment.NewLine);
await Assert.That(lines).Contains("keep");
await Assert.That(lines).Contains("overwritten");
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\process\Geekeey.Process.csproj" />
<ProjectReference Include="..\process.dummy.app\Geekeey.Process.Dummy.App.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,81 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class LineBreakTests
{
private static Command Echo()
{
return new Command(Testing.Fixture.Program.FilePath)
.WithArguments("echo-stdin");
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_newline()
{
// Arrange
const string data = "Foo\nBar\nBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLines).IsEquivalentTo(["Foo", "Bar", "Baz"]);
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return()
{
// Arrange
const string data = "Foo\rBar\rBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLines).IsEquivalentTo(["Foo", "Bar", "Baz"]);
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return_followed_by_newline()
{
// Arrange
const string data = "Foo\r\nBar\r\nBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLines).IsEquivalentTo(["Foo", "Bar", "Baz"]);
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_newline_while_including_empty_lines()
{
// Arrange
const string data = "Foo\r\rBar\n\nBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLines).IsEquivalentTo(["Foo", "", "Bar", "", "Baz"]);
}
}

View file

@ -0,0 +1,50 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Geekeey.Process.Buffered;
namespace Geekeey.Process.Tests;
internal sealed class PathResolutionTests
{
[Test]
public async Task I_can_execute_a_command_on_an_executable_using_its_short_name()
{
// Arrange
var cmd = new Command("dotnet")
.WithArguments("--version");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(result.ExitCode).IsEqualTo(0);
await Assert.That(result.StandardOutput.Trim()).Matches(@"^\d+\.\d+\.\d+$");
}
}
[Test]
[Platform(PlatformAttribute.Windows)]
public async Task I_can_execute_a_command_on_a_script_using_its_short_name()
{
// Arrange
using var dir = TestTempDirectory.Create();
await File.WriteAllTextAsync(Path.Combine(dir.Path, "script.cmd"), "@echo hi");
using var _1 = TestEnvironment.ExtendPath(dir.Path);
var cmd = new Command("script");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
// Assert
using (Assert.Multiple())
{
await Assert.That(result.ExitCode).IsEqualTo(0);
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("hi");
}
}
}

View file

@ -0,0 +1,536 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
using Geekeey.Process.Buffered;
namespace Geekeey.Process.Tests;
internal sealed class PipingTests
{
#region Stdin
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_an_async_anonymous_source()
{
// Arrange
var source = PipeSource.Create(async (destination, cancellationToken)
=> await destination.WriteAsync("Hello World!"u8.ToArray(), cancellationToken));
var cmd = source |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("Hello World!");
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_sync_anonymous_source()
{
// Arrange
var source = PipeSource.Create(destination
=> destination.Write("Hello World!"u8.ToArray()));
var cmd = source |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("Hello World!");
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_stream()
{
// Arrange
using var source = new MemoryStream("Hello World!"u8.ToArray());
var cmd = source |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("Hello World!");
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_memory()
{
// Arrange
var data = new ReadOnlyMemory<byte>("Hello World!"u8.ToArray());
var cmd = data |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("Hello World!");
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_byte_array()
{
// Arrange
var data = "Hello World!"u8.ToArray();
var cmd = data |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("Hello World!");
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_string()
{
// Arrange
var data = "Hello World!";
var cmd = data |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("Hello World!");
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_another_command()
{
// Arrange
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments("length");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("100000");
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_chain_of_commands()
{
// Arrange
var cmd =
"Hello world" |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments("echo-stdin") |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["echo-stdin", "--length", "5"]) |
new Command(Testing.Fixture.Program.FilePath)
.WithArguments("length");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("5");
}
#endregion
#region Stdout
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_an_async_anonymous_target()
{
// Arrange
using var stream = new MemoryStream();
var target = PipeTarget.Create(async (origin, cancellationToken) =>
// ReSharper disable once AccessToDisposedClosure
await origin.CopyToAsync(stream, cancellationToken)
);
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) |
target;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stream.Length).IsEqualTo(100_000);
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_sync_anonymous_target()
{
// Arrange
using var stream = new MemoryStream();
var target = PipeTarget.Create(origin =>
// ReSharper disable once AccessToDisposedClosure
origin.CopyTo(stream)
);
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) |
target;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stream.Length).IsEqualTo(100_000);
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_stream()
{
// Arrange
using var stream = new MemoryStream();
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) |
stream;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stream.Length).IsEqualTo(100_000);
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_string_builder()
{
// Arrange
var buffer = new StringBuilder();
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["echo", "Hello World!"]) |
buffer;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(buffer.ToString().Trim()).IsEqualTo("Hello World!");
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_an_async_delegate()
{
// Arrange
var stdOutLinesCount = 0;
async Task HandleStdOutAsync(string line)
{
await Task.Yield();
stdOutLinesCount++;
}
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "clob", "--lines", "100"]) |
HandleStdOutAsync;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLinesCount).IsEqualTo(100);
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_an_async_delegate_with_cancellation()
{
// Arrange
var stdOutLinesCount = 0;
async Task HandleStdOutAsync(string line, CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
stdOutLinesCount++;
}
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "clob", "--lines", "100"]) |
HandleStdOutAsync;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLinesCount).IsEqualTo(100);
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_sync_delegate()
{
// Arrange
var stdOutLinesCount = 0;
void HandleStdOut(string line)
{
stdOutLinesCount++;
}
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "clob", "--lines", "100"]) |
HandleStdOut;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLinesCount).IsEqualTo(100);
}
#endregion
#region Stdout & Stderr
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_stream()
{
// Arrange
using var stdOut = new MemoryStream();
using var stdErr = new MemoryStream();
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "blob", "--target", "all", "--length", "100000"]) |
(stdOut, stdErr);
// Act
await cmd.ExecuteAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(stdOut.Length).IsEqualTo(100_000);
await Assert.That(stdErr.Length).IsEqualTo(100_000);
}
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_string_builder()
{
// Arrange
var stdOutBuffer = new StringBuilder();
var stdErrBuffer = new StringBuilder();
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["echo", "Hello world!", "--target", "all"]) |
(stdOutBuffer, stdErrBuffer);
// Act
await cmd.ExecuteAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(stdOutBuffer.ToString().Trim()).IsEqualTo("Hello world!");
await Assert.That(stdErrBuffer.ToString().Trim()).IsEqualTo("Hello world!");
}
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_async_delegate()
{
// Arrange
var stdOutLinesCount = 0;
var stdErrLinesCount = 0;
async Task HandleStdOutAsync(string line)
{
await Task.Yield();
stdOutLinesCount++;
}
async Task HandleStdErrAsync(string line)
{
await Task.Yield();
stdErrLinesCount++;
}
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "clob", "--target", "all", "--lines", "100"]) |
(HandleStdOutAsync, HandleStdErrAsync);
// Act
await cmd.ExecuteAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(stdOutLinesCount).IsEqualTo(100);
await Assert.That(stdErrLinesCount).IsEqualTo(100);
}
}
[Test]
public async Task
I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_async_delegate_with_cancellation()
{
// Arrange
var stdOutLinesCount = 0;
var stdErrLinesCount = 0;
async Task HandleStdOutAsync(string line, CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
stdOutLinesCount++;
}
async Task HandleStdErrAsync(string line, CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
stdErrLinesCount++;
}
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "clob", "--target", "all", "--lines", "100"]) |
(HandleStdOutAsync, HandleStdErrAsync);
// Act
await cmd.ExecuteAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(stdOutLinesCount).IsEqualTo(100);
await Assert.That(stdErrLinesCount).IsEqualTo(100);
}
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_sync_delegate()
{
// Arrange
var stdOutLinesCount = 0;
var stdErrLinesCount = 0;
void HandleStdOut(string line)
{
stdOutLinesCount++;
}
void HandleStdErr(string line)
{
stdErrLinesCount++;
}
var cmd =
new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "clob", "--target", "all", "--lines", "100"]) |
(HandleStdOut, HandleStdErr);
// Act
await cmd.ExecuteAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(stdOutLinesCount).IsEqualTo(100);
await Assert.That(stdErrLinesCount).IsEqualTo(100);
}
}
#endregion
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_multiple_targets()
{
// Arrange
using var stream1 = new MemoryStream();
using var stream2 = new MemoryStream();
using var stream3 = new MemoryStream();
var target = PipeTarget.Merge(
PipeTarget.ToStream(stream1),
PipeTarget.ToStream(stream2),
PipeTarget.ToStream(stream3)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) |
target;
// Act
await cmd.ExecuteAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(stream1.Length).IsEqualTo(100_000);
await Assert.That(stream2.Length).IsEqualTo(100_000);
await Assert.That(stream3.Length).IsEqualTo(100_000);
await Assert.That(stream1.ToArray()).IsEquivalentTo(stream2.ToArray());
await Assert.That(stream2.ToArray()).IsEquivalentTo(stream3.ToArray());
}
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_multiple_hierarchical_targets()
{
// Arrange
using var stream1 = new MemoryStream();
using var stream2 = new MemoryStream();
using var stream3 = new MemoryStream();
using var stream4 = new MemoryStream();
var target = PipeTarget.Merge(
PipeTarget.ToStream(stream1),
PipeTarget.Merge(
PipeTarget.ToStream(stream2),
PipeTarget.Merge(
PipeTarget.ToStream(stream3),
PipeTarget.ToStream(stream4))));
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) |
target;
// Act
await cmd.ExecuteAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(stream1.Length).IsEqualTo(100_000);
await Assert.That(stream2.Length).IsEqualTo(100_000);
await Assert.That(stream3.Length).IsEqualTo(100_000);
await Assert.That(stream4.Length).IsEqualTo(100_000);
await Assert.That(stream1.ToArray()).IsEquivalentTo(stream2.ToArray());
await Assert.That(stream2.ToArray()).IsEquivalentTo(stream3.ToArray());
await Assert.That(stream3.ToArray()).IsEquivalentTo(stream4.ToArray());
}
}
}

View file

@ -0,0 +1,58 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Geekeey.Process.Buffered;
namespace Geekeey.Process.Tests;
internal sealed class ValidationTests
{
private static Command Exit()
{
return new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["exit", "1"]);
}
[Test]
public async Task I_can_try_to_execute_a_command_and_get_an_error_if_it_returns_a_non_zero_exit_code()
{
// Arrange
var cmd = Exit();
// Act & Assert
await Assert.That(async () => await cmd.ExecuteAsync()).Throws<CommandExecutionException>().And
.Member(static exception => exception.Message, static source => source.Contains("a non-zero exit code (1)")).And
.Member(static exception => exception.ExitCode, static source => source.IsEqualTo(1));
}
[Test]
public async Task I_can_try_to_execute_a_command_with_buffering_and_get_a_detailed_error_if_it_returns_a_non_zero_exit_code()
{
// Arrange
var cmd = Exit();
// Act & Assert
await Assert.That(async () => await cmd.ExecuteBufferedAsync()).Throws<CommandExecutionException>().And
.Member(static exception => exception.Message, static source => source.Contains("Exit code set to 1")).And
.Member(static exception => exception.ExitCode, static source => source.IsEqualTo(1));
}
[Test]
public async Task I_can_execute_a_command_without_validating_the_exit_code()
{
// Arrange
var cmd = Exit()
.WithExitValidation(ValidationMode.None);
// Act
var result = await cmd.ExecuteAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(result.ExitCode).IsEqualTo(1);
await Assert.That(result.IsSuccess).IsFalse();
}
}
}

View file

@ -0,0 +1,35 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class PlatformAttribute : SkipAttribute
{
// from the OperatingSystem definitions
public const string Browser = "BROWSER";
public const string Wasi = "WASI";
public const string Windows = "WINDOWS";
public const string Osx = "OSX";
public const string MacCatalyst = "MACCATALYST";
public const string Ios = "IOS";
public const string Tvos = "TVOS";
public const string Android = "ANDROID";
public const string Linux = "LINUX";
public const string Freebsd = "FREEBSD";
public const string Netbsd = "NETBSD";
public const string Illumos = "ILLUMOS";
public const string Solaris = "SOLARIS";
private readonly string[] _os;
public PlatformAttribute(params string[] os) : base("Test skipped on unsupported platform.")
{
_os = os;
}
public override Task<bool> ShouldSkip(TestRegisteredContext context)
{
return Task.FromResult(!_os.Any(OperatingSystem.IsOSPlatform));
}
}

View file

@ -0,0 +1,21 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal static class ProcessTree
{
public static bool HasExited(int id)
{
try
{
using var process = System.Diagnostics.Process.GetProcessById(id);
return process.HasExited;
}
catch
{
// GetProcessById throws if the process can not be found, which means it is not running!
return true;
}
}
}

View file

@ -0,0 +1,32 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class TestEnvironment : IDisposable
{
private readonly Action _action;
private TestEnvironment(Action action)
{
_action = action;
}
public static TestEnvironment Create(string name, string? value)
{
var lastValue = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
return new TestEnvironment(() => Environment.SetEnvironmentVariable(name, lastValue));
}
public static TestEnvironment ExtendPath(string path)
{
return Create("PATH", Environment.GetEnvironmentVariable("PATH") + Path.PathSeparator + path);
}
public void Dispose()
{
_action();
}
}

View file

@ -0,0 +1,34 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class TestTempDirectory : IDisposable
{
private TestTempDirectory(string path)
{
Path = path;
}
public static TestTempDirectory Create()
{
var location = System.Reflection.Assembly.GetExecutingAssembly().Location;
var pwd = System.IO.Path.GetDirectoryName(location) ?? Directory.GetCurrentDirectory();
var dirPath = System.IO.Path.Combine(pwd, "Temp", Guid.NewGuid().ToString());
Directory.CreateDirectory(dirPath);
return new TestTempDirectory(dirPath);
}
public string Path { get; }
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch (DirectoryNotFoundException) { }
}
}

View file

@ -0,0 +1,153 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Globalization;
using System.Text;
namespace Geekeey.Process;
/// <summary>
/// Builder that helps format command-line arguments into a string.
/// </summary>
public sealed partial class ArgumentsBuilder
{
private static readonly IFormatProvider DefaultFormatProvider = CultureInfo.InvariantCulture;
private readonly StringBuilder _buffer = new();
/// <summary>
/// Adds the specified value to the list of arguments.
/// </summary>
public ArgumentsBuilder Add(string value, bool escape = true)
{
if (_buffer.Length > 0)
{
_buffer.Append(' ');
}
_buffer.Append(escape ? Escape(value) : value);
return this;
}
/// <summary>
/// Adds the specified values to the list of arguments.
/// </summary>
public ArgumentsBuilder Add(IEnumerable<string> values, bool escape = true)
{
foreach (var value in values)
{
Add(value, escape);
}
return this;
}
/// <summary>
/// Adds the specified value to the list of arguments.
/// </summary>
public ArgumentsBuilder Add(IFormattable value, IFormatProvider formatProvider, bool escape = true)
{
return Add(value.ToString(null, formatProvider), escape);
}
/// <summary>
/// Adds the specified value to the list of arguments.
/// The value is converted to string using invariant culture.
/// </summary>
public ArgumentsBuilder Add(IFormattable value, bool escape = true)
{
return Add(value, DefaultFormatProvider, escape);
}
/// <summary>
/// Adds the specified values to the list of arguments.
/// </summary>
public ArgumentsBuilder Add(IEnumerable<IFormattable> values, IFormatProvider formatProvider, bool escape = true)
{
foreach (var value in values)
{
Add(value, formatProvider, escape);
}
return this;
}
/// <summary>
/// Adds the specified values to the list of arguments.
/// The values are converted to string using invariant culture.
/// </summary>
public ArgumentsBuilder Add(IEnumerable<IFormattable> values, bool escape = true)
{
return Add(values, DefaultFormatProvider, escape);
}
/// <summary>
/// Builds the resulting arguments string.
/// </summary>
public string Build()
{
return _buffer.ToString();
}
}
public partial class ArgumentsBuilder
{
private static string Escape(string argument)
{
// Short circuit if the argument is clean and doesn't need escaping
if (argument.Length > 0 && argument.All(c => !char.IsWhiteSpace(c) && c is not '"'))
{
return argument;
}
var buffer = new StringBuilder();
buffer.Append('"');
for (var i = 0; i < argument.Length;)
{
var c = argument[i++];
switch (c)
{
case '\\':
{
var backslashCount = 1;
while (i < argument.Length && argument[i] == '\\')
{
backslashCount++;
i++;
}
if (i == argument.Length)
{
buffer.Append('\\', backslashCount * 2);
}
else if (argument[i] == '"')
{
buffer.Append('\\', (backslashCount * 2) + 1).Append('"');
i++;
}
else
{
buffer.Append('\\', backslashCount);
}
break;
}
case '"':
buffer.Append('\\').Append('"');
break;
default:
buffer.Append(c);
break;
}
}
buffer.Append('"');
return buffer.ToString();
}
}

View file

@ -0,0 +1,91 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
namespace Geekeey.Process.Buffered;
/// <summary>
/// Buffered execution model.
/// </summary>
public static class BufferedCommandExtensions
{
/// <inheritdoc cref="BufferedCommandExtensions"/>
extension(Command command)
{
/// <summary>
/// Executes the command asynchronously with buffering.
/// Data written to the standard output and standard error streams is decoded as text
/// and returned as part of the result object.
/// </summary>
/// <remarks>
/// This method can be awaited.
/// </remarks>
public CommandTask<BufferedCommandResult> ExecuteBufferedAsync(Encoding standardOutputEncoding, Encoding standardErrorEncoding,
CancellationToken cancellationToken = default)
{
var stdOutBuffer = new StringBuilder();
var stdErrBuffer = new StringBuilder();
var stdOutPipe = PipeTarget.Merge(command.StandardOutputPipe,
PipeTarget.ToStringBuilder(stdOutBuffer, standardOutputEncoding));
var stdErrPipe = PipeTarget.Merge(command.StandardErrorPipe,
PipeTarget.ToStringBuilder(stdErrBuffer, standardErrorEncoding));
var commandWithPipes = command
.WithStandardOutputPipe(stdOutPipe)
.WithStandardErrorPipe(stdErrPipe);
return commandWithPipes
.ExecuteAsync(cancellationToken)
.Bind(async task =>
{
try
{
var result = await task;
return new BufferedCommandResult(result.ExitCode, result.StartTime, result.ExitTime,
stdOutBuffer.ToString(), stdErrBuffer.ToString());
}
catch (CommandExecutionException exception)
{
var message = $"""
Command execution failed, see the inner exception for details.
Standard error:
{stdErrBuffer.ToString().Trim()}
""";
throw new CommandExecutionException(exception.Command, exception.ExitCode, message, exception);
}
});
}
/// <summary>
/// Executes the command asynchronously with buffering.
/// Data written to the standard output and standard error streams is decoded as text
/// and returned as part of the result object.
/// </summary>
/// <remarks>
/// This method can be awaited.
/// </remarks>
public CommandTask<BufferedCommandResult> ExecuteBufferedAsync(Encoding encoding,
CancellationToken cancellationToken = default)
{
return command.ExecuteBufferedAsync(encoding, encoding, cancellationToken);
}
/// <summary>
/// Executes the command asynchronously with buffering.
/// Data written to the standard output and standard error streams is decoded as text
/// and returned as part of the result object.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
/// <remarks>
/// This method can be awaited.
/// </remarks>
public CommandTask<BufferedCommandResult> ExecuteBufferedAsync(CancellationToken cancellationToken = default)
{
return command.ExecuteBufferedAsync(Console.OutputEncoding, cancellationToken);
}
}
}

View file

@ -0,0 +1,51 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Buffered;
/// <summary>
/// Result of a command execution, with buffered text data from standard output and standard error streams.
/// </summary>
public partial class BufferedCommandResult : CommandResult
{
/// <summary>
/// Result of a command execution, with buffered text data from standard output and standard error streams.
/// </summary>
public BufferedCommandResult(int exitCode, DateTimeOffset startTime, DateTimeOffset exitTime, string standardOutput, string standardError)
: base(exitCode, startTime, exitTime)
{
StandardOutput = standardOutput;
StandardError = standardError;
}
/// <summary>
/// Standard output data produced by the underlying process.
/// </summary>
public string StandardOutput { get; }
/// <summary>
/// Standard error data produced by the underlying process.
/// </summary>
public string StandardError { get; }
/// <summary>
/// Deconstructs the result into its most important components.
/// </summary>
public void Deconstruct(out int exitCode, out string standardOutput, out string standardError)
{
exitCode = ExitCode;
standardOutput = StandardOutput;
standardError = StandardError;
}
}
public partial class BufferedCommandResult
{
/// <summary>
/// Converts the result to a string value that corresponds to the <see cref="StandardOutput" /> property.
/// </summary>
public static implicit operator string(BufferedCommandResult result)
{
return result.StandardOutput;
}
}

View file

@ -0,0 +1,129 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.Contracts;
namespace Geekeey.Process;
public sealed partial class Command
{
/// <summary>
/// Creates a copy of this command, setting the target file path to the specified value.
/// </summary>
[Pure]
public Command WithTargetFile(string targetFilePath)
{
return new Command(targetFilePath, Arguments, WorkingDirPath, Environment, Validation,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
}
/// <summary>
/// Creates a copy of this command, setting the arguments to the specified value.
/// </summary>
/// <remarks>
/// Avoid using this overload, as it requires the arguments to be escaped manually.
/// Formatting errors may lead to unexpected bugs and security vulnerabilities.
/// </remarks>
[Pure]
public Command WithArguments(string arguments)
{
return new Command(TargetFilePath, arguments, WorkingDirPath, Environment, Validation,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
}
/// <summary>
/// Creates a copy of this command, setting the arguments to the value
/// obtained by formatting the specified enumeration.
/// </summary>
[Pure]
public Command WithArguments(IEnumerable<string> arguments, bool escape = true)
{
return WithArguments(args => args.Add(arguments, escape));
}
/// <summary>
/// Creates a copy of this command, setting the arguments to the value
/// configured by the specified delegate.
/// </summary>
[Pure]
public Command WithArguments(Action<ArgumentsBuilder> configure)
{
var builder = new ArgumentsBuilder();
configure(builder);
return WithArguments(builder.Build());
}
/// <summary>
/// Creates a copy of this command, setting the working directory path to the specified value.
/// </summary>
[Pure]
public Command WithWorkingDirectory(string workingDirPath)
{
return new Command(TargetFilePath, Arguments, workingDirPath, Environment, Validation,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
}
/// <summary>
/// Creates a copy of this command, setting the environment variables to the specified value.
/// </summary>
[Pure]
public Command WithEnvironment(IReadOnlyDictionary<string, string?> environmentVariables)
{
return new Command(TargetFilePath, Arguments, WorkingDirPath, environmentVariables, Validation,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
}
/// <summary>
/// Creates a copy of this command, setting the environment variables to the value
/// configured by the specified delegate.
/// </summary>
[Pure]
public Command WithEnvironment(Action<EnvironmentVariablesBuilder> configure)
{
var builder = new EnvironmentVariablesBuilder();
configure(builder);
return WithEnvironment(builder.Build());
}
/// <summary>
/// Creates a copy of this command, setting the ExitMode options to the specified value.
/// </summary>
[Pure]
public Command WithExitValidation(ValidationMode validationMode)
{
return new Command(TargetFilePath, Arguments, WorkingDirPath, Environment, validationMode,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
}
/// <summary>
/// Creates a copy of this command, setting the standard input pipe to the specified source.
/// </summary>
[Pure]
public Command WithStandardInputPipe(PipeSource source)
{
return new Command(TargetFilePath, Arguments, WorkingDirPath, Environment, Validation,
source, StandardOutputPipe, StandardErrorPipe);
}
/// <summary>
/// Creates a copy of this command, setting the standard output pipe to the specified target.
/// </summary>
[Pure]
public Command WithStandardOutputPipe(PipeTarget target)
{
return new Command(TargetFilePath, Arguments, WorkingDirPath, Environment, Validation,
StandardInputPipe, target, StandardErrorPipe);
}
/// <summary>
/// Creates a copy of this command, setting the standard error pipe to the specified target.
/// </summary>
[Pure]
public Command WithStandardErrorPipe(PipeTarget target)
{
return new Command(TargetFilePath, Arguments, WorkingDirPath, Environment, Validation,
StandardInputPipe, StandardOutputPipe, target);
}
}

View file

@ -0,0 +1,231 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process;
public sealed partial class Command
{
private static readonly Lazy<string?> ProcessPathLazy = new(() =>
{
using var process = System.Diagnostics.Process.GetCurrentProcess();
return process.MainModule?.FileName;
});
private static readonly TimeSpan CancelWaitTimeout = TimeSpan.FromSeconds(5);
private static string? ProcessPath => ProcessPathLazy.Value;
/// <summary>
/// Executes the command asynchronously.
/// </summary>
/// <remarks>
/// This method can be awaited.
/// </remarks>
/// <exception cref="InvalidOperationException">The command failed to start.</exception>
/// <exception cref="CommandExecutionException">The executed command exits and the <see cref="Validation"/> was not met.</exception>
/// <exception cref="OperationCanceledException">The <paramref name="cancellationToken"/> was canceled and the process was killed.</exception>
public CommandTask<CommandResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var process = new Process
{
FileName = GetOptimallyQualifiedTargetFilePath(), //
Arguments = Arguments,
WorkingDirectory = WorkingDirPath,
};
foreach (var (key, value) in Environment)
{
if (value is not null)
{
process.Environment[key] = value;
}
else
{
// Null value means we should remove the variable
process.Environment.Remove(key);
}
}
if (!process.Start(out var exception))
{
var message = $"Failed to start a process with file path '{process.FileName}'. " +
$"Target file is not an executable or lacks execute permissions.";
throw new InvalidOperationException(message, exception);
}
// Extract the process ID before calling ExecuteAsync(), because the process may already be disposed by then.
var processId = process.Id;
var task = ExecuteAsync(process, cancellationToken);
return new CommandTask<CommandResult>(task, process, processId);
string GetOptimallyQualifiedTargetFilePath()
{
// Currently, we only need this workaround for script files on Windows, so short-circuit
// if we are on a different platform.
if (!OperatingSystem.IsWindows())
{
return TargetFilePath;
}
// Don't do anything for fully qualified paths or paths that already have an extension specified.
// System.Diagnostics.Process knows how to handle those without our help.
if (Path.IsPathFullyQualified(TargetFilePath) || !string.IsNullOrWhiteSpace(Path.GetExtension(TargetFilePath)))
{
return TargetFilePath;
}
return (
from probeDirPath in GetProbeDirectoryPaths()
where Directory.Exists(probeDirPath)
select Path.Combine(probeDirPath, TargetFilePath)
into baseFilePath
from extension in GetPathExtensions()
select Path.ChangeExtension(baseFilePath, extension)
).FirstOrDefault(File.Exists) ??
TargetFilePath;
static IEnumerable<string> GetProbeDirectoryPaths()
{
// Executable directory
if (!string.IsNullOrWhiteSpace(ProcessPath))
{
var processDirPath = Path.GetDirectoryName(ProcessPath);
if (!string.IsNullOrWhiteSpace(processDirPath))
{
yield return processDirPath;
}
}
// Working directory
yield return Directory.GetCurrentDirectory();
// Directories on the PATH
if (System.Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) is { } paths)
{
foreach (var path in paths)
{
yield return path;
}
}
}
static IEnumerable<string> GetPathExtensions()
{
if (System.Environment.GetEnvironmentVariable("PATHEXT")?.Split(Path.PathSeparator) is { } extensions)
{
foreach (var extension in extensions)
{
yield return extension;
}
}
else
{
yield return ".COM";
yield return ".EXE";
yield return ".BAT";
yield return ".CMD";
}
}
}
}
private async Task<CommandResult> ExecuteAsync(Process process, CancellationToken cancellationToken = default)
{
using var _ = process;
// timeout is triggered when the cancel timeout expires after we tried to stop the process
// -> release wait for exit and pumping tasks after that timeout
using var timeout = new CancellationTokenSource();
var stdout = PipeStdOutAsync(process, timeout.Token);
var stderr = PipeStdErrAsync(process, timeout.Token);
var stdin = PipeStdInAsync(process, timeout.Token);
var pump = Task.WhenAll(stdout, stderr, stdin);
await using var registration = cancellationToken.Register(Stop, (process, timeout));
// wait for the process to exit or cancellation to be requested when cancellation is requested,
// we try to stop the process and then wait for it to exit with a timeout.
// When the timeout expires, we cancel the pumping tasks as well as the wait for the process exit.
try
{
await process.WaitForExitAsync(CancellationToken.None).WaitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
}
// we still wait for the pumping to complete but ignore cancellation here
try
{
await pump;
}
catch (OperationCanceledException)
{
}
// if cancellation was requested, throw after the process was tried to stop
cancellationToken.ThrowIfCancellationRequested();
if (process.ExitCode is 0 || !Validation.HasFlag(ValidationMode.ZeroExitCode))
{
return new CommandResult(process.ExitCode, process.StartTime, process.ExitTime);
}
var message = $"Command execution failed because the underlying process ({process.FileName}#{process.Id}) " +
$"returned a non-zero exit code ({process.ExitCode}).";
throw new CommandExecutionException(this, process.ExitCode, message);
static void Stop(object? state)
{
if (state is (Process process, CancellationTokenSource timeout))
{
timeout.CancelAfter(CancelWaitTimeout);
process.Kill();
}
}
}
private async Task PipeStdOutAsync(Process process, CancellationToken cancellationToken = default)
{
await using (process.StandardOutput)
{
await StandardOutputPipe.CopyFromAsync(process.StandardOutput, cancellationToken);
}
}
private async Task PipeStdErrAsync(Process process, CancellationToken cancellationToken = default)
{
await using (process.StandardError)
{
await StandardErrorPipe.CopyFromAsync(process.StandardError, cancellationToken);
}
}
private async Task PipeStdInAsync(Process process, CancellationToken cancellationToken = default)
{
await using (process.StandardInput)
{
try
{
// Some streams do not support cancellation, so we add a fallback that drops the task and returns early.
// This is important with stdin because the process might finish before the pipe has been fully
// exhausted, and we don't want to wait for it.
await StandardInputPipe.CopyToAsync(process.StandardInput, cancellationToken).WaitAsync(cancellationToken);
}
// Expect IOException: "The pipe has been ended" (Windows) or "Broken pipe" (Unix). This may happen if the
// process is terminated before the pipe has been exhausted. It's not an exceptional situation because the
// process may not need the entire stdin to complete successfully. We also can't rely on process.HasExited
// here because of potential race conditions.
catch (IOException ex) when (ex.GetType() == typeof(IOException))
{
// Don't catch derived exceptions, such as FileNotFoundException, to avoid false positives.
}
}
}
}

View file

@ -0,0 +1,199 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.Contracts;
using System.Text;
namespace Geekeey.Process;
public sealed partial class Command
{
/// <summary>
/// Creates a new command that pipes its standard output to the specified target.
/// </summary>
[Pure]
public static Command operator |(Command source, PipeTarget target)
{
return source.WithStandardOutputPipe(target);
}
/// <summary>
/// Creates a new command that pipes its standard output to the specified stream.
/// </summary>
[Pure]
public static Command operator |(Command source, Stream target)
{
return source | PipeTarget.ToStream(target);
}
/// <summary>
/// Creates a new command that pipes its standard output to the specified string builder.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, StringBuilder target)
{
return source | PipeTarget.ToStringBuilder(target, Console.OutputEncoding);
}
/// <summary>
/// Creates a new command that pipes its standard output line-by-line to the specified
/// asynchronous delegate.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, Func<string, CancellationToken, Task> target)
{
return source | PipeTarget.ToDelegate(target, Console.OutputEncoding);
}
/// <summary>
/// Creates a new command that pipes its standard output line-by-line to the specified
/// asynchronous delegate.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, Func<string, Task> target)
{
return source | PipeTarget.ToDelegate(target, Console.OutputEncoding);
}
/// <summary>
/// Creates a new command that pipes its standard output line-by-line to the specified
/// synchronous delegate.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, Action<string> target)
{
return source | PipeTarget.ToDelegate(target, Console.OutputEncoding);
}
/// <summary>
/// Creates a new command that pipes its standard output and standard error to the
/// specified targets.
/// </summary>
[Pure]
public static Command operator |(Command source, (PipeTarget stdOut, PipeTarget stdErr) targets)
{
return source.WithStandardOutputPipe(targets.stdOut).WithStandardErrorPipe(targets.stdErr);
}
/// <summary>
/// Creates a new command that pipes its standard output and standard error to the
/// specified streams.
/// </summary>
[Pure]
public static Command operator |(Command source, (Stream stdOut, Stream stdErr) targets)
{
return source | (PipeTarget.ToStream(targets.stdOut), PipeTarget.ToStream(targets.stdErr));
}
/// <summary>
/// Creates a new command that pipes its standard output and standard error to the
/// specified string builders.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, (StringBuilder stdOut, StringBuilder stdErr) targets)
{
var stdout = PipeTarget.ToStringBuilder(targets.stdOut, Console.OutputEncoding);
var stderr = PipeTarget.ToStringBuilder(targets.stdErr, Console.OutputEncoding);
return source | (stdout, stderr);
}
/// <summary>
/// Creates a new command that pipes its standard output and standard error line-by-line
/// to the specified asynchronous delegates.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, (Func<string, CancellationToken, Task> stdOut, Func<string, CancellationToken, Task> stdErr) targets)
{
var stdout = PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding);
var stderr = PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding);
return source | (stdout, stderr);
}
/// <summary>
/// Creates a new command that pipes its standard output and standard error line-by-line
/// to the specified asynchronous delegates.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, (Func<string, Task> stdOut, Func<string, Task> stdErr) targets)
{
var stdout = PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding);
var stderr = PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding);
return source | (stdout, stderr);
}
/// <summary>
/// Creates a new command that pipes its standard output and standard error line-by-line
/// to the specified synchronous delegates.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, (Action<string> stdOut, Action<string> stdErr) targets)
{
var stdout = PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding);
var stderr = PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding);
return source | (stdout, stderr);
}
/// <summary>
/// Creates a new command that pipes its standard input from the specified source.
/// </summary>
[Pure]
public static Command operator |(PipeSource source, Command target)
{
return target.WithStandardInputPipe(source);
}
/// <summary>
/// Creates a new command that pipes its standard input from the specified stream.
/// </summary>
[Pure]
public static Command operator |(Stream source, Command target)
{
return PipeSource.FromStream(source) | target;
}
/// <summary>
/// Creates a new command that pipes its standard input from the specified memory buffer.
/// </summary>
[Pure]
public static Command operator |(ReadOnlyMemory<byte> source, Command target)
{
return PipeSource.FromBytes(source) | target;
}
/// <summary>
/// Creates a new command that pipes its standard input from the specified byte array.
/// </summary>
[Pure]
public static Command operator |(byte[] source, Command target)
{
return PipeSource.FromBytes(source) | target;
}
/// <summary>
/// Creates a new command that pipes its standard input from the specified string.
/// Uses <see cref="Console.InputEncoding" /> for encoding.
/// </summary>
[Pure]
public static Command operator |(string source, Command target)
{
return PipeSource.FromString(source, Console.InputEncoding) | target;
}
/// <summary>
/// Creates a new command that pipes its standard input from the standard output of the
/// specified command.
/// </summary>
[Pure]
public static Command operator |(Command source, Command target)
{
return PipeSource.FromCommand(source) | target;
}
}

84
src/process/Command.cs Normal file
View file

@ -0,0 +1,84 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.CodeAnalysis;
namespace Geekeey.Process;
/// <summary>
/// Instructions for running a process.
/// </summary>
public sealed partial class Command
{
/// <summary>
/// Initializes an instance of <see cref="Command" />.
/// </summary>
public Command(string targetFilePath, string arguments, string workingDirPath,
IReadOnlyDictionary<string, string?> environment, ValidationMode validation,
PipeSource standardInputPipe, PipeTarget standardOutputPipe, PipeTarget standardErrorPipe)
{
TargetFilePath = targetFilePath;
Arguments = arguments;
WorkingDirPath = workingDirPath;
Environment = environment;
Validation = validation;
StandardInputPipe = standardInputPipe;
StandardOutputPipe = standardOutputPipe;
StandardErrorPipe = standardErrorPipe;
}
/// <summary>
/// Initializes an instance of <see cref="Command" />.
/// </summary>
public Command(string targetFilePath) : this(targetFilePath, string.Empty, Directory.GetCurrentDirectory(),
new Dictionary<string, string?>(), ValidationMode.ZeroExitCode, PipeSource.Null, PipeTarget.Null, PipeTarget.Null)
{
}
/// <summary>
/// File path of the executable, batch file, or script, that this command runs.
/// </summary>
public string TargetFilePath { get; }
/// <summary>
/// File path of the executable, batch file, or script, that this command runs.
/// </summary>
public string Arguments { get; }
/// <summary>
/// File path of the executable, batch file, or script, that this command runs.
/// </summary>
public string WorkingDirPath { get; }
/// <summary>
/// Environment variables set for the underlying process.
/// </summary>
public IReadOnlyDictionary<string, string?> Environment { get; }
/// <summary>
/// Strategy for validating the result of the execution.
/// </summary>
public ValidationMode Validation { get; }
/// <summary>
/// Pipe source for the standard input stream of the underlying process.
/// </summary>
public PipeSource StandardInputPipe { get; }
/// <summary>
/// Pipe target for the standard output stream of the underlying process.
/// </summary>
public PipeTarget StandardOutputPipe { get; }
/// <summary>
/// Pipe target for the standard error stream of the underlying process.
/// </summary>
public PipeTarget StandardErrorPipe { get; }
/// <inheritdoc />
[ExcludeFromCodeCoverage]
public override string ToString()
{
return $"{TargetFilePath} {Arguments}";
}
}

View file

@ -0,0 +1,30 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process;
/// <summary>
/// Exception thrown when the command fails to execute correctly.
/// </summary>
public class CommandExecutionException : Exception
{
/// <summary>
/// Exception thrown when the command fails to execute correctly.
/// </summary>
public CommandExecutionException(Command command, int exitCode, string message, Exception? innerException = null)
: base(message, innerException)
{
Command = command;
ExitCode = exitCode;
}
/// <summary>
/// Command that triggered the exception.
/// </summary>
public Command Command { get; }
/// <summary>
/// Exit code returned by the process.
/// </summary>
public int ExitCode { get; }
}

View file

@ -0,0 +1,64 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process;
/// <summary>
/// Represents the result of a command execution.
/// </summary>
public partial class CommandResult
{
/// <summary>
/// Represents the result of a command execution.
/// </summary>
public CommandResult(int exitCode, DateTimeOffset startTime, DateTimeOffset exitTime)
{
ExitCode = exitCode;
StartTime = startTime;
ExitTime = exitTime;
}
/// <summary>
/// Exit code set by the underlying process.
/// </summary>
public int ExitCode { get; }
/// <summary>
/// Whether the command execution was successful (i.e. exit code is zero).
/// </summary>
public bool IsSuccess => ExitCode is 0;
/// <summary>
/// Time at which the command started executing.
/// </summary>
public DateTimeOffset StartTime { get; }
/// <summary>
/// Time at which the command finished executing.
/// </summary>
public DateTimeOffset ExitTime { get; }
/// <summary>
/// Total duration of the command execution.
/// </summary>
public TimeSpan RunTime => ExitTime - StartTime;
}
public partial class CommandResult
{
/// <summary>
/// Converts the result to an integer value that corresponds to the <see cref="ExitCode" /> property.
/// </summary>
public static implicit operator int(CommandResult result)
{
return result.ExitCode;
}
/// <summary>
/// Converts the result to a boolean value that corresponds to the <see cref="IsSuccess" /> property.
/// </summary>
public static implicit operator bool(CommandResult result)
{
return result.IsSuccess;
}
}

View file

@ -0,0 +1,94 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.CompilerServices;
namespace Geekeey.Process;
/// <summary>
/// Represents an asynchronous execution of a command.
/// </summary>
public sealed partial class CommandTask<TResult> : IDisposable
{
private readonly Process _process;
internal CommandTask(Task<TResult> task, Process process, int processId)
{
Task = task;
_process = process;
ProcessId = processId;
}
/// <summary>
/// Underlying task.
/// </summary>
public Task<TResult> Task { get; }
/// <summary>
/// Underlying process ID.
/// </summary>
public int ProcessId { get; }
internal CommandTask<T> Bind<T>(Func<Task<TResult>, Task<T>> transform)
{
return new CommandTask<T>(transform(Task), _process, ProcessId);
}
/// <summary>
/// Lazily maps the result of the task using the specified transform.
/// </summary>
internal CommandTask<T> Select<T>(Func<TResult, T> transform)
{
return Bind(async task => transform(await task));
}
/// <summary>
/// Signals the process with an interrupt request from the keyboard.
/// </summary>
public void Interrupt()
{
_process.Interrupt();
}
/// <summary>
/// Immediately stops the associated process and its descendent processes.
/// </summary>
public void Kill()
{
_process.Kill();
}
/// <summary>
/// Gets the awaiter of the underlying task.
/// Used to enable await expressions on this object.
/// </summary>
public TaskAwaiter<TResult> GetAwaiter()
{
return Task.GetAwaiter();
}
/// <summary>
/// Configures an awaiter used to await this task.
/// </summary>
public ConfiguredTaskAwaitable<TResult> ConfigureAwait(bool continueOnCapturedContext)
{
return Task.ConfigureAwait(continueOnCapturedContext);
}
/// <inheritdoc />
public void Dispose()
{
Task.Dispose();
}
}
public sealed partial class CommandTask<TResult>
{
/// <summary>
/// Converts the command task into a regular task.
/// </summary>
public static implicit operator Task<TResult>(CommandTask<TResult> commandTask)
{
return commandTask.Task;
}
}

View file

@ -0,0 +1,50 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process;
/// <summary>
/// Builder that helps configure environment variables.
/// </summary>
public sealed class EnvironmentVariablesBuilder
{
private readonly Dictionary<string, string?> _vars = new(StringComparer.Ordinal);
/// <summary>
/// Sets an environment variable with the specified name to the specified value.
/// </summary>
public EnvironmentVariablesBuilder Set(string name, string? value)
{
_vars[name] = value;
return this;
}
/// <summary>
/// Sets multiple environment variables from the specified sequence of key-value pairs.
/// </summary>
public EnvironmentVariablesBuilder Set(IEnumerable<KeyValuePair<string, string?>> variables)
{
foreach (var (name, value) in variables)
{
Set(name, value);
}
return this;
}
/// <summary>
/// Sets multiple environment variables from the specified dictionary.
/// </summary>
public EnvironmentVariablesBuilder Set(IReadOnlyDictionary<string, string?> variables)
{
return Set((IEnumerable<KeyValuePair<string, string?>>)variables);
}
/// <summary>
/// Builds the resulting environment variables.
/// </summary>
public IReadOnlyDictionary<string, string?> Build()
{
return new Dictionary<string, string?>(_vars, _vars.Comparer);
}
}

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
</PropertyGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- required because of native library import for libc -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
<PropertyGroup>
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
<PackageIcon>package-icon.png</PackageIcon>
<PackageProjectUrl>https://code.geekeey.de/geekeey/process/src/branch/main/src/process</PackageProjectUrl>
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,134 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
namespace Geekeey.Process;
internal sealed class MemoryBufferStream : Stream
{
public const int DefaultBufferSize = 81920;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly SemaphoreSlim _readLock = new(0, 1);
private IMemoryOwner<byte> _sharedBuffer = MemoryPool<byte>.Shared.Rent(DefaultBufferSize);
private int _sharedBufferBytes;
private int _sharedBufferBytesRead;
[ExcludeFromCodeCoverage] public override bool CanRead => true;
[ExcludeFromCodeCoverage] public override bool CanSeek => false;
[ExcludeFromCodeCoverage] public override bool CanWrite => true;
[ExcludeFromCodeCoverage] public override long Position { get; set; }
[ExcludeFromCodeCoverage] public override long Length => throw new NotSupportedException();
[ExcludeFromCodeCoverage]
public override void Write(byte[] buffer, int offset, int count)
{
WriteAsync(buffer, offset, count).GetAwaiter().GetResult();
}
[ExcludeFromCodeCoverage]
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await WriteAsync(buffer.AsMemory(offset, count), cancellationToken);
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
await _writeLock.WaitAsync(cancellationToken);
// Reset the buffer if the current one is too small for the incoming data
if (_sharedBuffer.Memory.Length < buffer.Length)
{
_sharedBuffer.Dispose();
_sharedBuffer = MemoryPool<byte>.Shared.Rent(buffer.Length);
}
buffer.CopyTo(_sharedBuffer.Memory);
_sharedBufferBytes = buffer.Length;
_sharedBufferBytesRead = 0;
_readLock.Release();
}
[ExcludeFromCodeCoverage]
public override int Read(byte[] buffer, int offset, int count)
{
return ReadAsync(buffer, offset, count).GetAwaiter().GetResult();
}
[ExcludeFromCodeCoverage]
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
return await ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
await _readLock.WaitAsync(cancellationToken);
var length = Math.Min(buffer.Length, _sharedBufferBytes - _sharedBufferBytesRead);
_sharedBuffer.Memory.Slice(_sharedBufferBytesRead, length).CopyTo(buffer);
_sharedBufferBytesRead += length;
// release the write lock if the consumer has finished reading all
// the previously written data.
if (_sharedBufferBytesRead >= _sharedBufferBytes)
{
_writeLock.Release();
}
// otherwise, release the read lock again so that the consumer can finish
// reading the data.
else
{
_readLock.Release();
}
return length;
}
public async Task ReportCompletionAsync(CancellationToken cancellationToken = default)
{
// write an empty buffer that will make ReadAsync(...) return 0, which signals the end of stream
await WriteAsync(Memory<byte>.Empty, cancellationToken);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_readLock.Dispose();
_writeLock.Dispose();
_sharedBuffer.Dispose();
}
base.Dispose(disposing);
}
[ExcludeFromCodeCoverage]
public override void Flush()
{
throw new NotSupportedException();
}
[ExcludeFromCodeCoverage]
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
[ExcludeFromCodeCoverage]
public override void SetLength(long value)
{
throw new NotSupportedException();
}
}

124
src/process/PipeSource.cs Normal file
View file

@ -0,0 +1,124 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
namespace Geekeey.Process;
/// <summary>
/// Represents a pipe for the process's standard input stream.
/// </summary>
public abstract partial class PipeSource
{
/// <summary>
/// Reads the binary content pushed into the pipe and writes it to the destination stream.
/// Destination stream represents the process's standard input stream.
/// </summary>
public abstract Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default);
}
public partial class PipeSource
{
private sealed class AnonymousPipeSource(Func<Stream, CancellationToken, Task> func) : PipeSource
{
public override async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default)
{
await func(destination, cancellationToken);
}
}
}
public abstract partial class PipeSource
{
/// <summary>
/// Pipe source that does not provide any data.
/// Functionally equivalent to a null device.
/// </summary>
public static PipeSource Null { get; } = Create((_, cancellationToken)
=> !cancellationToken.IsCancellationRequested ? Task.CompletedTask : Task.FromCanceled(cancellationToken));
/// <summary>
/// Creates an anonymous pipe source with the <see cref="CopyToAsync(Stream, CancellationToken)" /> method
/// implemented by the specified asynchronous delegate.
/// </summary>
public static PipeSource Create(Func<Stream, CancellationToken, Task> func)
{
return new AnonymousPipeSource(func);
}
/// <summary>
/// Creates an anonymous pipe source with the <see cref="CopyToAsync(Stream, CancellationToken)" /> method
/// implemented by the specified synchronous delegate.
/// </summary>
public static PipeSource Create(Action<Stream> action)
{
return Create((destination, _) =>
{
action(destination);
return Task.CompletedTask;
});
}
/// <summary>
/// Creates a pipe source that reads from the specified stream.
/// </summary>
public static PipeSource FromStream(Stream stream)
{
return Create(stream.CopyToAsync);
}
/// <summary>
/// Creates a pipe source that reads from the specified file.
/// </summary>
public static PipeSource FromFile(string filePath)
{
return Create(async (destination, cancellationToken) =>
{
await using var source = File.OpenRead(filePath);
await source.CopyToAsync(destination, cancellationToken);
});
}
/// <summary>
/// Creates a pipe source that reads from the specified memory buffer.
/// </summary>
public static PipeSource FromBytes(ReadOnlyMemory<byte> data)
{
return Create(async (destination, cancellationToken) =>
await destination.WriteAsync(data, cancellationToken));
}
/// <summary>
/// Creates a pipe source that reads from the specified byte array.
/// </summary>
public static PipeSource FromBytes(byte[] data)
{
return FromBytes((ReadOnlyMemory<byte>)data);
}
/// <summary>
/// Creates a pipe source that reads from the specified string.
/// </summary>
public static PipeSource FromString(string str, Encoding encoding)
{
return FromBytes(encoding.GetBytes(str));
}
/// <summary>
/// Creates a pipe source that reads from the specified string.
/// Uses <see cref="Console.InputEncoding" /> for encoding.
/// </summary>
public static PipeSource FromString(string str)
{
return FromString(str, Console.InputEncoding);
}
/// <summary>
/// Creates a pipe source that reads from the standard output of the specified command.
/// </summary>
public static PipeSource FromCommand(Command command)
{
return Create(async (destination, cancellationToken) =>
await command.WithStandardOutputPipe(PipeTarget.ToStream(destination)).ExecuteAsync(cancellationToken));
}
}

314
src/process/PipeTarget.cs Normal file
View file

@ -0,0 +1,314 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using System.Text;
namespace Geekeey.Process;
/// <summary>
/// Represents a pipe for the process's standard output or standard error stream.
/// </summary>
public abstract partial class PipeTarget
{
/// <summary>
/// Reads the binary content from the origin stream and pushes it into the pipe.
/// Origin stream represents the process's standard output or standard error stream.
/// </summary>
public abstract Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default);
}
public partial class PipeTarget
{
private const int DefaultBufferSize = 1024;
private sealed class AnonymousPipeTarget(Func<Stream, CancellationToken, Task> func) : PipeTarget
{
public override async Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default)
{
await func(origin, cancellationToken);
}
}
private sealed class AggregatePipeTarget(IReadOnlyList<PipeTarget> targets) : PipeTarget
{
public IReadOnlyList<PipeTarget> Targets { get; } = targets;
public override async Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// create a separate sub-stream for each target
var targetSubStreams = new Dictionary<PipeTarget, MemoryBufferStream>();
foreach (var target in Targets)
{
targetSubStreams[target] = new MemoryBufferStream();
}
try
{
// start piping in the background
async Task StartCopyAsync(KeyValuePair<PipeTarget, MemoryBufferStream> targetSubStream)
{
var (target, subStream) = targetSubStream;
try
{
// ReSharper disable once AccessToDisposedClosure
await target.CopyFromAsync(subStream, cts.Token);
}
catch
{
// abort the operation if any of the targets fail
// ReSharper disable once AccessToDisposedClosure
await cts.CancelAsync();
throw;
}
}
var readingTask = Task.WhenAll(targetSubStreams.Select(StartCopyAsync));
try
{
// read from the main stream and replicate the data to each sub-stream
using var buffer = MemoryPool<byte>.Shared.Rent(MemoryBufferStream.DefaultBufferSize);
while (true)
{
var bytesRead = await origin.ReadAsync(buffer.Memory, cts.Token);
if (bytesRead <= 0)
{
break;
}
foreach (var (_, subStream) in targetSubStreams)
{
await subStream.WriteAsync(buffer.Memory[..bytesRead], cts.Token);
}
}
// report that transmission is complete
foreach (var (_, subStream) in targetSubStreams)
{
await subStream.ReportCompletionAsync(cts.Token);
}
}
finally
{
// wait for all targets to finish and maybe propagate exceptions
await readingTask;
}
}
finally
{
foreach (var (_, stream) in targetSubStreams)
{
await stream.DisposeAsync();
}
}
}
}
}
public partial class PipeTarget
{
/// <summary>
/// Pipe target that discards all data. Functionally equivalent to a null device.
/// </summary>
/// <remarks>
/// Using this target results in the corresponding stream (standard output or standard error) not being opened for
/// the underlying process at all. In the vast majority of cases, this behavior should be functionally equivalent to
/// piping to a null stream, but without the performance overhead of consuming and discarding unneeded data. This
/// may be undesirable in certain situations, in which case it's recommended to pipe to a null stream explicitly
/// using <see cref="ToStream(Stream)" /> with <see cref="Stream.Null" />.
/// </remarks>
public static PipeTarget Null { get; } = Create((_, cancellationToken) =>
!cancellationToken.IsCancellationRequested ? Task.CompletedTask : Task.FromCanceled(cancellationToken));
/// <summary>
/// Creates an anonymous pipe target with the <see cref="CopyFromAsync(Stream, CancellationToken)" /> method
/// implemented by the specified asynchronous delegate.
/// </summary>
public static PipeTarget Create(Func<Stream, CancellationToken, Task> func)
{
return new AnonymousPipeTarget(func);
}
/// <summary>
/// Creates an anonymous pipe target with the <see cref="CopyFromAsync(Stream, CancellationToken)" /> method
/// implemented by the specified synchronous delegate.
/// </summary>
public static PipeTarget Create(Action<Stream> action)
{
return Create((origin, _) =>
{
action(origin);
return Task.CompletedTask;
});
}
/// <summary>
/// Creates a pipe target that writes to the specified stream.
/// </summary>
public static PipeTarget ToStream(Stream stream)
{
return Create(async (origin, cancellationToken) =>
await origin.CopyToAsync(stream, cancellationToken));
}
/// <summary>
/// Creates a pipe target that writes to the specified file.
/// </summary>
public static PipeTarget ToFile(string filePath)
{
return Create(async (origin, cancellationToken) =>
{
await using var target = File.Create(filePath);
await origin.CopyToAsync(target, cancellationToken);
});
}
/// <summary>
/// Creates a pipe target that writes to the specified string builder.
/// </summary>
public static PipeTarget ToStringBuilder(StringBuilder stringBuilder, Encoding encoding)
{
return Create(async (origin, cancellationToken) =>
{
using var reader = new StreamReader(origin, encoding, false, DefaultBufferSize, true);
using var buffer = MemoryPool<char>.Shared.Rent(DefaultBufferSize);
while (!cancellationToken.IsCancellationRequested)
{
var charsRead = await reader.ReadAsync(buffer.Memory, cancellationToken);
if (charsRead <= 0)
{
break;
}
stringBuilder.Append(buffer.Memory[..charsRead]);
}
});
}
/// <summary>
/// Creates a pipe target that writes to the specified string builder.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
public static PipeTarget ToStringBuilder(StringBuilder stringBuilder)
{
return ToStringBuilder(stringBuilder, Console.OutputEncoding);
}
/// <summary>
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
/// </summary>
public static PipeTarget ToDelegate(Func<string, CancellationToken, Task> func, Encoding encoding)
{
return Create(async (origin, cancellationToken) =>
{
using var reader = new StreamReader(origin, encoding, false, DefaultBufferSize, true);
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
await func(line, cancellationToken);
}
});
}
/// <summary>
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
public static PipeTarget ToDelegate(Func<string, CancellationToken, Task> func)
{
return ToDelegate(func, Console.OutputEncoding);
}
/// <summary>
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
/// </summary>
public static PipeTarget ToDelegate(Func<string, Task> func, Encoding encoding)
{
return ToDelegate(async (line, _) => await func(line), encoding);
}
/// <summary>
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
public static PipeTarget ToDelegate(Func<string, Task> func)
{
return ToDelegate(func, Console.OutputEncoding);
}
/// <summary>
/// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream.
/// </summary>
public static PipeTarget ToDelegate(Action<string> action, Encoding encoding)
{
return ToDelegate(
line =>
{
action(line);
return Task.CompletedTask;
}, encoding);
}
/// <summary>
/// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
public static PipeTarget ToDelegate(Action<string> action)
{
return ToDelegate(action, Console.OutputEncoding);
}
/// <summary>
/// Creates a pipe target that replicates data over multiple inner targets.
/// </summary>
public static PipeTarget Merge(params IEnumerable<PipeTarget> targets)
{
// optimize targets to avoid unnecessary piping
var optimizedTargets = OptimizeTargets(targets);
return optimizedTargets.Count switch
{
// avoid merging if there are no targets
0 => Null,
// avoid merging if there's only one target
1 => optimizedTargets.Single(),
_ => new AggregatePipeTarget(optimizedTargets)
};
static IReadOnlyList<PipeTarget> OptimizeTargets(IEnumerable<PipeTarget> targets)
{
var result = new List<PipeTarget>();
// unwrap merged targets
UnwrapTargets(targets, result);
// filter out no-op
result.RemoveAll(t => t == Null);
return result;
}
static void UnwrapTargets(IEnumerable<PipeTarget> targets, ICollection<PipeTarget> output)
{
foreach (var target in targets)
{
if (target is AggregatePipeTarget mergedTarget)
{
UnwrapTargets(mergedTarget.Targets, output);
}
else
{
output.Add(target);
}
}
}
}
}

View file

@ -0,0 +1,46 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Geekeey.Process;
internal sealed partial class Process
{
[SupportedOSPlatform("freebsd")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macOS")]
private static bool SendPosixSignal(int pid, PosixSignals signal)
{
return Posix.Kill(pid, (int)signal) is 0;
}
[SupportedOSPlatform("freebsd")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macOS")]
internal static partial class Posix
{
[LibraryImport("libc", EntryPoint = "kill", SetLastError = true)]
internal static partial int Kill(int pid, int sig);
}
private enum PosixSignals : int
{
SIGHUP = 1,
SIGINT = 2,
SIGQUIT = 3,
SIGILL = 4,
SIGTRAP = 5,
SIGABRT = 6,
SIGBUS = 7,
SIGFPE = 8,
SIGKILL = 9,
SIGUSR1 = 10,
SIGSEGV = 11,
SIGUSR2 = 12,
SIGPIPE = 13,
SIGALRM = 14,
SIGTERM = 15,
}
}

View file

@ -0,0 +1,33 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Geekeey.Process;
internal sealed partial class Process
{
[SupportedOSPlatform("windows")]
private static bool SendCtrlSignal(int processId, ConsoleCtrlEvent ctrl)
{
return Windows.GenerateConsoleCtrlEvent((uint)ctrl, (uint)processId);
}
[SupportedOSPlatform("windows")]
internal static partial class Windows
{
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
}
internal enum ConsoleCtrlEvent : uint
{
CTRL_C_EVENT = 0, // SIGINT
CTRL_BREAK_EVENT = 1, // SIGQUIT
CTRL_CLOSE_EVENT = 2, // SIGHUP
CTRL_LOGOFF_EVENT = 5, // SIGHUP
CTRL_SHUTDOWN_EVENT = 6, // SIGTERM
}
}

181
src/process/Process.cs Normal file
View file

@ -0,0 +1,181 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.ComponentModel;
using System.Diagnostics;
namespace Geekeey.Process;
internal sealed partial class Process : IDisposable
{
private readonly TaskCompletionSource _exit = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly System.Diagnostics.Process _process = new();
public Process()
{
// Redirect all standard streams
_process.StartInfo.RedirectStandardInput = true;
_process.StartInfo.RedirectStandardOutput = true;
_process.StartInfo.RedirectStandardError = true;
// Do not use the system shell to start the process
_process.StartInfo.UseShellExecute = false;
// This option only works on Windows and is required there to prevent the
// child processes from attaching to the parent console window if one exists.
// We need this to be able to send signals to one specific child process,
// without affecting any others that may also be running in parallel.
_process.StartInfo.CreateNoWindow = true;
// Only create a new process group on windows to allow sending ctrl-c/ctrl-break signals
// without affecting ourselves. This has the implication that the spawned process might not handle
// the ctrl-c/ctrl-break signals any more because the process is launched with the CREATE_NEW_PROCESS_GROUP flag.
// This is because it disables the default ctrl-c handling for the process.
// The process must reenable this behavior itself with a call to `SetConsoleCtrlHandler(null, false)`.
// > "If the HandlerRoutine parameter is NULL, a TRUE value causes the calling process to ignore CTRL+C input,
// > and a FALSE value restores normal processing of CTRL+C input.
// > This attribute of ignoring or processing CTRL+C is inherited by child processes."
if (OperatingSystem.IsWindows())
{
_process.StartInfo.CreateNewProcessGroup = true;
}
}
public int Id => _process.Id;
public string FileName
{
get => _process.StartInfo.FileName;
init => _process.StartInfo.FileName = value;
}
public string Arguments
{
get => _process.StartInfo.Arguments;
init => _process.StartInfo.Arguments = value;
}
public string WorkingDirectory
{
get => _process.StartInfo.WorkingDirectory;
init => _process.StartInfo.WorkingDirectory = value;
}
public IDictionary<string, string?> Environment => _process.StartInfo.Environment;
// we are purposely using Stream instead of StreamWriter/StreamReader to push the concerns of
// writing and reading to PipeSource/PipeTarget at the higher level.
public Stream StandardInput => _process.StartInfo.RedirectStandardInput ? _process.StandardInput.BaseStream : Stream.Null;
public Stream StandardOutput => _process.StartInfo.RedirectStandardOutput ? _process.StandardOutput.BaseStream : Stream.Null;
public Stream StandardError => _process.StartInfo.RedirectStandardError ? _process.StandardError.BaseStream : Stream.Null;
// we have to keep track of StartTime ourselves because it becomes inaccessible after the process exits
public DateTimeOffset StartTime { get; private set; }
// we have to keep track of ExitTime ourselves because it becomes inaccessible after the process exits
public DateTimeOffset ExitTime { get; private set; }
public int ExitCode => _process.ExitCode;
public bool Start(out Exception? exception)
{
exception = null;
_process.EnableRaisingEvents = true;
_process.Exited += OnProcessExited;
try
{
if (!_process.Start())
{
return false;
}
StartTime = DateTimeOffset.Now;
}
catch (Win32Exception value)
{
exception = value;
return false;
}
return true;
void OnProcessExited(object? _, EventArgs args)
{
_process.Exited -= OnProcessExited;
ExitTime = DateTimeOffset.Now;
_exit.TrySetResult();
}
}
public void Interrupt()
{
if (TryInterrupt())
{
return;
}
// In case of failure, revert to the default behavior of killing the process.
// Ideally, we should throw an exception here, but this method is called from
// a cancellation callback, which would prevent other callbacks from being called.
Kill();
Debug.Fail("Failed to send an interrupt signal.");
return;
bool TryInterrupt()
{
try
{
if (OperatingSystem.IsWindows())
{
return SendCtrlSignal(_process.Id, ConsoleCtrlEvent.CTRL_C_EVENT) ||
SendCtrlSignal(_process.Id, ConsoleCtrlEvent.CTRL_BREAK_EVENT);
}
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD())
{
return SendPosixSignal(_process.Id, PosixSignals.SIGINT) ||
SendPosixSignal(_process.Id, PosixSignals.SIGQUIT);
}
// Unsupported platform
return false;
}
catch
{
return false;
}
}
}
public void Kill()
{
try
{
_process.Kill(entireProcessTree: true);
}
catch when (_process.HasExited)
{
// The process has exited before we could kill it. This is fine.
}
catch
{
// The process either failed to exit or is in the process of exiting.
// We can't really do anything about it, so just ignore the exception.
Debug.Fail("Failed to kill the process.");
}
}
public async Task WaitForExitAsync(CancellationToken cancellationToken = default)
{
await _exit.Task.WaitAsync(cancellationToken);
}
public void Dispose()
{
_process.Dispose();
}
}

View file

@ -0,0 +1,21 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process;
/// <summary>
/// Strategy used for validating the result of a command execution.
/// </summary>
[Flags]
public enum ValidationMode
{
/// <summary>
/// No validation.
/// </summary>
None = 0b0,
/// <summary>
/// Ensure that the command returned a zero exit code.
/// </summary>
ZeroExitCode = 0b1,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,51 @@
Process is a library for interacting with external command-line interfaces. It provides a convenient model for launching
processes, redirecting input and output streams, awaiting completion, handling cancellation, and more.
## Usage
### Execute a command and capturing its output:
```csharp
public static async Task<int> Main()
{
var stdout = new StringBuilder();
var cmd = new Command("git").WithArguments(["config", "--get", "user.name"]) | stdout;
await cmd.ExecuteAsync();
Console.WriteLine(stdout.ToString());
return 0;
}
```
### Execute a command and redirect its output to another command:
```csharp
public static Task<int> Main()
{
var cmd = new Command("cat").WithArguments(["file.txt"]) | new Command("wc");
await cmd.ExecuteAsync();
Console.WriteLine(stdout.ToString());
}
```
### Execute a command with cancellation support:
```csharp
public static async Task<int> Main()
{
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (s, e) =>
{
e.Cancel = true;
cts.Cancel();
};
var cmd = new Command("long-running-command");
// kills the process if Ctrl+C is pressed
var app = cmd.ExecuteAsync(cts.Token);
// manually interrupt after 5 seconds
await Task.Delay(5000);
app.Interrupt();
// wait for process to exit
var result = await app;
return 0;
}
```