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:
Line 254 in 10c1f71
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
- Removing the first
if (${*s})
to ensure the console output remains consistent in both cases (with and without the switch) and - 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