Skip to content
This repository has been archived by the owner on Jan 23, 2023. It is now read-only.
/ corefx Public archive

Improving UseShellExecute implementation on Process.Start #23705

Merged
merged 7 commits into from
Sep 12, 2017
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,54 @@ internal DateTime StartTimeCore
}
}

/// <summary>Gets arguments.</summary>
/// <param name="startInfo">The start info with which to start the process.</param>
private string[] GetArgs(ProcessStartInfo startInfo)
{
if (!startInfo.UseShellExecute)
{
return ParseArgv(startInfo);
}

string shellArgs = string.IsNullOrEmpty(startInfo.Arguments) ? startInfo.FileName : startInfo.FileName + " " + startInfo.Arguments;
return new string[2] { GetExecPath(), shellArgs };
}

/// <summary>Gets execution path</summary>
private string GetExecPath()
{
string[] allowedProgramsToRun = { "xdg-open", "gnome-open", "kfmclient" };
foreach (var program in allowedProgramsToRun)
{
string pathToProgram = GetPathToProgram(program);
if (!string.IsNullOrEmpty(pathToProgram))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when will it be null?

Copy link
Member Author

@maryamariyan maryamariyan Sep 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function returns null when program is not installed. GetPathToProgram(), now renamed to FindProgramInPath(), returns null when program was not found in path.

{
return Path.Combine(pathToProgram, program);
}
}
return string.Empty;
}

/// <summary>
/// Gets the path to the program
/// </summary>
/// <param name="program"></param>
/// <returns></returns>
private string GetPathToProgram(string program)
{
string path = Environment.GetEnvironmentVariable("PATH");
string[] dirs = path.Split(":");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If for some reason PATH is not set in this shell, this will throw NRE.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like Mono falls back to the current directory.
https://github.com/mono/mono/blob/master/mono/eglib/gpath.c#L236

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could eliminate all this code and just call ResolvePath() if you don't mind a slightly different resolution algorithm. I think that's fine.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I just noticed ResolvePath() is also calling path to get path. I'm going to extract that logic into a private method and reuse that private method both in ResolvePath() and in here. Thanks

foreach (var dir in dirs)
{
string[] files = Directory.GetFiles(dir, program);
Copy link
Member

@danmoseley danmoseley Sep 10, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to handle at least some garbage in $PATH. Eg.,

try 
{
 files = Directory.GetFiles(dir, program);
}
catch (Exception e) when (ex is ArgumentException || ex is IOException || ex is UnauthorizedAccessException)
{}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually better still just use File.Exists(Path.Combine(dir, program)). it doesn't throw.

if (files.Length != 0)
{
return dir;
}
}
return string.Empty;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think null is probably a better sentinel.

}

/// <summary>
/// Gets the amount of time the associated process has spent utilizing the CPU.
/// It is the sum of the <see cref='System.Diagnostics.Process.UserProcessorTime'/> and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ internal DateTime StartTimeCore
}
}

/// <summary>Gets arguments.</summary>
/// <param name="startInfo">The start info with which to start the process.</param>
private string[] GetArgs(ProcessStartInfo startInfo)
{
if (!startInfo.UseShellExecute)
{
return ParseArgv(startInfo);
}

return new string[3] { GetExecPath(), "--args", startInfo.FileName + " " + startInfo.Arguments };
}

/// <summary>Gets execution path</summary>
private string GetExecPath()
{
return "/usr/bin/open";
}

/// <summary>
/// Gets the amount of time the associated process has spent utilizing the CPU.
/// It is the sum of the <see cref='System.Diagnostics.Process.UserProcessorTime'/> and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,18 +232,11 @@ private bool StartCore(ProcessStartInfo startInfo)
{
throw new InvalidOperationException(SR.CantRedirectStreams);
}

const string ShellPath = "/bin/sh";

filename = ShellPath;
argv = new string[3] { ShellPath, "-c", startInfo.FileName + " " + startInfo.Arguments};
}
else
{
filename = ResolvePath(startInfo.FileName);
argv = ParseArgv(startInfo);
}

filename = ResolvePath(startInfo.FileName);
argv = ParseArgv(startInfo);

string[] envp = CreateEnvp(startInfo);
string cwd = !string.IsNullOrWhiteSpace(startInfo.WorkingDirectory) ? startInfo.WorkingDirectory : null;

