Skip to content

Instantly share code, notes, and snippets.

@SteveL-MSFT
Last active November 30, 2018 13:52
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save SteveL-MSFT/edf969ee676206a47d520a9032dcf6e3 to your computer and use it in GitHub Desktop.
Save SteveL-MSFT/edf969ee676206a47d520a9032dcf6e3 to your computer and use it in GitHub Desktop.
ErrorRecord Format
# Define the PS code to embed in the XML below here.
$sb = {
# Make sure we control the specific strict mode in effect.
# (The strict-mode setting is inherited from the parent scope.)
Set-StrictMode -Version 1
# Save the error record at hand as well as its invocation info.
$err = $_
$myInv = $err.InvocationInfo
function append($s, $new, $sep = ': ') { $s + $(if ($s) { $sep } else { '' }) + $new }
switch ($ErrorView) {
'CategoryView' { $err.CategoryInfo.GetMessage(); break }
Default { # 'NormalView'
if ($err.FullyQualifiedErrorId -like 'NativeCommandError*') { # External-utility STDERR output - covers both 'NativeCommandError' and 'NativeCommandErrorMessage'
# NOTE:
# By default, stderr lines output by external utilities are NOT processed here, because they now go straight to the host.
# Only if you redirect stder output to the success stream, using 2>&1, are stderr lines processed here - one by one.
# The *first* stderr line is reported as 'NativeCommandError', and all subsequent lines as 'NativeCommandErrorMessage'
# Generally, external utilities prefix their error message with their executable name, such as 'FINDSTR: ...', so no addtional
# context is needed.
# However, invoking `cmd.exe` builtins with `cmd /c` does NOT (e.g., `cmd /c 'dir /z'`), so, if the error message doesn't start with "<someIdentifier>:",
# prepend the invoking command's name.
$msg = $err.Exception.Message
if ($err.FullyQualifiedErrorId -eq 'NativeCommandError' -and $msg -notmatch '^\w+:') {
$msg = $myInv.MyCommand.Name + ': ' + $msg
}
# Output the message.
$msg
} else { # PS error
# See if the error is a parsing error.
# !! Apparently, a parsing error that occurs *directly on the command line* results in an $err.Exception
# !! of type [System.Management.Automation.ParentContainsErrorRecordException]
# !! By contrast, if you inspect $Error[0] after provoking a parsing error directly on the command line
# !! you get a [System.Management.Automation.ParseException] *directly*, which *itself* also implements the
# !! [System.Management.Automation.IContainsErrorRecord] *interface*.
$isParseError = $err.Exception -is [System.Management.Automation.ParseException] -or $err.Exception -is [System.Management.Automation.ParentContainsErrorRecordException]
$isRemotingError = $err -is [System.Management.Automation.Runspaces.RemotingErrorRecord] # remoting error, including from jobs
$isScriptTerminatingError = [bool] $err.Exception.WasThrownFromThrowStatement # $err.CategoryInfo.Category -eq 'OperationStopped'
# Identify the *immediate* source of the error:
$source = ''
if ($isParseError) {
# Clearly mark parsing errors as such.
# NOTE: ?? This string will have to be localized.
$source = 'PARSE ERROR'
# If $myInv.MyCommand.Name has a value, the implication is that the exception
# is an *indirectly* triggered parse error, via Invoke-Expression, so we indicate that.
# Otherwise, we rely on the exception message to provide sufficient information.
if ($myInv.MyCommand.Name) {
$source = append $source $myInv.MyCommand.Name
}
} else {
if ($isScriptTerminatingError) {
# Clearly mark a script-terminating error (triggered with Throw) as such.
# NOTE: ?? This string will have to be localized.
$source = 'STOPPED'
}
if ($isRemotingError) { # remoting error (including from jobs)
# Prepend the name of the originating computer.
$source = append $source ("[{0}]" -f $err.OriginInfo.PSComputerName)
# Add the command name, if a command caused the error.
if ($err.CategoryInfo.Activity) { # Contains the command name, if a *command* caused the error.
$source = $source + ' ' + $err.CategoryInfo.Activity
}
} elseif ($myInv.MyCommand.Name) {
# If a *command* is the error source: this generally evaluates to the resolved underlying cmdlet/function name,
# even if the invocation used an alias or & or .
$source = append $source $myInv.MyCommand.Name
# If the invocation name was different - i.e., if an alias was used - append the alias name (e.g., 'Get-Item (gi)')
# ?? This doesn't always work, notably not with . and & (in which case $myInv.InvocationName contains '.' and '&',
# !! and with statement-terminating errors such as `Get-Item -NoSuchParam`.
# !! Examining .Line is not an option, because unrelated statements may preceded the offending one on the same line.
if ($myInv.InvocationName -and $myInv.InvocationName -notmatch '[.&]' -and $myInv.InvocationName -ne $myInv.MyCommand.Name) {
$source += ' ({0})' -f $myInv.InvocationName
}
} elseif ($myInv) { # expression such as `1/0` or `& { 1/0 }` or Throw statements
$source = "ERROR"
}
}
$context = ''
# Transform the message to a single-line string.
if ($isParseError) {
# Parse errors have multi-line messages in the following format:
# At ....:<line> char:<col>
# + <broken-part-of-statement>
# + ~
# where the "~" indicates the precise location of the error.
# We reformat this message to a single line
# $contextPart, $offendingLinePart, $indicatorLine, $reason, $rest = $err.ToString() -split '\r?\n'
# We omit the "~" line and directly append '<<<' to the offending part of the
# line and also strip the '+ ' prefix, then append the specific parsing error message.
# !! There could be MULTIPLE parsing errors, however, we only report the FIRST.
# ?? Is this a reason to *always* present parsing errors as multi-line errors?
# $msg = '{0}<<< {1}' -f ($offendingLinePart -replace '^[+]\s+'), $reason
$msg = $err.Exception.Errors[0].Message
$extent = $err.Exception.Errors[0].Extent.Text
$context = $myInv.Line.Replace($extent, "`e[35m$extent`e[33m")
} else {
# All other errors: replace newlines with spaces to output a single line.
$msg = $err.ToString() -replace '\r?\n', ' '
if ($myInv.PositionMessage) {
$errorLength = $myInv.PositionMessage.Split("+")[2].Trim().Length
for ($i = 0; $i -lt $myInv.Line.Length; $i++ )
{
if ($i -eq $myInv.OffsetInLine - 1)
{
$context += "`e[35m"
}
elseif ($i -eq ($myInv.OffsetInLine + $errorLength - 1))
{
$context += "`e[33m"
}
$context += $myInv.Line[$i];
}
}
}
# ?? When is $err.PSMessageDetails ever filled and does it matter?
# Output the synthesized message as a *single-line* string.
# Note that no effort is made to word-wrap overly long lines:
# * Hopefully, most terminals are wide enough to fit most errors on 1 line
# * When redirecting to a file / transcribing, processing is easier if every
# error can be assumed to occupy just 1 line.
$combinedMsg = "{0}: `e[35m{1}" -f $source, $msg
if ($context) {
$combinedMsg += "`nLine: `e[33m{0}" -f $context.Trim()
}
$combinedMsg
}
}
}
}
# Create a *.ps1xml file on the fly, using a fixed-per-session file path
# based on the process ID. This is helpful, because PS remembers previously
# loaded *.ps1xml files and tries to reload them, so if we used a different
# path every time (and deleted the file after), every subsequent
# Update-FormatData call would complain about missing files.
@"
<Configuration>
<ViewDefinitions>
<View>
<Name>ErrorInstance</Name>
<OutOfBand />
<ViewSelectedBy>
<TypeName>System.Management.Automation.ErrorRecord</TypeName>
</ViewSelectedBy>
<CustomControl>
<CustomEntries>
<CustomEntry>
<CustomItem>
<ExpressionBinding>
<ScriptBlock>
<![CDATA[
$sb
]]>
</ScriptBlock>
</ExpressionBinding>
</CustomItem>
</CustomEntry>
</CustomEntries>
</CustomControl>
</View>
</ViewDefinitions>
</Configuration>
"@ > ($tmpFile = [io.path]::GetTempPath() + "$PID.ps1xml")
# Load the format data via -PrependPath, which *preempts* preloaded definitions.
Update-FormatData -PrependPath $tmpFile
# Clean up.
Remove-Item $tmpFile
# === Create a test script that provokes various errors
$sbs = {
# Expression-statement-terminating error (from an all-PS expression)
1 / 0
},
{
# Expression-statement-terminating error (from a .NET method call)
[int]::Parse('not-a-number')
},
{
# Non-terminating error
Get-Item /NoSuchItem
},
{
# Pipeline-statement-terminating error
Get-Item -NoSuchParam
},
{
# Indirect parse error, via Invoke-Expression. (If we tried ``1 * 2)`` directly in a script, the whole script would fail to parse.)
Invoke-Expression '1 * 2)'
},
{
# Remoting (job) error
Receive-Job -Wait -AutoRemoveJob (Start-Job { Get-Item /NoSuchItem })
},
{
# Script-terminating error, via Throw
Remove-Item $PSCommandPath # Since the Throw statement below terminates this temp. script, we self-delete it first here.
Throw "Not at $HOME"
}
# !! Even the current 'NormalView' view makes no attempt to identify
# !! the *function* inside a script in which the error occurred.
# {
# # Error inside a function
# Function Foo { Get-Item /NoSuchItem }; Foo
# }
$tmpScript = [io.path]::GetTempPath() + "$PID.ps1"
$sbs | % {
@"
'$(($_.ToString() -replace "'", "''").Trim())'
$($_.Tostring())
""
"@
} > $tmpScript
# Write-Verbose -Verbose $tmpScript
# Invoke the temp. script to show how the errors are formatted.
# !! Do not use try / finally here, because the first terminating error
# !! would trigger the finally block.
& $tmpScript
# Since the Throw statement inside the temp. script aborts this script too,
# we never get here.
# Remove-Item $tmpScript
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment