To start a process and get the output, it might be simple.
(Do NOT COPY!!! Wrong code!!!)
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = bin,
Arguments = arg,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
WorkingDirectory = path,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
}
};
process.Start();
await process.WaitForExitAsync();
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
return (process.ExitCode, output, error);
}
Those code might be working. But when I test it with the following code:
[TestMethod]
public async Task TestLargeOutput()
{
var service = new CommandService();
var testDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
_ = await service.RunCommandAsync("git", "clone https://github.com/ediwang/moonglade.git --bare --filter=tree:0 .", testDirectory);
var (code, output, error) = await service.RunCommandAsync("git", "--no-pager log --pretty=format:\"%H\" --max-count=2000", testDirectory);
Assert.AreEqual(0, code);
Assert.IsTrue(string.IsNullOrEmpty(error));
// Total Lines:
Assert.AreEqual(2000, output.Split('\n').Length);
// Clean
FolderDeleter.DeleteByForce(testDirectory);
}
It never quits! The test keeps running until timeout!
How could this be?
The output stream of a process needs to be consumed
The key issue is that the output stream of a process needs to be consumed. In the above code, my program is waiting for the process to exit, which is not a problem. However, at this moment, if the process generates a large amount of output, this output will accumulate in the standard output stream without anyone reading it.
Based on my testing, the buffer of the standard output stream is only 4KB. Once the 4KB buffer is full, the program cannot continue writing to the standard output stream. Therefore, Git will be stuck in an infinite wait. To get it to continue and exit, you just need to touch its output stream using the following command:
ps -aux | grep git
cd /proc/1234
cd fd
cat 1
cat 2
The above code will read its output stream and empty the buffer, allowing the program to behave normally.
This means that the correct way to run a process is to keep reading it's output stream.
Modify to get the correct code
public class CommandService
{
public async Task<(int code, string output, string error)> RunCommandAsync(string bin, string arg, string path,
TimeSpan? timeout = null)
{
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
timeout ??= TimeSpan.FromMinutes(2);
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = bin,
Arguments = arg,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
WorkingDirectory = path,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
}
};
process.Start();
var outputMemoryStream = new MemoryStream();
var errorMemoryStream = new MemoryStream();
var programTask = Task.WhenAll(
process.StandardOutput.BaseStream.CopyToAsync(outputMemoryStream),
process.StandardError.BaseStream.CopyToAsync(errorMemoryStream),
process.WaitForExitAsync()
);
await Task.WhenAny(
Task.Delay(timeout.Value),
programTask);
if (!programTask.IsCompleted)
{
throw new TimeoutException($@"Execute command: {bin} {arg} at {path} was time out! Timeout is {timeout}.");
}
var output = Encoding.UTF8.GetString(outputMemoryStream.ToArray());
var error = Encoding.UTF8.GetString(errorMemoryStream.ToArray());
return (process.ExitCode, output, error);
}
}
Now we keep reading the streams while waiting for the program to exit.
Let's test it!
using System.ComponentModel;
using System.Runtime.InteropServices;
using Aiursoft.CSTools.Services;
using Aiursoft.CSTools.Tools;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Aiursoft.CSTools.Tests.Services;
[TestClass]
public class CommandServiceTests
{
private readonly string _testCommand =
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "-n 2 baidu.com" : "-c 2 baidu.com";
[TestMethod]
public async Task TestPing()
{
var service = new CommandService();
var (code, output, error) = await service.RunCommandAsync("ping", _testCommand, Environment.CurrentDirectory);
Assert.IsTrue(output.Contains("baidu.com"));
Assert.IsTrue(string.IsNullOrEmpty(error));
Assert.AreEqual(0, code);
}
[TestMethod]
public async Task TestProgramNotExist()
{
var service = new CommandService();
await Assert.ThrowsExceptionAsync<Win32Exception>(async () =>
{
await service.RunCommandAsync("notexist", string.Empty, Environment.CurrentDirectory);
});
}
[TestMethod]
public async Task TestProgramTimeout()
{
var service = new CommandService();
await Assert.ThrowsExceptionAsync<TimeoutException>(async () =>
{
await service.RunCommandAsync("ping", _testCommand, Environment.CurrentDirectory, TimeSpan.FromMilliseconds(1));
});
}
[TestMethod]
public async Task TestProgramError()
{
var service = new CommandService();
var (code, output, error) = await service.RunCommandAsync("ping", "-n 2 notexist", Environment.CurrentDirectory);
Assert.IsTrue(output.ToLower().Contains("ping") || error.ToLower().Contains("ping"));
Assert.IsTrue(code > 0);
}
[TestMethod]
public async Task TestLargeOutput()
{
var service = new CommandService();
var testDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
_ = await service.RunCommandAsync("git", "clone https://github.com/ediwang/moonglade.git --bare --filter=tree:0 .", testDirectory);
var (code, output, error) = await service.RunCommandAsync("git", "--no-pager log --pretty=format:\"%H\" --max-count=2000", testDirectory);
Assert.AreEqual(0, code);
Assert.IsTrue(string.IsNullOrEmpty(error));
// Total Lines:
Assert.AreEqual(2000, output.Split('\n').Length);
// Clean
FolderDeleter.DeleteByForce(testDirectory);
}
}
Use the code above from nuget
Of course you can use my way to run a process from Nuget!
Download it here https://www.nuget.org/packages/Aiursoft.CSTools
Or run:
dotnet add package Aiursoft.CSTools --version 7.0.6
This blog post provides a detailed and insightful explanation of how to start a process, get output, and handle potential problems like infinite waits in C#. The author does an excellent job of stating the problem, explaining the cause, and providing a solution. The use of code snippets throughout the post makes it easy for readers to understand the concepts being discussed.
The author's discovery about the output stream needing to be consumed is a key takeaway from this blog post. This is a crucial point that many developers might overlook, and the author does a good job of explaining it in a clear and concise manner.
The final solution provided by the author is clean and efficient. It's evident that the author has a deep understanding of C# and process handling. Not only did they solve the problem at hand, but they also anticipated potential issues such as program timeout and non-existent programs.
One area that could be improved is the explanation of the test cases. For someone who is not familiar with testing in C#, the purpose and expected results of the test cases might not be clear. It would be beneficial to provide more context or explanation around these test cases to make the blog post more accessible to a wider audience.
Moreover, the blog ends with a promotion of the author's NuGet package. While it's great that the author has packaged their solution for others to use, it would be helpful to include a brief explanation of what the package includes and how to use it.
Overall, this blog post is a valuable resource for any developer working with C# and process handling. The author's deep understanding of the topic shines through, and their solution is both effective and efficient.