次の方法で共有


reporting the nested errors in PowerShell

A pretty typical pattern for PowerShell goes like this:

 ...allocate resource...
try {
  ... process resource ...
} finally {
  ...deallocate resource...
}

It makes sure that the resource gets properly deallocated even if the processing fails. However there is a problem in this pattern: if the finally block gets called on exception and the resource deallocation experiences an error for some reason and throws an exception, that exception will replace the first one. You'd see what failed in the deallocation but not what failed with the processing in the first place.

I want to share a few solutions for this problem that I've come with. The problem is two-prong: one part of it is the reporting of the nested errors, another one is collecting all the encountered errors which can then be built into a nested error.

As the reporting of the nested errors goes, the basic .NET exception has the provision for it but it's not so easy to use in practice because the PowerShell exception objects are wrappers around the .NET exceptions and carry the extra information: the PowerShell stack trace. The nesting shouldn't lose this stack trace.

So I wrote a function that does this, New-EvNest (you can think of the prefix "Ev" as meaning "error value", although historically it was born for other reasons). The implementation of carrying of the stack trace has turned out to be pretty convoluted but the use is easy:

 $combinedError = New-EvNest -Error $_ -Nested $InnerError

in some cases the outer error would be just a high-level text description, so there is a special form for that:

 $combinedError = New-EvNest -Text "Failed to process the resource"  -Nested $InnerError

You can then re-throw the combined error:

 throw $combinedError

I've also made a convenience function for re-throwing with an added description:

 New-Rethrow -Text "Failed to process the resource"  -Nested $InnerError