Expand All @@ -253,11 +246,33 @@ private bool StartCore(ProcessStartInfo startInfo)
// is used to fork/execve as executing managed code in a forked process is not safe (only
// the calling thread will transfer, thread IDs aren't stable across the fork, etc.)
int childPid, stdinFd, stdoutFd, stderrFd;
Interop.Sys.ForkAndExecProcess(
filename, argv, envp, cwd,
startInfo.RedirectStandardInput, startInfo.RedirectStandardOutput, startInfo.RedirectStandardError,
out childPid,
out stdinFd, out stdoutFd, out stderrFd);

try
{
Interop.Sys.ForkAndExecProcess(
filename, argv, envp, cwd,
startInfo.RedirectStandardInput, startInfo.RedirectStandardOutput, startInfo.RedirectStandardError,
out childPid,
out stdinFd, out stdoutFd, out stderrFd);
}
catch (Win32Exception e)
{
if (!startInfo.UseShellExecute)
{
throw e;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer throw; as it does not reset the stack (not that it matters really here). you don't need e

}
else
{
filename = GetExecPath();
argv = GetArgs(startInfo);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be just assert (!startInfo.UseShellExecute)

Interop.Sys.ForkAndExecProcess(
filename, argv, envp, cwd,
startInfo.RedirectStandardInput, startInfo.RedirectStandardOutput, startInfo.RedirectStandardError,
out childPid,
out stdinFd, out stdoutFd, out stderrFd);
}
}

// Store the child's information into this Process object.
Debug.Assert(childPid >= 0);
Expand Down
3 changes: 2 additions & 1 deletion src/System.Diagnostics.Process/tests/Configurations.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
<BuildConfigurations>
netstandard-Windows_NT;
netstandard-Unix;
netstandard-OSX;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

netstandard-OSX; [](start = 4, length = 18)

I think we can write the tests to work on different platforms (Linux and OSX) and we can pass different data to such tests according to the OS we are running on. We can use PlatformDetection to decide what set of data we can pass to the tests. doing that we don't have to add OSX configurations.

uap-Windows_NT;
uapaot-Windows_NT;
</BuildConfigurations>
</PropertyGroup>
</Project>
</Project>
86 changes: 86 additions & 0 deletions src/System.Diagnostics.Process/tests/ProcessTests.Linux.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Security;
using Xunit;
using Xunit.NetCore.Extensions;

