// Copyright Keysight Technologies 2012-2019 // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, you can obtain one at http://mozilla.org/MPL/2.0/. using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Text; using System.Threading; namespace OpenTap.Plugins.BasicSteps { [Display("Run Program", Group: "OpenTap", Description: "Runs a program, and optionally applies regular expressions (regex) to the output.",Order:1)] public class ProcessStep : RegexOutputStep { public class EnvironmentVariable : ValidatingObject { [Display("Name", "The name of the environment variable.")] public string Name { get; set; } [Display("Value", "The value of the environment variable.")] public string Value { get; set; } public EnvironmentVariable() { Rules.Add(() => !string.IsNullOrEmpty(Name), "Name must not be empty.", nameof(Name)); } public override bool Equals(object obj) { if (obj is EnvironmentVariable ev) { return ev.Name == Name && ev.Value == Value; } return base.Equals(obj); } public override int GetHashCode() { var h1 = Name?.GetHashCode() ?? 0; var h2 = Value?.GetHashCode() ?? 0; return (((h1 + 742759321) * 1593213024) + h2) * 1079741372; } } public override bool GeneratesOutput => WaitForEnd; [Display("Application", Order: -2.5, Description: "The path to the program. It should contain either a relative path to OpenTAP installation folder or an absolute path to the program.")] [FilePath(FilePathAttribute.BehaviorChoice.Open)] public string Application { get; set; } = ""; [Display("Command Line Arguments", Order: -2.4, Description: "The arguments passed to the program.")] [DefaultValue("")] public string Arguments { get; set; } = ""; [Display("Working Directory", Order: -2.3, Description: "The directory where the program will be started in.")] [DirectoryPath] public string WorkingDirectory { get; set; } = ""; [Display("Environment Variables", Order: -2.25, Description: "The environment variables passed to the program.")] public List EnvironmentVariables { get; set; } = new List(); [Display("Wait For Process to End", Order: -2.2, Description: "Wait for the process to terminate before continuing.")] [DefaultValue(true)] public bool WaitForEnd { get; set; } = true; int timeoutMs = 0; [Display("Wait Timeout", Order: -2.1, Description: "The time to wait for the process to end. Set to 0 to wait forever.")] [Unit("s", PreScaling: 1000)] [EnabledIf("WaitForEnd", true, HideIfDisabled = true)] public Int32 Timeout { get { return timeoutMs; } set { if (value >= 0) timeoutMs = value; else throw new Exception("Timeout must be positive"); } } [EnabledIf(nameof(GeneratesOutput), true, HideIfDisabled = true)] [Display("Add to Log", Order: -2.05, Description: "If enabled the result of the query is added to the log.")] public bool AddToLog { get; set; } [EnabledIf(nameof(AddToLog), true, HideIfDisabled = true)] [EnabledIf(nameof(GeneratesOutput), true, HideIfDisabled = true)] [Display("Log Header", Order: -2.0, Description: "This string is added to the front of the result of the query.")] [DefaultValue("")] public string LogHeader { get; set; } = ""; string prepend; [Display("Check Exit Code", "Check the exit code of the application and set verdict to fail if it is non-zero, else pass. 'Wait For End' must be set for this to work.", "Set Verdict", Order: 1.1)] [EnabledIf(nameof(WaitForEnd), true, HideIfDisabled = true)] public bool CheckExitCode { get; set; } [Display("Run As Administrator", "Attempt to run the application as administrator.", Order: -2.06)] internal bool RunElevated { get; set; } = false; // this is disabled for now. [Display("Exit Code", Group: "Results", Order: 1.53, Collapsed: true, Description: "The exit code of the process.")] [Output] [Browsable(true)] [EnabledIf(nameof(WaitForEnd), true, HideIfDisabled = true)] public int ExitCode { get; private set; } StringBuilder output; [Display("Output", Group: "Results", Order: 1.53, Collapsed: true, Description: "The result of the execution.")] [EnabledIf(nameof(GeneratesOutput), true, HideIfDisabled = true)] [Output] [Browsable(true)] [Layout(LayoutMode.Normal, maxRowHeight: 1)] public string Output => output?.ToString() ?? ""; public ProcessStep() { Rules.Add(HasNoDuplicateEnvironmentVariables, "Environment variable names must be unique.", nameof(EnvironmentVariables)); Rules.Add(new ValidationRule(() => !string.IsNullOrWhiteSpace(Application), "Application must be set", nameof(Application))); } private bool HasNoDuplicateEnvironmentVariables() { HashSet seenVariables = new HashSet(); foreach (var variable in EnvironmentVariables) { if (!seenVariables.Add(variable.Name)) { return false; } } return true; } public override void Run() { if (WaitForEnd == false) { Run0(); } else { // for timeouts we use the regular TapThread abort feature. // this way it will work like if the user clicked the stop button // in the test plan (except from the abort verdict). TapThread.WithNewContext(() => { if(timeoutMs > 0) TapThread.Current.AbortAfter(TimeSpan.FromMilliseconds(Timeout)); Run0(); }, TapThread.Current); } } void Run0() { output?.Clear(); ThrowOnValidationError(true); if (RunElevated &&!SubProcessHost.IsAdmin()) { // note, this part is currently never enabled. try { // Set RunElevated = false so ProcessHelper doesn't infinitely loop RunElevated = false; var processRunner = new SubProcessHost { ForwardLogs = AddToLog, LogHeader = LogHeader}; var verdict = processRunner.Run(this, true, TapThread.Current.AbortToken); UpgradeVerdict(verdict); return; } catch { UpgradeVerdict(Verdict.Error); throw; } finally { RunElevated = true; } } prepend = string.IsNullOrEmpty(LogHeader) ? "" : LogHeader + " "; var process = new Process { StartInfo = { FileName = Application, Arguments = Arguments, WorkingDirectory = string.IsNullOrEmpty(WorkingDirectory) ? Directory.GetCurrentDirectory() : Path.GetFullPath(WorkingDirectory), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = true, CreateNoWindow = true } }; foreach (var environmentVariable in EnvironmentVariables) { process.StartInfo.Environment.Add(environmentVariable.Name, environmentVariable.Value); } var startEvent = new ManualResetEventSlim(); var abortRegistration = TapThread.Current.AbortToken.Register(() => { startEvent.Wait(500); if (process.HasExited) return; Log.Debug("Ending process '{0}'.", Application); try { // process.Kill may throw if it has already exited. try { // signal to the sub process that no more input will arrive. // For many process this has the same effect as CTRL+C as stdin is closed. process.StandardInput.Close(); } catch { // this might be ok. It probably means that the input has already been closed. } if (!process.HasExited && !process.WaitForExit(500)) // give some time for the process to close by itself. { Thread.Sleep(100); if(process.HasExited == false) process.Kill(); } } catch(Exception ex) { Log.Warning("Caught exception when killing process. {0}", ex.Message); } }); if (WaitForEnd) { output = new StringBuilder(); using(process) using(abortRegistration) { process.OutputDataReceived += OutputDataRecv; process.ErrorDataReceived += ErrorDataRecv; Log.Debug("Starting process {0} with arguments \"{1}\"", Application, Arguments); // Ensure that all asynchronous processing is completed by calling process.WaitForExit() // Only the overload with no parameters has this guarantee. See remarks at // https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.process.waitforexit?view=netframework-4.8 var done = new ManualResetEventSlim(false); var elapsed = Stopwatch.StartNew(); process.Exited += (s, e) => { done.Set(); elapsed.Stop(); }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); startEvent.Set(); // Wait for the process to exit, allowing for cancellation every 100 ms while (process.WaitForExit(100) == false) if (TapThread.Current.AbortToken.IsCancellationRequested) break; while (process.HasExited == false && done.WaitHandle.WaitOne(100) == false) { if (process.HasExited) break; if (TapThread.Current.AbortToken.IsCancellationRequested) break; } if (TapThread.Current.AbortToken.IsCancellationRequested) { try { process.WaitForExit(500); } catch { // ok } } ExitCode = process.HasExited ? process.ExitCode : -1; if (elapsed.Elapsed.TotalMilliseconds < timeoutMs || timeoutMs == 0) { var resultData = output.ToString(); ProcessOutput(resultData); if (CheckExitCode) { if (ExitCode != 0) UpgradeVerdict(Verdict.Fail); else UpgradeVerdict(Verdict.Pass); } } else { process.OutputDataReceived -= OutputDataRecv; process.ErrorDataReceived -= ErrorDataRecv; var resultData = output.ToString(); ProcessOutput(resultData); if (!TapThread.Current.Parent.AbortToken.IsCancellationRequested) { Log.Info("Timed out while waiting for process to end."); Verdict = Verdict.Fail; } else { TapThread.ThrowIfAborted(); } } } } else { TapThread.Start(() => { using (process) using(abortRegistration) { process.Start(); process.WaitForExit(); } }); } } void OutputDataRecv(object sender, DataReceivedEventArgs e) { try { if (e.Data != null) { if(AddToLog) Log.Info("{0}{1}", prepend, e.Data); lock(output) output.AppendLine(e.Data); } } catch (ObjectDisposedException) { // Suppress - Test plan has been aborted and process is disconnected } } void ErrorDataRecv(object sender, DataReceivedEventArgs e) { try { if (e.Data != null) { if(AddToLog) Log.Error("{0}{1}", prepend, e.Data); lock(output) output.AppendLine(e.Data); } } catch (ObjectDisposedException) { // Suppress - Test plan has been aborted and process is disconnected } } } }