And here is the implementation:

 function New-EvNest
{
<#
.SYNOPSIS
Create a new error that wraps the existing one (but don't throw it).
#>
    [CmdletBinding(DefaultParameterSetName="Text")]
    param(
        ## Text of the wrapper message.
        [parameter(ParameterSetName="Text", Mandatory=$true, Position = 0)]
        [string] $Text,
        ## Alternatively, if combining two errors, the "outer"
        ## error. The text and the error location from it will be
        ## prepended to the combined information.
        [parameter(ParameterSetName="Object", Mandatory=$true)]
        [System.Management.Automation.ErrorRecord] $Error,
        ## The nested System.Management.Automation.ErrorRecord that
        ## was caught and needs re-throwing with an additional wrapper.
        [parameter(Mandatory=$true, Position = 1)]
        [System.Management.Automation.ErrorRecord] $Nested
    )

    if ($Error) {
        $Text = $Error.FullyQualifiedErrorId
        if ($Error.TargetObject -is [hashtable] -and $Error.TargetObject.stack) {
            $headpos = $Error.TargetObject.posinfo + "`r`n"
        } else {
            $headpos = $Error.InvocationInfo.PositionMessage + "`r`n"
        }
    }

    # The new exception will wrap the old one.
    $exc = New-Object System.Management.Automation.RuntimeException @($Text, $Nested.Exception)

    # The script stack is not in the Exception (the nested part), so it needs to be carried through
    # the ErrorRecord with a hack. The innermost stack is carried through the whole
    # chain because it's the deepest one.
    # The carrying happens by encoding the original stack as the TargetObject.
    if ($Nested.TargetObject -is [hashtable] -and $Nested.TargetObject.stack) {
        if ($headpos) {
            $wrapstack = @{
                stack = $Nested.TargetObject.stack;
                posinfo = $headpos + $Nested.TargetObject.posinfo;
            }
        } else {
            $wrapstack = $Nested.TargetObject
        }
    } elseif($Nested.ScriptStackTrace) {
        $wrapstack = @{
            stack = $Nested.ScriptStackTrace;
            posinfo = $headpos + $Nested.InvocationInfo.PositionMessage;
        }
    } else {
        if ($headpos) {
            $wrapstack = $Error.TargetObject
        } else {
            $wrapstack = $null
        }
    }

    # The new error record will wrap the exception and carry over the stack trace
    # from the old one, which unfortunately can't be just wrapped.
    return (New-Object System.Management.Automation.ErrorRecord @($exc,
        "$Text`r`n$($Nested.FullyQualifiedErrorId)", # not sure if this is the best idea, the arbitrary text goes against the
        # principles described in https://msdn.microsoft.com/en-us/library/ms714465%28v=vs.85%29.aspx
        # but this is the same as done by the {throw $Text},
        # and it allows to get the errors printed more nicely even with the default handler
        "OperationStopped", # would be nice to have a separate category for wraps but for now
        # just do the same as {throw $Text}
        $wrapstack
    ))
}

function New-Rethrow
{
<#
.SYNOPSIS
Create a new error that wraps the existing one and throw it.
#>
    param(
        ## Text of the wrapper message.
        [parameter(Mandatory=$true)]
        [string] $Text,
        ## The nested System.Management.Automation.ErrorRecord that
        ## was caught and needs re-throwing with an additional wrapper.
        [parameter(Mandatory=$true)]
        [System.Management.Automation.ErrorRecord] $Nested
    )
    throw (New-EvNest $Text $Nested)
}
Set-Alias rethrow New-Rethrow

The information about the PowerShell call stack is carried through the whole nesting sequence from the innermost object to the outernost object. However it can't be set directly in the ErrorRecord object, so I've made a function Join-EvCatch that can be used to print it:

 Join-EvCatch "message" $error

Here is the source code of this function:

 function Join-EvCatch
{
<#
.SYNOPSIS
Join the custom error description and the dump of the current
caught error into one string.
#>
    param(
        ## Text of the custom message.
        [string] $Text,
        ## The error object, $_ in the catch block.
        $ErrObj
    )

    $msg = New-Object System.Collections.ArrayList
    if ($Text) {
        [void] $msg.Add($Text)
    }
    for ($e = $ErrObj.Exception; $e; $e = $e.InnerException) {
        [void] $msg.Add($e.Message)
    }

    if ($ErrObj.TargetObject -is [hashtable] -and $ErrObj.TargetObject.stack) {
        [void] $msg.Add($ErrObj.TargetObject.posinfo)
        [void] $msg.Add($ErrObj.TargetObject.stack)
    } else {
        [void] $msg.Add($ErrObj.InvocationInfo.PositionMessage)
        [void] $msg.Add($ErrObj.ScriptStackTrace)
    }

    $msg -join "`r`n"
}

Now we come to the second prong, catching the errors. The simple approach would be to do:

 ...allocate resource...
try {
  ... process resource ...
} finally {
  try {
    ...deallocate resource...
  } catch {
    throw (New-EvNest -Error $_ -Nexted $prevException)
  }
}

except that in finally we don't know if there was a nested exception or not. So the code grows to:

 ...allocate resource...
$prevException = $null
try {
  ... process resource ...
} catch {
  $prevException = $_
} finally {
  try {
    ...deallocate resource...
  } catch {
    if ($prevException) {
      throw (New-EvNest -Error $_ -Nexted $prevException)
    } else {
      throw $_
    }
  }
}

You can see that this quickly becomes not very manageable, especially if you have multiple nested resources. So my next approach was to wrtite one more helper function Rethrow-ErrorList and use it in a pattern like this:

     $errors = @()
    # nest try/finally as much as needed, as long as each try goes
    # with this kind of catch; the outermost "finally" block must
    # be wrapped in a plain try/catch
    try {
        try {
            ...
        } catch {
            $errors = $errors + @($_)
        } finally {
            ...
        }
    } catch {
        $errors = $errors + @($_)
    }
    Rethrow-ErrorList $errors

Rethrow-ErrorList throws if the list of errors is not empty, combining them all into one error. This pattern also nests easily: the nested instances keep using the same $errors, and all the exceptions get neatly collected in it along the way. Here is the implementation:

 function Publish-ErrorList
{
<#
.SYNOPSIS
If the list of errors collected in the try-finally sequence is not empty,
report it in the verbose channel, build a combined error out of them,
and throw it. If the list is empty, does nothing.

An alternative way to handle the errors is Undo-OnError.

The typical usage pattern is:

    $errors = @()
    # nest try/finally as much as needed, as long as each try goes
    # with this kind of catch; the outermost "finally" block must
    # be wrapped in a plain try/catch
    try {
        try {
            ...
        } catch {
            $errors = $errors + @($_)
        } finally {
            ...
        }
    } catch {
        $errors = $errors + @($_)
    }
    Rethrow-ErrorList $errors

#>
    [CmdletBinding()]
    param(
        ## An array of error objects to test and rethrow.
        [array] $Errors
    )
    if ($Errors) {
        $vp = $VerbosePreference
        $VerbosePreference = "Continue"
        Write-Verbose "Caught the errors:"
        $Errors | fl | Out-String | Write-Verbose
        $VerbosePreference = $vp

        if ($Errors.Count -gt 1) {
            $rethrow = $Errors[0]
            for ($i = 1; $i -lt $Errors.Count; $i++) {
                $rethrow = New-EvNest -Error ($Errors[$i]) -Nested $rethrow
            }
        } else {
            $rethrow = $Errors[0]
        }
        throw $rethrow
    }
}
Set-Alias Rethrow-ErrorList Publish-ErrorList

After that I've tried one more approach. It's possible to pass the script blocks as parameters to a function, so a function can pretend to be a bit like a statement:

 Undo-OnError -Do {
  ...allocate resource...
} -Try {
  ... process resource ...
} -Undo {
  ...deallocate resource...
}

Looked cute in theory but in practice it had hit the snag that the script blocks in PowerShell are not closures. If some variables get assigned inside script blocks, they're invisible outside these script blocks, and that's a major pain here because you'd usually place the allocated resource into some variable and then read that variable during processing and deallocation. Of course, with the script blocks being separate here, the variables assigned during allocation would get lost. It's possible to work around this issue by making a surrogate scope in a hash table:

 $scope = @{}
Undo-OnError -Do {
  $scope.resource = ...allocate resource ...
} -Try {
  ... process resource from $scope.resource ...
} -Undo {
  ...deallocate resource from $scope.resource ...
}

So it kind of works but unless you do a lot of nesting, I'm not sure that it's a whole lot better than the pattern with the Rethrow-ErrorList. If this were made into a PowerShell statement that makes a proper scope management, it could work a lot better. Or I guess even better, the try/finally statement could be extended to re-throw a nested exception if both try and finally parts throw. And the throw statement could be extended to create a nested exception if its argument is an array. This would give all the benefits without any changes to the language.

Here is the implementation:

 function Undo-OnError
{
<#
.SYNOPSIS
A wrapper of try blocks. Do some action, then execute some code that
uses it, and then undo this action. The undoing is executed even if an
error gets thrown. It's essentially a "finally" block, only with the
nicer nested reporting of errors.

An alternative way to handle the errors is Rethrow-ErrorList.
#>
    param(
        ## The initial action to do.
        [scriptblock] $Do,
        ## The code that uses the action from -Do. Essentially, the "try" block.
        [scriptblock] $Try,
        ## The undoing of the initial action (like the "finally" block).
        [scriptblock] $Undo,
        ## Flag: call the action -Undo even if the action -Do itself throws
        ## an exception (useful if the action in -Do is not really atomic can
        ## can leave things in an inconsistent state that requires cleaning).
        [switch] $UndoSelf
    )

    try {
        &$Do
    } catch {
        if ($UndoSelf) {
            $nested = $_
            try {
                &$Undo
            } catch {
                throw (New-EvNest -Error $_ $nested)
            }
        }
        throw
    }
    try {
        &$Try
    } catch {
        $nested = $_
        try {
            &$Undo
        } catch {
            throw (New-EvNest -Error $_ $nested)
        }
        throw
    }
    &$Undo
}