namespace System.Diagnostics.Tests
{
public partial class ProcessTests : ProcessTestBase
{
[Fact]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please make tests that pop up windows (like shell exec ones) be [OuterLoop] to not be obnoxious. Note CI doesn't normally run outer loop, so if you want to use CI for these tests, you'll either have to request outer loop or change them to outer loop in your last commit.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added (and for now commented out) [OuterLoop] for these tests. On the final commit I will uncomment those lines.

public void ProcessStart_UseShellExecuteTrue_OpenFile_ThrowsIfNoDefaultProgramInstalledSucceedsOtherwise()
{
string fileToOpen = GetTestFilePath() + ".txt";
File.WriteAllText(fileToOpen, $"{nameof(ProcessStart_UseShellExecuteTrue_OpenFile_ThrowsIfNoDefaultProgramInstalledSucceedsOtherwise)}");

string[] allowedProgramsToRun = { "xdg-open", "gnome-open", "kfmclient" };
foreach (var program in allowedProgramsToRun)
{
if (IsProgramInstalled(program))
{
var startInfo = new ProcessStartInfo { UseShellExecute = true, FileName = fileToOpen };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tihnk it would be reasonable, especially as this is outer loop, to write to the console which of the three you're launching. That owuld allow you to look in the official run logs, and verify that it's actually doing something -- and also whether xdg-open exists on all the images or not.

using (var px = Process.Start(startInfo))
{
if (px != null)
{
Assert.Equal(program, px.ProcessName);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test I mostly care to test which process we ended up using to open the input file. therefore I could remove the extra assertions that check if px exited and the exitcode value assertion as well

px.Kill();
px.WaitForExit();
Assert.True(px.HasExited);
Assert.Equal(137, px.ExitCode); // 137 means the process was killed
}
}
return;
}
}

Win32Exception e = Assert.Throws<Win32Exception>(() => Process.Start(new ProcessStartInfo { UseShellExecute = true, FileName = fileToOpen }));
}

[Fact]
[OuterLoop("Returns failure exit code when default program, xdg-open, is installed")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it should not fail on any of our machines. either it should succeed if xdg-open is not present, or we must require xdg-open on all our images.

Copy link
Member Author

@maryamariyan maryamariyan Sep 10, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the linux image (ubuntu 14.04) I cloned from DevTestLab doesnt have xdg-open installed by default.

@danmosemsft based on this, what would you say should be the expected behavior in this case?

do we change the images to have xdg-open installed from now on? or do I change how the API behaves so that it wont throw when xdg-open is not installed?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's assume at least some of our helix (test ruN) machines have it. make the test write to console if it doesn't find it. It's outer loop so that's OK. After you've checked in, we'll see whether any of the machines actually have it. If non have it we might want to update hte images so at least some configuration is exercising the code.

public void ProcessStart_UseShellExecuteTrue_OpenMissingFile_DefaultProgramInstalled_ReturnsFailureExitCode()
{
string fileToOpen = Path.Combine(Environment.CurrentDirectory, "_no_such_file.TXT");
using (var p = Process.Start(new ProcessStartInfo { UseShellExecute = true, FileName = fileToOpen }))
{
if (p != null)
{
Assert.Equal("xdg-open", p.ProcessName);
p.WaitForExit();
Assert.True(p.HasExited);
Assert.Equal(2, p.ExitCode);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test I wanted to show that when no file exists the xdg-open process will return the expected exitcode it uses when file doesnt exist. so the most important assertion here is Assert.Equal(2, p.ExitCode) and the rest could be removed if needed

}
}
}

/// <summary>
/// Gets the path to the program
/// </summary>
/// <param name="program"></param>
/// <returns></returns>
private bool IsProgramInstalled(string program)
{
string path = Environment.GetEnvironmentVariable("PATH");
string[] dirs = path.Split(':');
foreach (var dir in dirs)
{
string[] files = Directory.GetFiles(dir, program);
if (files.Length != 0)
{
return true;
}
}
return false;
}
}
}
58 changes: 58 additions & 0 deletions src/System.Diagnostics.Process/tests/ProcessTests.OSX.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Security;
using Xunit;
using Xunit.NetCore.Extensions;
Copy link
Member Author

@maryamariyan maryamariyan Sep 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to check if I can remove unnecessary usings from here. also, there is room to cleanup the osx tests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, you can always remove unnecessary usings. VS should do it for you in a couple clicks.

Copy link
Member Author

@maryamariyan maryamariyan Sep 10, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surely. But I have VS installed on windows and VSCode on the other machine.

When I'm using VS on windows, the "remove and sort" feature is not available for me on this file. I think it is because the intellisense for this file is not available, probably because in csproj it is not included for windows.

VSCode also has limited functionality I think. It doesn't have the feature to remove and sort. I think there is limited intellisense functionality available in VSCode in general. I may be wrong, just my observation so far.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right. I wouldn't bother with it then. we don't do it consistently and it's easy for us to do it periodically.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maryamariyan It's definitely not obvious at first, but if you go into the "Configuration Manager" menu in VS, you can switch the build configuration for each project. You can select the OSX configuration for this project and then do refactorings.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that you need to rush to do that or anything, just a tip for the future 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mellinoe :)


namespace System.Diagnostics.Tests
{
public partial class ProcessTests : ProcessTestBase
{
[Fact]
[OuterLoop("Launches default application")]
public void TestWithFilename_ShouldUseOpenWithDefaultApp()
{
string file = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "PATENTS.TXT");
using (var px = Process.Start("/usr/bin/open", file))
{
Assert.False(px.HasExited);
px.WaitForExit();
Assert.True(px.HasExited);
Assert.Equal(0, px.ExitCode); // Exit Code 0 from open means success
}
}

[Fact]
[OuterLoop("Launches default browser")]
public void TestWithUrl_ShouldUseOpenWithDefaultApp()
{
using (var px = Process.Start("/usr/bin/open", "http://www.google.com"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is /usr/bin/open relevant here? does xdg-open etc not exist on OSX? and if it does not, should we be looking for /usr/bin/open?
On Windows, Process.Start("http://bing.com") pops the browser. Ideally that would work on OSX and Linux just the same. Doesn't it in Mono?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Process.Start("http://bing.com") won't work, as UseShellExecute is AFAIK false by default in dotnet core. (Maybe the default could be changed to true after UseShellExecute is correctly implemented on all platforms?(via this pull request)) I agree, that /usr/bin/open should not be used explicitly, instead UseShellExecute should be used.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Youre right, I didn't mean literally, I meant without usr bin open.

{
Assert.False(px.HasExited);
px.WaitForExit();
Assert.True(px.HasExited);
Assert.Equal(0, px.ExitCode); // Exit Code 0 from open means success
}
}

[Fact]
// TODO fix behavior to ThrowWin32Exception instead?
public void ProcessStart_TryOpenFileThatDoesntExist_UseShellExecuteIsTrue_ThrowsWin32Exception()
{
string file = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "_no_such_file.TXT");
using (var p = Process.Start(new ProcessStartInfo { UseShellExecute = true, FileName = file }))
{
Assert.True(p.WaitForExit(WaitInMS));
Assert.Equal(1, p.ExitCode); // Exit Code 1 from open means something went wrong
}
}
}
}
22 changes: 18 additions & 4 deletions src/System.Diagnostics.Process/tests/ProcessTests.Unix.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,27 @@ public void TestRootGetProcessById()
Assert.Equal(1, p.Id);
}

[Theory, InlineData(true), InlineData(false)]
public void ProcessStart_TryExitCommandAsFileName_ThrowsWin32Exception(bool useShellExecute)
{
Win32Exception e = Assert.Throws<Win32Exception>(() => Process.Start(new ProcessStartInfo { UseShellExecute = useShellExecute, FileName = "exit", Arguments = "42" }));
}

[Fact]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test and the one below, do they match Windows behavior? If so they shoud lbe in ProcessTests.cs (ie., not Unix specific file). This will help make certain the behavior stays the same betwen Unix and windows.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and if they don't match Windows behavior, hopefully we thought that through, and a comment here explaining why we chose to diverge would be good

public void TestUseShellExecute_Unix_Succeeds()
public void ProcessStart_UseShellExecuteTrue_OpenNano_OpensNano()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fails on osx. I need to investigate

{
using (var p = Process.Start(new ProcessStartInfo { UseShellExecute = true, FileName = "exit", Arguments = "42" }))
string appToOpen = "nano";
var startInfo = new ProcessStartInfo { UseShellExecute = true, FileName = appToOpen };
using (var px = Process.Start(startInfo))
{
Assert.True(p.WaitForExit(WaitInMS));
Assert.Equal(42, p.ExitCode);
if (px != null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if Process.Start returns null, this test will pass. is that desirable?

{
Assert.Equal(appToOpen, px.ProcessName);
px.Kill();
px.WaitForExit();
Assert.True(px.HasExited);
Assert.Equal(137, px.ExitCode);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
<DefineConstants Condition="'$(TargetsWindows)' == 'true'">$(DefineConstants);TargetsWindows</DefineConstants>
</PropertyGroup>
<!-- Default configurations to help VS understand the configurations -->
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'netstandard-OSX-Debug|AnyCPU'" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'netstandard-OSX-Release|AnyCPU'" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'netstandard-Unix-Debug|AnyCPU'" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'netstandard-Unix-Release|AnyCPU'" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'netstandard-Windows_NT-Debug|AnyCPU'" />
Expand All @@ -32,6 +34,8 @@
<Compile Condition="'$(TargetGroup)' != 'uap'" Include="ProcessTestBase.NonUap.cs" />
<Compile Condition="'$(TargetGroup)' == 'uap'" Include="ProcessTestBase.Uap.cs" />
<Compile Include="ProcessTests.cs" />
<Compile Include="ProcessTests.Linux.cs" Condition=" '$(TargetsLinux)' == 'true'" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: inconsistent spacing around quotes

<Compile Include="ProcessTests.OSX.cs" Condition=" '$(TargetsOSX)' == 'true'" />
<Compile Include="ProcessTests.Unix.cs" Condition="'$(TargetsWindows)' != 'true'" />
<Compile Include="ProcessThreadTests.cs" />
<Compile Include="ProcessWaitingTests.cs" />
Expand Down