Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Start-Process -Wait behaves inconsistently vs Wait-Process when the new process launches children then exits before the children #15555

Closed
ringerc opened this issue Jun 10, 2021 · 20 comments
Labels
Needs-Triage The issue is new and needs to be triaged by a work group. Resolution-No Activity Issue has had no activity for 6 months or more WG-Cmdlets-Management cmdlets in the Microsoft.PowerShell.Management module

Comments

@ringerc
Copy link

ringerc commented Jun 10, 2021

This issue was encountered when using runas.exe to launch a task, but applies to any situation where Start-Process launches a child process that terminates before its own children do.

Start-Process -Wait waits until the new process and all its children exit. By contrast, Wait-Process waits only until the process specified terminates, with no concern for its children. This asymmetry appears to be undocumented, and it's rather unintuitive.

PSVersion 7.1.3 but observed in various back-versions too.

The difference is because Start-Process -Wait has a special-case behaviour that tracks child processes and tracks them as a powershell job - see the ProcessCollection class for details.

Steps to reproduce

Compare:

PS C:\Users\test> Measure-Command { Start-Process -Wait -FilePath 'cmd' -ArgumentList @("/D /S /C `"start timeout 5`"") } | Select TotalSeconds 

TotalSeconds
------------
   5.0259809

with

PS C:\Users\test> Measure-Command { 
    $proc = Start-Process -PassThru -FilePath 'cmd' -ArgumentList @("/D /S /C `"start timeout 5`"")
    $proc | Wait-Process
} | Select TotalSeconds

TotalSeconds
------------
   0.0146624

PS C:\Users\test> ($timeout = Get-Process -Name timeout) | Format-Table -Property @('Id','ProcessName','CommandLine','HasExited','ExitCode')   

  Id ProcessName CommandLine HasExited ExitCode
  -- ----------- ----------- --------- --------
2616 timeout     timeout  5      False

PS C:\Users\test> sleep 5
PS C:\Users\test> $timeout.HasExited
True

Expected behavior

Intuitively, one would expect that Start-Process -Wait and $proc = Start-Process | Wait-Process would have the same effect. So either Start-Process -Wait would exit as soon as the immediate child process exits, or Wait-Process would wait until the process and all its children terminate.

But this is not the case.

Actual behavior

As shown above, Start-Process -Wait will wait until the whole process tree exits, wheras Wait-Process exits as soon as the process that was launched exits.

Background

I originally encountered this when invoking a command under runas.exe to launch it in an unprivileged session. runas.exe exits as soon as it has started the unprivileged child, so it doesn't forward the child process exit code or channel stdio between child and parent processes

I found that

Start-Process -Wait -FilePath runas -ArgumentList @('/trustlevel:0x20000','"cmd /S /D /C thecommand.exe"')

waited for thecommand.exe to terminate - as expected - but

$proc = Start-Process -PassThru -FilePath runas -ArgumentList @('/trustlevel:0x20000','"cmd /S /D /C thecommand.exe"')
$proc | Wait-Process

stopped waiting immediately. Attempted workarounds like waiting for $proc.WaitForExitAsync() or polling $proc.HasExited didn't help since they all reflect the state of the process, not process tree.

The underlying problem is really that runas.exe behaves like cmd.exe's start command, not like start /wait.

Workaround

After reading the Start-Process cmdlet's source to work out why the behaviour differs, I was able to work around this by launching the child process in a powershell job:

$job = Start-ThreadJob -ScriptBlock {
    $proc = Start-Process -PassThru -FilePath 'cmd' -ArgumentList @("/D /S /C `"start timeout 5`"")
}
Wait-Job $job
Remove-Job $job

tracked the whole process graph, so it exited only when the child exited.

Environment data

PS C:\Users\test> $PSVersionTable | Format-Table

Name                           Value
----                           -----
PSVersion                      7.1.3
PSEdition                      Core
GitCommitId                    7.1.3
OS                             Microsoft Windows 10.0.19043
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

P.S.

Please document that Start-Process -ArgumentList simply concatenates the arguments with space separators and absolutely no concern for quoting or otherwise grouping up arguments to preserve the argument vector - it behaves like win32's spawnv not unix's execv.

@ringerc ringerc added the Needs-Triage The issue is new and needs to be triaged by a work group. label Jun 10, 2021
@iSazonov iSazonov added the WG-Cmdlets-Management cmdlets in the Microsoft.PowerShell.Management module label Jun 10, 2021
@237dmitry
Copy link

237dmitry commented Jun 10, 2021

From where does Wait-Process know what process it has to wait?

 $ & {
 >>    Start-Process -FilePath cmd -ArgumentList " /c timeout 5"
 >>    Get-Process | ? CommandLine -eq "cmd /c timeout 5" | Wait-Process
 >>  }
 $ & {
 >>    Start-Process -Verb Runas -FilePath cmd -ArgumentList " /c timeout 5"
 >>    Get-Process | ? CommandLine -eq "cmd /c timeout 5" | Wait-Process
 >>  }
 $  h -Count 2

  Id     Duration CommandLine
  --     -------- -----------
  65        5.628 & {…
  66        6.044 & {…

@jborean93
Copy link
Collaborator

In curious what are you expecting to come out of here. You cannot change Wait-Process to use the grandchild wait and -Wait on Start-Process has been the behaviour since the beginning. Either change will be a breaking change so is it just a docfix you are wanting?

@vexx32
Copy link
Collaborator

vexx32 commented Jun 10, 2021

Potentially Wait-Process could be augmented to add the detection for child processes as an opt-in as well.

I do think the asymmetry here is concerning, and doesn't make a lot of sense though; folks will use Start-Process -Wait and then be very confused when they try to do similar with Wait-Process for processes they haven't directly started themselves, and something may break because they behave differently.

@jborean93
Copy link
Collaborator

jborean93 commented Jun 10, 2021

I'm not sure if doing it with Wait-Process would even work. It would have to be tested but I believe if you add a process to a job after it has started to a job then only processes it subsequently spawns (and not ones it has already) will be part of that job and thus will be waiting on. There's also the difficulty when dealing with processes that are already part of an existing job. There are 3 concerning behaviours that would need to be considered:

  • On older OS' (pre Win8/Server 2012) you cannot have nested jobs
    • If the process is already part of a job (WinRM session is one of those) then it will fail
  • If the process is already part of a job then the job we assign needs to be a nested job
    • I'm not even sure how you can create a nested job
    • There's also a limitation if the existing job has UI limits placed upon the job
  • There's a run condition where the process exists when Wait-Process starts but exists when creating the job
    • This is probably more just something to keep in mind if it's implemented as we can ignore the failure
    • Start-Process doesn't suffer from this because the process is created as a suspended process and isn't resumed until after it's added to the job

I feel that there are enough edge cases here to consider keeping the behaviour as is. The alternative is to have Wait-Process try and wait for all the child processes but revert back to the original behaviour sometimes and those sometimes will be hard for the caller to really determine.

@jborean93
Copy link
Collaborator

It would have to be tested but I believe if you add a process to a job after it has started to a job then only processes it subsequently spawns (and not ones it has already) will be part of that job and thus will be waiting on

So I just tested this assumption and it is correct. If you were to add a process to a job then any child processes it has already spawned will not be included in the job and thus will not be waited on. Only subsequent processes that it spawns after it was added to the job is.

A reproducer for this is

Add-Type -TypeDefinition @'
using Microsoft.Win32.SafeHandles;
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;

public class NativeMethods
{
    [DllImport("Kernel32.dll", EntryPoint = "AssignProcessToJobObject", SetLastError = true)]
    private static extern bool NativeAssignProcessToJobObject(
        SafeHandle hJob,
        SafeHandle hProcess
    );

    [DllImport("Kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "CreateJobObjectW", SetLastError = true)]
    private static extern SafeFileHandle NativeCreateJobObjectW(
        IntPtr lpJobAttributes,
        string lpName
    );

    public static void AssignProcessToJobObject(SafeHandle job, SafeHandle process)
    {
        if (!NativeAssignProcessToJobObject(job, process))
            throw new Win32Exception();
    }
    
    public static SafeHandle CreateJobObjectW(string name)
    {
        SafeHandle job = NativeCreateJobObjectW(IntPtr.Zero, name);
        if (job.IsInvalid)
            throw new Win32Exception();
            
        return job;
    }
}
'@

$job = [NativeMethods]::CreateJobObjectW('MyJob')
$parentProc = Start-Process powershell.exe -PassThru

# In the new process start a new powershell process again

[NativeMethods]::AssignProcessToJobObject($job, $parentProc.SafeHandle)

# In the new process start another powershell process again

# Use your favour process explorer tool to see the job setup and close processes once you are done

$job.Dispose()

Using procexp I can see that the job only contains the one process assigned to it and the 2nd process it had spawned spawned (and it's conhost process) and not the first one

image

@vexx32
Copy link
Collaborator

vexx32 commented Jun 10, 2021

Is it possible to enumerate the current child processes in a different fashion, then?

@jborean93
Copy link
Collaborator

jborean93 commented Jun 10, 2021

You can enumerate all processes and get the parent PID (pwsh has the logic for this luckily) and then assign them to the job yourself. There are 2 problems with this though

  • Unless you are an admin you can only really inspect your own user's processes
    • This is actually another case where Wait-Process cannot assign a specified process to a job and thus wait like Start-Process
  • A process' parent might no longer exist and thus cannot determine the lineage back to the process specified in Wait-Process
    • This means it won't be waited on where Start-Process -Wait would have picked it up

@vexx32
Copy link
Collaborator

vexx32 commented Jun 11, 2021

Those are good points. Yeah, it makes sense that any effort to determine the process hierarchy will pretty much only be able to be a best-effort approach.

I think that's an OK approach to take in order to reduce the disparity between Start-Process -Wait and Wait-Process, but we'd still need to document that it's not going to be flawless at detecting child processes.

@jborean93
Copy link
Collaborator

One thing I should mention is that I currently rely on this behaviour and have come across people in the Discord either wanted to rely on only the specific process (Wait-Process) vs the process tree (Start-Process -Wait). If the behaviour is going to change I personally think it should either be behind a switch or at least there's a switch to bring back the existing behaviour.

@rkeithhill
Copy link
Collaborator

rkeithhill commented Jun 11, 2021

I agree that IF the Wait-Process behavior were to change to wait on child-processes, it should be done via a new switch. Even though it is inconsistent with Start-Process -Wait, Wait-Process currently behaves the way I expect it to. It's the Start-Process -Wait behavior that is "news" to me.

@ringerc
Copy link
Author

ringerc commented Jun 11, 2021

To start with, clearly documenting this behaviour would be a big help, especially if the docs suggest wrapping the child process in a PS job as a workaround per the above notes.

I agree that a new switch definitely should be required if Wait-Process was to change behaviour. As well as being a most undesirable BC breaking surprise, it'd just be confusing, and I'm not sure it'd be possible to do in a race-free manner for arbitrary process IDs anyway.

I was extremely confused by all this for some time, because I couldn't understand how Start-Process -Wait continued to block if the child process actually exited immediately, as it seemed to. It took reading the sources to finally understand what was going on. It took a lot of time and test test case writing to work it out.

A possible solution might be to return a wrapper or subclass of System.Diagnostics.Process from Start-Process -PassThru that tracks the child processes like ProcessCollection does. Then a new WaitProcess -WaitForChildren would require an object of that type as its input, refusing to try to wait for an arbitrary System.Diagnostics.Process's children.

It'd be better if Wait-Process could assemble a child process graph when it starts waiting, and maintain it even once the target exits, but that'd suffer from obvious race conditions when the target process exits before Wait-Process has finished enumerating its children. So it might be better not to allow that, or to provide a separate cmdlet to Get-ProcessTree for a process.

(I wrote some stuff about runas, trustlevel, etc here, but I'll actually raise a separate issue for it.)

@rkeithhill
Copy link
Collaborator

rkeithhill commented Jun 11, 2021

clearly documenting this behaviour would be a big help

Agreed. Would you mind submitting an issue to the https://github.com/powershell/powershell-docs repo? They also accept community PR's if you're interested in writing up some text to document this behavior.

GitHub
The official PowerShell documentation sources. Contribute to MicrosoftDocs/PowerShell-Docs development by creating an account on GitHub.

@ringerc
Copy link
Author

ringerc commented Jun 11, 2021

@jborean93 I'm reporting a defect and inconsistency. I don't have a strong opinion on the "right" fix.

To understand the full picture in which this fits, take a look at the attached horror script I concocted to run a child process without admin privileges, stream its stdout to the current window, and kill the process tree if the powershell script is terminated. File ext is .txt because github is fussy. I've only been using Powershell for a week or two, so there are probably lots of things in there that are not done the optimal way, but it should give you the idea. Compare the "is admin" vs "not admin" branches...
trustlevel.txt

A doc fix is definitely needed. But beyond that, when it comes to behaviour I see a few options.

  • Give Start-Process a new -NoWaitForChildren flag or something, document that it behaves like Wait-Process, document that -Start has special child process tracking behaviour, and document in Wait-Process that child procs are not waited for; or
  • Teach Start-Process -PassThru to return a subclass or wrapper for System.Diagnostics.Process that tracks child processes. Add a new Wait-Process -WaitForChildren that requires the extended Process object as an InputObject, so it cannot work on an arbitrary process obtained from Get-Process. Document this special case.

Essentially I want consistency and predictability of behaviour, and the behaviour clearly reflected in the docs.

@vexx32

I do think the asymmetry here is concerning, and doesn't make a lot of sense though; folks will use Start-Process -Wait and then be very confused when they try to do similar with Wait-Process for processes they haven't directly started themselves, and something may break because they behave differently.

Or in my case, a process I did start myself, as a lower trustlevel, where I wanted to read and echo its output while it was running. You'd think that'd be easy, but you'd be wrong. runas.exe exits immediately and offers no way to connect a child process's stdio (or get its exit code).

@rkeithhill Yeah, I'll submit an issue. I've spent a couple of days on this already so I can't presently update the docs, but I will try to get to it once the work delayed by working around this is done.

@ringerc
Copy link
Author

ringerc commented Jun 11, 2021

I opened #15562 to suggest a way for pwsh's Start-Process to entirely remove the pain around using runas.exe etc by handling the process separation for the user.

@ringerc
Copy link
Author

ringerc commented Jun 11, 2021

clearly documenting this behaviour would be a big help

Agreed. Would you mind submitting an issue to the https://github.com/powershell/powershell-docs repo? They also accept community PR's if you're interested in writing up some text to document this behavior.

Filed as linked above: MicrosoftDocs/PowerShell-Docs#7700

I also wrote a separate docs issue for the confusing behaviour of Start-Process -ArgumentList, which I think is a giant foot-cannon for users: MicrosoftDocs/PowerShell-Docs#7701

GitHub
The official PowerShell documentation sources. Contribute to MicrosoftDocs/PowerShell-Docs development by creating an account on GitHub.

@mklement0
Copy link
Contributor

Re Start-Process -ArgumentList: the relevant bug report is #5576

Copy link
Contributor

This issue has not had any activity in 6 months, if this is a bug please try to reproduce on the latest version of PowerShell and reopen a new issue and reference this issue if this is still a blocker for you.

2 similar comments
Copy link
Contributor

This issue has not had any activity in 6 months, if this is a bug please try to reproduce on the latest version of PowerShell and reopen a new issue and reference this issue if this is still a blocker for you.

Copy link
Contributor

This issue has not had any activity in 6 months, if this is a bug please try to reproduce on the latest version of PowerShell and reopen a new issue and reference this issue if this is still a blocker for you.

Copy link
Contributor

This issue has been marked as "No Activity" as there has been no activity for 6 months. It has been closed for housekeeping purposes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs-Triage The issue is new and needs to be triaged by a work group. Resolution-No Activity Issue has had no activity for 6 months or more WG-Cmdlets-Management cmdlets in the Microsoft.PowerShell.Management module
Projects
None yet
Development

No branches or pull requests

7 participants