nightroman / Invoke-Build

Build Automation in PowerShell

Home Page:https://github.com/nightroman/Invoke-Build/wiki

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Invoke-BuildExec should pass the original stderr error to the thrown terminating error

hanshardmeier opened this issue · comments

Currently Invoke-BuildExec does not forward the original error of the native call as internal error in the terminating error. It forwards the original native call only. There is also no possibility to configure this:

function Invoke-BuildExec([Parameter(Mandatory=1)][scriptblock]$Command, [int[]]$ExitCode=0, [string]$ErrorMessage, [switch]$Echo) {

Would it make sense to extend the terminating error message with the original native command stderr to allow easier debugging similar to the concept of internal exceptions?

The terminating exception is pretty minimal and useless without the stderr output:

C:\dev> Invoke-Exec { & test} 2>$null
Invoke-Exec: Command exited with code 1. { & test}

My idea would be to incorporate the stderr to the exception message:

C:\dev> Invoke-Exec { & test} 2>$null
Invoke-Exec: Command exited with code 1. { & test}. ErrorMessage: &: The term 'test' is not recognized as a name of a cmdlet, function, script file, or executable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again.

Is this a plausible use case? Or am I missunderstand the usability of the exec{} command?

@hanshardmeier It's a reasonable request, let me think about this.

If you already have ideas, e.g. what parameter(s) to use, how to capture stderr or stdout or both, please do not hesitate to share.

Quick and dirty, something like this in Invoke-Build.ps1

function Invoke-BuildExec([Parameter(Mandatory=1)][scriptblock]$Command, [int[]]$ExitCode=0, [string]$ErrorMessage, [string]$StdErr, [switch]$Echo) {
	${private:*c} = $Command
	${private:*x} = $ExitCode
	${private:*m} = $ErrorMessage
	${private:*s} = $StdErr
	${private:*e} = $Echo
	Remove-Variable Command, ExitCode, ErrorMessage, StdErr, Echo
	if (${*e}) {
		*Echo ${*c}
	}

	if (${*s}) {
		& ${*c} 2> ${*s}
	}
	else {
		& ${*c}
	}
	if (${*x} -contains $global:LastExitCode) {return}

	$m = "$(if (${*m}) {"${*m} "})Command exited with code $global:LastExitCode. {${*c}}"
	if (${*s}) {
		$m = $($m; Get-Content ${*s}) | Out-String
	}
	*Die $m 8
}

Example

task ExecWithStdErr {
	exec { git bar } -StdErr tmp.stderr
}

A caller is / will be responsible for choosing the proper and ready file suitable for both 2 > file and Get-Content file. Caveats:

  • it's not literal path, so special chars need escaping
  • IB is not going to remove this file
  • file must not exist else it musts not be hidden or read only
  • I'm not sure we should do the same/similar for StdOut (?) (errors may be their, too, it's all up to an app)

I believe it's not IB caprices but the quirks of redirection.

Overall, I am not totally happy with adding all this. But I agree that some straightforward cases may benefit. In some others, this may lead to new issues with files, names, locks, permissions, etc.

Thus this feature is definitely opt-in and a caller is responsible for such a call working fine.
And it needs some time to sink in and get some more thoughts and alternatives.

There is no direct need to write a file and read it. AFAIK you can separate streams with something like:

if (${*s}) {
	${*s} = $( $stdout = & ${*c} ) 2>&1
	$stdout
}

or

if (${*s}) {
	$stdall= & ${*c} 2>&1
	# Filter stderr
	${*s} = $stdall | ?{$_.gettype().Name -eq "ErrorRecord"}
	# Filter and print stdout
	$stdall | ?{$_.gettype().Name -ne "ErrorRecord"}
}

Cons:

  • You are caching all the stdout output
  • $ErrorPreference would need to be set to Continue

Thank you, I'll play with this, too.

Q: why do you redirect errors? To avoid terminating errors?

Invoke-Exec { & test } 2>$null

My biggest use case currently is debugging automatic test scripts. I am not really redirecting errors, but running multiple tests which use native calls throw exceptions to the test process which can be caught, but do not help understand what went wrong (or if it went wrong the expected way).

Currently I need to manually read the stderr in the console of the test process (which might be out of order due to parallel tests being executed) to understand the error that actually happened.

Capturing all without using files

