From 1ccd64f687707ad25a4be24414c9b4639a12d1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konrad=20Kruczy=C5=84ski?= Date: Thu, 7 Mar 2019 18:54:24 +0000 Subject: [PATCH] Added AttachedCommand. AttachedCommand is the Command used when attaching to already running process. --- MedallionShell.Tests/AttachingTests.cs | 107 +++++++++++++++ .../MedallionShell.Tests.csproj | 1 + .../PlatformCompatibilityTest.cs | 3 + MedallionShell/AttachedCommand.cs | 127 ++++++++++++++++++ MedallionShell/Command.cs | 17 +++ MedallionShell/ProcessCommand.cs | 115 +--------------- MedallionShell/ProcessHelper.cs | 123 +++++++++++++++++ MedallionShell/Shell.cs | 68 ++++++++++ SampleCommand/PlatformCompatibilityTests.cs | 10 ++ 9 files changed, 459 insertions(+), 112 deletions(-) create mode 100644 MedallionShell.Tests/AttachingTests.cs create mode 100644 MedallionShell/AttachedCommand.cs create mode 100644 MedallionShell/ProcessHelper.cs diff --git a/MedallionShell.Tests/AttachingTests.cs b/MedallionShell.Tests/AttachingTests.cs new file mode 100644 index 0000000..ec5856a --- /dev/null +++ b/MedallionShell.Tests/AttachingTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Threading; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Medallion.Shell.Tests +{ + [TestClass] + public class AttachingTests + { + [TestMethod] + public void TestAttachingToExistingProcess() + { + var processCommand = Command.Run("SampleCommand", new[] { "sleep", "10000" }); + var processId = processCommand.ProcessId; + Command.TryAttachToProcess(processId, out _) + .ShouldEqual(true, "Attaching to process failed."); + processCommand.Kill(); + } + + [TestMethod] + public void TestWaitingForAttachedProcessExit() + { + var processCommand = Command.Run("SampleCommand", new[] { "sleep", "100" }); + Command.TryAttachToProcess(processCommand.ProcessId, out var attachedCommand) + .ShouldEqual(true, "Attaching to process failed."); + var commandResult = attachedCommand.Task; + commandResult.IsCompleted.ShouldEqual(false, "Task has finished too early."); + Thread.Sleep(300); + commandResult.IsCompleted.ShouldEqual(true, "Task has not finished on time."); + } + + [TestMethod] + public void TestGettingExitCodeFromAttachedProcess() + { + var processCommand = Command.Run("SampleCommand", new[] { "exit", "16" }); + Command.TryAttachToProcess(processCommand.ProcessId, out var attachedCommand) + .ShouldEqual(true, "Attaching to process failed."); + var task = attachedCommand.Task; + task.Wait(1000).ShouldEqual(true, "Task has not finished on time."); + task.Result.ExitCode.ShouldEqual(16, "Exit code was not correct."); + } + + [TestMethod] + public void TestAttachingToNonExistingProcess() + { + var processCommand = Command.Run("SampleCommand", new[] { "exit", "0" }); + var processId = processCommand.ProcessId; + processCommand.Task.Wait(1000).ShouldEqual(true, "Process has not exited, test is inconclusive."); + Command.TryAttachToProcess(processId, out _) + .ShouldEqual(false, "Attaching succeeded although process has already exited."); + } + + [TestMethod] + public void TestKillingAttachedProcess() + { + var processCommand = Command.Run("SampleCommand", new[] { "sleep", "10000" }); + var processId = processCommand.ProcessId; + Command.TryAttachToProcess( + processId, + options => options.DisposeOnExit(false), + out var attachedCommand) + .ShouldEqual(true, "Attaching to process failed."); + + attachedCommand.Kill(); + attachedCommand.Process.HasExited + .ShouldEqual(true, "The process is still alive after Kill() has finished."); + } + + [TestMethod] + public void TestAttachingWithAlreadyCancelledToken() + { + var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + var processCommand = Command.Run("SampleCommand", new[] { "sleep", "10000" }); + var processId = processCommand.ProcessId; + Command.TryAttachToProcess( + processId, + options => options.CancellationToken(cancellationTokenSource.Token).DisposeOnExit(false), + out var attachedCommand); + attachedCommand.Process.HasExited.ShouldEqual(true, "The process wasn't killed."); + } + + [TestMethod] + public void TestTimeout() + { + const string expectedTimeoutexceptionDidNotOccur = "Expected TimeoutException did not occur."; + + var processCommand = Command.Run("SampleCommand", new[] { "sleep", "10000" }); + Thread.Sleep(200); + var processId = processCommand.ProcessId; + Command.TryAttachToProcess( + processId, + options => options.Timeout(TimeSpan.FromMilliseconds(150)), + out var attachedCommand); + + // the timeout is counted from the moment we attached to the process so it shouldn't throw at this moment + attachedCommand.Task.Wait(100); + + // but should eventually throw + var exception = UnitTestHelpers.AssertThrows( + () => attachedCommand.Task.Wait(150), + expectedTimeoutexceptionDidNotOccur); + + (exception.InnerException.InnerException is TimeoutException).ShouldEqual(true, expectedTimeoutexceptionDidNotOccur); + } + } +} diff --git a/MedallionShell.Tests/MedallionShell.Tests.csproj b/MedallionShell.Tests/MedallionShell.Tests.csproj index a2c5d73..22307c9 100644 --- a/MedallionShell.Tests/MedallionShell.Tests.csproj +++ b/MedallionShell.Tests/MedallionShell.Tests.csproj @@ -79,6 +79,7 @@ + diff --git a/MedallionShell.Tests/PlatformCompatibilityTest.cs b/MedallionShell.Tests/PlatformCompatibilityTest.cs index 44ddb5c..e224cb3 100644 --- a/MedallionShell.Tests/PlatformCompatibilityTest.cs +++ b/MedallionShell.Tests/PlatformCompatibilityTest.cs @@ -30,6 +30,9 @@ public class PlatformCompatibilityTest [TestMethod] public void TestBadProcessFile() => RunTest(() => PlatformCompatibilityTests.TestBadProcessFile()); + [TestMethod] + public void TestAttaching() => RunTest(() => PlatformCompatibilityTests.TestAttaching()); + private static void RunTest(Expression testMethod) { var compiled = testMethod.Compile(); diff --git a/MedallionShell/AttachedCommand.cs b/MedallionShell/AttachedCommand.cs new file mode 100644 index 0000000..5e28179 --- /dev/null +++ b/MedallionShell/AttachedCommand.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Medallion.Shell.Streams; + +namespace Medallion.Shell +{ + internal sealed class AttachedCommand : Command + { + private const string StreamPropertyExceptionMessage = + "This property cannot be used when attaching to already running process."; + private readonly Process process; + private readonly Task commandResultTask; + private readonly bool disposeOnExit; + private readonly Lazy> processes; + + internal AttachedCommand( + Process process, + bool throwOnError, + TimeSpan timeout, + CancellationToken cancellationToken, + bool disposeOnExit) + { + this.process = process; + this.disposeOnExit = disposeOnExit; + var processMonitoringTask = CreateProcessMonitoringTask(process); + var processTask = ProcessHelper.CreateProcessTask(this.process, processMonitoringTask, throwOnError, timeout, cancellationToken); + + this.commandResultTask = processTask.ContinueWith(continuedTask => + { + if (disposeOnExit) + { + this.process.Dispose(); + } + return new CommandResult(continuedTask.Result, this); + }); + + this.processes = new Lazy>(() => new ReadOnlyCollection(new[] { this.process })); + } + + public override Process Process + { + get + { + this.ThrowIfDisposed(); + Throw.If( + this.disposeOnExit, + ProcessHelper.ProcessNotAccessibleWithDisposeOnExitEnabled + ); + return this.process; + } + } + + public override IReadOnlyList Processes + { + get + { + this.ThrowIfDisposed(); + return this.processes.Value; + } + } + + public override int ProcessId + { + get + { + this.ThrowIfDisposed(); + + return this.process.Id; + } + } + + public override IReadOnlyList ProcessIds => new ReadOnlyCollection(new[] { this.ProcessId }); + + public override ProcessStreamWriter StandardInput => throw new InvalidOperationException(StreamPropertyExceptionMessage); + + public override ProcessStreamReader StandardOutput => throw new InvalidOperationException(StreamPropertyExceptionMessage); + + public override ProcessStreamReader StandardError => throw new InvalidOperationException(StreamPropertyExceptionMessage); + + public override void Kill() + { + this.ThrowIfDisposed(); + + ProcessHelper.TryKillProcess(this.process); + } + + public override Task Task => this.commandResultTask; + + protected override void DisposeInternal() + { + this.Process.Dispose(); + } + + private static Task CreateProcessMonitoringTask(Process process) + { + var taskBuilder = new TaskCompletionSource(); + + // EnableRaisingEvents will throw if the process has already exited; to account for + // that race condition we return a simple blocking task in that case + try + { + process.EnableRaisingEvents = true; + } + catch (InvalidOperationException) + { + return System.Threading.Tasks.Task.Run(() => process.WaitForExit()); + } + + process.Exited += (sender, e) => taskBuilder.TrySetResult(false); + + // we must account for the race condition where the process exits between enabling events and + // subscribing to Exited. Therefore, we do exit check after the subscription to account + // for this + if (process.HasExited) + { + taskBuilder.TrySetResult(false); + } + + return taskBuilder.Task; + } + } +} diff --git a/MedallionShell/Command.cs b/MedallionShell/Command.cs index b39855e..22e2d01 100644 --- a/MedallionShell/Command.cs +++ b/MedallionShell/Command.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; @@ -410,6 +411,22 @@ public static Command Run(string executable, IEnumerable arguments = nul return Shell.Default.Run(executable, arguments, options); } + /// + /// A convenience method for calling on + /// + public static bool TryAttachToProcess(int processId, Action options, out Command attachedCommand) + { + return Shell.Default.TryAttachToProcess(processId, options, out attachedCommand); + } + + /// + /// A convenience method for calling on + /// + public static bool TryAttachToProcess(int processId, out Command attachedCommand) + { + return Shell.Default.TryAttachToProcess(processId, out attachedCommand); + } + /// /// A convenience method for calling on /// diff --git a/MedallionShell/ProcessCommand.cs b/MedallionShell/ProcessCommand.cs index 349106b..91f555b 100644 --- a/MedallionShell/ProcessCommand.cs +++ b/MedallionShell/ProcessCommand.cs @@ -74,7 +74,7 @@ internal ProcessCommand( // we only set up timeout and cancellation AFTER starting the process. This prevents a race // condition where we immediately try to kill the process before having started it and then proceed to start it. // While we could avoid starting at all in such cases, that would leave the command in a weird state (no PID, no streams, etc) - var processTask = CreateProcessTask(this.process, processMonitoringTask, throwOnError, timeout, cancellationToken); + var processTask = ProcessHelper.CreateProcessTask(this.process, processMonitoringTask, throwOnError, timeout, cancellationToken); this.task = this.CreateCombinedTask(processTask, ioTasks); } @@ -107,7 +107,7 @@ public override System.Diagnostics.Process Process this.ThrowIfDisposed(); Throw.If( this.disposeOnExit, - "Process can only be accessed when the command is not set to dispose on exit. This is to prevent non-deterministic code which may access the process before or after it exits" + ProcessHelper.ProcessNotAccessibleWithDisposeOnExitEnabled ); return this.process; } @@ -180,7 +180,7 @@ public override void Kill() { this.ThrowIfDisposed(); - TryKillProcess(this.process); + ProcessHelper.TryKillProcess(this.process); } /// @@ -199,115 +199,6 @@ private static Task CreateProcessMonitoringTask(Process process) return taskBuilder.Task; } - private static readonly object CompletedSentinel = new object(), - CanceledSentinel = new object(); - - /// - /// Creates a task which will either return the exit code for the , throw - /// , throw or be canceled. When - /// the returned completes, the is guaranteed to have - /// exited - /// - private static Task CreateProcessTask( - Process process, - Task processMonitoringTask, - bool throwOnError, - TimeSpan timeout, - CancellationToken cancellationToken) - { - // the implementation here is somewhat tricky. We want to guarantee that the process has exited when the resulting - // task returns. That means that we can't trigger task completion on timeout or cancellation. At the same time, we - // want the task result to match the exit condition. The approach is to use a TCS for the returned task that gets completed - // in a continuation on the monitoring task. We can't use the continuation itself as the result because it won't be canceled - // even from throwing an OCE. To determine the result of the TCS, we set off a "race" between timeout, cancellation, and - // processExit to set a resultObject, which is done thread-safely using Interlocked. When timeout or cancellation win, they - // also kill the process to propagate the continuation execution - - var taskBuilder = new TaskCompletionSource(); - var disposables = new List(); - object resultObject = null; - - if (cancellationToken.CanBeCanceled) - { - disposables.Add(cancellationToken.Register(() => - { - if (Interlocked.CompareExchange(ref resultObject, CanceledSentinel, null) == null) - { - TryKillProcess(process); // if cancellation wins the race, kill the process - } - })); - } - - if (timeout != Timeout.InfiniteTimeSpan) - { - var timeoutSource = new CancellationTokenSource(timeout); - disposables.Add(timeoutSource.Token.Register(() => - { - var timeoutException = new TimeoutException("Process killed after exceeding timeout of " + timeout); - if (Interlocked.CompareExchange(ref resultObject, timeoutException, null) == null) - { - TryKillProcess(process); // if timeout wins the race, kill the process - } - })); - disposables.Add(timeoutSource); - } - - processMonitoringTask.ContinueWith( - _ => - { - var resultObjectValue = Interlocked.CompareExchange(ref resultObject, CompletedSentinel, null); - if (resultObjectValue == null) // process completed naturally - { - // try-catch because in theory any process property access could fail if someone - // disposes out from under us - try - { - var exitCode = process.SafeGetExitCode(); - if (throwOnError && exitCode != 0) - { - taskBuilder.SetException(new ErrorExitCodeException(process)); - } - else - { - taskBuilder.SetResult(exitCode); - } - } - catch (Exception ex) - { - taskBuilder.SetException(ex); - } - } - else if (resultObjectValue == CanceledSentinel) - { - taskBuilder.SetCanceled(); - } - else - { - taskBuilder.SetException((Exception)resultObjectValue); - } - - // perform cleanup - disposables.ForEach(d => d.Dispose()); - }, - TaskContinuationOptions.ExecuteSynchronously - ); - - return taskBuilder.Task; - } - - private static void TryKillProcess(Process process) - { - try - { - // the try-catch is because Kill() will throw if the process is disposed - process.Kill(); - } - catch (Exception ex) - { - Log.WriteLine("Exception killing process: " + ex); - } - } - protected override void DisposeInternal() { this.process.Dispose(); diff --git a/MedallionShell/ProcessHelper.cs b/MedallionShell/ProcessHelper.cs new file mode 100644 index 0000000..094893e --- /dev/null +++ b/MedallionShell/ProcessHelper.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Shell +{ + internal static class ProcessHelper + { + public const string ProcessNotAccessibleWithDisposeOnExitEnabled = + "Process can only be accessed when the command is not set to dispose on exit. This is to prevent non-deterministic code which may access the process before or after it exits"; + + public static void TryKillProcess(Process process) + { + try + { + // the try-catch is because Kill() will throw if the process is disposed + process.Kill(); + } + catch (Exception ex) + { + Log.WriteLine("Exception killing process: " + ex); + } + } + + /// + /// Creates a task which will either return the exit code for the , throw + /// , throw or be canceled. When + /// the returned completes, the is guaranteed to have + /// exited + /// + public static Task CreateProcessTask( + Process process, + Task processMonitoringTask, + bool throwOnError, + TimeSpan timeout, + CancellationToken cancellationToken) + { + // the implementation here is somewhat tricky. We want to guarantee that the process has exited when the resulting + // task returns. That means that we can't trigger task completion on timeout or cancellation. At the same time, we + // want the task result to match the exit condition. The approach is to use a TCS for the returned task that gets completed + // in a continuation on the monitoring task. We can't use the continuation itself as the result because it won't be canceled + // even from throwing an OCE. To determine the result of the TCS, we set off a "race" between timeout, cancellation, and + // processExit to set a resultObject, which is done thread-safely using Interlocked. When timeout or cancellation win, they + // also kill the process to propagate the continuation execution + + var taskBuilder = new TaskCompletionSource(); + var disposables = new List(); + object resultObject = null; + + if (cancellationToken.CanBeCanceled) + { + disposables.Add(cancellationToken.Register(() => + { + if (Interlocked.CompareExchange(ref resultObject, CanceledSentinel, null) == null) + { + TryKillProcess(process); // if cancellation wins the race, kill the process + } + })); + } + + if (timeout != Timeout.InfiniteTimeSpan) + { + var timeoutSource = new CancellationTokenSource(timeout); + disposables.Add(timeoutSource.Token.Register(() => + { + var timeoutException = new TimeoutException("Process killed after exceeding timeout of " + timeout); + if (Interlocked.CompareExchange(ref resultObject, timeoutException, null) == null) + { + TryKillProcess(process); // if timeout wins the race, kill the process + } + })); + disposables.Add(timeoutSource); + } + + processMonitoringTask.ContinueWith( + _ => + { + var resultObjectValue = Interlocked.CompareExchange(ref resultObject, CompletedSentinel, null); + if (resultObjectValue == null) // process completed naturally + { + // try-catch because in theory any process property access could fail if someone + // disposes out from under us + try + { + var exitCode = process.SafeGetExitCode(); + if (throwOnError && exitCode != 0) + { + taskBuilder.SetException(new ErrorExitCodeException(process)); + } + else + { + taskBuilder.SetResult(exitCode); + } + } + catch (Exception ex) + { + taskBuilder.SetException(ex); + } + } + else if (resultObjectValue == CanceledSentinel) + { + taskBuilder.SetCanceled(); + } + else + { + taskBuilder.SetException((Exception)resultObjectValue); + } + + // perform cleanup + disposables.ForEach(d => d.Dispose()); + }, + TaskContinuationOptions.ExecuteSynchronously + ); + + return taskBuilder.Task; + } + + private static readonly object CompletedSentinel = new object(), + CanceledSentinel = new object(); + } +} diff --git a/MedallionShell/Shell.cs b/MedallionShell/Shell.cs index 15fd418..900cdbf 100644 --- a/MedallionShell/Shell.cs +++ b/MedallionShell/Shell.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Linq; @@ -71,6 +72,66 @@ public Command Run(string executable, IEnumerable arguments = null, Acti return command; } + /// + /// Tries to attach to an already running process, given its , + /// giving representing the process and returning + /// true if this succeeded, otherwise false. + /// + public bool TryAttachToProcess(int processId, out Command attachedCommand) + { + return this.TryAttachToProcess(processId, options: null, attachedCommand: out attachedCommand); + } + + /// + /// Tries to attach to an already running process, given its + /// and , giving representing + /// the process and returning true if this succeeded, otherwise false. + /// + public bool TryAttachToProcess(int processId, Action options, out Command attachedCommand) + { + var finalOptions = this.GetOptions(options); + if (finalOptions.ProcessStreamEncoding != null || finalOptions.StartInfoInitializers.Count != 0) + { + throw new InvalidOperationException( + "Setting encoding or using StartInfo initializers is not available when attaching to an already running process."); + } + + attachedCommand = null; + Process process = null; + try + { + process = Process.GetProcessById(processId); + + // Since simply getting (Safe)Handle from the process enables us to read + // the exit code later, and handle itself is disposed when the whole class + // is disposed, we do not need its value. Hence the bogus call to GetType(). +#if NET45 + process.Handle.GetType(); +#else + process.SafeHandle.GetType(); +#endif + } + catch (Exception e) when (IsIgnorableAttachingException(e)) + { + process?.Dispose(); + return false; + } + catch (Exception e) when (e is Win32Exception || e is NotSupportedException) + { + throw new InvalidOperationException( + "Could not attach to the process from reasons other than it had already exited. See inner exception for details.", + e); + } + + attachedCommand = new AttachedCommand( + process, + finalOptions.ThrowExceptionOnError, + finalOptions.ProcessTimeout, + finalOptions.ProcessCancellationToken, + finalOptions.DisposeProcessOnExit); + return true; + } + /// /// Executes the given with the given /// @@ -275,5 +336,12 @@ public Options CancellationToken(CancellationToken cancellationToken) #endregion } #endregion + + private static bool IsIgnorableAttachingException(Exception exception) + { + return + exception is ArgumentException || // process has already exited or ID is invalid + exception is InvalidOperationException; // process exited after its creation but before taking its handle + } } } diff --git a/SampleCommand/PlatformCompatibilityTests.cs b/SampleCommand/PlatformCompatibilityTests.cs index b651f6b..6b26ad0 100644 --- a/SampleCommand/PlatformCompatibilityTests.cs +++ b/SampleCommand/PlatformCompatibilityTests.cs @@ -70,6 +70,16 @@ public static void TestBadProcessFile() AssertThrows(() => Command.Run(Path.Combine(baseDirectory, "DOES_NOT_EXIST.exe"))); } + public static void TestAttaching() + { + var processCommand = Command.Run("SampleCommand", new[] { "sleep", "10000" }); + var processId = processCommand.ProcessId; + if (!Command.TryAttachToProcess(processId, out _)) + { + throw new InvalidOperationException("Wasn't able to attach to the running process."); + } + } + private static void AssertThrows(Action action) where TException : Exception { try { action(); }