Skip to content

Commit

Permalink
Added AttachedCommand.
Browse files Browse the repository at this point in the history
AttachedCommand is the Command used when attaching to already running
process.
  • Loading branch information
Konrad Kruczyński committed Apr 5, 2019
1 parent 91f45f9 commit 1ccd64f
Show file tree
Hide file tree
Showing 9 changed files with 459 additions and 112 deletions.
107 changes: 107 additions & 0 deletions MedallionShell.Tests/AttachingTests.cs
Original file line number Diff line number Diff line change
@@ -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<AggregateException>(
() => attachedCommand.Task.Wait(150),
expectedTimeoutexceptionDidNotOccur);

(exception.InnerException.InnerException is TimeoutException).ShouldEqual(true, expectedTimeoutexceptionDidNotOccur);
}
}
}
1 change: 1 addition & 0 deletions MedallionShell.Tests/MedallionShell.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
<Otherwise />
</Choose>
<ItemGroup>
<Compile Include="AttachingTests.cs" />
<Compile Include="GeneralTest.cs" />
<Compile Include="PipeTest.cs" />
<Compile Include="PlatformCompatibilityTest.cs" />
Expand Down
3 changes: 3 additions & 0 deletions MedallionShell.Tests/PlatformCompatibilityTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Action> testMethod)
{
var compiled = testMethod.Compile();
Expand Down
127 changes: 127 additions & 0 deletions MedallionShell/AttachedCommand.cs
Original file line number Diff line number Diff line change
@@ -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<CommandResult> commandResultTask;
private readonly bool disposeOnExit;
private readonly Lazy<ReadOnlyCollection<Process>> 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<ReadOnlyCollection<Process>>(() => new ReadOnlyCollection<Process>(new[] { this.process }));
}

public override Process Process
{
get
{
this.ThrowIfDisposed();
Throw<InvalidOperationException>.If(
this.disposeOnExit,
ProcessHelper.ProcessNotAccessibleWithDisposeOnExitEnabled
);
return this.process;
}
}

public override IReadOnlyList<Process> Processes
{
get
{
this.ThrowIfDisposed();
return this.processes.Value;
}
}

public override int ProcessId
{
get
{
this.ThrowIfDisposed();

return this.process.Id;
}
}

public override IReadOnlyList<int> ProcessIds => new ReadOnlyCollection<int>(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<CommandResult> Task => this.commandResultTask;

protected override void DisposeInternal()
{
this.Process.Dispose();
}

private static Task CreateProcessMonitoringTask(Process process)
{
var taskBuilder = new TaskCompletionSource<bool>();

// 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;
}
}
}
17 changes: 17 additions & 0 deletions MedallionShell/Command.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
Expand Down Expand Up @@ -410,6 +411,22 @@ public static Command Run(string executable, IEnumerable<object> arguments = nul
return Shell.Default.Run(executable, arguments, options);
}

/// <summary>
/// A convenience method for calling <see cref="Shell.TryAttachToProcess(int, Action{Shell.Options}, out Medallion.Shell.Command)"/> on <see cref="Shell.Default"/>
/// </summary>
public static bool TryAttachToProcess(int processId, Action<Shell.Options> options, out Command attachedCommand)
{
return Shell.Default.TryAttachToProcess(processId, options, out attachedCommand);
}

/// <summary>
/// A convenience method for calling <see cref="Shell.TryAttachToProcess(int, out Medallion.Shell.Command)"/> on <see cref="Shell.Default"/>
/// </summary>
public static bool TryAttachToProcess(int processId, out Command attachedCommand)
{
return Shell.Default.TryAttachToProcess(processId, out attachedCommand);
}

/// <summary>
/// A convenience method for calling <see cref="Shell.Run(string, object[])"/> on <see cref="Shell.Default"/>
/// </summary>
Expand Down
Loading

0 comments on commit 1ccd64f

Please sign in to comment.