function Invoke-BuildExec([Parameter(Mandatory=1)][scriptblock]$Command, [int[]]$ExitCode=0, [string]$ErrorMessage, [switch]$Echo, [switch]$StdErr) {
	${private:*c} = $Command
	${private:*x} = $ExitCode
	${private:*m} = $ErrorMessage
	${private:*e} = $Echo
	${private:*s} = $StdErr
	Remove-Variable Command, ExitCode, ErrorMessage, Echo, StdErr
	if (${*e}) {
		*Echo ${*c}
	}

	if (${*s}) {
		$ErrorActionPreference = 2
		$o = & ${*c} 2>&1
		$o | Out-String
	}
	else {
		& ${*c}
	}
	if (${*x} -contains $global:LastExitCode) {return}

	$m = "$(if (${*m}) {"${*m} "})Command exited with code $global:LastExitCode. {${*c}}"
	if (${*s}) {
		$m = $($m; foreach($_ in $o) {if ($_ -is [System.Management.Automation.ErrorRecord]) {$_}}) | Out-String
	}
	*Die $m 8
}

Example

task StdErr2 {
	exec { git bar } -StdErr
}

It looks nice in PS Core. In PS Desktop it outputs error records as formatted errors (it's PS, not IB :)). Not sure how to deal with this or if something should be done.

Thank you, this looks promising.

We might want to return $ErrorActionPreference to its old value after the native call to avoid changing the global state.

Additionally, what do you think of

  1. Removing the first if (${*s}) to ensure the console output remains consistent in both cases (with and without the switch) and
  2. Still write to stderr via Write-Error to mimic the same behavior as before?

I am a bit concerned that we simply completely remove the stderr output there. Some folks might be currently parsing stderr if an exception is thrown and these change might have a big impact to them.

Regarding the PS Core vs PS Desktop, you could:

  if (${*s}) {
     $m = $($m; $o | %{if ($_ -is [System.Management.Automation.ErrorRecord]) {$_.Exception.Message}}) 
  }

Should return the same in both cases.

We might want to return $ErrorActionPreference to its old value after the native call to avoid changing the global state.

Setting it changes / creates the local variable and state. We do not have to restore it.

Additionally, what do you think of

Can you give the example code? To be sure what you suggest.
But I afraid this might be a potentially breaking change even if cases are rare.

Regarding the PS Core vs PS Desktop, you could:

I believed Exception might be null. But the documentation says it's never null. We may try this, yes.

Here is the current variant

function Invoke-BuildExec([Parameter(Mandatory=1)][scriptblock]$Command, [int[]]$ExitCode=0, [string]$ErrorMessage, [switch]$Echo, [switch]$StdErr) {
	${private:*c} = $Command
	${private:*x} = $ExitCode
	${private:*m} = $ErrorMessage
	${private:*e} = $Echo
	${private:*s} = $StdErr
	Remove-Variable Command, ExitCode, ErrorMessage, Echo, StdErr
	if (${*e}) {
		*Echo ${*c}
	}

	if (${*s}) {
		$ErrorActionPreference = 2
		$o = & ${*c} 2>&1
		foreach($_ in $o) {if ($_ -is [System.Management.Automation.ErrorRecord]) {$_.Exception.Message} else {$_}}
	}
	else {
		& ${*c}
	}
	if (${*x} -contains $global:LastExitCode) {return}

	$m = "$(if (${*m}) {"${*m} "})Command exited with code $global:LastExitCode. {${*c}}"
	if (${*s}) {
		$m = @($m; foreach($_ in $o) {if ($_ -is [System.Management.Automation.ErrorRecord]) {$_.Exception.Message}}) -join "`n"
	}
	*Die $m 8
}

The new parameter help. Is the name StdErr all right? Other suggestions?

		StdErr = @'
		Tells to set $ErrorActionPreference to Continue, capture all output
		and, if the call fails, add the standard error output to the message.

Apologies for the delay. I was playing around with this too.

Setting it changes / creates the local variable and state. We do not have to restore it.

True. My bad.

Can you give the example code?

The idea is exactly to avoid making the code semi-breaking. The current solution returns differently depending on the flag. Note that we are not piping stderr but its content is returned via stdout:

Current last proposal:

C:\dev\test> try{ $output = Invoke-BuildExec -Command { & gito bla}}catch{}
C:\dev\test> $output

C:\dev\test> try{ $output = Invoke-BuildExec -Command { & gito bla} -StdErr }catch{}
C:\dev\test> $output
The term 'gito' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.

With the proposal below, we would not polute stdout and keep the streams separated while adding the stderr content to the terminating exception:

function Invoke-BuildExec([Parameter(Mandatory=1)][scriptblock]$Command, [int[]]$ExitCode=0, [string]$ErrorMessage, [switch]$Echo, [switch]$StdErr) {
	${private:*c} = $Command
	${private:*x} = $ExitCode
	${private:*m} = $ErrorMessage
	${private:*e} = $Echo
	${private:*s} = $StdErr
	Remove-Variable Command, ExitCode, ErrorMessage, Echo, StdErr
	if (${*e}) {
		*Echo ${*c}
	}

    if(${*s}){
        $ErrorActionPreference = 'Continue'
        $o = & ${*c} 2>&1

        # This is important to keep the order of the logs
        $errors = [System.Collections.ArrayList]@()
        foreach($_ in $o) {
            if ($_ -is [System.Management.Automation.ErrorRecord]) {
                Write-Error $_
                $_ = $errors.Add($_)
            } else {
                $_
            }
        }    
    }else{
        & ${*c}
    }
    if (${*x} -contains $global:LastExitCode) {return}

    $m = "$(if (${*m}) {"${*m} "})Command exited with code $global:LastExitCode. {${*c}}"
    if (${*s}) {
       $m = @($m; foreach($_ in $errors) {$_.Exception.Message}) -join "`n"
    }
    *Die $m 8
}

In theory we could also remove the first if(${*s}) as we are returning the same in both cases. The only difference is how the error messages are displayed in stderr:

C:\dev\test> Invoke-BuildExec -Command { & git bla}
git: 'bla' is not a git command. See 'git --help'.

The most similar command is
        blame
Invoke-BuildExec: Command exited with code 1. { & git bla}

C:\dev\test> Invoke-BuildExec -Command { & git bla} -StdErr
Invoke-BuildExec: git: 'bla' is not a git command. See 'git --help'.
Invoke-BuildExec:
Invoke-BuildExec: The most similar command is
Invoke-BuildExec:       blame
Invoke-BuildExec: Command exited with code 1. { & git bla}
git: 'bla' is not a git command. See 'git --help'.

The most similar command is
        blame

We might leave it though (as in my proposal) to avoid unexpected breaking changes.

What do you think?

It looks reasonable, let me try and think.

Have you tried? It seems Write-Error $_ produces not very nice output in both Desktop and Core.

My last snippet in the last comment is the output that I got with the proposed solution (pwsh 7.3.11). Is there another way to write to stderr?

This is what I currently see with latest pwsh 7.4.1 and the version with Write-Error:

C:\dev\test> Invoke-BuildExec -Command { & gito bla} -StdErr
Invoke-BuildExec: The term 'gito' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.

C:\dev\test> Invoke-BuildExec -Command { & gito bla}
&: The term 'gito' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.

Alternatively, we can simply avoid printing the errors to the console via Write-Error as they will be duplicated either way in the exception.

FWIW Invoke-BuildExec -Command { & gito bla} is a valid but not a common use case of what exec is for -- valid command with not zero code and standard errors. gito bla fails in PS, not because LastExitCode is not 0.

I think with -StdErr we should not try to write/output errors in the usual way. We document that errors as text are in the error message of the error thrown by exec.

Fair enough. Might be important to document that the exit code is important in those cases too. Otherwise it might be confusing as to why the stderr disappeared:

C:\dev\test> try { Invoke-BuildExec -Command { & git bla} -StdErr -ErrorMessage "Custom Message" } catch{}

C:\dev\test> try { Invoke-BuildExec -Command { & git bla} -ErrorMessage "Custom Message" } catch{}
git: 'bla' is not a git command. See 'git --help'.

The most similar command is
        blame

Without the Write-Error it intuitively feels like the stderr output moves to the exception:

C:\dev\test> try { Invoke-BuildExec -Command { & git bla} -StdErr -ErrorMessage "Custom Message" } catch{$_}
Invoke-BuildExec: Custom Message Command exited with code 1. { & git bla}
git: 'bla' is not a git command. See 'git --help'.

The most similar command is
        blame
C:\dev\test> try { Invoke-BuildExec -Command { & git bla} -ErrorMessage "Custom Message" } catch{$_}
git: 'bla' is not a git command. See 'git --help'.

The most similar command is
        blame
Invoke-BuildExec: Custom Message Command exited with code 1. { & git bla}

I see a problem. With $ErrorActionPreference set to Continue, invalid commands (like gito) do not fail in PS and they do not set $LastExitCode to 1 either. How do we know it's a failure then?

Here is the latest, note the added try/catch around the call, this catches/rethrows "Command is not found" and other non-native-app-call errors that we might miss otherwise. Other errors (native-app-errors) are printed as usual output, this preserves what users see in the console on direct calls of an app. Then, if LastExitCode is failure, errors are added to the thrown exec error message.

function Invoke-BuildExec([Parameter(Mandatory=1)][scriptblock]$Command, [int[]]$ExitCode=0, [string]$ErrorMessage, [switch]$Echo, [switch]$StdErr) {
	${private:*c} = $Command
	${private:*x} = $ExitCode
	${private:*m} = $ErrorMessage
	${private:*e} = $Echo
	${private:*s} = $StdErr
	Remove-Variable Command, ExitCode, ErrorMessage, Echo, StdErr
	if (${*e}) {
		*Echo ${*c}
	}

	$global:LastExitCode = 0
	if (${*s}) {
		$ErrorActionPreference = 2
		$o = try {& ${*c} 2>&1} catch {throw}
		$e = @()
		foreach($_ in $o) {
			if ($_ -is [System.Management.Automation.ErrorRecord]) {
				$_ = $_.Exception.Message
				$e += $_
			}
			$_
		}
	}
	else {
		& ${*c}
	}
	if (${*x} -contains $global:LastExitCode) {return}

	$m = "$(if (${*m}) {"${*m} "})Command exited with code $global:LastExitCode. {${*c}}"
	if (${*s}) {
		$m = @($m; $e) -join "`n"
	}
	*Die $m 8
}

With $_ = $_.Exception.Message we are still poluting stdout with stderr messages.

Do we want to go into that direction to still have some kind of error logs outside the exception?

With $_ = $_.Exception.Message we are still poluting stdout with stderr messages.

Yes. Is this a problem? It's is not "stdout" in the usual sense, it is the "build log", pure text to see by a user.

If you run the same "bad command" in the console directly you will see both output and errors. Same as in exec. It's rather a good thing, IMHO.

Do you suggest moving stderr to the exec error completely?
But what if an app has stderr (verbose info, etc.) but does not fail (zero exit code). This stderr output will be missed then.

In my opinion, exec should preserve the visual appearance of console output, stdout and stderr, with proper order, while helping to get errors to the exec error message. That's all.

Preserving actual stdout and stderr of a particular task in a build, and trying doing this in PowerShell (with all its quirks and different versions), it's out of the scope of exec.

But my way of using IB may be not same as yours and you may know and want something that I do not. We may replace the switch StdErr with a parameter/value that explains how it should be handled.

| ...catches/rethrows "Command is not found" and other non-native-app-call errors that we might miss otherwise.

Isn't the exception automatically thrown? Why catch it and throw it?

| Is this a problem?

It is just not really intuitive that the flag does that. I would have assumed that it only adds the stderr text to the exception. Nevertheless documentation should be enough in this case.

| But what if an app has stderr (verbose info, etc.)...

We are not manipulating the verbose or info streams. Only the error one. And that is the reason why I thought we could add the Write-Error part.

| We may replace the switch StdErr...

I like the switch and its simplicity. No need to make it more complicated IMHO

One at a time.

We are not manipulating the verbose or info streams.

I am not talking about PS verbose stream. I am talking about a native app using stderr for some "verbose"/extra output, not "errors" as such. We do not want this output to be lost if a command does not fail.

Isn't the exception automatically thrown? Why catch it and throw it?

Note that we set $ErrorActionPreference = 'Continue'

Compare

$ErrorActionPreference = 'Continue'
foobar
'this should not happen!'

-- 'this should not happen!' happens
and

$ErrorActionPreference = 'Continue'
try {foobar} catch {throw}
'this should not happen!'

-- 'this should not happen!' does not happen

P.S. This try/catch trick would not be needed in usual build tasks because try/catch already exists in the engine.
But exec should work for direct calls, too, e.g. after dot-sourcing Invoke-Build. In this case try/catch is needed.

I like the switch and its simplicity. No need to make it more complicated IMHO

I agree the switch should be enough and documented properly. So far it is

		StdErr = @'
		Tells to set $ErrorActionPreference to Continue, capture all output and
		write as strings. Then, if the exit code is failure, add the standard
		error output text to the error message.

Thank you very much for the responsiveness and great collaboration. This is a great tool and I am glad you see the usefulness of this feature request.

@hanshardmeier Thank you for the useful feature suggestion and your patience on discussing this.
Please let me sleep on this a little bit and test more before releasing. It's a tricky area with dragons.
But it should not take too long.

Let me know if you need help discussing this further. Glad if I can help.

v5.11.0 is out there, please let me know how it goes.
The final changes in exec:

  • simplified error message building
  • process output and pass through immediately