|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Internal;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Internal;
internal sealed class ProcessEx : IDisposable
{
private static readonly TimeSpan DefaultProcessTimeout = TimeSpan.FromMinutes(15);
private static readonly string NUGET_PACKAGES = GetNugetPackagesRestorePath();
private readonly ITestOutputHelper _output;
private readonly Process _process;
private readonly StringBuilder _stderrCapture;
private readonly StringBuilder _stdoutCapture;
private readonly object _pipeCaptureLock = new object();
private readonly object _testOutputLock = new object();
private BlockingCollection<string> _stdoutLines;
private readonly TaskCompletionSource<int> _exited;
private readonly CancellationTokenSource _stdoutLinesCancellationSource = new CancellationTokenSource(TimeSpan.FromMinutes(5));
private readonly CancellationTokenSource _processTimeoutCts;
private bool _disposed;
public ProcessEx(ITestOutputHelper output, Process proc, TimeSpan timeout)
{
_output = output;
_stdoutCapture = new StringBuilder();
_stderrCapture = new StringBuilder();
_stdoutLines = new BlockingCollection<string>();
_exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
_process = proc;
proc.EnableRaisingEvents = true;
proc.OutputDataReceived += OnOutputData;
proc.ErrorDataReceived += OnErrorData;
proc.Exited += OnProcessExited;
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
if (proc.HasExited)
{
OnProcessExited();
}
// We greedily create a timeout exception message even though a timeout is unlikely to happen for two reasons:
// 1. To make it less likely for Process getters to throw exceptions like "System.InvalidOperationException: Process has exited, ..."
// 2. To ensure if/when exceptions are thrown from Process getters, these exceptions can easily be observed.
var timeoutExMessage = $"Process proc {proc.ProcessName} {proc.StartInfo.Arguments} timed out after {timeout}.";
_processTimeoutCts = new CancellationTokenSource(timeout);
_processTimeoutCts.Token.Register(() =>
{
_exited.TrySetException(new TimeoutException(timeoutExMessage));
});
}
public Process Process => _process;
public Task Exited => _exited.Task;
public bool HasExited => _process.HasExited;
public string Error
{
get
{
lock (_pipeCaptureLock)
{
return _stderrCapture.ToString();
}
}
}
public string Output
{
get
{
lock (_pipeCaptureLock)
{
return _stdoutCapture.ToString();
}
}
}
public IEnumerable<string> OutputLinesAsEnumerable => _stdoutLines.GetConsumingEnumerable(_stdoutLinesCancellationSource.Token);
public int ExitCode => _process.ExitCode;
public object Id => _process.Id;
public static ProcessEx Run(ITestOutputHelper output, string workingDirectory, string command, string args = null, IDictionary<string, string> envVars = null, TimeSpan? timeout = default)
{
var startInfo = new ProcessStartInfo(command, args)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = workingDirectory
};
if (envVars != null)
{
foreach (var envVar in envVars)
{
startInfo.EnvironmentVariables[envVar.Key] = envVar.Value;
}
}
startInfo.EnvironmentVariables["NUGET_PACKAGES"] = NUGET_PACKAGES;
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("helix")))
{
startInfo.EnvironmentVariables["NUGET_FALLBACK_PACKAGES"] = Environment.GetEnvironmentVariable("NUGET_FALLBACK_PACKAGES");
}
output.WriteLine($"==> {startInfo.FileName} {startInfo.Arguments} [{startInfo.WorkingDirectory}]");
var proc = Process.Start(startInfo);
return new ProcessEx(output, proc, timeout ?? DefaultProcessTimeout);
}
private void OnErrorData(object sender, DataReceivedEventArgs e)
{
if (e.Data == null)
{
return;
}
lock (_pipeCaptureLock)
{
_stderrCapture.AppendLine(e.Data);
}
lock (_testOutputLock)
{
if (!_disposed)
{
_output.WriteLine("[ERROR] " + e.Data);
}
}
}
private void OnOutputData(object sender, DataReceivedEventArgs e)
{
if (e.Data == null)
{
return;
}
lock (_pipeCaptureLock)
{
_stdoutCapture.AppendLine(e.Data);
}
lock (_testOutputLock)
{
if (!_disposed)
{
_output.WriteLine(e.Data);
}
}
_stdoutLines?.Add(e.Data);
}
private void OnProcessExited(object sender = null, EventArgs e = null)
{
lock (_testOutputLock)
{
if (!_disposed)
{
_output.WriteLine("Process exited.");
}
}
// Don't remove this line - There is a race condition where the process exits and we grab the output before the stdout/stderr completed writing.
_process.WaitForExit();
_stdoutLines?.CompleteAdding();
_stdoutLines = null;
_exited.TrySetResult(_process.ExitCode);
}
internal string GetFormattedOutput()
{
if (!_process.HasExited)
{
Assert.Fail($"Process {_process.ProcessName} with pid: {_process.Id} has not finished running.");
}
return $"Process exited with code {_process.ExitCode}\nStdErr: {Error}\nStdOut: {Output}";
}
public void WaitForExit(bool assertSuccess, TimeSpan? timeSpan = null)
{
if (!timeSpan.HasValue)
{
timeSpan = TimeSpan.FromSeconds(600);
}
var exited = Exited.Wait(timeSpan.Value);
if (!exited)
{
lock (_testOutputLock)
{
_output.WriteLine($"The process didn't exit within the allotted time ({timeSpan.Value.TotalSeconds} seconds).");
}
_process.Dispose();
}
else if (assertSuccess && _process.ExitCode != 0)
{
Assert.Fail($"Process exited with code {_process.ExitCode}\nStdErr: {Error}\nStdOut: {Output}");
}
}
private static string GetNugetPackagesRestorePath() => (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NUGET_RESTORE")))
? typeof(ProcessEx).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(attribute => attribute.Key == "TestPackageRestorePath")
?.Value
: Environment.GetEnvironmentVariable("NUGET_RESTORE");
public void Dispose()
{
_processTimeoutCts.Dispose();
lock (_testOutputLock)
{
_disposed = true;
}
if (_process != null && !_process.HasExited)
{
_process.KillTree();
}
if (_process != null)
{
_process.CancelOutputRead();
_process.CancelErrorRead();
_process.ErrorDataReceived -= OnErrorData;
_process.OutputDataReceived -= OnOutputData;
_process.Exited -= OnProcessExited;
_process.Dispose();
}
}
}
|