diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/ImplicitRemotingCommands.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/ImplicitRemotingCommands.cs index 325632c44cee..19bc5d44aec7 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/ImplicitRemotingCommands.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/ImplicitRemotingCommands.cs @@ -2823,13 +2823,14 @@ private void GenerateHelperFunctions(TextWriter writer) $clientSideParameters = Get-PSImplicitRemotingClientSideParameters $PSBoundParameters ${8} - $scriptCmd = {{ & $script:InvokeCommand ` - @clientSideParameters ` - -HideComputerName ` - -Session (Get-PSImplicitRemotingSession -CommandName '{0}') ` - -Arg ('{0}', $PSBoundParameters, $positionalArguments) ` - -Script {{ param($name, $boundParams, $unboundParams) & $name @boundParams @unboundParams }} ` - }} + $scriptCmd = {{ + & $script:InvokeCommand ` + @clientSideParameters ` + -HideComputerName ` + -Session (Get-PSImplicitRemotingSession -CommandName '{0}') ` + -Arg ('{0}', $PSBoundParameters, $positionalArguments) ` + -Script {{ param($name, $boundParams, $unboundParams) & $name @boundParams @unboundParams }} ` + }} $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin) $steppablePipeline.Begin($myInvocation.ExpectingInput, $ExecutionContext) diff --git a/src/System.Management.Automation/engine/CommandBase.cs b/src/System.Management.Automation/engine/CommandBase.cs index 048eafd56c8c..bb7aec959f61 100644 --- a/src/System.Management.Automation/engine/CommandBase.cs +++ b/src/System.Management.Automation/engine/CommandBase.cs @@ -233,6 +233,13 @@ internal virtual void DoStopProcessing() { } + /// + /// When overridden in the derived class, performs clean-up after the command execution. + /// + internal virtual void DoCleanResource() + { + } + #endregion Override /// diff --git a/src/System.Management.Automation/engine/CommandInfo.cs b/src/System.Management.Automation/engine/CommandInfo.cs index 7e28fece99d2..1acca145ff9b 100644 --- a/src/System.Management.Automation/engine/CommandInfo.cs +++ b/src/System.Management.Automation/engine/CommandInfo.cs @@ -529,7 +529,7 @@ private void GetMergedCommandParameterMetadata(out MergedCommandParameterMetadat processor = scriptCommand != null ? new CommandProcessor(scriptCommand, _context, useLocalScope: true, fromScriptFile: false, sessionState: scriptCommand.ScriptBlock.SessionStateInternal ?? Context.EngineSessionState) - : new CommandProcessor((CmdletInfo)this, _context) { UseLocalScope = true }; + : new CommandProcessor((CmdletInfo)this, _context); ParameterBinderController.AddArgumentsToCommandProcessor(processor, Arguments); CommandProcessorBase oldCurrentCommandProcessor = Context.CurrentCommandProcessor; diff --git a/src/System.Management.Automation/engine/CommandMetadata.cs b/src/System.Management.Automation/engine/CommandMetadata.cs index eb59f9140a29..bbe2b6666b2e 100644 --- a/src/System.Management.Automation/engine/CommandMetadata.cs +++ b/src/System.Management.Automation/engine/CommandMetadata.cs @@ -875,8 +875,11 @@ internal string GetProxyCommand(string helpComment, bool generateDynamicParamete end {{{5}}} + +clean +{{{6}}} <# -{6} +{7} #> ", GetDecl(), @@ -885,6 +888,7 @@ internal string GetProxyCommand(string helpComment, bool generateDynamicParamete GetBeginBlock(), GetProcessBlock(), GetEndBlock(), + GetCleanBlock(), CodeGeneration.EscapeBlockCommentContent(helpComment)); return result; @@ -1063,6 +1067,11 @@ internal string GetBeginBlock() internal string GetProcessBlock() { + // The reason we wrap scripts in 'try { } catch { throw }' (here and elsewhere) is to turn + // an exception that could be thrown from .NET method invocation into a terminating error + // that can be propagated up. + // By default, an exception thrown from .NET method is not terminating, but when enclosed + // in try/catch, it will be turned into a terminating error. return @" try { $steppablePipeline.Process($_) @@ -1113,6 +1122,16 @@ internal string GetEndBlock() "; } + internal string GetCleanBlock() + { + // Here we don't need to enclose the script in a 'try/catch' like elsewhere, because + // 1. the 'Clean' block doesn't propagate up any exception (terminating error); + // 2. only one expression in the script, so nothing else needs to be stopped when invoking the method fails. + return @" + $steppablePipeline.Clean() +"; + } + #endregion #region Helper methods for restricting commands needed by implicit and interactive remoting diff --git a/src/System.Management.Automation/engine/CommandProcessor.cs b/src/System.Management.Automation/engine/CommandProcessor.cs index fb1fe81fc540..62eebe5d9377 100644 --- a/src/System.Management.Automation/engine/CommandProcessor.cs +++ b/src/System.Management.Automation/engine/CommandProcessor.cs @@ -309,13 +309,11 @@ internal override void DoBegin() internal override void ProcessRecord() { // Invoke the Command method with the request object - if (!this.RanBeginAlready) { RanBeginAlready = true; try { - // NOTICE-2004/06/08-JonN 959638 using (commandRuntime.AllowThisCommandToWrite(true)) { if (Context._debuggingMode > 0 && Command is not PSScriptCmdlet) @@ -326,12 +324,9 @@ internal override void ProcessRecord() Command.DoBeginProcessing(); } } - // 2004/03/18-JonN This is understood to be - // an FXCOP violation, cleared by KCwalina. - catch (Exception e) // Catch-all OK, 3rd party callout. + catch (Exception e) { - // This cmdlet threw an exception, so - // wrap it and bubble it up. + // This cmdlet threw an exception, so wrap it and bubble it up. throw ManageInvocationException(e); } } @@ -366,6 +361,7 @@ internal override void ProcessRecord() // NOTICE-2004/06/08-JonN 959638 using (commandRuntime.AllowThisCommandToWrite(true)) + using (ParameterBinderBase.bindingTracer.TraceScope("CALLING ProcessRecord")) { if (CmdletParameterBinderController.ObsoleteParameterWarningList != null && CmdletParameterBinderController.ObsoleteParameterWarningList.Count > 0) @@ -400,14 +396,13 @@ internal override void ProcessRecord() } catch (LoopFlowException) { - // Win8:84066 - Don't wrap LoopFlowException, we incorrectly raise a PipelineStoppedException + // Don't wrap LoopFlowException, we incorrectly raise a PipelineStoppedException // which gets caught by a script try/catch if we wrap here. throw; } - // 2004/03/18-JonN This is understood to be - // an FXCOP violation, cleared by KCwalina. - catch (Exception e) // Catch-all OK, 3rd party callout. + catch (Exception e) { + // Catch-all OK, 3rd party callout. exceptionToThrow = e; } finally diff --git a/src/System.Management.Automation/engine/CommandProcessorBase.cs b/src/System.Management.Automation/engine/CommandProcessorBase.cs index c6825cc69252..8033e2b3677b 100644 --- a/src/System.Management.Automation/engine/CommandProcessorBase.cs +++ b/src/System.Management.Automation/engine/CommandProcessorBase.cs @@ -5,8 +5,7 @@ using System.Collections.ObjectModel; using System.Management.Automation.Internal; using System.Management.Automation.Language; - -using Dbg = System.Management.Automation.Diagnostics; +using System.Runtime.InteropServices; namespace System.Management.Automation { @@ -46,6 +45,7 @@ internal CommandProcessorBase(CommandInfo commandInfo) string errorTemplate = expAttribute.ExperimentAction == ExperimentAction.Hide ? DiscoveryExceptions.ScriptDisabledWhenFeatureOn : DiscoveryExceptions.ScriptDisabledWhenFeatureOff; + string errorMsg = StringUtil.Format(errorTemplate, expAttribute.ExperimentName); ErrorRecord errorRecord = new ErrorRecord( new InvalidOperationException(errorMsg), @@ -54,6 +54,8 @@ internal CommandProcessorBase(CommandInfo commandInfo) commandInfo); throw new CmdletInvocationException(errorRecord); } + + HasCleanBlock = scriptCommand.ScriptBlock.HasCleanBlock; } CommandInfo = commandInfo; @@ -87,6 +89,11 @@ internal bool AddedToPipelineAlready /// internal CommandInfo CommandInfo { get; set; } + /// + /// Gets whether the command has a 'Clean' block defined. + /// + internal bool HasCleanBlock { get; } + /// /// This indicates whether this command processor is created from /// a script file. @@ -371,13 +378,10 @@ internal void RestorePreviousScope() Context.EngineSessionState = _previousCommandSessionState; - if (_previousScope != null) - { - // Restore the scope but use the same session state instance we - // got it from because the command may have changed the execution context - // session state... - CommandSessionState.CurrentScope = _previousScope; - } + // Restore the scope but use the same session state instance we + // got it from because the command may have changed the execution context + // session state... + CommandSessionState.CurrentScope = _previousScope; } private SessionStateScope _previousScope; @@ -452,16 +456,14 @@ internal void DoPrepare(IDictionary psDefaultParameterValues) HandleObsoleteCommand(ObsoleteAttribute); } } - catch (Exception) + catch (InvalidComObjectException e) { - if (_useLocalScope) - { - // If we had an exception during Prepare, we're done trying to execute the command - // so the scope we created needs to release any resources it hold.s - CommandSessionState.RemoveScope(CommandScope); - } + // This type of exception could be thrown from parameter binding. + string msg = StringUtil.Format(ParserStrings.InvalidComObjectException, e.Message); + var newEx = new RuntimeException(msg, e); - throw; + newEx.SetErrorId("InvalidComObjectException"); + throw newEx; } finally { @@ -508,26 +510,23 @@ internal virtual void DoBegin() // The RedirectShellErrorOutputPipe flag is used by the V2 hosting API to force the // redirection. // - if (this.RedirectShellErrorOutputPipe || _context.ShellFunctionErrorOutputPipe != null) + if (RedirectShellErrorOutputPipe || _context.ShellFunctionErrorOutputPipe is not null) { - _context.ShellFunctionErrorOutputPipe = this.commandRuntime.ErrorOutputPipe; + _context.ShellFunctionErrorOutputPipe = commandRuntime.ErrorOutputPipe; } _context.CurrentCommandProcessor = this; + SetCurrentScopeToExecutionScope(); + using (commandRuntime.AllowThisCommandToWrite(true)) + using (ParameterBinderBase.bindingTracer.TraceScope("CALLING BeginProcessing")) { - using (ParameterBinderBase.bindingTracer.TraceScope( - "CALLING BeginProcessing")) + if (Context._debuggingMode > 0 && Command is not PSScriptCmdlet) { - SetCurrentScopeToExecutionScope(); - - if (Context._debuggingMode > 0 && Command is not PSScriptCmdlet) - { - Context.Debugger.CheckCommand(this.Command.MyInvocation); - } - - Command.DoBeginProcessing(); + Context.Debugger.CheckCommand(Command.MyInvocation); } + + Command.DoBeginProcessing(); } } catch (Exception e) @@ -589,20 +588,14 @@ internal virtual void Complete() try { using (commandRuntime.AllowThisCommandToWrite(true)) + using (ParameterBinderBase.bindingTracer.TraceScope("CALLING EndProcessing")) { - using (ParameterBinderBase.bindingTracer.TraceScope( - "CALLING EndProcessing")) - { - this.Command.DoEndProcessing(); - } + this.Command.DoEndProcessing(); } } - // 2004/03/18-JonN This is understood to be - // an FXCOP violation, cleared by KCwalina. catch (Exception e) { - // This cmdlet threw an exception, so - // wrap it and bubble it up. + // This cmdlet threw an exception, wrap it as needed and bubble it up. throw ManageInvocationException(e); } } @@ -631,46 +624,121 @@ internal void DoComplete() // The RedirectShellErrorOutputPipe flag is used by the V2 hosting API to force the // redirection. // - if (this.RedirectShellErrorOutputPipe || _context.ShellFunctionErrorOutputPipe != null) + if (RedirectShellErrorOutputPipe || _context.ShellFunctionErrorOutputPipe is not null) { - _context.ShellFunctionErrorOutputPipe = this.commandRuntime.ErrorOutputPipe; + _context.ShellFunctionErrorOutputPipe = commandRuntime.ErrorOutputPipe; } _context.CurrentCommandProcessor = this; - SetCurrentScopeToExecutionScope(); Complete(); } finally { - OnRestorePreviousScope(); - _context.ShellFunctionErrorOutputPipe = oldErrorOutputPipe; _context.CurrentCommandProcessor = oldCurrentCommandProcessor; - // Destroy the local scope at this point if there is one... - if (_useLocalScope && CommandScope != null) - { - CommandSessionState.RemoveScope(CommandScope); - } + RestorePreviousScope(); + } + } - // and the previous scope... - if (_previousScope != null) + protected virtual void CleanResource() + { + try + { + using (commandRuntime.AllowThisCommandToWrite(permittedToWriteToPipeline: true)) + using (ParameterBinderBase.bindingTracer.TraceScope("CALLING CleanResource")) { - // Restore the scope but use the same session state instance we - // got it from because the command may have changed the execution context - // session state... - CommandSessionState.CurrentScope = _previousScope; + Command.DoCleanResource(); } + } + catch (HaltCommandException) + { + throw; + } + catch (FlowControlException) + { + throw; + } + catch (Exception e) + { + // This cmdlet threw an exception, so wrap it and bubble it up. + throw ManageInvocationException(e); + } + } + + internal void DoCleanup() + { + // The property 'PropagateExceptionsToEnclosingStatementBlock' controls whether a general exception + // (an exception thrown from a .NET method invocation, or an expression like '1/0') will be turned + // into a terminating error, which will be propagated up and thus stop the rest of the running script. + // It is usually used by TryStatement and TrapStatement, which makes the general exception catch-able. + // + // For the 'Clean' block, we don't want to bubble up the general exception when the command is enclosed + // in a TryStatement or has TrapStatement accompanying, because no exception can escape from 'Clean' and + // thus it's pointless to bubble up the general exception in this case. + // + // Therefore we set this property to 'false' here to mask off the previous setting that could be from a + // TryStatement or TrapStatement. Example: + // PS:1> function b { end {} clean { 1/0; Write-Host 'clean' } } + // PS:2> b + // RuntimeException: Attempted to divide by zero. + // clean + // ## Note that, outer 'try/trap' doesn't affect the general exception happens in 'Clean' block. + // ## so its behavior is consistent regardless of whether the command is enclosed by 'try/catch' or not. + // PS:3> try { b } catch { 'outer catch' } + // RuntimeException: Attempted to divide by zero. + // clean + // + // Be noted that, this doesn't affect the TryStatement/TrapStatement within the 'Clean' block. Example: + // ## 'try/trap' within 'Clean' block makes the general exception catch-able. + // PS:3> function a { end {} clean { try { 1/0; Write-Host 'clean' } catch { Write-Host "caught: $_" } } } + // PS:4> a + // caught: Attempted to divide by zero. + bool oldExceptionPropagationState = _context.PropagateExceptionsToEnclosingStatementBlock; + _context.PropagateExceptionsToEnclosingStatementBlock = false; - // Restore the previous session state - if (_previousCommandSessionState != null) + Pipe oldErrorOutputPipe = _context.ShellFunctionErrorOutputPipe; + CommandProcessorBase oldCurrentCommandProcessor = _context.CurrentCommandProcessor; + + try + { + if (RedirectShellErrorOutputPipe || _context.ShellFunctionErrorOutputPipe is not null) { - Context.EngineSessionState = _previousCommandSessionState; + _context.ShellFunctionErrorOutputPipe = commandRuntime.ErrorOutputPipe; } + + _context.CurrentCommandProcessor = this; + SetCurrentScopeToExecutionScope(); + CleanResource(); + } + finally + { + _context.PropagateExceptionsToEnclosingStatementBlock = oldExceptionPropagationState; + _context.ShellFunctionErrorOutputPipe = oldErrorOutputPipe; + _context.CurrentCommandProcessor = oldCurrentCommandProcessor; + + RestorePreviousScope(); } } + internal void ReportCleanupError(Exception exception) + { + var error = exception is IContainsErrorRecord icer + ? icer.ErrorRecord + : new ErrorRecord(exception, "Clean.ReportException", ErrorCategory.NotSpecified, targetObject: null); + + PSObject errorWrap = PSObject.AsPSObject(error); + errorWrap.WriteStream = WriteStreamType.Error; + + var errorPipe = commandRuntime.ErrorMergeTo == MshCommandRuntime.MergeDataStream.Output + ? commandRuntime.OutputPipe + : commandRuntime.ErrorOutputPipe; + + errorPipe.Add(errorWrap); + _context.QuestionMarkVariableValue = false; + } + /// /// For diagnostic purposes. /// @@ -777,23 +845,16 @@ internal PipelineStoppedException ManageInvocationException(Exception e) { do // false loop { - ProviderInvocationException pie = e as ProviderInvocationException; - if (pie != null) + if (e is ProviderInvocationException pie) { - // If a ProviderInvocationException occurred, - // discard the ProviderInvocationException and - // re-wrap in CmdletProviderInvocationException - e = new CmdletProviderInvocationException( - pie, - Command.MyInvocation); + // If a ProviderInvocationException occurred, discard the ProviderInvocationException + // and re-wrap it in CmdletProviderInvocationException. + e = new CmdletProviderInvocationException(pie, Command.MyInvocation); break; } - // 1021203-2005/05/09-JonN - // HaltCommandException will cause the command - // to stop, but not be reported as an error. - // 906445-2005/05/16-JonN - // FlowControlException should not be wrapped + // HaltCommandException will cause the command to stop, but not be reported as an error. + // FlowControlException should not be wrapped. if (e is PipelineStoppedException || e is CmdletInvocationException || e is ActionPreferenceStopException @@ -813,9 +874,7 @@ internal PipelineStoppedException ManageInvocationException(Exception e) } // wrap all other exceptions - e = new CmdletInvocationException( - e, - Command.MyInvocation); + e = new CmdletInvocationException(e, Command.MyInvocation); } while (false); // commandRuntime.ManageException will always throw PipelineStoppedException @@ -943,15 +1002,27 @@ public void Dispose() private void Dispose(bool disposing) { if (_disposed) + { return; + } if (disposing) { - // 2004/03/05-JonN Look into using metadata to check - // whether IDisposable is implemented, in order to avoid - // this expensive reflection cast. - IDisposable id = Command as IDisposable; - if (id != null) + if (UseLocalScope) + { + // Clean up the PS drives that are associated with this local scope. + // This operation may be needed at multiple stages depending on whether the 'clean' block is declared: + // 1. when there is a 'clean' block, it needs to be done only after 'clean' block runs, because the scope + // needs to be preserved until the 'clean' block finish execution. + // 2. when there is no 'clean' block, it needs to be done when + // (1) there is any exception thrown from 'DoPrepare()', 'DoBegin()', 'DoExecute()', or 'DoComplete'; + // (2) OR, the command runs to the end successfully; + // Doing this cleanup at those multiple stages is cumbersome. Since we will always dispose the command in + // the end, doing this cleanup here will cover all the above cases. + CommandSessionState.RemoveScope(CommandScope); + } + + if (Command is IDisposable id) { id.Dispose(); } diff --git a/src/System.Management.Automation/engine/ExecutionContext.cs b/src/System.Management.Automation/engine/ExecutionContext.cs index 8ffd25949c5b..18ba461a68ec 100644 --- a/src/System.Management.Automation/engine/ExecutionContext.cs +++ b/src/System.Management.Automation/engine/ExecutionContext.cs @@ -783,11 +783,6 @@ internal Pipe RedirectErrorPipe(Pipe newPipe) return oldPipe; } - internal void RestoreErrorPipe(Pipe pipe) - { - ShellFunctionErrorOutputPipe = pipe; - } - /// /// Reset all of the redirection book keeping variables. This routine should be called when starting to /// execute a script. @@ -840,15 +835,13 @@ internal void ResetRedirection() internal void AppendDollarError(object obj) { ErrorRecord objAsErrorRecord = obj as ErrorRecord; - if (objAsErrorRecord == null && obj is not Exception) + if (objAsErrorRecord is null && obj is not Exception) { Diagnostics.Assert(false, "Object to append was neither an ErrorRecord nor an Exception in ExecutionContext.AppendDollarError"); return; } - object old = this.DollarErrorVariable; - ArrayList arraylist = old as ArrayList; - if (arraylist == null) + if (DollarErrorVariable is not ArrayList arraylist) { Diagnostics.Assert(false, "$error should be a global constant ArrayList"); return; diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index dd0f72096350..cb0be3193a74 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -23,6 +23,7 @@ public class ExperimentalFeature internal const string EngineSource = "PSEngine"; internal const string PSNativeCommandArgumentPassingFeatureName = "PSNativeCommandArgumentPassing"; internal const string PSNativeCommandErrorActionPreferenceFeatureName = "PSNativeCommandErrorActionPreference"; + internal const string PSCleanBlockFeatureName = "PSCleanBlock"; #endregion @@ -126,6 +127,9 @@ static ExperimentalFeature() new ExperimentalFeature( name: PSNativeCommandErrorActionPreferenceFeatureName, description: "Native commands with non-zero exit codes issue errors according to $ErrorActionPreference when $PSNativeCommandUseErrorActionPreference is $true"), + new ExperimentalFeature( + name: PSCleanBlockFeatureName, + description: "Add support of a 'Clean' block to functions and script cmdlets for easy resource cleanup"), }; EngineExperimentalFeatures = new ReadOnlyCollection(engineFeatures); diff --git a/src/System.Management.Automation/engine/MshCommandRuntime.cs b/src/System.Management.Automation/engine/MshCommandRuntime.cs index 209652584627..16fa8652bcce 100644 --- a/src/System.Management.Automation/engine/MshCommandRuntime.cs +++ b/src/System.Management.Automation/engine/MshCommandRuntime.cs @@ -942,10 +942,8 @@ internal void SetupOutVariable() // Handle the creation of OutVariable in the case of Out-Default specially, // as it needs to handle much of its OutVariable support itself. - if ( - (!string.IsNullOrEmpty(this.OutVariable)) && - (!(this.OutVariable.StartsWith('+'))) && - string.Equals("Out-Default", _thisCommand.CommandInfo.Name, StringComparison.OrdinalIgnoreCase)) + if (!OutVariable.StartsWith('+') && + string.Equals("Out-Default", _commandInfo.Name, StringComparison.OrdinalIgnoreCase)) { if (_state == null) _state = new SessionState(Context.EngineSessionState); @@ -2426,7 +2424,6 @@ public Exception ManageException(Exception e) } // Log a command health event - MshLog.LogCommandHealthEvent( Context, e, @@ -3765,8 +3762,10 @@ internal void SetVariableListsInPipe() { Diagnostics.Assert(_thisCommand is PSScriptCmdlet, "this is only done for script cmdlets"); - if (_outVarList != null) + if (_outVarList != null && !OutputPipe.IgnoreOutVariableList) { + // A null pipe is used when executing the 'Clean' block of a PSScriptCmdlet. + // In such a case, we don't capture output to the out variable list. this.OutputPipe.AddVariableList(VariableStreamKind.Output, _outVarList); } @@ -3793,7 +3792,7 @@ internal void SetVariableListsInPipe() internal void RemoveVariableListsInPipe() { - if (_outVarList != null) + if (_outVarList != null && !OutputPipe.IgnoreOutVariableList) { this.OutputPipe.RemoveVariableList(VariableStreamKind.Output, _outVarList); } diff --git a/src/System.Management.Automation/engine/Pipe.cs b/src/System.Management.Automation/engine/Pipe.cs index 8ee000a4faf9..9e24c6471db6 100644 --- a/src/System.Management.Automation/engine/Pipe.cs +++ b/src/System.Management.Automation/engine/Pipe.cs @@ -109,6 +109,13 @@ public override string ToString() /// internal int OutBufferCount { get; set; } = 0; + /// + /// Gets whether the out variable list should be ignored. + /// This is used for scenarios like the `clean` block, where writing to output stream is intentionally + /// disabled and thus out variables should also be ignored. + /// + internal bool IgnoreOutVariableList { get; set; } + /// /// If true, then all input added to this pipe will simply be discarded... /// diff --git a/src/System.Management.Automation/engine/ProxyCommand.cs b/src/System.Management.Automation/engine/ProxyCommand.cs index 5703adec664f..6a2e6dce66d4 100644 --- a/src/System.Management.Automation/engine/ProxyCommand.cs +++ b/src/System.Management.Automation/engine/ProxyCommand.cs @@ -247,6 +247,30 @@ public static string GetEnd(CommandMetadata commandMetadata) return commandMetadata.GetEndBlock(); } + /// + /// This method constructs a string representing the clean block of the command + /// specified by . The returned string only contains the + /// script, it is not enclosed in "clean { }". + /// + /// + /// An instance of CommandMetadata representing a command. + /// + /// + /// A string representing the end block of the command. + /// + /// + /// If is null. + /// + public static string GetClean(CommandMetadata commandMetadata) + { + if (commandMetadata == null) + { + throw PSTraceSource.NewArgumentNullException(nameof(commandMetadata)); + } + + return commandMetadata.GetCleanBlock(); + } + private static T GetProperty(PSObject obj, string property) where T : class { T result = null; diff --git a/src/System.Management.Automation/engine/ScriptCommandProcessor.cs b/src/System.Management.Automation/engine/ScriptCommandProcessor.cs index 99b685e07d25..54d4f45849b0 100644 --- a/src/System.Management.Automation/engine/ScriptCommandProcessor.cs +++ b/src/System.Management.Automation/engine/ScriptCommandProcessor.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Management.Automation.Internal; using System.Management.Automation.Language; +using System.Management.Automation.Runspaces; using System.Reflection; using Dbg = System.Management.Automation.Diagnostics; @@ -47,7 +48,7 @@ protected ScriptCommandProcessorBase(IScriptCommandInfo commandInfo, ExecutionCo protected bool _dontUseScopeCommandOrigin; /// - /// If true, then an exit exception will be rethrown to instead of caught and processed... + /// If true, then an exit exception will be rethrown instead of caught and processed... /// protected bool _rethrowExitException; @@ -237,6 +238,7 @@ internal sealed class DlrScriptCommandProcessor : ScriptCommandProcessorBase private MutableTuple _localsTuple; private bool _runOptimizedCode; private bool _argsBound; + private bool _anyClauseExecuted; private FunctionContext _functionContext; internal DlrScriptCommandProcessor(ScriptBlock scriptBlock, ExecutionContext context, bool useNewScope, CommandOrigin origin, SessionStateInternal sessionState, object dollarUnderbar) @@ -327,8 +329,7 @@ internal override void DoBegin() ScriptBlock.LogScriptBlockStart(_scriptBlock, Context.CurrentRunspace.InstanceId); - // Even if there is no begin, we need to set up the execution scope for this - // script... + // Even if there is no begin, we need to set up the execution scope for this script... SetCurrentScopeToExecutionScope(); CommandProcessorBase oldCurrentCommandProcessor = Context.CurrentCommandProcessor; try @@ -410,6 +411,7 @@ internal override void Complete() if (_scriptBlock.HasEndBlock) { var endBlock = _runOptimizedCode ? _scriptBlock.EndBlock : _scriptBlock.UnoptimizedEndBlock; + if (this.CommandRuntime.InputPipe.ExternalReader == null) { if (IsPipelineInputExpected()) @@ -433,7 +435,33 @@ internal override void Complete() } finally { - ScriptBlock.LogScriptBlockEnd(_scriptBlock, Context.CurrentRunspace.InstanceId); + if (!_scriptBlock.HasCleanBlock) + { + ScriptBlock.LogScriptBlockEnd(_scriptBlock, Context.CurrentRunspace.InstanceId); + } + } + } + + protected override void CleanResource() + { + if (_scriptBlock.HasCleanBlock && _anyClauseExecuted) + { + // The 'Clean' block doesn't write to pipeline. + Pipe oldOutputPipe = _functionContext._outputPipe; + _functionContext._outputPipe = new Pipe { NullPipe = true }; + + try + { + RunClause( + clause: _runOptimizedCode ? _scriptBlock.CleanBlock : _scriptBlock.UnoptimizedCleanBlock, + dollarUnderbar: AutomationNull.Value, + inputToProcess: AutomationNull.Value); + } + finally + { + _functionContext._outputPipe = oldOutputPipe; + ScriptBlock.LogScriptBlockEnd(_scriptBlock, Context.CurrentRunspace.InstanceId); + } } } @@ -459,6 +487,7 @@ private void RunClause(Action clause, object dollarUnderbar, ob { ExecutionContext.CheckStackDepth(); + _anyClauseExecuted = true; Pipe oldErrorOutputPipe = this.Context.ShellFunctionErrorOutputPipe; // If the script block has a different language mode than the current, @@ -553,7 +582,7 @@ private void RunClause(Action clause, object dollarUnderbar, ob } finally { - this.Context.RestoreErrorPipe(oldErrorOutputPipe); + Context.ShellFunctionErrorOutputPipe = oldErrorOutputPipe; if (oldLanguageMode.HasValue) { @@ -584,15 +613,12 @@ private void RunClause(Action clause, object dollarUnderbar, ob } catch (RuntimeException e) { - ManageScriptException(e); // always throws - // This quiets the compiler which wants to see a return value - // in all codepaths. - throw; + // This method always throws. + ManageScriptException(e); } catch (Exception e) { - // This cmdlet threw an exception, so - // wrap it and bubble it up. + // This cmdlet threw an exception, so wrap it and bubble it up. throw ManageInvocationException(e); } } diff --git a/src/System.Management.Automation/engine/debugger/Breakpoint.cs b/src/System.Management.Automation/engine/debugger/Breakpoint.cs index 5daf3bdc18b6..e51d79f4afa8 100644 --- a/src/System.Management.Automation/engine/debugger/Breakpoint.cs +++ b/src/System.Management.Automation/engine/debugger/Breakpoint.cs @@ -531,15 +531,16 @@ internal bool TrySetBreakpoint(string scriptFile, FunctionContext functionContex // Not found. First, we check if the line/column is before any real code. If so, we'll // move the breakpoint to the first interesting sequence point (could be a dynamicparam, - // begin, process, or end block.) + // begin, process, end, or clean block.) if (scriptBlock != null) { var ast = scriptBlock.Ast; var bodyAst = ((IParameterMetadataProvider)ast).Body; - if ((bodyAst.DynamicParamBlock == null || bodyAst.DynamicParamBlock.Extent.IsAfter(Line, Column)) && - (bodyAst.BeginBlock == null || bodyAst.BeginBlock.Extent.IsAfter(Line, Column)) && - (bodyAst.ProcessBlock == null || bodyAst.ProcessBlock.Extent.IsAfter(Line, Column)) && - (bodyAst.EndBlock == null || bodyAst.EndBlock.Extent.IsAfter(Line, Column))) + if ((bodyAst.DynamicParamBlock == null || bodyAst.DynamicParamBlock.Extent.IsAfter(Line, Column)) + && (bodyAst.BeginBlock == null || bodyAst.BeginBlock.Extent.IsAfter(Line, Column)) + && (bodyAst.ProcessBlock == null || bodyAst.ProcessBlock.Extent.IsAfter(Line, Column)) + && (bodyAst.EndBlock == null || bodyAst.EndBlock.Extent.IsAfter(Line, Column)) + && (bodyAst.CleanBlock == null || bodyAst.CleanBlock.Extent.IsAfter(Line, Column))) { SetBreakpoint(functionContext, 0); return true; diff --git a/src/System.Management.Automation/engine/hostifaces/Pipeline.cs b/src/System.Management.Automation/engine/hostifaces/Pipeline.cs index 874691852cbe..c7b962b7668e 100644 --- a/src/System.Management.Automation/engine/hostifaces/Pipeline.cs +++ b/src/System.Management.Automation/engine/hostifaces/Pipeline.cs @@ -445,7 +445,7 @@ internal void SetHadErrors(bool status) /// /// This flag is used to force the redirection. By default it is false to maintain compatibility with /// V1, but the V2 hosting interface (PowerShell class) sets this flag to true to ensure the global - /// error output pipe is always set and $ErrorActionPreference when invoking the Pipeline. + /// error output pipe is always set and $ErrorActionPreference is checked when invoking the Pipeline. /// internal bool RedirectShellErrorOutputPipe { get; set; } = false; diff --git a/src/System.Management.Automation/engine/lang/scriptblock.cs b/src/System.Management.Automation/engine/lang/scriptblock.cs index 0c6c1d5dff1a..07f53c132ffe 100644 --- a/src/System.Management.Automation/engine/lang/scriptblock.cs +++ b/src/System.Management.Automation/engine/lang/scriptblock.cs @@ -1280,7 +1280,42 @@ public Array End() { // then pop this pipeline and dispose it... _context.PopPipelineProcessor(true); - _pipeline.Dispose(); + Dispose(); + } + } + + /// + /// Clean resources for script commands of this steppable pipeline. + /// + /// + /// The way we handle 'Clean' blocks in a steppable pipeline makes sure that: + /// 1. The 'Clean' blocks get to run if any exception is thrown from 'Begin/Process/End'. + /// 2. The 'Clean' blocks get to run if 'End' finished successfully. + /// However, this is not enough for a steppable pipeline, because the function, where the steppable + /// pipeline gets used, may fail (think about a proxy function). And that may lead to the situation + /// where "no exception was thrown from the steppable pipeline" but "the steppable pipeline didn't + /// run to the end". In that case, 'Clean' won't run unless it's triggered explicitly on the steppable + /// pipeline. This method allows a user to do that from the 'Clean' block of the proxy function. + /// + public void Clean() + { + if (_pipeline.Commands is null) + { + // The pipeline commands have been disposed. In this case, 'Clean' + // should have already been called on the pipeline processor. + return; + } + + try + { + _context.PushPipelineProcessor(_pipeline); + _pipeline.DoCleanup(); + } + finally + { + // then pop this pipeline and dispose it... + _context.PopPipelineProcessor(true); + Dispose(); } } @@ -1293,23 +1328,13 @@ public Array End() /// When this object is disposed, the contained pipeline should also be disposed. /// public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - private void Dispose(bool disposing) { if (_disposed) { return; } - if (disposing) - { - _pipeline.Dispose(); - } - + _pipeline.Dispose(); _disposed = true; } diff --git a/src/System.Management.Automation/engine/parser/Compiler.cs b/src/System.Management.Automation/engine/parser/Compiler.cs index bcddbee0b242..9956fb29e306 100644 --- a/src/System.Management.Automation/engine/parser/Compiler.cs +++ b/src/System.Management.Automation/engine/parser/Compiler.cs @@ -2026,6 +2026,7 @@ internal void Compile(CompiledScriptBlockData scriptBlock, bool optimize) scriptBlock.BeginBlock = CompileTree(_beginBlockLambda, compileInterpretChoice); scriptBlock.ProcessBlock = CompileTree(_processBlockLambda, compileInterpretChoice); scriptBlock.EndBlock = CompileTree(_endBlockLambda, compileInterpretChoice); + scriptBlock.CleanBlock = CompileTree(_cleanBlockLambda, compileInterpretChoice); scriptBlock.LocalsMutableTupleType = LocalVariablesTupleType; scriptBlock.LocalsMutableTupleCreator = MutableTuple.TupleCreator(LocalVariablesTupleType); scriptBlock.NameToIndexMap = nameToIndexMap; @@ -2036,6 +2037,7 @@ internal void Compile(CompiledScriptBlockData scriptBlock, bool optimize) scriptBlock.UnoptimizedBeginBlock = CompileTree(_beginBlockLambda, compileInterpretChoice); scriptBlock.UnoptimizedProcessBlock = CompileTree(_processBlockLambda, compileInterpretChoice); scriptBlock.UnoptimizedEndBlock = CompileTree(_endBlockLambda, compileInterpretChoice); + scriptBlock.UnoptimizedCleanBlock = CompileTree(_cleanBlockLambda, compileInterpretChoice); scriptBlock.UnoptimizedLocalsMutableTupleType = LocalVariablesTupleType; scriptBlock.UnoptimizedLocalsMutableTupleCreator = MutableTuple.TupleCreator(LocalVariablesTupleType); } @@ -2221,6 +2223,7 @@ internal LoopGotoTargets(string label, LabelTarget breakLabel, LabelTarget conti private Expression> _beginBlockLambda; private Expression> _processBlockLambda; private Expression> _endBlockLambda; + private Expression> _cleanBlockLambda; private readonly List _loopTargets = new List(); private bool _generatingWhileOrDoLoop; @@ -2463,6 +2466,13 @@ public object VisitScriptBlock(ScriptBlockAst scriptBlockAst) } _endBlockLambda = CompileNamedBlock(scriptBlockAst.EndBlock, funcName, rootForDefiningTypesAndUsings); + rootForDefiningTypesAndUsings = null; + } + + if (scriptBlockAst.CleanBlock != null) + { + _cleanBlockLambda = CompileNamedBlock(scriptBlockAst.CleanBlock, funcName + "", rootForDefiningTypesAndUsings); + rootForDefiningTypesAndUsings = null; } return null; diff --git a/src/System.Management.Automation/engine/parser/Parser.cs b/src/System.Management.Automation/engine/parser/Parser.cs index 3afc55e98613..c5f9d81f4b84 100644 --- a/src/System.Management.Automation/engine/parser/Parser.cs +++ b/src/System.Management.Automation/engine/parser/Parser.cs @@ -724,12 +724,13 @@ internal static bool TryParseAsConstantHashtable(string input, out Hashtable res ParseError[] parseErrors; var ast = Parser.ParseInput(input, out throwAwayTokens, out parseErrors); - if ((ast == null) || - parseErrors.Length > 0 || - ast.BeginBlock != null || - ast.ProcessBlock != null || - ast.DynamicParamBlock != null || - ast.EndBlock.Traps != null) + if (ast == null + || parseErrors.Length > 0 + || ast.BeginBlock != null + || ast.ProcessBlock != null + || ast.CleanBlock != null + || ast.DynamicParamBlock != null + || ast.EndBlock.Traps != null) { return false; } @@ -1713,9 +1714,9 @@ private ScriptBlockAst NamedBlockListRule(Token lCurly, List NamedBlockAst beginBlock = null; NamedBlockAst processBlock = null; NamedBlockAst endBlock = null; - IScriptExtent startExtent = lCurly != null - ? lCurly.Extent - : paramBlockAst?.Extent; + NamedBlockAst cleanBlock = null; + + IScriptExtent startExtent = lCurly?.Extent ?? paramBlockAst?.Extent; IScriptExtent endExtent = null; IScriptExtent extent = null; IScriptExtent scriptBlockExtent = null; @@ -1757,6 +1758,7 @@ private ScriptBlockAst NamedBlockListRule(Token lCurly, List case TokenKind.Begin: case TokenKind.Process: case TokenKind.End: + case TokenKind.Clean: break; } @@ -1797,6 +1799,10 @@ private ScriptBlockAst NamedBlockListRule(Token lCurly, List { endBlock = new NamedBlockAst(extent, TokenKind.End, statementBlock, false); } + else if (blockNameToken.Kind == TokenKind.Clean && cleanBlock == null) + { + cleanBlock = new NamedBlockAst(extent, TokenKind.Clean, statementBlock, false); + } else if (blockNameToken.Kind == TokenKind.Dynamicparam && dynamicParamBlock == null) { dynamicParamBlock = new NamedBlockAst(extent, TokenKind.Dynamicparam, statementBlock, false); @@ -1818,7 +1824,14 @@ private ScriptBlockAst NamedBlockListRule(Token lCurly, List CompleteScriptBlockBody(lCurly, ref extent, out scriptBlockExtent); return_script_block_ast: - return new ScriptBlockAst(scriptBlockExtent, usingStatements, paramBlockAst, beginBlock, processBlock, endBlock, + return new ScriptBlockAst( + scriptBlockExtent, + usingStatements, + paramBlockAst, + beginBlock, + processBlock, + endBlock, + cleanBlock, dynamicParamBlock); } diff --git a/src/System.Management.Automation/engine/parser/SemanticChecks.cs b/src/System.Management.Automation/engine/parser/SemanticChecks.cs index 927b2c33ae8b..b792530193bc 100644 --- a/src/System.Management.Automation/engine/parser/SemanticChecks.cs +++ b/src/System.Management.Automation/engine/parser/SemanticChecks.cs @@ -406,10 +406,11 @@ public override AstVisitAction VisitFunctionMember(FunctionMemberAst functionMem ParserStrings.ParamBlockNotAllowedInMethod); } - if (body.BeginBlock != null || - body.ProcessBlock != null || - body.DynamicParamBlock != null || - !body.EndBlock.Unnamed) + if (body.BeginBlock != null + || body.ProcessBlock != null + || body.CleanBlock != null + || body.DynamicParamBlock != null + || !body.EndBlock.Unnamed) { _parser.ReportError(Parser.ExtentFromFirstOf(body.DynamicParamBlock, body.BeginBlock, body.ProcessBlock, body.EndBlock), nameof(ParserStrings.NamedBlockNotAllowedInMethod), diff --git a/src/System.Management.Automation/engine/parser/VariableAnalysis.cs b/src/System.Management.Automation/engine/parser/VariableAnalysis.cs index b38954ebfc62..09b336099cb4 100644 --- a/src/System.Management.Automation/engine/parser/VariableAnalysis.cs +++ b/src/System.Management.Automation/engine/parser/VariableAnalysis.cs @@ -945,25 +945,11 @@ public object VisitScriptBlock(ScriptBlockAst scriptBlockAst) { _currentBlock = _entryBlock; - if (scriptBlockAst.DynamicParamBlock != null) - { - scriptBlockAst.DynamicParamBlock.Accept(this); - } - - if (scriptBlockAst.BeginBlock != null) - { - scriptBlockAst.BeginBlock.Accept(this); - } - - if (scriptBlockAst.ProcessBlock != null) - { - scriptBlockAst.ProcessBlock.Accept(this); - } - - if (scriptBlockAst.EndBlock != null) - { - scriptBlockAst.EndBlock.Accept(this); - } + scriptBlockAst.DynamicParamBlock?.Accept(this); + scriptBlockAst.BeginBlock?.Accept(this); + scriptBlockAst.ProcessBlock?.Accept(this); + scriptBlockAst.EndBlock?.Accept(this); + scriptBlockAst.CleanBlock?.Accept(this); _currentBlock.FlowsTo(_exitBlock); diff --git a/src/System.Management.Automation/engine/parser/ast.cs b/src/System.Management.Automation/engine/parser/ast.cs index 1f03eb68df5e..b40a8e66a1e8 100644 --- a/src/System.Management.Automation/engine/parser/ast.cs +++ b/src/System.Management.Automation/engine/parser/ast.cs @@ -818,6 +818,46 @@ public class ScriptBlockAst : Ast, IParameterMetadataProvider NamedBlockAst processBlock, NamedBlockAst endBlock, NamedBlockAst dynamicParamBlock) + : this( + extent, + usingStatements, + attributes, + paramBlock, + beginBlock, + processBlock, + endBlock, + cleanBlock: null, + dynamicParamBlock) + { + } + + /// + /// Initializes a new instance of the class. + /// This construction uses explicitly named begin/process/end/clean blocks. + /// + /// The extent of the script block. + /// The list of using statments, may be null. + /// The set of attributes for the script block. + /// The ast for the param block, may be null. + /// The ast for the begin block, may be null. + /// The ast for the process block, may be null. + /// The ast for the end block, may be null. + /// The ast for the clean block, may be null. + /// The ast for the dynamicparam block, may be null. + /// + /// If is null. + /// + [SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "param")] + public ScriptBlockAst( + IScriptExtent extent, + IEnumerable usingStatements, + IEnumerable attributes, + ParamBlockAst paramBlock, + NamedBlockAst beginBlock, + NamedBlockAst processBlock, + NamedBlockAst endBlock, + NamedBlockAst cleanBlock, + NamedBlockAst dynamicParamBlock) : base(extent) { SetUsingStatements(usingStatements); @@ -856,6 +896,12 @@ public class ScriptBlockAst : Ast, IParameterMetadataProvider SetParent(endBlock); } + if (cleanBlock != null) + { + this.CleanBlock = cleanBlock; + SetParent(cleanBlock); + } + if (dynamicParamBlock != null) { this.DynamicParamBlock = dynamicParamBlock; @@ -888,6 +934,35 @@ public class ScriptBlockAst : Ast, IParameterMetadataProvider { } + /// + /// Initializes a new instance of the class. + /// This construction uses explicitly named begin/process/end/clean blocks. + /// + /// The extent of the script block. + /// The list of using statments, may be null. + /// The ast for the param block, may be null. + /// The ast for the begin block, may be null. + /// The ast for the process block, may be null. + /// The ast for the end block, may be null. + /// The ast for the clean block, may be null. + /// The ast for the dynamicparam block, may be null. + /// + /// If is null. + /// + [SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "param")] + public ScriptBlockAst( + IScriptExtent extent, + IEnumerable usingStatements, + ParamBlockAst paramBlock, + NamedBlockAst beginBlock, + NamedBlockAst processBlock, + NamedBlockAst endBlock, + NamedBlockAst cleanBlock, + NamedBlockAst dynamicParamBlock) + : this(extent, usingStatements, null, paramBlock, beginBlock, processBlock, endBlock, cleanBlock, dynamicParamBlock) + { + } + /// /// Construct a ScriptBlockAst that uses explicitly named begin/process/end blocks. /// @@ -911,6 +986,33 @@ public class ScriptBlockAst : Ast, IParameterMetadataProvider { } + /// + /// Initializes a new instance of the class. + /// This construction uses explicitly named begin/process/end/clean blocks. + /// + /// The extent of the script block. + /// The ast for the param block, may be null. + /// The ast for the begin block, may be null. + /// The ast for the process block, may be null. + /// The ast for the end block, may be null. + /// The ast for the clean block, may be null. + /// The ast for the dynamicparam block, may be null. + /// + /// If is null. + /// + [SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "param")] + public ScriptBlockAst( + IScriptExtent extent, + ParamBlockAst paramBlock, + NamedBlockAst beginBlock, + NamedBlockAst processBlock, + NamedBlockAst endBlock, + NamedBlockAst cleanBlock, + NamedBlockAst dynamicParamBlock) + : this(extent, null, paramBlock, beginBlock, processBlock, endBlock, cleanBlock, dynamicParamBlock) + { + } + /// /// Construct a ScriptBlockAst that does not use explicitly named blocks. /// @@ -1115,6 +1217,11 @@ private void SetUsingStatements(IEnumerable usingStatements) /// public NamedBlockAst EndBlock { get; } + /// + /// Gets the ast representing the clean block for a script block, or null if no clean block was specified. + /// + public NamedBlockAst CleanBlock { get; } + /// /// The ast representing the dynamicparam block for a script block, or null if no dynamicparam block was specified. /// @@ -1194,17 +1301,25 @@ public override Ast Copy() var newBeginBlock = CopyElement(this.BeginBlock); var newProcessBlock = CopyElement(this.ProcessBlock); var newEndBlock = CopyElement(this.EndBlock); + var newCleanBlock = CopyElement(this.CleanBlock); var newDynamicParamBlock = CopyElement(this.DynamicParamBlock); var newAttributes = CopyElements(this.Attributes); var newUsingStatements = CopyElements(this.UsingStatements); - var scriptBlockAst = new ScriptBlockAst(this.Extent, newUsingStatements, newAttributes, newParamBlock, newBeginBlock, newProcessBlock, - newEndBlock, newDynamicParamBlock) + return new ScriptBlockAst( + this.Extent, + newUsingStatements, + newAttributes, + newParamBlock, + newBeginBlock, + newProcessBlock, + newEndBlock, + newCleanBlock, + newDynamicParamBlock) { IsConfiguration = this.IsConfiguration, ScriptRequirements = this.ScriptRequirements }; - return scriptBlockAst; } internal string ToStringForSerialization() @@ -1367,17 +1482,27 @@ internal override AstVisitAction InternalVisit(AstVisitor visitor) } } - if (action == AstVisitAction.Continue && ParamBlock != null) - action = ParamBlock.InternalVisit(visitor); - if (action == AstVisitAction.Continue && DynamicParamBlock != null) - action = DynamicParamBlock.InternalVisit(visitor); - if (action == AstVisitAction.Continue && BeginBlock != null) - action = BeginBlock.InternalVisit(visitor); - if (action == AstVisitAction.Continue && ProcessBlock != null) - action = ProcessBlock.InternalVisit(visitor); - if (action == AstVisitAction.Continue && EndBlock != null) - action = EndBlock.InternalVisit(visitor); + if (action == AstVisitAction.Continue) + { + _ = VisitAndShallContinue(ParamBlock) && + VisitAndShallContinue(DynamicParamBlock) && + VisitAndShallContinue(BeginBlock) && + VisitAndShallContinue(ProcessBlock) && + VisitAndShallContinue(EndBlock) && + VisitAndShallContinue(CleanBlock); + } + return visitor.CheckForPostAction(this, action); + + bool VisitAndShallContinue(Ast ast) + { + if (ast is not null) + { + action = ast.InternalVisit(visitor); + } + + return action == AstVisitAction.Continue; + } } #endregion Visitors @@ -1581,9 +1706,12 @@ bool IParameterMetadataProvider.UsesCmdletBinding() internal PipelineAst GetSimplePipeline(bool allowMultiplePipelines, out string errorId, out string errorMsg) { - if (BeginBlock != null || ProcessBlock != null || DynamicParamBlock != null) + if (BeginBlock != null + || ProcessBlock != null + || CleanBlock != null + || DynamicParamBlock != null) { - errorId = "CanConvertOneClauseOnly"; + errorId = nameof(AutomationExceptions.CanConvertOneClauseOnly); errorMsg = AutomationExceptions.CanConvertOneClauseOnly; return null; } @@ -1749,7 +1877,7 @@ internal static bool UsesCmdletBinding(IEnumerable parameters) public class NamedBlockAst : Ast { /// - /// Construct the ast for a begin, process, end, or dynamic param block. + /// Construct the ast for a begin, process, end, clean, or dynamic param block. /// /// /// The extent of the block. If is false, the extent includes @@ -1761,6 +1889,7 @@ public class NamedBlockAst : Ast /// /// /// + /// /// /// /// @@ -1779,8 +1908,7 @@ public NamedBlockAst(IScriptExtent extent, TokenKind blockName, StatementBlockAs { // Validate the block name. If the block is unnamed, it must be an End block (for a function) // or Process block (for a filter). - if (!blockName.HasTrait(TokenFlags.ScriptBlockBlockName) - || (unnamed && (blockName == TokenKind.Begin || blockName == TokenKind.Dynamicparam))) + if (HasInvalidBlockName(blockName, unnamed)) { throw PSTraceSource.NewArgumentException(nameof(blockName)); } @@ -1838,6 +1966,7 @@ public NamedBlockAst(IScriptExtent extent, TokenKind blockName, StatementBlockAs /// /// /// + /// /// /// /// @@ -1877,6 +2006,14 @@ public override Ast Copy() return new NamedBlockAst(this.Extent, this.BlockKind, statementBlock, this.Unnamed); } + private static bool HasInvalidBlockName(TokenKind blockName, bool unnamed) + { + return !blockName.HasTrait(TokenFlags.ScriptBlockBlockName) + || (unnamed + && blockName != TokenKind.Process + && blockName != TokenKind.End); + } + // Used by the debugger for command breakpoints internal IScriptExtent OpenCurlyExtent { get; } diff --git a/src/System.Management.Automation/engine/parser/token.cs b/src/System.Management.Automation/engine/parser/token.cs index 893ec9fc8e06..43a3e86ebb73 100644 --- a/src/System.Management.Automation/engine/parser/token.cs +++ b/src/System.Management.Automation/engine/parser/token.cs @@ -588,6 +588,9 @@ public enum TokenKind /// The 'default' keyword Default = 169, + /// The 'clean' keyword. + Clean = 170, + #endregion Keywords } @@ -659,7 +662,7 @@ public enum TokenFlags Keyword = 0x00000010, /// - /// The token one of the keywords that is a part of a script block: 'begin', 'process', 'end', or 'dynamicparam'. + /// The token is one of the keywords that is a part of a script block: 'begin', 'process', 'end', 'clean', or 'dynamicparam'. /// ScriptBlockBlockName = 0x00000020, @@ -948,6 +951,7 @@ public static class TokenTraits /* Hidden */ TokenFlags.Keyword, /* Base */ TokenFlags.Keyword, /* Default */ TokenFlags.Keyword, + /* Clean */ TokenFlags.Keyword | TokenFlags.ScriptBlockBlockName, #endregion Flags for keywords }; @@ -1147,6 +1151,7 @@ public static class TokenTraits /* Hidden */ "hidden", /* Base */ "base", /* Default */ "default", + /* Clean */ "clean", #endregion Text for keywords }; @@ -1154,10 +1159,12 @@ public static class TokenTraits #if DEBUG static TokenTraits() { - Diagnostics.Assert(s_staticTokenFlags.Length == ((int)TokenKind.Default + 1), - "Table size out of sync with enum - _staticTokenFlags"); - Diagnostics.Assert(s_tokenText.Length == ((int)TokenKind.Default + 1), - "Table size out of sync with enum - _tokenText"); + Diagnostics.Assert( + s_staticTokenFlags.Length == ((int)TokenKind.Clean + 1), + "Table size out of sync with enum - _staticTokenFlags"); + Diagnostics.Assert( + s_tokenText.Length == ((int)TokenKind.Clean + 1), + "Table size out of sync with enum - _tokenText"); // Some random assertions to make sure the enum and the traits are in sync Diagnostics.Assert(GetTraits(TokenKind.Begin) == (TokenFlags.Keyword | TokenFlags.ScriptBlockBlockName), "Table out of sync with enum - flags Begin"); diff --git a/src/System.Management.Automation/engine/parser/tokenizer.cs b/src/System.Management.Automation/engine/parser/tokenizer.cs index 161e53cef0c7..43046c9d29e6 100644 --- a/src/System.Management.Automation/engine/parser/tokenizer.cs +++ b/src/System.Management.Automation/engine/parser/tokenizer.cs @@ -635,23 +635,23 @@ internal class Tokenizer /*A*/ "configuration", "public", "private", "static", /*A*/ /*B*/ "interface", "enum", "namespace", "module", /*B*/ /*C*/ "type", "assembly", "command", "hidden", /*C*/ - /*D*/ "base", "default", /*D*/ + /*D*/ "base", "default", "clean", /*D*/ }; private static readonly TokenKind[] s_keywordTokenKind = new TokenKind[] { - /*1*/ TokenKind.ElseIf, TokenKind.If, TokenKind.Else, TokenKind.Switch, /*1*/ - /*2*/ TokenKind.Foreach, TokenKind.From, TokenKind.In, TokenKind.For, /*2*/ - /*3*/ TokenKind.While, TokenKind.Until, TokenKind.Do, TokenKind.Try, /*3*/ - /*4*/ TokenKind.Catch, TokenKind.Finally, TokenKind.Trap, TokenKind.Data, /*4*/ - /*5*/ TokenKind.Return, TokenKind.Continue, TokenKind.Break, TokenKind.Exit, /*5*/ - /*6*/ TokenKind.Throw, TokenKind.Begin, TokenKind.Process, TokenKind.End, /*6*/ - /*7*/ TokenKind.Dynamicparam, TokenKind.Function, TokenKind.Filter, TokenKind.Param, /*7*/ - /*8*/ TokenKind.Class, TokenKind.Define, TokenKind.Var, TokenKind.Using, /*8*/ - /*9*/ TokenKind.Workflow, TokenKind.Parallel, TokenKind.Sequence, TokenKind.InlineScript, /*9*/ - /*A*/ TokenKind.Configuration, TokenKind.Public, TokenKind.Private, TokenKind.Static, /*A*/ - /*B*/ TokenKind.Interface, TokenKind.Enum, TokenKind.Namespace, TokenKind.Module, /*B*/ - /*C*/ TokenKind.Type, TokenKind.Assembly, TokenKind.Command, TokenKind.Hidden, /*C*/ - /*D*/ TokenKind.Base, TokenKind.Default, /*D*/ + /*1*/ TokenKind.ElseIf, TokenKind.If, TokenKind.Else, TokenKind.Switch, /*1*/ + /*2*/ TokenKind.Foreach, TokenKind.From, TokenKind.In, TokenKind.For, /*2*/ + /*3*/ TokenKind.While, TokenKind.Until, TokenKind.Do, TokenKind.Try, /*3*/ + /*4*/ TokenKind.Catch, TokenKind.Finally, TokenKind.Trap, TokenKind.Data, /*4*/ + /*5*/ TokenKind.Return, TokenKind.Continue, TokenKind.Break, TokenKind.Exit, /*5*/ + /*6*/ TokenKind.Throw, TokenKind.Begin, TokenKind.Process, TokenKind.End, /*6*/ + /*7*/ TokenKind.Dynamicparam, TokenKind.Function, TokenKind.Filter, TokenKind.Param, /*7*/ + /*8*/ TokenKind.Class, TokenKind.Define, TokenKind.Var, TokenKind.Using, /*8*/ + /*9*/ TokenKind.Workflow, TokenKind.Parallel, TokenKind.Sequence, TokenKind.InlineScript, /*9*/ + /*A*/ TokenKind.Configuration, TokenKind.Public, TokenKind.Private, TokenKind.Static, /*A*/ + /*B*/ TokenKind.Interface, TokenKind.Enum, TokenKind.Namespace, TokenKind.Module, /*B*/ + /*C*/ TokenKind.Type, TokenKind.Assembly, TokenKind.Command, TokenKind.Hidden, /*C*/ + /*D*/ TokenKind.Base, TokenKind.Default, TokenKind.Clean, /*D*/ }; internal static readonly string[] _operatorText = new string[] { @@ -699,8 +699,16 @@ static Tokenizer() Diagnostics.Assert(s_keywordText.Length == s_keywordTokenKind.Length, "Keyword table sizes must match"); Diagnostics.Assert(_operatorText.Length == s_operatorTokenKind.Length, "Operator table sizes must match"); + bool isCleanBlockFeatureEnabled = ExperimentalFeature.IsEnabled(ExperimentalFeature.PSCleanBlockFeatureName); + for (int i = 0; i < s_keywordText.Length; ++i) { + if (!isCleanBlockFeatureEnabled && s_keywordText[i] == "clean") + { + // Skip adding the 'clean' keyword when the feature is disabled. + continue; + } + s_keywordTable.Add(s_keywordText[i], s_keywordTokenKind[i]); } diff --git a/src/System.Management.Automation/engine/pipeline.cs b/src/System.Management.Automation/engine/pipeline.cs index 78fb19dbc653..185e60e072f3 100644 --- a/src/System.Management.Automation/engine/pipeline.cs +++ b/src/System.Management.Automation/engine/pipeline.cs @@ -66,7 +66,6 @@ internal class PipelineProcessor : IDisposable public void Dispose() { Dispose(true); - GC.SuppressFinalize(this); } private void Dispose(bool disposing) @@ -267,9 +266,12 @@ internal int Add(CommandProcessorBase commandProcessor) internal void AddRedirectionPipe(PipelineProcessor pipelineProcessor) { - if (pipelineProcessor == null) throw PSTraceSource.NewArgumentNullException(nameof(pipelineProcessor)); - if (_redirectionPipes == null) - _redirectionPipes = new List(); + if (pipelineProcessor is null) + { + throw PSTraceSource.NewArgumentNullException(nameof(pipelineProcessor)); + } + + _redirectionPipes ??= new List(); _redirectionPipes.Add(pipelineProcessor); } @@ -347,18 +349,15 @@ private int AddCommand(CommandProcessorBase commandProcessor, int readFromComman } else { - CommandProcessorBase prevcommandProcessor = _commands[readFromCommand - 1] as CommandProcessorBase; - if (prevcommandProcessor == null || prevcommandProcessor.CommandRuntime == null) - { - // "PipelineProcessor.AddCommand(): previous request object == null" - throw PSTraceSource.NewInvalidOperationException(); - } + var prevcommandProcessor = _commands[readFromCommand - 1] as CommandProcessorBase; + ValidateCommandProcessorNotNull(prevcommandProcessor, errorMessage: null); + + Pipe UpstreamPipe = (readErrorQueue) + ? prevcommandProcessor.CommandRuntime.ErrorOutputPipe + : prevcommandProcessor.CommandRuntime.OutputPipe; - Pipe UpstreamPipe = (readErrorQueue) ? - prevcommandProcessor.CommandRuntime.ErrorOutputPipe : prevcommandProcessor.CommandRuntime.OutputPipe; if (UpstreamPipe == null) { - // "PipelineProcessor.AddCommand(): UpstreamPipe == null" throw PSTraceSource.NewInvalidOperationException(); } @@ -381,11 +380,8 @@ private int AddCommand(CommandProcessorBase commandProcessor, int readFromComman for (int i = 0; i < _commands.Count; i++) { prevcommandProcessor = _commands[i]; - if (prevcommandProcessor == null || prevcommandProcessor.CommandRuntime == null) - { - // "PipelineProcessor.AddCommand(): previous request object == null" - throw PSTraceSource.NewInvalidOperationException(); - } + ValidateCommandProcessorNotNull(prevcommandProcessor, errorMessage: null); + // check whether the error output is already claimed if (prevcommandProcessor.CommandRuntime.ErrorOutputPipe.DownstreamCmdlet != null) continue; @@ -470,194 +466,305 @@ internal Array SynchronousExecuteEnumerate(object input) throw new PipelineStoppedException(); } - ExceptionDispatchInfo toRethrowInfo; + bool pipelineSucceeded = false; + ExceptionDispatchInfo toRethrowInfo = null; + CommandProcessorBase commandRequestingUpstreamCommandsToStop = null; + try { - CommandProcessorBase commandRequestingUpstreamCommandsToStop = null; try { - // If the caller specified an input object array, - // we run assuming there is an incoming "stream" - // of objects. This will prevent the one default call - // to ProcessRecord on the first command. - Start(input != AutomationNull.Value); + try + { + // If the caller specified an input object array, we run assuming there is an incoming "stream" + // of objects. This will prevent the one default call to ProcessRecord on the first command. + Start(incomingStream: input != AutomationNull.Value); - // Start has already validated firstcommandProcessor - CommandProcessorBase firstCommandProcessor = _commands[0]; + // Start has already validated firstcommandProcessor + CommandProcessorBase firstCommandProcessor = _commands[0]; - // Add any input to the first command. - if (ExternalInput != null) + // Add any input to the first command. + if (ExternalInput is not null) + { + firstCommandProcessor.CommandRuntime.InputPipe.ExternalReader = ExternalInput; + } + + Inject(input, enumerate: true); + } + catch (PipelineStoppedException) { - firstCommandProcessor.CommandRuntime.InputPipe.ExternalReader - = ExternalInput; + if (_firstTerminatingError?.SourceException is StopUpstreamCommandsException exception) + { + _firstTerminatingError = null; + commandRequestingUpstreamCommandsToStop = exception.RequestingCommandProcessor; + } + else + { + throw; + } } - Inject(input, enumerate: true); + DoCompleteCore(commandRequestingUpstreamCommandsToStop); + pipelineSucceeded = true; } - catch (PipelineStoppedException) + finally { - StopUpstreamCommandsException stopUpstreamCommandsException = - _firstTerminatingError != null - ? _firstTerminatingError.SourceException as StopUpstreamCommandsException - : null; - if (stopUpstreamCommandsException == null) - { - throw; - } - else - { - _firstTerminatingError = null; - commandRequestingUpstreamCommandsToStop = stopUpstreamCommandsException.RequestingCommandProcessor; - } + // Clean up resources for script commands, no matter the pipeline succeeded or not. + // This method catches and handles all exceptions inside, so it will never throw. + Clean(); } - DoCompleteCore(commandRequestingUpstreamCommandsToStop); - - // By this point, we are sure all commandProcessors hosted by the current pipelineProcess are done execution, - // so if there are any redirection pipelineProcessors associated with any of those commandProcessors, we should - // call DoComplete on them. - if (_redirectionPipes != null) + if (pipelineSucceeded) { - foreach (PipelineProcessor redirectPipelineProcessor in _redirectionPipes) + // Now, we are sure all 'commandProcessors' hosted by the current 'pipelineProcessor' are done execution, + // so if there are any redirection 'pipelineProcessors' associated with any of those 'commandProcessors', + // they must have successfully executed 'StartStepping' and 'Step', and thus we should call 'DoComplete' + // on them for completeness. + if (_redirectionPipes is not null) { - redirectPipelineProcessor.DoCompleteCore(null); + foreach (PipelineProcessor redirectPipelineProcessor in _redirectionPipes) + { + // The 'Clean' block for each 'commandProcessor' might still write to a pipe that is associated + // with the redirection 'pipelineProcessor' (e.g. a redirected error pipe), which would trigger + // the call to 'pipelineProcessor.Step'. + // It's possible (though very unlikely) that the call to 'pipelineProcessor.Step' failed with an + // exception, and in such case, the 'pipelineProcessor' would have been disposed, and therefore + // the call to 'DoComplete' will simply return, because '_commands' was already set to null. + redirectPipelineProcessor.DoCompleteCore(null); + } } - } - return RetrieveResults(); + // The 'Clean' blocks write nothing to the output pipe, so the results won't be affected by them. + return RetrieveResults(); + } } catch (RuntimeException e) { - // The error we want to report is the first terminating error - // which occurred during pipeline execution, regardless - // of whether other errors occurred afterward. - toRethrowInfo = _firstTerminatingError ?? ExceptionDispatchInfo.Capture(e); - this.LogExecutionException(toRethrowInfo.SourceException); - } - // NTRAID#Windows Out Of Band Releases-929020-2006/03/14-JonN - catch (System.Runtime.InteropServices.InvalidComObjectException comException) - { - // The error we want to report is the first terminating error - // which occurred during pipeline execution, regardless - // of whether other errors occurred afterward. - if (_firstTerminatingError != null) - { - toRethrowInfo = _firstTerminatingError; - } - else - { - string message = StringUtil.Format(ParserStrings.InvalidComObjectException, comException.Message); - var rte = new RuntimeException(message, comException); - rte.SetErrorId("InvalidComObjectException"); - toRethrowInfo = ExceptionDispatchInfo.Capture(rte); - } - - this.LogExecutionException(toRethrowInfo.SourceException); + toRethrowInfo = GetFirstError(e); } finally { DisposeCommands(); } - // By rethrowing the exception outside of the handler, - // we allow the CLR on X64/IA64 to free from the stack - // the exception records related to this exception. + // By rethrowing the exception outside of the handler, we allow the CLR on X64/IA64 to free from + // the stack the exception records related to this exception. - // The only reason we should get here is if - // an exception should be rethrown. + // The only reason we should get here is if an exception should be rethrown. Diagnostics.Assert(toRethrowInfo != null, "Alternate protocol path failure"); toRethrowInfo.Throw(); - return null; // UNREACHABLE + + // UNREACHABLE + return null; + } + + private ExceptionDispatchInfo GetFirstError(RuntimeException e) + { + // The error we want to report is the first terminating error which occurred during pipeline execution, + // regardless of whether other errors occurred afterward. + var firstError = _firstTerminatingError ?? ExceptionDispatchInfo.Capture(e); + LogExecutionException(firstError.SourceException); + return firstError; + } + + private void ThrowFirstErrorIfExisting(bool logException) + { + if (_firstTerminatingError != null) + { + if (logException) + { + LogExecutionException(_firstTerminatingError.SourceException); + } + + _firstTerminatingError.Throw(); + } } private void DoCompleteCore(CommandProcessorBase commandRequestingUpstreamCommandsToStop) { - // Call DoComplete() for all the commands. DoComplete() will internally call Complete() + if (_commands is null) + { + // This could happen to a redirection pipeline, either for an expression (e.g. 1 > a.txt) + // or for a command (e.g. command > a.txt). + // An exception may be thrown from the call to 'StartStepping' or 'Step' on the pipeline, + // which causes the pipeline commands to be disposed. + return; + } + + // Call DoComplete() for all the commands, which will internally call Complete() MshCommandRuntime lastCommandRuntime = null; - if (_commands != null) + for (int i = 0; i < _commands.Count; i++) { - for (int i = 0; i < _commands.Count; i++) - { - CommandProcessorBase commandProcessor = _commands[i]; + CommandProcessorBase commandProcessor = _commands[i]; - if (commandProcessor == null) - { - // "null command " + i - throw PSTraceSource.NewInvalidOperationException(); - } + if (commandProcessor is null) + { + // An internal error that should not happen. + throw PSTraceSource.NewInvalidOperationException(); + } - if (object.ReferenceEquals(commandRequestingUpstreamCommandsToStop, commandProcessor)) - { - commandRequestingUpstreamCommandsToStop = null; - continue; // do not call DoComplete/EndProcessing on the command that initiated stopping - } + if (object.ReferenceEquals(commandRequestingUpstreamCommandsToStop, commandProcessor)) + { + // Do not call DoComplete/EndProcessing on the command that initiated stopping. + commandRequestingUpstreamCommandsToStop = null; + continue; + } - if (commandRequestingUpstreamCommandsToStop != null) - { - continue; // do not call DoComplete/EndProcessing on commands that were stopped upstream - } + if (commandRequestingUpstreamCommandsToStop is not null) + { + // Do not call DoComplete/EndProcessing on commands that were stopped upstream. + continue; + } - try + try + { + commandProcessor.DoComplete(); + } + catch (PipelineStoppedException) + { + if (_firstTerminatingError?.SourceException is StopUpstreamCommandsException exception) { - commandProcessor.DoComplete(); + _firstTerminatingError = null; + commandRequestingUpstreamCommandsToStop = exception.RequestingCommandProcessor; } - catch (PipelineStoppedException) + else { - StopUpstreamCommandsException stopUpstreamCommandsException = - _firstTerminatingError != null - ? _firstTerminatingError.SourceException as StopUpstreamCommandsException - : null; - if (stopUpstreamCommandsException == null) - { - throw; - } - else - { - _firstTerminatingError = null; - commandRequestingUpstreamCommandsToStop = stopUpstreamCommandsException.RequestingCommandProcessor; - } + throw; } + } - EtwActivity.SetActivityId(commandProcessor.PipelineActivityId); - - // Log a command stopped event - MshLog.LogCommandLifecycleEvent( - commandProcessor.Command.Context, - CommandState.Stopped, - commandProcessor.Command.MyInvocation); + EtwActivity.SetActivityId(commandProcessor.PipelineActivityId); - // Log the execution of a command (not script chunks, as they - // are not commands in and of themselves) - if (commandProcessor.CommandInfo.CommandType != CommandTypes.Script) - { - commandProcessor.CommandRuntime.PipelineProcessor.LogExecutionComplete( - commandProcessor.Command.MyInvocation, commandProcessor.CommandInfo.Name); - } + // Log a command stopped event + MshLog.LogCommandLifecycleEvent( + commandProcessor.Command.Context, + CommandState.Stopped, + commandProcessor.Command.MyInvocation); - lastCommandRuntime = commandProcessor.CommandRuntime; + // Log the execution of a command (not script chunks, as they are not commands in and of themselves). + if (commandProcessor.CommandInfo.CommandType != CommandTypes.Script) + { + LogExecutionComplete(commandProcessor.Command.MyInvocation, commandProcessor.CommandInfo.Name); } + + lastCommandRuntime = commandProcessor.CommandRuntime; } // Log the pipeline completion. - if (lastCommandRuntime != null) + if (lastCommandRuntime is not null) { // Only log the pipeline completion if this wasn't a nested pipeline, as // pipeline state in transcription is associated with the toplevel pipeline - if ((this.LocalPipeline == null) || (!this.LocalPipeline.IsNested)) + if (LocalPipeline is null || !LocalPipeline.IsNested) { lastCommandRuntime.PipelineProcessor.LogPipelineComplete(); } } // If a terminating error occurred, report it now. - if (_firstTerminatingError != null) + // This pipeline could have been stopped asynchronously, by 'Ctrl+c' manually or + // 'PowerShell.Stop' programatically. We need to check and see if that's the case. + // An example: + // - 'Start-Sleep' is running in this pipeline, and 'pipelineProcessor.Stop' gets + // called on a different thread, which sets a 'PipelineStoppedException' object + // to '_firstTerminatingError' and runs 'StopProcessing' on 'Start-Sleep'. + // - The 'StopProcessing' will cause 'Start-Sleep' to return from 'ProcessRecord' + // call, and thus the pipeline execution will move forward to run 'DoComplete' + // for the 'Start-Sleep' command and thus the code flow will reach here. + // For this given example, we need to check '_firstTerminatingError' and throw out + // the 'PipelineStoppedException' if the pipeline was indeed being stopped. + ThrowFirstErrorIfExisting(logException: true); + } + + /// + /// Clean up resources for script commands in this pipeline processor. + /// + /// + /// Exception from a 'Clean' block is not allowed to propagate up and terminate the pipeline + /// so that other 'Clean' blocks can run without being affected. Therefore, this method will + /// catch and handle all exceptions inside, and it will never throw. + /// + private void Clean() + { + if (!_executionStarted || _commands is null) { - this.LogExecutionException(_firstTerminatingError.SourceException); - _firstTerminatingError.Throw(); + // Simply return if the pipeline execution wasn't even started, or the commands of + // the pipeline have already been disposed. + return; + } + + // So far, if '_firstTerminatingError' is not null, then it must be a terminating error + // thrown from one of 'Begin/Process/End' blocks. There can be terminating error thrown + // from 'Clean' block as well, which needs to be handled in this method. + // In order to capture the subsequent first terminating error thrown from 'Clean', we + // need to forget the previous '_firstTerminatingError' value before calling 'DoClean' + // on each command processor, so we have to save the old value here and restore later. + ExceptionDispatchInfo oldFirstTerminatingError = _firstTerminatingError; + + // Suspend a stopping pipeline by setting 'IsStopping' to false and restore it afterwards. + bool oldIsStopping = ExceptionHandlingOps.SuspendStoppingPipelineImpl(LocalPipeline); + + try + { + foreach (CommandProcessorBase commandProcessor in _commands) + { + if (commandProcessor is null || !commandProcessor.HasCleanBlock) + { + continue; + } + + try + { + // Forget the terminating error we saw before, so a terminating error thrown + // from the subsequent 'Clean' block can be recorded and handled properly. + _firstTerminatingError = null; + commandProcessor.DoCleanup(); + } + catch (RuntimeException e) + { + // Retrieve and report the terminating error that was thrown in the 'Clean' block. + ExceptionDispatchInfo firstError = GetFirstError(e); + commandProcessor.ReportCleanupError(firstError.SourceException); + } + catch (Exception ex) + { + // Theoretically, only 'RuntimeException' could be thrown out, but we catch + // all and log them here just to be safe. + // Skip special flow control exceptions and log others. + if (ex is not FlowControlException && ex is not HaltCommandException) + { + MshLog.LogCommandHealthEvent(commandProcessor.Context, ex, Severity.Warning); + } + } + } + } + finally + { + _firstTerminatingError = oldFirstTerminatingError; + ExceptionHandlingOps.RestoreStoppingPipelineImpl(LocalPipeline, oldIsStopping); } } + /// + /// Clean up resources for the script commands of a steppable pipeline. + /// + /// + /// The way we handle 'Clean' blocks in 'StartStepping', 'Step', and 'DoComplete' makes sure that: + /// 1. The 'Clean' blocks get to run if any exception is thrown from the pipeline execution. + /// 2. The 'Clean' blocks get to run if the pipeline runs to the end successfully. + /// However, this is not enough for a steppable pipeline, because the function, where the steppable + /// pipeline gets used, may fail (think about a proxy function). And that may lead to the situation + /// where "no exception was thrown from the steppable pipeline" but "the steppable pipeline didn't + /// run to the end". In that case, 'Clean' won't run unless it's triggered explicitly on the steppable + /// pipeline. This method is how we will expose this functionality to 'SteppablePipeline'. + /// + internal void DoCleanup() + { + Clean(); + DisposeCommands(); + } + /// /// Implements DoComplete as a stand-alone function for completing /// the execution of a steppable pipeline. @@ -665,98 +772,79 @@ private void DoCompleteCore(CommandProcessorBase commandRequestingUpstreamComman /// The results of the execution. internal Array DoComplete() { - if (Stopping) - { - throw new PipelineStoppedException(); - } - if (!_executionStarted) { throw PSTraceSource.NewInvalidOperationException( PipelineStrings.PipelineNotStarted); } - ExceptionDispatchInfo toRethrowInfo; try { - DoCompleteCore(null); + if (Stopping) + { + throw new PipelineStoppedException(); + } - return RetrieveResults(); - } - catch (RuntimeException e) - { - // The error we want to report is the first terminating error - // which occurred during pipeline execution, regardless - // of whether other errors occurred afterward. - toRethrowInfo = _firstTerminatingError ?? ExceptionDispatchInfo.Capture(e); - this.LogExecutionException(toRethrowInfo.SourceException); - } - // NTRAID#Windows Out Of Band Releases-929020-2006/03/14-JonN - catch (System.Runtime.InteropServices.InvalidComObjectException comException) - { - // The error we want to report is the first terminating error - // which occurred during pipeline execution, regardless - // of whether other errors occurred afterward. - if (_firstTerminatingError != null) + ExceptionDispatchInfo toRethrowInfo; + try { - toRethrowInfo = _firstTerminatingError; + DoCompleteCore(null); + return RetrieveResults(); } - else + catch (RuntimeException e) { - string message = StringUtil.Format(ParserStrings.InvalidComObjectException, comException.Message); - var rte = new RuntimeException(message, comException); - rte.SetErrorId("InvalidComObjectException"); - toRethrowInfo = ExceptionDispatchInfo.Capture(rte); + toRethrowInfo = GetFirstError(e); } - this.LogExecutionException(toRethrowInfo.SourceException); + // By rethrowing the exception outside of the handler, we allow the CLR on X64/IA64 to free from the stack + // the exception records related to this exception. + + // The only reason we should get here is an exception should be rethrown. + Diagnostics.Assert(toRethrowInfo != null, "Alternate protocol path failure"); + toRethrowInfo.Throw(); + + // UNREACHABLE + return null; } finally { + Clean(); DisposeCommands(); } - - // By rethrowing the exception outside of the handler, - // we allow the CLR on X64/IA64 to free from the stack - // the exception records related to this exception. - - // The only reason we should get here is if - // an exception should be rethrown. - Diagnostics.Assert(toRethrowInfo != null, "Alternate protocol path failure"); - toRethrowInfo.Throw(); - return null; // UNREACHABLE } /// - /// This routine starts the stepping process. It is optional to - /// call this but can be useful if you want the begin clauses - /// of the pipeline to be run even when there may not be any input - /// to process as is the case for I/O redirection into a file. We - /// still want the file opened, even if there was nothing to write to it. + /// This routine starts the stepping process. It is optional to call this but can be useful + /// if you want the begin clauses of the pipeline to be run even when there may not be any + /// input to process as is the case for I/O redirection into a file. We still want the file + /// opened, even if there was nothing to write to it. /// /// True if you want to write to this pipeline. internal void StartStepping(bool expectInput) { + bool startSucceeded = false; try { Start(expectInput); + startSucceeded = true; - // If a terminating error occurred, report it now. - if (_firstTerminatingError != null) - { - _firstTerminatingError.Throw(); - } + // Check if this pipeline is being stopped asynchronously. + ThrowFirstErrorIfExisting(logException: false); } - catch (PipelineStoppedException) + catch (Exception e) { + Clean(); DisposeCommands(); - // The error we want to report is the first terminating error - // which occurred during pipeline execution, regardless - // of whether other errors occurred afterward. - if (_firstTerminatingError != null) + if (!startSucceeded && e is PipelineStoppedException) { - _firstTerminatingError.Throw(); + // When a terminating error happens during command execution, PowerShell will first save it + // to '_firstTerminatingError', and then throw a 'PipelineStoppedException' to tear down the + // pipeline. So when the caught exception here is 'PipelineStoppedException', it may not be + // the actual original terminating error. + // In this case, we want to report the first terminating error which occurred during pipeline + // execution, regardless of whether other errors occurred afterward. + ThrowFirstErrorIfExisting(logException: false); } throw; @@ -773,35 +861,35 @@ internal void Stop() // Only call StopProcessing if the pipeline is being stopped // for the first time - if (!RecordFailure(new PipelineStoppedException(), null)) + if (!RecordFailure(new PipelineStoppedException(), command: null)) + { return; + } // Retain copy of _commands in case Dispose() is called List commands = _commands; - if (commands == null) + if (commands is null) + { return; + } // Call StopProcessing() for all the commands. - for (int i = 0; i < commands.Count; i++) + foreach (CommandProcessorBase commandProcessor in commands) { - CommandProcessorBase commandProcessor = commands[i]; - if (commandProcessor == null) { throw PSTraceSource.NewInvalidOperationException(); } -#pragma warning disable 56500 + try { commandProcessor.Command.DoStopProcessing(); } catch (Exception) { - // 2004/04/26-JonN We swallow exceptions - // which occur during StopProcessing. + // We swallow exceptions which occur during StopProcessing. continue; } -#pragma warning restore 56500 } } @@ -844,43 +932,35 @@ internal void Stop() /// internal Array Step(object input) { - if (Stopping) - { - throw new PipelineStoppedException(); - } - + bool injectSucceeded = false; try { Start(true); Inject(input, enumerate: false); + injectSucceeded = true; - // If a terminating error occurred, report it now. - if (_firstTerminatingError != null) - { - _firstTerminatingError.Throw(); - } - + // Check if this pipeline is being stopped asynchronously. + ThrowFirstErrorIfExisting(logException: false); return RetrieveResults(); } - catch (PipelineStoppedException) + catch (Exception e) { + Clean(); DisposeCommands(); - // The error we want to report is the first terminating error - // which occurred during pipeline execution, regardless - // of whether other errors occurred afterward. - if (_firstTerminatingError != null) + if (!injectSucceeded && e is PipelineStoppedException) { - _firstTerminatingError.Throw(); + // When a terminating error happens during command execution, PowerShell will first save it + // to '_firstTerminatingError', and then throw a 'PipelineStoppedException' to tear down the + // pipeline. So when the caught exception here is 'PipelineStoppedException', it may not be + // the actual original terminating error. + // In this case, we want to report the first terminating error which occurred during pipeline + // execution, regardless of whether other errors occurred afterward. + ThrowFirstErrorIfExisting(logException: false); } throw; } - catch (Exception) - { - DisposeCommands(); - throw; - } } /// @@ -927,7 +1007,9 @@ private void Start(bool incomingStream) } if (_executionStarted) + { return; + } if (_commands == null || _commands.Count == 0) { @@ -936,12 +1018,7 @@ private void Start(bool incomingStream) } CommandProcessorBase firstcommandProcessor = _commands[0]; - if (firstcommandProcessor == null - || firstcommandProcessor.CommandRuntime == null) - { - throw PSTraceSource.NewInvalidOperationException( - PipelineStrings.PipelineExecuteRequiresAtLeastOneCommand); - } + ValidateCommandProcessorNotNull(firstcommandProcessor, PipelineStrings.PipelineExecuteRequiresAtLeastOneCommand); // Set the execution scope using the current scope if (_executionScope == null) @@ -951,17 +1028,11 @@ private void Start(bool incomingStream) // add ExternalSuccessOutput to the last command CommandProcessorBase LastCommandProcessor = _commands[_commands.Count - 1]; - if (LastCommandProcessor == null - || LastCommandProcessor.CommandRuntime == null) - { - // "PipelineProcessor.Start(): LastCommandProcessor == null" - throw PSTraceSource.NewInvalidOperationException(); - } + ValidateCommandProcessorNotNull(LastCommandProcessor, errorMessage: null); if (ExternalSuccessOutput != null) { - LastCommandProcessor.CommandRuntime.OutputPipe.ExternalWriter - = ExternalSuccessOutput; + LastCommandProcessor.CommandRuntime.OutputPipe.ExternalWriter = ExternalSuccessOutput; } // add ExternalErrorOutput to all commands whose error @@ -981,14 +1052,11 @@ private void Start(bool incomingStream) _executionStarted = true; - // // Allocate the pipeline iteration array; note that the pipeline position for // each command starts at 1 so we need to allocate _commands.Count + 1 items. - // int[] pipelineIterationInfo = new int[_commands.Count + 1]; - // Prepare all commands from Engine's side, - // and make sure they are all valid + // Prepare all commands from Engine's side, and make sure they are all valid for (int i = 0; i < _commands.Count; i++) { CommandProcessorBase commandProcessor = _commands[i]; @@ -1000,8 +1068,6 @@ private void Start(bool incomingStream) // Generate new Activity Id for the thread Guid pipelineActivityId = EtwActivity.CreateActivityId(); - - // commandProcess.PipelineActivityId = new Activity id EtwActivity.SetActivityId(pipelineActivityId); commandProcessor.PipelineActivityId = pipelineActivityId; @@ -1011,20 +1077,16 @@ private void Start(bool incomingStream) CommandState.Started, commandProcessor.Command.MyInvocation); - // Telemetry here - // the type of command should be sent along - // commandProcessor.CommandInfo.CommandType + // Send telemetry that includes the type of command. ApplicationInsightsTelemetry.SendTelemetryMetric(TelemetryType.ApplicationType, commandProcessor.Command.CommandInfo.CommandType.ToString()); #if LEGACYTELEMETRY Microsoft.PowerShell.Telemetry.Internal.TelemetryAPI.TraceExecutedCommand(commandProcessor.Command.CommandInfo, commandProcessor.Command.CommandOrigin); #endif - // Log the execution of a command (not script chunks, as they - // are not commands in and of themselves) + // Log the execution of a command (not script chunks, as they are not commands in and of themselves) if (commandProcessor.CommandInfo.CommandType != CommandTypes.Script) { - commandProcessor.CommandRuntime.PipelineProcessor.LogExecutionInfo( - commandProcessor.Command.MyInvocation, commandProcessor.CommandInfo.Name); + LogExecutionInfo(commandProcessor.Command.MyInvocation, commandProcessor.CommandInfo.Name); } InvocationInfo myInfo = commandProcessor.Command.MyInvocation; @@ -1057,8 +1119,7 @@ private void Start(bool incomingStream) } /// - /// Add ExternalErrorOutput to all commands whose error - /// output is not yet claimed. + /// Add ExternalErrorOutput to all commands whose error output is not yet claimed. /// private void SetExternalErrorOutput() { @@ -1067,14 +1128,12 @@ private void SetExternalErrorOutput() for (int i = 0; i < _commands.Count; i++) { CommandProcessorBase commandProcessor = _commands[i]; - Pipe UpstreamPipe = - commandProcessor.CommandRuntime.ErrorOutputPipe; + Pipe errorPipe = commandProcessor.CommandRuntime.ErrorOutputPipe; // check whether a cmdlet is consuming the error pipe - if (!UpstreamPipe.IsRedirected) + if (!errorPipe.IsRedirected) { - UpstreamPipe.ExternalWriter = - ExternalErrorOutput; + errorPipe.ExternalWriter = ExternalErrorOutput; } } } @@ -1085,14 +1144,9 @@ private void SetExternalErrorOutput() /// private void SetupParameterVariables() { - for (int i = 0; i < _commands.Count; i++) + foreach (CommandProcessorBase commandProcessor in _commands) { - CommandProcessorBase commandProcessor = _commands[i]; - if (commandProcessor == null || commandProcessor.CommandRuntime == null) - { - // "null command " + i - throw PSTraceSource.NewInvalidOperationException(); - } + ValidateCommandProcessorNotNull(commandProcessor, errorMessage: null); commandProcessor.CommandRuntime.SetupOutVariable(); commandProcessor.CommandRuntime.SetupErrorVariable(); @@ -1102,6 +1156,16 @@ private void SetupParameterVariables() } } + private static void ValidateCommandProcessorNotNull(CommandProcessorBase commandProcessor, string errorMessage) + { + if (commandProcessor?.CommandRuntime is null) + { + throw errorMessage is null + ? PSTraceSource.NewInvalidOperationException() + : PSTraceSource.NewInvalidOperationException(errorMessage, Array.Empty()); + } + } + /// /// Partially execute the pipeline. The output remains in /// the pipes. @@ -1131,12 +1195,7 @@ private void Inject(object input, bool enumerate) { // Add any input to the first command. CommandProcessorBase firstcommandProcessor = _commands[0]; - if (firstcommandProcessor == null - || firstcommandProcessor.CommandRuntime == null) - { - throw PSTraceSource.NewInvalidOperationException( - PipelineStrings.PipelineExecuteRequiresAtLeastOneCommand); - } + ValidateCommandProcessorNotNull(firstcommandProcessor, PipelineStrings.PipelineExecuteRequiresAtLeastOneCommand); if (input != AutomationNull.Value) { @@ -1174,27 +1233,26 @@ private void Inject(object input, bool enumerate) /// private Array RetrieveResults() { + if (_commands is null) + { + // This could happen to an expression redirection pipeline (e.g. 1 > a.txt). + // An exception may be thrown from the call to 'StartStepping' or 'Step' on the pipeline, + // which causes the pipeline commands to be disposed. + return MshCommandRuntime.StaticEmptyArray; + } + // If the error queue has been linked, it's up to the link to // deal with the output. Don't do anything here... if (!_linkedErrorOutput) { - // Retrieve any accumulated error objects from each of the pipes - // and add them to the error results hash table. - for (int i = 0; i < _commands.Count; i++) + foreach (CommandProcessorBase commandProcessor in _commands) { - CommandProcessorBase commandProcessor = _commands[i]; - if (commandProcessor == null - || commandProcessor.CommandRuntime == null) - { - // "null command or request or ErrorOutputPipe " + i - throw PSTraceSource.NewInvalidOperationException(); - } + ValidateCommandProcessorNotNull(commandProcessor, errorMessage: null); Pipe ErrorPipe = commandProcessor.CommandRuntime.ErrorOutputPipe; if (ErrorPipe.DownstreamCmdlet == null && !ErrorPipe.Empty) { - // 2003/10/02-JonN - // Do not return the same error results more than once + // Clear the error pipe if it's not empty and will not be consumed. ErrorPipe.Clear(); } } @@ -1203,26 +1261,18 @@ private Array RetrieveResults() // If the success queue has been linked, it's up to the link to // deal with the output. Don't do anything here... if (_linkedSuccessOutput) + { return MshCommandRuntime.StaticEmptyArray; + } CommandProcessorBase LastCommandProcessor = _commands[_commands.Count - 1]; - if (LastCommandProcessor == null - || LastCommandProcessor.CommandRuntime == null) - { - // "PipelineProcessor.RetrieveResults(): LastCommandProcessor == null" - throw PSTraceSource.NewInvalidOperationException(); - } + ValidateCommandProcessorNotNull(LastCommandProcessor, errorMessage: null); - Array results = - LastCommandProcessor.CommandRuntime.GetResultsAsArray(); + Array results = LastCommandProcessor.CommandRuntime.GetResultsAsArray(); - // 2003/10/02-JonN // Do not return the same results more than once LastCommandProcessor.CommandRuntime.OutputPipe.Clear(); - - if (results == null) - return MshCommandRuntime.StaticEmptyArray; - return results; + return results is null ? MshCommandRuntime.StaticEmptyArray : results; } /// @@ -1236,12 +1286,7 @@ internal void LinkPipelineSuccessOutput(Pipe pipeToUse) Dbg.Assert(pipeToUse != null, "Caller should verify pipeToUse != null"); CommandProcessorBase LastCommandProcessor = _commands[_commands.Count - 1]; - if (LastCommandProcessor == null - || LastCommandProcessor.CommandRuntime == null) - { - // "PipelineProcessor.RetrieveResults(): LastCommandProcessor == null" - throw PSTraceSource.NewInvalidOperationException(); - } + ValidateCommandProcessorNotNull(LastCommandProcessor, errorMessage: null); LastCommandProcessor.CommandRuntime.OutputPipe = pipeToUse; _linkedSuccessOutput = true; @@ -1251,15 +1296,9 @@ internal void LinkPipelineErrorOutput(Pipe pipeToUse) { Dbg.Assert(pipeToUse != null, "Caller should verify pipeToUse != null"); - for (int i = 0; i < _commands.Count; i++) + foreach (CommandProcessorBase commandProcessor in _commands) { - CommandProcessorBase commandProcessor = _commands[i]; - if (commandProcessor == null - || commandProcessor.CommandRuntime == null) - { - // "null command or request or ErrorOutputPipe " + i - throw PSTraceSource.NewInvalidOperationException(); - } + ValidateCommandProcessorNotNull(commandProcessor, errorMessage: null); if (commandProcessor.CommandRuntime.ErrorOutputPipe.DownstreamCmdlet == null) { @@ -1280,76 +1319,65 @@ internal void LinkPipelineErrorOutput(Pipe pipeToUse) private void DisposeCommands() { // Note that this is not in a lock. - // We do not make Dispose() wait until StopProcessing() - // has completed. + // We do not make Dispose() wait until StopProcessing() has completed. _stopping = true; + if (_commands is null && _redirectionPipes is null) + { + // Commands were already disposed. + return; + } + LogToEventLog(); - if (_commands != null) + if (_commands is not null) { - for (int i = 0; i < _commands.Count; i++) + foreach (CommandProcessorBase commandProcessor in _commands) { - CommandProcessorBase commandProcessor = _commands[i]; - if (commandProcessor != null) + if (commandProcessor is null) { -#pragma warning disable 56500 - // If Dispose throws an exception, record it as a - // pipeline failure and continue disposing cmdlets. - try + continue; + } + + // If Dispose throws an exception, record it as a pipeline failure and continue disposing cmdlets. + try + { + // Only cmdlets can have variables defined via the common parameters. + // We handle the cleanup of those variables only if we need to. + if (commandProcessor is CommandProcessor) { - // Only cmdlets can have variables defined via the common parameters. - // We handle the cleanup of those variables only if we need to. - if (commandProcessor is CommandProcessor) + if (commandProcessor.Command is not PSScriptCmdlet) { - if (commandProcessor.Command is not PSScriptCmdlet) - { - // For script cmdlets, the variable lists were already removed when exiting a scope. - // So we only need to take care of binary cmdlets here. - commandProcessor.CommandRuntime.RemoveVariableListsInPipe(); - } - - // Remove the pipeline variable if we need to. - commandProcessor.CommandRuntime.RemovePipelineVariable(); + // For script cmdlets, the variable lists were already removed when exiting a scope. + // So we only need to take care of binary cmdlets here. + commandProcessor.CommandRuntime.RemoveVariableListsInPipe(); } - commandProcessor.Dispose(); + // Remove the pipeline variable if we need to. + commandProcessor.CommandRuntime.RemovePipelineVariable(); } - // 2005/04/13-JonN: The only vaguely plausible reason - // for a failure here is an exception in Command.Dispose. - // As such, this should be covered by the overall - // exemption. - catch (Exception e) // Catch-all OK, 3rd party callout. - { - InvocationInfo myInvocation = null; - if (commandProcessor.Command != null) - myInvocation = commandProcessor.Command.MyInvocation; - ProviderInvocationException pie = - e as ProviderInvocationException; - if (pie != null) - { - e = new CmdletProviderInvocationException( - pie, - myInvocation); - } - else - { - e = new CmdletInvocationException( - e, - myInvocation); - - // Log a command health event + commandProcessor.Dispose(); + } + catch (Exception e) + { + // The only vaguely plausible reason for a failure here is an exception in 'Command.Dispose'. + // As such, this should be covered by the overall exemption. + InvocationInfo myInvocation = commandProcessor.Command?.MyInvocation; - MshLog.LogCommandHealthEvent( - commandProcessor.Command.Context, - e, - Severity.Warning); - } + if (e is ProviderInvocationException pie) + { + e = new CmdletProviderInvocationException(pie, myInvocation); + } + else + { + e = new CmdletInvocationException(e, myInvocation); - RecordFailure(e, commandProcessor.Command); + // Log a command health event + MshLog.LogCommandHealthEvent(commandProcessor.Command.Context, e, Severity.Warning); } -#pragma warning restore 56500 + + RecordFailure(e, commandProcessor.Command); } } } @@ -1357,25 +1385,31 @@ private void DisposeCommands() _commands = null; // Now dispose any pipes that were used for redirection... - if (_redirectionPipes != null) + if (_redirectionPipes is not null) { foreach (PipelineProcessor redirPipe in _redirectionPipes) { -#pragma warning disable 56500 + if (redirPipe is null) + { + continue; + } + + // Clean resources for script commands. + // It is possible (though very unlikely) that the call to 'Step' on the redirection pipeline failed. + // In such a case, 'Clean' would have run and the 'pipelineProcessor' would have been disposed. + // Therefore, calling 'Clean' again will simply return, because '_commands' was already set to null. + redirPipe.Clean(); + // The complicated logic of disposing the commands is taken care // of through recursion, this routine should not be getting any // exceptions... try { - if (redirPipe != null) - { - redirPipe.Dispose(); - } + redirPipe.Dispose(); } catch (Exception) { } -#pragma warning restore 56500 } } @@ -1399,11 +1433,9 @@ internal bool RecordFailure(Exception e, InternalCommand command) { _firstTerminatingError = ExceptionDispatchInfo.Capture(e); } - // 905900-2005/05/12 - // Drop5: Error Architecture: Log/trace second and subsequent RecordFailure - // Note that the pipeline could have been stopped asynchronously - // before hitting the error, therefore we check whether - // firstTerminatingError is PipelineStoppedException. + // Error Architecture: Log/trace second and subsequent RecordFailure. + // Note that the pipeline could have been stopped asynchronously before hitting the error, + // therefore we check whether '_firstTerminatingError' is 'PipelineStoppedException'. else if (_firstTerminatingError.SourceException is not PipelineStoppedException && command?.Context != null) { @@ -1422,11 +1454,10 @@ internal bool RecordFailure(Exception e, InternalCommand command) ex.GetType().Name, ex.StackTrace ); - InvalidOperationException ioe - = new InvalidOperationException(message, ex); + MshLog.LogCommandHealthEvent( command.Context, - ioe, + new InvalidOperationException(message, ex), Severity.Warning); } } diff --git a/src/System.Management.Automation/engine/runtime/CompiledScriptBlock.cs b/src/System.Management.Automation/engine/runtime/CompiledScriptBlock.cs index 2e6db041126a..79881796cad6 100644 --- a/src/System.Management.Automation/engine/runtime/CompiledScriptBlock.cs +++ b/src/System.Management.Automation/engine/runtime/CompiledScriptBlock.cs @@ -12,11 +12,9 @@ using System.Management.Automation.Runspaces; using System.Management.Automation.Tracing; using System.Reflection; -using System.Runtime.CompilerServices; using System.Runtime.Serialization; using System.Security.Cryptography.X509Certificates; using System.Text; -using System.Threading.Tasks; #if LEGACYTELEMETRY using Microsoft.PowerShell.Telemetry.Internal; #endif @@ -35,6 +33,7 @@ internal enum ScriptBlockClauseToInvoke Begin, Process, End, + Clean, ProcessBlockOnly, } @@ -247,6 +246,7 @@ bool IsScriptBlockInFactASafeHashtable() if (scriptBlockAst.BeginBlock != null || scriptBlockAst.ProcessBlock != null + || scriptBlockAst.CleanBlock != null || scriptBlockAst.ParamBlock != null || scriptBlockAst.DynamicParamBlock != null || scriptBlockAst.ScriptRequirements != null @@ -316,6 +316,8 @@ private IParameterMetadataProvider DelayParseScriptText() internal Dictionary NameToIndexMap { get; set; } + #region Named Blocks + internal Action DynamicParamBlock { get; set; } internal Action UnoptimizedDynamicParamBlock { get; set; } @@ -332,6 +334,12 @@ private IParameterMetadataProvider DelayParseScriptText() internal Action UnoptimizedEndBlock { get; set; } + internal Action CleanBlock { get; set; } + + internal Action UnoptimizedCleanBlock { get; set; } + + #endregion Named Blocks + internal IScriptExtent[] SequencePoints { get; set; } private RuntimeDefinedParameterDictionary _runtimeDefinedParameterDictionary; @@ -753,7 +761,7 @@ private PipelineAst GetSimplePipeline(Func errorHandler) { errorHandler ??= (static _ => null); - if (HasBeginBlock || HasProcessBlock) + if (HasBeginBlock || HasProcessBlock || HasCleanBlock) { return errorHandler(AutomationExceptions.CanConvertOneClauseOnly); } @@ -891,7 +899,10 @@ internal bool SkipLogging Parser parser = new Parser(); var ast = AstInternal; - if (HasBeginBlock || HasProcessBlock || ast.Body.ParamBlock != null) + if (HasBeginBlock + || HasProcessBlock + || HasCleanBlock + || ast.Body.ParamBlock is not null) { Ast errorAst = ast.Body.BeginBlock ?? (Ast)ast.Body.ProcessBlock ?? ast.Body.ParamBlock; parser.ReportError( @@ -974,6 +985,11 @@ internal bool SkipLogging InvocationInfo invocationInfo, params object[] args) { + if (clauseToInvoke == ScriptBlockClauseToInvoke.Clean) + { + throw new PSNotSupportedException(ParserStrings.InvokingCleanBlockNotSupported); + } + if ((clauseToInvoke == ScriptBlockClauseToInvoke.Begin && !HasBeginBlock) || (clauseToInvoke == ScriptBlockClauseToInvoke.Process && !HasProcessBlock) || (clauseToInvoke == ScriptBlockClauseToInvoke.End && !HasEndBlock)) @@ -991,7 +1007,7 @@ internal bool SkipLogging throw new PipelineStoppedException(); } - // Validate at the arguments are consistent. The only public API that gets you here never sets createLocalScope to false... + // Validate that the arguments are consistent. The only public API that gets you here never sets createLocalScope to false... Diagnostics.Assert( createLocalScope || functionsToDefine == null, "When calling ScriptBlock.InvokeWithContext(), if 'functionsToDefine' != null then 'createLocalScope' must be true"); @@ -1195,7 +1211,7 @@ internal bool SkipLogging _sequencePoints = SequencePoints, }; - ScriptBlock.LogScriptBlockStart(this, context.CurrentRunspace.InstanceId); + LogScriptBlockStart(this, context.CurrentRunspace.InstanceId); try { @@ -1203,7 +1219,7 @@ internal bool SkipLogging } finally { - ScriptBlock.LogScriptBlockEnd(this, context.CurrentRunspace.InstanceId); + LogScriptBlockEnd(this, context.CurrentRunspace.InstanceId); } } catch (TargetInvocationException tie) @@ -1362,7 +1378,7 @@ internal static void SetAutomaticVariable(AutomaticVariable variable, object val private Action GetCodeToInvoke(ref bool optimized, ScriptBlockClauseToInvoke clauseToInvoke) { if (clauseToInvoke == ScriptBlockClauseToInvoke.ProcessBlockOnly - && (HasBeginBlock || (HasEndBlock && HasProcessBlock))) + && (HasBeginBlock || HasCleanBlock || (HasEndBlock && HasProcessBlock))) { throw PSTraceSource.NewInvalidOperationException(AutomationExceptions.ScriptBlockInvokeOnOneClauseOnly); } @@ -1379,6 +1395,8 @@ private Action GetCodeToInvoke(ref bool optimized, ScriptBlockC return _scriptBlockData.ProcessBlock; case ScriptBlockClauseToInvoke.End: return _scriptBlockData.EndBlock; + case ScriptBlockClauseToInvoke.Clean: + return _scriptBlockData.CleanBlock; default: return HasProcessBlock ? _scriptBlockData.ProcessBlock : _scriptBlockData.EndBlock; } @@ -1392,6 +1410,8 @@ private Action GetCodeToInvoke(ref bool optimized, ScriptBlockC return _scriptBlockData.UnoptimizedProcessBlock; case ScriptBlockClauseToInvoke.End: return _scriptBlockData.UnoptimizedEndBlock; + case ScriptBlockClauseToInvoke.Clean: + return _scriptBlockData.UnoptimizedCleanBlock; default: return HasProcessBlock ? _scriptBlockData.UnoptimizedProcessBlock : _scriptBlockData.UnoptimizedEndBlock; } @@ -2147,11 +2167,17 @@ internal static void LogScriptBlockEnd(ScriptBlock scriptBlock, Guid runspaceId) internal Action UnoptimizedEndBlock { get => _scriptBlockData.UnoptimizedEndBlock; } + internal Action CleanBlock { get => _scriptBlockData.CleanBlock; } + + internal Action UnoptimizedCleanBlock { get => _scriptBlockData.UnoptimizedCleanBlock; } + internal bool HasBeginBlock { get => AstInternal.Body.BeginBlock != null; } internal bool HasProcessBlock { get => AstInternal.Body.ProcessBlock != null; } internal bool HasEndBlock { get => AstInternal.Body.EndBlock != null; } + + internal bool HasCleanBlock { get => AstInternal.Body.CleanBlock != null; } } [Serializable] @@ -2197,11 +2223,13 @@ internal sealed class PSScriptCmdlet : PSCmdlet, IDynamicParameters, IDisposable private readonly bool _useLocalScope; private readonly bool _runOptimized; private readonly bool _rethrowExitException; - private MshCommandRuntime _commandRuntime; private readonly MutableTuple _localsTuple; - private bool _exitWasCalled; private readonly FunctionContext _functionContext; + private MshCommandRuntime _commandRuntime; + private bool _exitWasCalled; + private bool _anyClauseExecuted; + public PSScriptCmdlet(ScriptBlock scriptBlock, bool useNewScope, bool fromScriptFile, ExecutionContext context) { _scriptBlock = scriptBlock; @@ -2291,6 +2319,34 @@ internal override void DoEndProcessing() } } + internal override void DoCleanResource() + { + if (_scriptBlock.HasCleanBlock && _anyClauseExecuted) + { + // The 'Clean' block doesn't write any output to pipeline, so we use a 'NullPipe' here and + // disallow the output to be collected by an 'out' variable. However, the error, warning, + // and information records should still be collectable by the corresponding variables. + Pipe oldOutputPipe = _commandRuntime.OutputPipe; + _functionContext._outputPipe = _commandRuntime.OutputPipe = new Pipe + { + NullPipe = true, + IgnoreOutVariableList = true, + }; + + try + { + RunClause( + clause: _runOptimized ? _scriptBlock.CleanBlock : _scriptBlock.UnoptimizedCleanBlock, + dollarUnderbar: AutomationNull.Value, + inputToProcess: AutomationNull.Value); + } + finally + { + _functionContext._outputPipe = _commandRuntime.OutputPipe = oldOutputPipe; + } + } + } + private void EnterScope() { _commandRuntime.SetVariableListsInPipe(); @@ -2303,6 +2359,7 @@ private void ExitScope() private void RunClause(Action clause, object dollarUnderbar, object inputToProcess) { + _anyClauseExecuted = true; Pipe oldErrorOutputPipe = this.Context.ShellFunctionErrorOutputPipe; // If the script block has a different language mode than the current, @@ -2356,9 +2413,9 @@ private void RunClause(Action clause, object dollarUnderbar, ob } finally { - this.Context.RestoreErrorPipe(oldErrorOutputPipe); + Context.ShellFunctionErrorOutputPipe = oldErrorOutputPipe; - // Set the language mode + // Restore the language mode if (oldLanguageMode.HasValue) { Context.LanguageMode = oldLanguageMode.Value; @@ -2518,9 +2575,6 @@ public void Dispose() commandRuntime = null; currentObjectInPipeline = null; _input.Clear(); - // _scriptBlock = null; - // _localsTuple = null; - // _functionContext = null; base.InternalDispose(true); _disposed = true; diff --git a/src/System.Management.Automation/engine/runtime/Operations/MiscOps.cs b/src/System.Management.Automation/engine/runtime/Operations/MiscOps.cs index de9ef697fc61..8d50fcf5f1aa 100644 --- a/src/System.Management.Automation/engine/runtime/Operations/MiscOps.cs +++ b/src/System.Management.Automation/engine/runtime/Operations/MiscOps.cs @@ -1578,23 +1578,33 @@ private static int FindMatchingHandlerByType(Type exceptionType, Type[] types) internal static bool SuspendStoppingPipeline(ExecutionContext context) { - LocalPipeline lpl = (LocalPipeline)context.CurrentRunspace.GetCurrentlyRunningPipeline(); - if (lpl != null) + var localPipeline = (LocalPipeline)context.CurrentRunspace.GetCurrentlyRunningPipeline(); + return SuspendStoppingPipelineImpl(localPipeline); + } + + internal static void RestoreStoppingPipeline(ExecutionContext context, bool oldIsStopping) + { + var localPipeline = (LocalPipeline)context.CurrentRunspace.GetCurrentlyRunningPipeline(); + RestoreStoppingPipelineImpl(localPipeline, oldIsStopping); + } + + internal static bool SuspendStoppingPipelineImpl(LocalPipeline localPipeline) + { + if (localPipeline is not null) { - bool oldIsStopping = lpl.Stopper.IsStopping; - lpl.Stopper.IsStopping = false; + bool oldIsStopping = localPipeline.Stopper.IsStopping; + localPipeline.Stopper.IsStopping = false; return oldIsStopping; } return false; } - internal static void RestoreStoppingPipeline(ExecutionContext context, bool oldIsStopping) + internal static void RestoreStoppingPipelineImpl(LocalPipeline localPipeline, bool oldIsStopping) { - LocalPipeline lpl = (LocalPipeline)context.CurrentRunspace.GetCurrentlyRunningPipeline(); - if (lpl != null) + if (localPipeline is not null) { - lpl.Stopper.IsStopping = oldIsStopping; + localPipeline.Stopper.IsStopping = oldIsStopping; } } @@ -2855,6 +2865,11 @@ internal static object ForEach(IEnumerator enumerator, object expression, object ScriptBlock sb = expression as ScriptBlock; if (sb != null) { + if (sb.HasCleanBlock) + { + throw new PSNotSupportedException(ParserStrings.ForEachNotSupportCleanBlock); + } + Pipe outputPipe = new Pipe(result); if (sb.HasBeginBlock) { diff --git a/src/System.Management.Automation/resources/ParserStrings.resx b/src/System.Management.Automation/resources/ParserStrings.resx index d21719157ac9..a0569a6f4e41 100644 --- a/src/System.Management.Automation/resources/ParserStrings.resx +++ b/src/System.Management.Automation/resources/ParserStrings.resx @@ -497,7 +497,7 @@ The correct form is: foreach ($a in $b) {...} Script command clause '{0}' has already been defined. - unexpected token '{0}', expected 'begin', 'process', 'end', or 'dynamicparam'. + unexpected token '{0}', expected 'begin', 'process', 'end', 'clean', or 'dynamicparam'. Missing closing '}' in statement block or type definition. @@ -1126,6 +1126,9 @@ ModuleVersion : Version of module to import. If used, ModuleName must represent Unable to convert input to the target type [{0}] passed to the ForEach() operator. Please check the specified type and try running your script again. + + Script block with a 'clean' block is not supported by the 'ForEach' method. + The 'numberToReturn' value provided to the third argument of the Where() operator must be greater than zero. Please correct the argument's value and try running your script again. @@ -1479,4 +1482,7 @@ ModuleVersion : Version of module to import. If used, ModuleName must represent Background operators can only be used at the end of a pipeline chain. + + Directly invoking the 'clean' block of a script block is not supported. + diff --git a/test/powershell/Language/Parser/Parser.Tests.ps1 b/test/powershell/Language/Parser/Parser.Tests.ps1 index 0f8a6c74726a..0368dfe9c495 100644 --- a/test/powershell/Language/Parser/Parser.Tests.ps1 +++ b/test/powershell/Language/Parser/Parser.Tests.ps1 @@ -67,6 +67,8 @@ Describe "ParserTests (admin\monad\tests\monad\src\engine\core\ParserTests.cs)" } end {} + + clean {} } '@ $functionDefinition>$functionDefinitionFile diff --git a/test/powershell/Language/Parser/Parsing.Tests.ps1 b/test/powershell/Language/Parser/Parsing.Tests.ps1 index c764830954b9..97e46bcbe3cd 100644 --- a/test/powershell/Language/Parser/Parsing.Tests.ps1 +++ b/test/powershell/Language/Parser/Parsing.Tests.ps1 @@ -148,17 +148,21 @@ Describe 'named blocks parsing' -Tags "CI" { ShouldBeParseError 'begin' MissingNamedStatementBlock 5 ShouldBeParseError 'process' MissingNamedStatementBlock 7 ShouldBeParseError 'end' MissingNamedStatementBlock 3 + ShouldBeParseError 'clean' MissingNamedStatementBlock 5 ShouldBeParseError 'dynamicparam' MissingNamedStatementBlock 12 ShouldBeParseError 'begin process {}' MissingNamedStatementBlock 6 -CheckColumnNumber ShouldBeParseError 'end process {}' MissingNamedStatementBlock 4 -CheckColumnNumber + ShouldBeParseError 'clean process {}' MissingNamedStatementBlock 6 -CheckColumnNumber ShouldBeParseError 'dynamicparam process {}' MissingNamedStatementBlock 13 -CheckColumnNumber ShouldBeParseError 'process begin {}' MissingNamedStatementBlock 8 -CheckColumnNumber - ShouldBeParseError 'begin process end' MissingNamedStatementBlock,MissingNamedStatementBlock,MissingNamedStatementBlock 6,14,18 -CheckColumnNumber + ShouldBeParseError 'begin process end clean' MissingNamedStatementBlock, MissingNamedStatementBlock, MissingNamedStatementBlock, MissingNamedStatementBlock 6, 14, 18, 24 -CheckColumnNumber Test-Ast 'begin' 'begin' 'begin' Test-Ast 'begin end' 'begin end' 'begin' 'end' Test-Ast 'begin end process' 'begin end process' 'begin' 'end' 'process' Test-Ast 'begin {} end' 'begin {} end' 'begin {}' 'end' + Test-Ast 'begin process end clean' 'begin process end clean' 'begin' 'clean' 'end' 'process' + Test-Ast 'begin {} process end clean {}' 'begin {} process end clean {}' 'begin {}' 'clean {}' 'end' 'process' } # diff --git a/test/powershell/Language/Scripting/CleanBlockErrorHandling.Tests.ps1 b/test/powershell/Language/Scripting/CleanBlockErrorHandling.Tests.ps1 new file mode 100644 index 000000000000..68904d4c79f1 --- /dev/null +++ b/test/powershell/Language/Scripting/CleanBlockErrorHandling.Tests.ps1 @@ -0,0 +1,1374 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe "Error handling within a single 'Clean' block" -Tag 'CI' { + + BeforeAll { + function ErrorInClean { + [CmdletBinding()] + param( + [switch] $ThrowTerminatingError, + [switch] $ErrorActionStop, + [switch] $ThrowException, + [switch] $WriteErrorAPI, + [switch] $WriteErrorCmdlet, + [switch] $MethodInvocationThrowException, + [switch] $ExpressionThrowException + ) + + End { <# use an empty end block to allow the clean block to actually run #> } + + clean { + if ($ThrowTerminatingError) { + $ex = [System.ArgumentException]::new('terminating-exception') + $er = [System.Management.Automation.ErrorRecord]::new($ex, 'ThrowTerminatingError:error', 'InvalidArgument', $null) + $PSCmdlet.ThrowTerminatingError($er) + Write-Verbose -Verbose "verbose-message" + } + elseif ($ErrorActionStop) { + Get-Command NonExist -ErrorAction Stop + Write-Verbose -Verbose "verbose-message" + } + elseif ($ThrowException) { + throw 'throw-exception' + Write-Verbose -Verbose "verbose-message" + } + elseif ($WriteErrorAPI) { + $ex = [System.ArgumentException]::new('arg-exception') + $er = [System.Management.Automation.ErrorRecord]::new($ex, 'WriteErrorAPI:error', 'InvalidArgument', $null) + $PSCmdlet.WriteError($er) + Write-Verbose -Verbose "verbose-message" + } + elseif ($WriteErrorCmdlet) { + Write-Error 'write-error-cmdlet' + Write-Verbose -Verbose "verbose-message" + } + elseif ($MethodInvocationThrowException) { + ## This method call throws exception. + $iss = [initialsessionstate]::Create() + $iss.ImportPSModule($null) + Write-Verbose -Verbose "verbose-message" + } + elseif ($ExpressionThrowException) { + 1/0 ## throw exception. + Write-Verbose -Verbose "verbose-message" + } + } + } + + function ErrorInEnd { + [CmdletBinding()] + param( + [switch] $ThrowTerminatingError, + [switch] $ErrorActionStop, + [switch] $ThrowException, + [switch] $WriteErrorAPI, + [switch] $WriteErrorCmdlet, + [switch] $MethodInvocationThrowException, + [switch] $ExpressionThrowException + ) + + if ($ThrowTerminatingError) { + $ex = [System.ArgumentException]::new('terminating-exception') + $er = [System.Management.Automation.ErrorRecord]::new($ex, 'ThrowTerminatingError:error', 'InvalidArgument', $null) + $PSCmdlet.ThrowTerminatingError($er) + Write-Verbose -Verbose "verbose-message" + } + elseif ($ErrorActionStop) { + Get-Command NonExist -ErrorAction Stop + Write-Verbose -Verbose "verbose-message" + } + elseif ($ThrowException) { + throw 'throw-exception' + Write-Verbose -Verbose "verbose-message" + } + elseif ($WriteErrorAPI) { + $ex = [System.ArgumentException]::new('arg-exception') + $er = [System.Management.Automation.ErrorRecord]::new($ex, 'WriteErrorAPI:error', 'InvalidArgument', $null) + $PSCmdlet.WriteError($er) + Write-Verbose -Verbose "verbose-message" + } + elseif ($WriteErrorCmdlet) { + Write-Error 'write-error-cmdlet' + Write-Verbose -Verbose "verbose-message" + } + elseif ($MethodInvocationThrowException) { + ## This method call throws exception. + $iss = [initialsessionstate]::Create() + $iss.ImportPSModule($null) + Write-Verbose -Verbose "verbose-message" + } + elseif ($ExpressionThrowException) { + 1/0 ## throw exception. + Write-Verbose -Verbose "verbose-message" + } + } + + function DivideByZeroWrappedInTry { + [CmdletBinding()] + param() + + end {} + clean { + try { + 1/0 + Write-Verbose -Verbose 'clean' + } + catch { Write-Verbose -Verbose $_.Exception.InnerException.GetType().FullName } + } + } + + function ArgumentNullWrappedInTry { + [CmdletBinding()] + param() + + end {} + clean { + try { + $iss = [initialsessionstate]::Create() + $iss.ImportPSModule($null) + Write-Verbose -Verbose 'clean' + } + catch { Write-Verbose -Verbose $_.Exception.InnerException.GetType().FullName } + } + } + + function DivideByZeroWithTrap { + [CmdletBinding()] + param() + + end {} + clean { + trap { + Write-Verbose -Verbose $_.Exception.GetType().FullName + continue + } + + 1/0 + Write-Verbose -Verbose 'clean' + } + } + + function ArgumentNullWithTrap { + [CmdletBinding()] + param() + + end {} + clean { + trap { + Write-Verbose -Verbose $_.Exception.GetType().FullName + continue + } + + $iss = [initialsessionstate]::Create() + $iss.ImportPSModule($null) + Write-Verbose -Verbose 'clean' + } + } + + #region Helper + + $pwsh = [PowerShell]::Create() + $text = (Get-Command ErrorInClean).ScriptBlock.Ast.Extent.Text + $pwsh.AddScript($text).Invoke() + + $pwsh.Commands.Clear() + $text = (Get-Command ErrorInEnd).ScriptBlock.Ast.Extent.Text + $pwsh.AddScript($text).Invoke() + + function RunCommand { + param( + [ValidateSet('ErrorInClean', 'ErrorInEnd')] + [string] $Command, + + [ValidateSet('ThrowTerminatingError', 'ErrorActionStop', 'ThrowException', 'WriteErrorAPI', + 'WriteErrorCmdlet', 'MethodInvocationThrowException', 'ExpressionThrowException')] + [string] $ParamNameToUse, + + [ValidateSet('Continue', 'Ignore', 'SilentlyContinue', 'Stop')] + [string] $ErrorAction + ) + + $pwsh.Commands.Clear() + $pwsh.Streams.ClearStreams() + $pwsh.AddCommand($Command).AddParameter($ParamNameToUse, $true) > $null + if ($ErrorAction) { $pwsh.AddParameter('ErrorAction', $ErrorAction) > $null } + $pwsh.Invoke() + } + + function RunScript { + param([string] $Script) + + $pwsh.Commands.Clear() + $pwsh.Streams.ClearStreams() + $pwsh.AddScript($Script).Invoke() + } + + function GetLastError { + $pwsh.Commands.Clear() + $pwsh.AddCommand('Get-Error').Invoke() + } + + function ClearDollarError { + $pwsh.Commands.Clear() + $pwsh.AddScript('$Error.Clear()').Invoke() + } + + #endregion + } + + AfterAll { + $pwsh.Dispose() + } + + It "Terminating error should stop the 'Clean' block execution but should not be propagated up" { + ## 'ThrowTerminatingException' stops the execution within the 'Clean' block, but the error doesn't get + ## propagated out of the 'Clean' block. Instead, the error is written to the 'ErrorOutput' pipe. + RunCommand -Command 'ErrorInClean' -ParamNameToUse 'ThrowTerminatingError' + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'terminating-exception' + + ## 'throw' statement stops the execution within the 'Clean' block by default, but the error doesn't get + ## propagated out of the 'Clean' block. Instead, the error is written to the 'ErrorOutput' pipe. + RunCommand -Command 'ErrorInClean' -ParamNameToUse 'ThrowException' + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'throw-exception' + + ## '-ErrorAction Stop' stops the execution within the 'Clean' block, but the error doesn't get propagated + ## out of the 'Clean' block. Instead, the error is written to the 'ErrorOutput' pipe. + RunCommand -Command 'ErrorInClean' -ParamNameToUse 'ErrorActionStop' + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].FullyQualifiedErrorId | Should -BeExactly 'CommandNotFoundException,Microsoft.PowerShell.Commands.GetCommandCommand' + + ## Turn non-terminating errors into terminating by '-ErrorAction Stop' explicitly. + ## Execution within the 'Clean' block should be stopped. The resulted terminating error should not get + ## propagated, but instead should be written to 'ErrorOutput' pipe. + RunCommand -Command 'ErrorInClean' -ParamNameToUse 'WriteErrorAPI' -ErrorAction Stop + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'arg-exception' + + RunCommand -Command 'ErrorInClean' -ParamNameToUse 'WriteErrorCmdlet' -ErrorAction Stop + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'write-error-cmdlet' + + RunCommand -Command 'ErrorInClean' -ParamNameToUse 'MethodInvocationThrowException' -ErrorAction Stop + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + + RunCommand -Command 'ErrorInClean' -ParamNameToUse 'ExpressionThrowException' -ErrorAction Stop + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + } + + It "Terminating error should set `$? correctly" { + RunScript -Script 'ErrorInClean -ThrowTerminatingError; $?' | Should -BeFalse + RunScript -Script 'ErrorInClean -ThrowException; $?' | Should -BeFalse + RunScript -Script 'ErrorInClean -ErrorActionStop; $?' | Should -BeFalse + RunScript -Script 'ErrorInClean -WriteErrorAPI -ErrorAction Stop; $?' | Should -BeFalse + RunScript -Script 'ErrorInClean -WriteErrorCmdlet -ErrorAction Stop; $?' | Should -BeFalse + RunScript -Script 'ErrorInClean -MethodInvocationThrowException -ErrorAction Stop; $?' | Should -BeFalse + RunScript -Script 'ErrorInClean -ExpressionThrowException -ErrorAction Stop; $?' | Should -BeFalse + } + + It "Track the `$? behavior for non-terminating errors within 'Clean' and 'End' blocks" { + RunScript -Script 'ErrorInClean -WriteErrorAPI; $?' | Should -BeFalse + RunScript -Script 'ErrorInEnd -WriteErrorAPI; $?' | Should -BeFalse + + ## The 'Write-Error' is specially weird, in that when a command emits error because of 'Write-Error' within it, + ## the following '$?' won't reflect '$false', but will be '$true'. + ## Frankly, this is counter-intuitive, but it's the existing behavior. The tests below just keeps track of this + ## behavior. Feel free to change this test if someone is fixing this seemingly wrong behavior. + RunScript -Script 'ErrorInClean -WriteErrorCmdlet; $?' | Should -BeTrue + RunScript -Script 'ErrorInEnd -WriteErrorCmdlet; $?' | Should -BeTrue + + ## Similarly, when a command emits error because of a method invocation within it throws an exception, + ## the following '$?' won't reflect '$false', but will be '$true'. + ## Again, this seems wrong, but it's the existing behavior. The tests below just keeps track of this + ## behavior. Feel free to change this test if someone is fixing this seemingly wrong behavior. + RunScript -Script 'ErrorInClean -MethodInvocationThrowException; $?' | Should -BeTrue + RunScript -Script 'ErrorInEnd -MethodInvocationThrowException; $?' | Should -BeTrue + + ## Again, when a command emits error because of an expression within it throws an exception, + ## the following '$?' won't reflect '$false', but will be '$true'. + ## This seems wrong, but it's the existing behavior. The tests below just keeps track of this + ## behavior. Feel free to change this test if someone is fixing this seemingly wrong behavior. + RunScript -Script 'ErrorInClean -ExpressionThrowException; $?' | Should -BeTrue + RunScript -Script 'ErrorInEnd -ExpressionThrowException; $?' | Should -BeTrue + } + + It "Non-terminating error within 'Clean' block should act based on ErrorActionPreference: - Continue" -TestCases @( + @{ ParamName = 'WriteErrorAPI'; AssertScript = { param($err) $err.Exception.Message | Should -BeExactly 'arg-exception' } } + @{ ParamName = 'WriteErrorCmdlet'; AssertScript = { param($err) $err.Exception.Message | Should -BeExactly 'write-error-cmdlet' } } + @{ ParamName = 'MethodInvocationThrowException'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' } } + @{ ParamName = 'ExpressionThrowException'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' } } + ) { + param($ParamName, $AssertScript) + + RunCommand -Command 'ErrorInClean' -ParamNameToUse $ParamName + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + $pwsh.Streams.Error.Count | Should -Be 1 + & $AssertScript $pwsh.Streams.Error[0] + } + + It "Non-terminating error within 'End' block should act based on ErrorActionPreference: - Continue" -TestCases @( + @{ ParamName = 'WriteErrorAPI'; AssertScript = { param($err) $err.Exception.Message | Should -BeExactly 'arg-exception' } } + @{ ParamName = 'WriteErrorCmdlet'; AssertScript = { param($err) $err.Exception.Message | Should -BeExactly 'write-error-cmdlet' } } + @{ ParamName = 'MethodInvocationThrowException'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' } } + @{ ParamName = 'ExpressionThrowException'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' } } + ) { + param($ParamName, $AssertScript) + + RunCommand -Command 'ErrorInEnd' -ParamNameToUse $ParamName + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + $pwsh.Streams.Error.Count | Should -Be 1 + & $AssertScript $pwsh.Streams.Error[0] + } + + It "Non-terminating error within 'Clean' block should act based on ErrorActionPreference: - " -TestCases @( + ### When error action is 'Ignore', non-terminating errors emitted by 'WriteErrorAPI' and 'WriteErrorCmdlet' are not captured in $Error, + ### but non-terminating errors emitted by method exception or expression exception are captured in $Error. + ### This inconsistency is surprising, but it's the existing behavior -- same in other named blocks. + @{ ParamName = 'WriteErrorAPI'; Action = 'Ignore'; AssertScript = $null } + @{ ParamName = 'WriteErrorCmdlet'; Action = 'Ignore'; AssertScript = $null } + @{ ParamName = 'MethodInvocationThrowException'; Action = 'Ignore'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' } } + @{ ParamName = 'ExpressionThrowException'; Action = 'Ignore'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' } } + + @{ ParamName = 'WriteErrorAPI'; Action = 'SilentlyContinue'; AssertScript = { param($err) $err.Exception.Message | Should -BeExactly 'arg-exception' } } + @{ ParamName = 'WriteErrorCmdlet'; Action = 'SilentlyContinue'; AssertScript = { param($err) $err.Exception.Message | Should -BeExactly 'write-error-cmdlet' } } + @{ ParamName = 'MethodInvocationThrowException'; Action = 'SilentlyContinue'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' } } + @{ ParamName = 'ExpressionThrowException'; Action = 'SilentlyContinue'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' } } + ) { + param($ParamName, $Action, $AssertScript) + + ClearDollarError + RunCommand -Command 'ErrorInClean' -ParamNameToUse $ParamName -ErrorAction $Action + + $pwsh.Streams.Error.Count | Should -Be 0 + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + + $lastErr = GetLastError + if ($null -eq $AssertScript) { + $lastErr | Should -BeNullOrEmpty + } else { + $lastErr | Should -Not -BeNullOrEmpty + & $AssertScript $lastErr + } + } + + ### These tests are targeting 'End' block but with the same settings as the ones right above. + ### They are used as a comparison to prove the consistent behavior in 'End' and 'Clean'. + It "Non-terminating error within 'End' block should act based on ErrorActionPreference: - " -TestCases @( + @{ ParamName = 'WriteErrorAPI'; Action = 'Ignore'; AssertScript = $null } + @{ ParamName = 'WriteErrorCmdlet'; Action = 'Ignore'; AssertScript = $null } + @{ ParamName = 'MethodInvocationThrowException'; Action = 'Ignore'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' } } + @{ ParamName = 'ExpressionThrowException'; Action = 'Ignore'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' } } + + @{ ParamName = 'WriteErrorAPI'; Action = 'SilentlyContinue'; AssertScript = { param($err) $err.Exception.Message | Should -BeExactly 'arg-exception' } } + @{ ParamName = 'WriteErrorCmdlet'; Action = 'SilentlyContinue'; AssertScript = { param($err) $err.Exception.Message | Should -BeExactly 'write-error-cmdlet' } } + @{ ParamName = 'MethodInvocationThrowException'; Action = 'SilentlyContinue'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' } } + @{ ParamName = 'ExpressionThrowException'; Action = 'SilentlyContinue'; AssertScript = { param($err) $err.Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' } } + ) { + param($ParamName, $Action, $AssertScript) + + ClearDollarError + RunCommand -Command 'ErrorInEnd' -ParamNameToUse $ParamName -ErrorAction $Action + + $pwsh.Streams.Error.Count | Should -Be 0 + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + + $lastErr = GetLastError + if ($null -eq $AssertScript) { + $lastErr | Should -BeNullOrEmpty + } else { + $lastErr | Should -Not -BeNullOrEmpty + & $AssertScript $lastErr + } + } + + It "'try/catch' and 'trap' should turn general exception thrown from method/expression into terminating error within 'Clean' block. ErrorAction: " -TestCases @( + @{ ErrorAction = 'Continue' } + @{ ErrorAction = 'Ignore' } + @{ ErrorAction = 'SilentlyContinue' } + ) { + param($ErrorAction) + + $verbose = DivideByZeroWrappedInTry -ErrorAction $ErrorAction 4>&1 + $verbose.Count | Should -Be 1 + $verbose.Message | Should -BeExactly 'System.DivideByZeroException' + + $verbose = ArgumentNullWrappedInTry -ErrorAction $ErrorAction 4>&1 + $verbose.Count | Should -Be 1 + $verbose.Message | Should -BeExactly 'System.ArgumentNullException' + + $verbose = DivideByZeroWithTrap -ErrorAction $ErrorAction 4>&1 + $verbose.Count | Should -Be 2 + $verbose[0].Message | Should -BeExactly 'System.DivideByZeroException' + $verbose[1].Message | Should -BeExactly 'clean' + + $verbose = ArgumentNullWithTrap -ErrorAction $ErrorAction 4>&1 + $verbose.Count | Should -Be 2 + $verbose[0].Message | Should -BeExactly 'System.ArgumentNullException' + $verbose[1].Message | Should -BeExactly 'clean' + } + + It "'try/catch' and 'trap' outside the command should NOT affect general exception thrown from method/expression in the 'Clean' block" { + ## The catch block should not run + RunScript -Script "try { ErrorInClean -MethodInvocationThrowException } catch { Write-Debug -Debug 'caught-something' }" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + $pwsh.Streams.Debug.Count | Should -Be 0 + + ## The catch block should not run + RunScript -Script "try { ErrorInClean -ExpressionThrowException } catch { Write-Debug -Debug 'caught-something' }" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + $pwsh.Streams.Debug.Count | Should -Be 0 + + ## The trap block should not run + RunScript -Script "trap { Write-Debug -Debug 'caught-something'; continue } ErrorInClean -MethodInvocationThrowException" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + $pwsh.Streams.Debug.Count | Should -Be 0 + + ## The trap block should not run + RunScript -Script "trap { Write-Debug -Debug 'caught-something'; continue } ErrorInClean -ExpressionThrowException" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + $pwsh.Streams.Debug.Count | Should -Be 0 + } + + It "'try/catch' and 'trap' outside the command should NOT affect 'throw' statement in the 'Clean' block. ErrorAction: " -TestCases @( + @{ ErrorAction = 'Ignore' } + @{ ErrorAction = 'SilentlyContinue' } + ) { + param ($ErrorAction) + + ## 'throw' statement should be suppressed by 'Ignore' or 'SilentlyContinue' within a 'Clean' block, + ## even if the command is wrapped in try/catch. + RunScript -Script "try { ErrorInClean -ThrowException -ErrorAction $ErrorAction } catch { Write-Debug -Debug 'caught-something' }" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + + ## Nothing written to error stream + $pwsh.Streams.Error.Count | Should -Be 0 + ## Nothing written to debug stream + $pwsh.Streams.Debug.Count | Should -Be 0 + + ## The suppressed 'throw' exception is kept in '$Error' + $err = GetLastError + $err | Should -Not -BeNullOrEmpty + $err.FullyQualifiedErrorId | Should -BeExactly 'throw-exception' + + + ## 'throw' statement should be suppressed by 'Ignore' or 'SilentlyContinue' within a 'Clean' block, + ## even if the command is accompanied by 'trap'. + RunScript -Script "trap { Write-Debug -Debug 'caught-something'; continue } ErrorInClean -ThrowException -ErrorAction $ErrorAction" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'verbose-message' + + ## Nothing written to error stream + $pwsh.Streams.Error.Count | Should -Be 0 + ## Nothing written to debug stream + $pwsh.Streams.Debug.Count | Should -Be 0 + + ## The suppressed 'throw' exception is kept in '$Error' + $err = GetLastError + $err | Should -Not -BeNullOrEmpty + $err.FullyQualifiedErrorId | Should -BeExactly 'throw-exception' + } + + It "Error out-variable should work for the 'Clean' block" { + ## Terminating errors thrown from 'Clean' block are captured and written to the error pipe. + ## Here we redirect the error pipe to discard the error stream, so the error doesn't pollute + ## the test output. + ErrorInClean -ThrowTerminatingError -ErrorVariable err 2>&1 > $null + $err.Count | Should -Be 1 + $err[0].Message | Should -BeExactly 'terminating-exception' + + ## $err.Count is 3 in this case. It's the same for other named blocks too. + ## This looks like an existing bug because $err.Count should be 1 since only 1 error happened. + ## Opened issue https://github.com/PowerShell/PowerShell/issues/15739 + ErrorInClean -ErrorActionStop -ErrorVariable err 2>&1 > $null + $err[0] | Should -BeOfType 'System.Management.Automation.ActionPreferenceStopException' + + ## $err.Count is 2 in this case. It's the same for other named blocks too. + ## Similarly, this looks like an existing bug and $err.Count should be 1. + ## This is tracked by the same issue above. + ErrorInClean -ThrowException -ErrorVariable err 2>&1 > $null + $err[0].Exception.Message | Should -BeExactly 'throw-exception' + + ErrorInClean -WriteErrorAPI -ErrorVariable err *>&1 > $null + $err.Count | Should -Be 1 + $err[0].Exception.Message | Should -BeExactly 'arg-exception' + + ErrorInClean -WriteErrorCmdlet -ErrorVariable err *>&1 > $null + $err.Count | Should -Be 1 + $err[0].Exception.Message | Should -BeExactly 'write-error-cmdlet' + + ErrorInClean -MethodInvocationThrowException -ErrorVariable err *>&1 > $null + $err.Count | Should -Be 1 + $err[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + + ErrorInClean -ExpressionThrowException -ErrorVariable err *>&1 > $null + $err.Count | Should -Be 1 + $err[0].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + } +} + +Describe "Multiple errors from 'Clean' and another named block" { + + BeforeAll { + function MultipleErrors { + [CmdletBinding()] + param( + [ValidateSet('ThrowTerminatingError', 'ErrorActionStop', 'ThrowException', 'WriteErrorAPI', + 'WriteErrorCmdlet', 'MethodInvocationThrowException', 'ExpressionThrowException')] + [Parameter(Mandatory)] + [string] $ErrorFromEndBlock, + + [ValidateSet('ThrowTerminatingError', 'ErrorActionStop', 'ThrowException', 'WriteErrorAPI', + 'WriteErrorCmdlet', 'MethodInvocationThrowException', 'ExpressionThrowException')] + [Parameter(Mandatory)] + [string] $ErrorFromCleanBlock + ) + + End { + switch ($ErrorFromEndBlock) { + 'ThrowTerminatingError' { + $ex = [System.ArgumentException]::new('end-terminating-exception') + $er = [System.Management.Automation.ErrorRecord]::new($ex, 'ThrowTerminatingError:end-error', 'InvalidArgument', $null) + $PSCmdlet.ThrowTerminatingError($er) + Write-Verbose -Verbose "end-verbose-message" + } + + 'ErrorActionStop' { + Get-Command NonExistEnd -ErrorAction Stop + Write-Verbose -Verbose "end-verbose-message" + } + + 'ThrowException' { + throw 'end-throw-exception' + Write-Verbose -Verbose "end-verbose-message" + } + + 'WriteErrorAPI' { + $ex = [System.ArgumentException]::new('end-arg-exception') + $er = [System.Management.Automation.ErrorRecord]::new($ex, 'WriteErrorAPI:end-error', 'InvalidArgument', $null) + $PSCmdlet.WriteError($er) + Write-Verbose -Verbose "end-verbose-message" + } + + 'WriteErrorCmdlet' { + Write-Error 'end-write-error-cmdlet' + Write-Verbose -Verbose "end-verbose-message" + } + + 'MethodInvocationThrowException' { + ## This method call throws exception. + $iss = [initialsessionstate]::Create() + $iss.ImportPSModule($null) + Write-Verbose -Verbose "end-verbose-message" + } + + 'ExpressionThrowException' { + 1/0 ## throw exception. + Write-Verbose -Verbose "end-verbose-message" + } + } + } + + clean { + switch ($ErrorFromCleanBlock) { + 'ThrowTerminatingError' { + $ex = [System.ArgumentException]::new('clean-terminating-exception') + $er = [System.Management.Automation.ErrorRecord]::new($ex, 'ThrowTerminatingError:clean-error', 'InvalidArgument', $null) + $PSCmdlet.ThrowTerminatingError($er) + Write-Verbose -Verbose "clean-verbose-message" + } + + 'ErrorActionStop' { + Get-Command NonExistClean -ErrorAction Stop + Write-Verbose -Verbose "clean-verbose-message" + } + + 'ThrowException' { + throw 'clean-throw-exception' + Write-Verbose -Verbose "clean-verbose-message" + } + + 'WriteErrorAPI' { + $ex = [System.ArgumentException]::new('clean-arg-exception') + $er = [System.Management.Automation.ErrorRecord]::new($ex, 'WriteErrorAPI:clean-error', 'InvalidArgument', $null) + $PSCmdlet.WriteError($er) + Write-Verbose -Verbose "clean-verbose-message" + } + + 'WriteErrorCmdlet' { + Write-Error 'clean-write-error-cmdlet' + Write-Verbose -Verbose "clean-verbose-message" + } + + 'MethodInvocationThrowException' { + ## This method call throws exception. + $iss = [initialsessionstate]::Create() + $iss.ImportPSModule($null) + Write-Verbose -Verbose "clean-verbose-message" + } + + 'ExpressionThrowException' { + 1/0 ## throw exception. + Write-Verbose -Verbose "clean-verbose-message" + } + } + } + } + + #region Helper + + $pwsh = [PowerShell]::Create() + $text = (Get-Command MultipleErrors).ScriptBlock.Ast.Extent.Text + $pwsh.AddScript($text).Invoke() + + function RunCommand { + param( + [ValidateSet('MultipleErrors')] + [string] $Command, + + [ValidateSet('ThrowTerminatingError', 'ErrorActionStop', 'ThrowException', 'WriteErrorAPI', + 'WriteErrorCmdlet', 'MethodInvocationThrowException', 'ExpressionThrowException')] + [string] $ErrorFromEndBlock, + + [ValidateSet('ThrowTerminatingError', 'ErrorActionStop', 'ThrowException', 'WriteErrorAPI', + 'WriteErrorCmdlet', 'MethodInvocationThrowException', 'ExpressionThrowException')] + [string] $ErrorFromCleanBlock, + + [ValidateSet('Continue', 'Ignore', 'SilentlyContinue', 'Stop')] + [string] $ErrorAction + ) + + $pwsh.Commands.Clear() + $pwsh.Streams.ClearStreams() + $pwsh.AddCommand($Command) > $null + $pwsh.AddParameter('ErrorFromEndBlock', $ErrorFromEndBlock) > $null + $pwsh.AddParameter('ErrorFromCleanBlock', $ErrorFromCleanBlock) > $null + if ($ErrorAction) { $pwsh.AddParameter('ErrorAction', $ErrorAction) > $null } + $pwsh.Invoke() + } + + function RunScript { + param([string] $Script) + + $pwsh.Commands.Clear() + $pwsh.Streams.ClearStreams() + $pwsh.AddScript($Script).Invoke() + } + + function GetLastError { + $pwsh.Commands.Clear() + $pwsh.AddCommand('Get-Error').Invoke() + } + + function GetAllErrors { + $pwsh.Commands.Clear() + $pwsh.AddScript('$Error').Invoke() + } + + function ClearDollarError { + $pwsh.Commands.Clear() + $pwsh.AddScript('$Error.Clear()').Invoke() + } + + #endregion + } + + AfterAll { + $pwsh.Dispose() + } + + It "Terminating errors from both 'End' (ThrowTerminatingError) and 'Clean' (ThrowTerminatingError) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowTerminatingError' -ErrorFromCleanBlock 'ThrowTerminatingError' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException | Should -BeOfType 'System.Management.Automation.CmdletInvocationException' + $failure.Exception.InnerException.InnerException | Should -BeOfType 'System.ArgumentException' + $failure.Exception.InnerException.InnerException.Message | Should -BeExactly 'end-terminating-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception | Should -BeOfType 'System.ArgumentException' + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'clean-terminating-exception' + } + + It "Terminating errors from both 'End' (ErrorActionStop) and 'Clean' (ThrowTerminatingError) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ErrorActionStop' -ErrorFromCleanBlock 'ThrowTerminatingError' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException | Should -BeOfType 'System.Management.Automation.ActionPreferenceStopException' + $failure.Exception.InnerException.Message | should -BeLike "*'NonExistEnd'*" + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception | Should -BeOfType 'System.ArgumentException' + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'clean-terminating-exception' + } + + It "Terminating errors from both 'End' (ThrowException) and 'Clean' (ThrowTerminatingError) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowException' -ErrorFromCleanBlock 'ThrowTerminatingError' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException.Message | should -BeExactly 'end-throw-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception | Should -BeOfType 'System.ArgumentException' + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'clean-terminating-exception' + } + + It "Terminating errors from both 'End' (ThrowTerminatingError) and 'Clean' (ErrorActionStop) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowTerminatingError' -ErrorFromCleanBlock 'ErrorActionStop' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException | Should -BeOfType 'System.Management.Automation.CmdletInvocationException' + $failure.Exception.InnerException.InnerException | Should -BeOfType 'System.ArgumentException' + $failure.Exception.InnerException.InnerException.Message | Should -BeExactly 'end-terminating-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].FullyQualifiedErrorId | Should -BeExactly 'CommandNotFoundException,Microsoft.PowerShell.Commands.GetCommandCommand' + $pwsh.Streams.Error[0].Exception.Message | Should -BeLike "*'NonExistClean'*" + } + + It "Terminating errors from both 'End' (ErrorActionStop) and 'Clean' (ErrorActionStop) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ErrorActionStop' -ErrorFromCleanBlock 'ErrorActionStop' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException | Should -BeOfType 'System.Management.Automation.ActionPreferenceStopException' + $failure.Exception.InnerException.Message | should -BeLike "*'NonExistEnd'*" + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].FullyQualifiedErrorId | Should -BeExactly 'CommandNotFoundException,Microsoft.PowerShell.Commands.GetCommandCommand' + $pwsh.Streams.Error[0].Exception.Message | Should -BeLike "*'NonExistClean'*" + } + + It "Terminating errors from both 'End' (ThrowException) and 'Clean' (ErrorActionStop) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowException' -ErrorFromCleanBlock 'ErrorActionStop' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException.Message | should -BeExactly 'end-throw-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].FullyQualifiedErrorId | Should -BeExactly 'CommandNotFoundException,Microsoft.PowerShell.Commands.GetCommandCommand' + $pwsh.Streams.Error[0].Exception.Message | Should -BeLike "*'NonExistClean'*" + } + + It "Terminating errors from both 'End' (ThrowTerminatingError) and 'Clean' (ThrowException) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowTerminatingError' -ErrorFromCleanBlock 'ThrowException' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException | Should -BeOfType 'System.Management.Automation.CmdletInvocationException' + $failure.Exception.InnerException.InnerException | Should -BeOfType 'System.ArgumentException' + $failure.Exception.InnerException.InnerException.Message | Should -BeExactly 'end-terminating-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'clean-throw-exception' + } + + It "Terminating errors from both 'End' (ErrorActionStop) and 'Clean' (ThrowException) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ErrorActionStop' -ErrorFromCleanBlock 'ThrowException' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException | Should -BeOfType 'System.Management.Automation.ActionPreferenceStopException' + $failure.Exception.InnerException.Message | should -BeLike "*'NonExistEnd'*" + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'clean-throw-exception' + } + + It "Terminating errors from both 'End' (ThrowException) and 'Clean' (ThrowException) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowException' -ErrorFromCleanBlock 'ThrowException' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException.Message | should -BeExactly 'end-throw-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'clean-throw-exception' + } + + It "Terminating errors from both 'End' (ThrowException) and 'Clean' (ThrowException) with ErrorAction '' should work properly" -TestCases @( + @{ ErrorAction = 'Ignore' } + @{ ErrorAction = 'SilentlyContinue' } + ) { + param($ErrorAction) + + ClearDollarError + + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowException' -ErrorFromCleanBlock 'ThrowException' -ErrorAction $ErrorAction + + $pwsh.Streams.Error.Count | Should -Be 0 + $pwsh.Streams.Verbose.Count | Should -Be 2 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + $pwsh.Streams.Verbose[1] | Should -BeExactly 'clean-verbose-message' + + $ers = GetAllErrors + $ers.Count | Should -Be 2 + $ers[0].Exception.Message | Should -BeExactly 'clean-throw-exception' + $ers[1].Exception.Message | Should -BeExactly 'end-throw-exception' + } + + It "Non-terminating error from 'End' (WriteErrorAPI) and terminating error from 'Clean' (ThrowTerminatingError) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorAPI' -ErrorFromCleanBlock 'ThrowTerminatingError' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-arg-exception' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-terminating-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (WriteErrorAPI) and terminating error from 'Clean' (ErrorActionStop) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorAPI' -ErrorFromCleanBlock 'ErrorActionStop' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-arg-exception' + $pwsh.Streams.Error[1].Exception.Message | Should -BeLike "*'NonExistClean'*" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (WriteErrorAPI) and terminating error from 'Clean' (ThrowException) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorAPI' -ErrorFromCleanBlock 'ThrowException' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-arg-exception' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-throw-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (WriteErrorCmdlet) and terminating error from 'Clean' (ThrowTerminatingError) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorCmdlet' -ErrorFromCleanBlock 'ThrowTerminatingError' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-write-error-cmdlet' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-terminating-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (WriteErrorCmdlet) and terminating error from 'Clean' (ErrorActionStop) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorCmdlet' -ErrorFromCleanBlock 'ErrorActionStop' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-write-error-cmdlet' + $pwsh.Streams.Error[1].Exception.Message | Should -BeLike "*'NonExistClean'*" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (WriteErrorCmdlet) and terminating error from 'Clean' (ThrowException) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorCmdlet' -ErrorFromCleanBlock 'ThrowException' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-write-error-cmdlet' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-throw-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (MethodInvocationThrowException) and terminating error from 'Clean' (ThrowTerminatingError) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'MethodInvocationThrowException' -ErrorFromCleanBlock 'ThrowTerminatingError' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-terminating-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (MethodInvocationThrowException) and terminating error from 'Clean' (ErrorActionStop) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'MethodInvocationThrowException' -ErrorFromCleanBlock 'ErrorActionStop' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + $pwsh.Streams.Error[1].Exception.Message | Should -BeLike "*'NonExistClean'*" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (MethodInvocationThrowException) and terminating error from 'Clean' (ThrowException) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'MethodInvocationThrowException' -ErrorFromCleanBlock 'ThrowException' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-throw-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (ExpressionThrowException) and terminating error from 'Clean' (ThrowTerminatingError) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ExpressionThrowException' -ErrorFromCleanBlock 'ThrowTerminatingError' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-terminating-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (ExpressionThrowException) and terminating error from 'Clean' (ErrorActionStop) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ExpressionThrowException' -ErrorFromCleanBlock 'ErrorActionStop' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + $pwsh.Streams.Error[1].Exception.Message | Should -BeLike "*'NonExistClean'*" + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Non-terminating error from 'End' (ExpressionThrowException) and terminating error from 'Clean' (ThrowException) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ExpressionThrowException' -ErrorFromCleanBlock 'ThrowException' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-throw-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'end-verbose-message' + } + + It "Terminating error from 'End' (ThrowException) and non-terminating error from 'Clean' (WriteErrorAPI) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowException' -ErrorFromCleanBlock 'WriteErrorAPI' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException.Message | should -BeExactly 'end-throw-exception' + + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'clean-arg-exception' + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'clean-verbose-message' + } + + It "Terminating error from 'End' (ThrowException) and non-terminating error from 'Clean' (WriteErrorCmdlet) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowException' -ErrorFromCleanBlock 'WriteErrorCmdlet' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException.Message | should -BeExactly 'end-throw-exception' + + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'clean-write-error-cmdlet' + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'clean-verbose-message' + } + + It "Terminating error from 'End' (ThrowException) and non-terminating error from 'Clean' (MethodInvocationThrowException) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowException' -ErrorFromCleanBlock 'MethodInvocationThrowException' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException.Message | should -BeExactly 'end-throw-exception' + + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'clean-verbose-message' + } + + It "Terminating error from 'End' (ThrowException) and non-terminating error from 'Clean' (ExpressionThrowException) should work properly" { + $failure = $null + try { + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'ThrowException' -ErrorFromCleanBlock 'ExpressionThrowException' + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException.Message | should -BeExactly 'end-throw-exception' + + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Verbose[0] | Should -BeExactly 'clean-verbose-message' + } + + It "Non-terminating error from 'End' (WriteErrorAPI) and non-terminating error from 'Clean' (WriteErrorCmdlet) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorAPI' -ErrorFromCleanBlock 'WriteErrorCmdlet' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-arg-exception' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-write-error-cmdlet' + + $pwsh.Streams.Verbose.Count | Should -Be 2 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'end-verbose-message' + $pwsh.Streams.Verbose[1].Message | Should -BeExactly 'clean-verbose-message' + } + + It "Non-terminating error from 'End' (WriteErrorAPI) and non-terminating error from 'Clean' (MethodInvocationThrowException) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorAPI' -ErrorFromCleanBlock 'MethodInvocationThrowException' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-arg-exception' + $pwsh.Streams.Error[1].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + + $pwsh.Streams.Verbose.Count | Should -Be 2 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'end-verbose-message' + $pwsh.Streams.Verbose[1].Message | Should -BeExactly 'clean-verbose-message' + } + + It "Non-terminating error from 'End' (WriteErrorCmdlet) and non-terminating error from 'Clean' (WriteErrorAPI) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorCmdlet' -ErrorFromCleanBlock 'WriteErrorAPI' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-write-error-cmdlet' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-arg-exception' + + $pwsh.Streams.Verbose.Count | Should -Be 2 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'end-verbose-message' + $pwsh.Streams.Verbose[1].Message | Should -BeExactly 'clean-verbose-message' + } + + It "Non-terminating error from 'End' (WriteErrorCmdlet) and non-terminating error from 'Clean' (ExpressionThrowException) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'WriteErrorCmdlet' -ErrorFromCleanBlock 'ExpressionThrowException' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'end-write-error-cmdlet' + $pwsh.Streams.Error[1].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + + $pwsh.Streams.Verbose.Count | Should -Be 2 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'end-verbose-message' + $pwsh.Streams.Verbose[1].Message | Should -BeExactly 'clean-verbose-message' + } + + It "Non-terminating error from 'End' (MethodInvocationThrowException) and non-terminating error from 'Clean' (WriteErrorCmdlet) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'MethodInvocationThrowException' -ErrorFromCleanBlock 'WriteErrorCmdlet' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'clean-write-error-cmdlet' + + $pwsh.Streams.Verbose.Count | Should -Be 2 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'end-verbose-message' + $pwsh.Streams.Verbose[1].Message | Should -BeExactly 'clean-verbose-message' + } + + It "Non-terminating error from 'End' (MethodInvocationThrowException) and non-terminating error from 'Clean' (ExpressionThrowException) should work properly" { + ## No exception should be thrown + RunCommand -Command 'MultipleErrors' -ErrorFromEndBlock 'MethodInvocationThrowException' -ErrorFromCleanBlock 'ExpressionThrowException' + + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.InnerException | Should -BeOfType 'System.ArgumentNullException' + $pwsh.Streams.Error[1].Exception.InnerException | Should -BeOfType 'System.DivideByZeroException' + + $pwsh.Streams.Verbose.Count | Should -Be 2 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'end-verbose-message' + $pwsh.Streams.Verbose[1].Message | Should -BeExactly 'clean-verbose-message' + } +} + +Describe "Error handling within a pipeline (multiple commands with 'Clean' block)" -Tag 'CI' { + BeforeAll { + function test1 { + param([switch] $EmitErrorInProcess) + process { + if ($EmitErrorInProcess) { + throw 'test1-process' + } + Write-Output 'process-obj' + } + clean { + throw 'test1-clean' + Write-Verbose -Verbose 'test1-clean-verbose' + } + } + + function test2 { + param([switch] $EmitErrorInProcess) + process { + if ($EmitErrorInProcess) { + throw 'test2-process' + } + Write-Verbose -Verbose $_ + } + clean { + throw 'test2-clean' + Write-Verbose -Verbose 'test2-clean-verbose' + } + } + + function test-1 { + param([switch] $EmitException) + process { 'output' } + clean { + if ($EmitException) { + throw 'test-1-clean-exception' + } + Write-Verbose -Verbose 'test-1-clean' + } + } + + function test-2 { + param([switch] $EmitException) + process { $_ } + clean { + if ($EmitException) { + throw 'test-2-clean-exception' + } + Write-Verbose -Verbose 'test-2-clean' + } + } + + function test-3 { + param([switch] $EmitException) + process { Write-Warning $_ } + clean { + if ($EmitException) { + throw 'test-3-clean-exception' + } + Write-Verbose -Verbose 'test-3-clean' + } + } + + #region Helper + + $pwsh = [PowerShell]::Create() + $text = (Get-Command test1, test2, test-1, test-2, test-3).ScriptBlock.Ast.Extent.Text + $pwsh.AddScript($text -join "`n").Invoke() + + function RunScript { + param([string] $Script) + + $pwsh.Commands.Clear() + $pwsh.Streams.ClearStreams() + $pwsh.AddScript($Script).Invoke() + } + + #endregion + } + + It "Errors from multiple 'Clean' blocks should work properly" { + ## No exception should be thrown + RunScript -Script "test1 | test2" + + ## Exceptions thrown from 'throw' statement are not propagated up, but instead written to the error stream. + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'test1-clean' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'test2-clean' + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'process-obj' + } + + It "ErrorAction should be honored by 'Clean' blocks" { + try { + RunScript -Script '$ErrorActionPreference = "SilentlyContinue"' + ## No exception should be thrown. + RunScript -Script "test1 | test2" + + ## The exception from 'throw' statement should be suppressed by 'SilentlyContinue'. + $pwsh.Streams.Error.Count | Should -Be 0 + $pwsh.Streams.Verbose.Count | Should -Be 3 + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'process-obj' + $pwsh.Streams.Verbose[1].Message | Should -BeExactly 'test1-clean-verbose' + $pwsh.Streams.Verbose[2].Message | Should -BeExactly 'test2-clean-verbose' + } + finally { + ## Revert error action back to 'Continue' + RunScript -Script '$ErrorActionPreference = "Continue"' + } + } + + It "Errors from 'Clean' blocks should work properly when another named block emits error" { + $failure = $null + try { + RunScript -Script "test1 | test2 -EmitErrorInProcess" + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException.Message | Should -BeExactly 'test2-process' + + $pwsh.Streams.Verbose.Count | Should -Be 0 + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'test1-clean' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'test2-clean' + } + + It "The 'Clean' block should not run when the none of other named blocks from the same command get to run" { + $failure = $null + try { + RunScript -Script "test1 -EmitErrorInProcess | test2" + } catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType 'System.Management.Automation.MethodInvocationException' + $failure.Exception.InnerException.Message | Should -BeExactly 'test1-process' + + ## Only the 'Clean' block from 'test1' will run. + ## The 'Clean' block from 'test2' won't run because none of the other blocks from 'test2' gets to run + ## due to the terminating exception thrown from 'test1.Process'. + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'test1-clean' + } + + It "Exception from the 'Clean' block at should not affect other 'Clean' blocks" -TestCases @( + @{ Position = 'beginning-of-pipeline'; Script = 'test-1 -EmitException | test-2 | test-3'; ExceptionMessage = 'test-1-clean-exception'; VerboseMessages = @('test-2-clean', 'test-3-clean') } + @{ Position = 'middle-of-pipeline'; Script = 'test-1 | test-2 -EmitException | test-3'; ExceptionMessage = 'test-2-clean-exception'; VerboseMessages = @('test-1-clean', 'test-3-clean') } + @{ Position = 'end-of-pipeline'; Script = 'test-1 | test-2 | test-3 -EmitException'; ExceptionMessage = 'test-3-clean-exception'; VerboseMessages = @('test-1-clean', 'test-2-clean') } + ) { + param($Script, $ExceptionMessage, $VerboseMessages) + + RunScript -Script $Script + $pwsh.Streams.Error.Count | Should -Be 1 + $pwsh.Streams.Verbose.Count | Should -Be 2 + $pwsh.Streams.Warning.Count | Should -Be 1 + + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly $ExceptionMessage + $pwsh.Streams.Verbose[0].Message | Should -BeExactly $VerboseMessages[0] + $pwsh.Streams.Verbose[1].Message | Should -BeExactly $VerboseMessages[1] + $pwsh.Streams.Warning[0].Message | Should -BeExactly 'output' + } + + It "Multiple exceptions from 'Clean' blocks should not affect other 'Clean' blocks" { + RunScript -Script 'test-1 -EmitException | test-2 | test-3 -EmitException' + $pwsh.Streams.Error.Count | Should -Be 2 + $pwsh.Streams.Verbose.Count | Should -Be 1 + $pwsh.Streams.Warning.Count | Should -Be 1 + + $pwsh.Streams.Error[0].Exception.Message | Should -BeExactly 'test-1-clean-exception' + $pwsh.Streams.Error[1].Exception.Message | Should -BeExactly 'test-3-clean-exception' + $pwsh.Streams.Verbose[0].Message | Should -BeExactly 'test-2-clean' + $pwsh.Streams.Warning[0].Message | Should -BeExactly 'output' + } +} diff --git a/test/powershell/Language/Scripting/PipelineBehaviour.Tests.ps1 b/test/powershell/Language/Scripting/PipelineBehaviour.Tests.ps1 new file mode 100644 index 000000000000..9551343da85c --- /dev/null +++ b/test/powershell/Language/Scripting/PipelineBehaviour.Tests.ps1 @@ -0,0 +1,591 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Function Pipeline Behaviour' -Tag 'CI' { + + BeforeAll { + $filePath = "$TestDrive\output.txt" + if (Test-Path $filePath) { + Remove-Item $filePath -Force + } + } + + Context "'Clean' block runs when any other named blocks run" { + + AfterEach { + if (Test-Path $filePath) { + Remove-Item $filePath -Force + } + } + + It "'Clean' block executes only if at least one of the other named blocks executed" { + ## The 'Clean' block is for cleanup purpose. When none of other named blocks execute, + ## there is no point to execute the 'Clean' block, so it will be skipped in this case. + function test-1 { + clean { 'clean-redirected-output' > $filePath } + } + + function test-2 { + End { 'end' } + clean { 'clean-redirected-output' > $filePath } + } + + ## The 'Clean' block is skipped. + test-1 | Should -BeNullOrEmpty + Test-Path -Path $filePath | Should -BeFalse + + ## The 'Clean' block runs. + test-2 | Should -BeExactly 'end' + Test-Path -Path $filePath | Should -BeTrue + Get-Content $filePath | Should -BeExactly 'clean-redirected-output' + } + + It "'Clean' block is skipped when the command doesn't run due to no input from upstream command" { + function test-1 ([switch] $WriteOutput) { + Process { + if ($WriteOutput) { + Write-Output 'process' + } else { + Write-Verbose -Verbose 'process' + } + } + } + + function test-2 { + Process { Write-Output "test-2: $_" } + clean { Write-Warning 'test-2-clean-warning' } + } + + ## No output from 'test-1.Process', so 'test-2.Process' didn't run, and thus 'test-2.Clean' was skipped. + test-1 | test-2 *>&1 | Should -BeNullOrEmpty + + ## Output from 'test-1.Process' would trigger 'test-2.Process' to run, and thus 'test-2.Clean' would run. + $output = test-1 -WriteOutput | test-2 *>&1 + $output | Should -Be @('test-2: process', 'test-2-clean-warning') + } + + It "'Clean' block is skipped when the command doesn't run due to terminating error from upstream Process block" { + function test-1 ([switch] $ThrowException) { + Process { + if ($ThrowException) { + throw 'process' + } else { + Write-Output 'process' + } + } + } + + function test-2 { + Process { Write-Output "test-2: $_" } + clean { 'clean-redirected-output' > $filePath } + } + + $failure = $null + try { test-1 -ThrowException | test-2 } catch { $failure = $_ } + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -BeExactly 'process' + ## 'test-2' didn't run because 'test-1' throws terminating exception, so 'test-2.Clean' didn't run either. + Test-Path -Path $filePath | Should -BeFalse + + test-1 | test-2 | Should -BeExactly 'test-2: process' + Test-Path -Path $filePath | Should -BeTrue + Get-Content $filePath | Should -BeExactly 'clean-redirected-output' + } + + It "'Clean' block is skipped when the command doesn't run due to terminating error from upstream Begin block" { + function test-1 { + Begin { throw 'begin' } + End { 'end' } + } + + function test-2 { + Begin { 'begin' } + Process { Write-Output "test-2: $_" } + clean { 'clean-redirected-output' > $filePath } + } + + $failure = $null + try { test-1 | test-2 } catch { $failure = $_ } + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -BeExactly 'begin' + ## 'test-2' didn't run because 'test-1' throws terminating exception, so 'test-2.Clean' didn't run either. + Test-Path -Path $filePath | Should -BeFalse + } + + It "'Clean' block runs when '' runs" -TestCases @( + @{ Script = { [CmdletBinding()]param() begin { 'output' } clean { Write-Warning 'clean-warning' } }; BlockName = 'Begin' } + @{ Script = { [CmdletBinding()]param() process { 'output' } clean { Write-Warning 'clean-warning' } }; BlockName = 'Process' } + @{ Script = { [CmdletBinding()]param() end { 'output' } clean { Write-Warning 'clean-warning' } }; BlockName = 'End' } + ) { + param($Script, $BlockName) + + & $Script -WarningVariable wv | Should -BeExactly 'output' + $wv | Should -BeExactly 'clean-warning' + } + + It "'Clean' block runs when '' throws terminating error" -TestCases @( + @{ Script = { [CmdletBinding()]param() begin { throw 'failure' } clean { Write-Warning 'clean-warning' } }; BlockName = 'Begin' } + @{ Script = { [CmdletBinding()]param() process { throw 'failure' } clean { Write-Warning 'clean-warning' } }; BlockName = 'Process' } + @{ Script = { [CmdletBinding()]param() end { throw 'failure' } clean { Write-Warning 'clean-warning' } }; BlockName = 'End' } + ) { + param($Script, $BlockName) + + $failure = $null + try { & $Script -WarningVariable wv } catch { $failure = $_ } + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -BeExactly 'failure' + $wv | Should -BeExactly 'clean-warning' + } + + It "'Clean' block runs in pipeline - simple function" { + function test-1 { + param([switch] $EmitError) + process { + if ($EmitError) { + throw 'test-1-process-error' + } else { + Write-Output 'test-1' + } + } + + clean { 'test-1-clean' >> $filePath } + } + + function test-2 { + begin { Write-Verbose -Verbose 'test-2-begin' } + process { $_ } + clean { 'test-2-clean' >> $filePath } + } + + function test-3 { + end { Write-Verbose -Verbose 'test-3-end' } + clean { 'test-3-clean' >> $filePath } + } + + ## All command will run, so all 'Clean' blocks will run + test-1 | test-2 | test-3 + Test-Path $filePath | Should -BeTrue + $content = Get-Content $filePath + $content | Should -Be @('test-1-clean', 'test-2-clean', 'test-3-clean') + + $failure = $null + Remove-Item $filePath -Force + try { + test-1 -EmitError | test-2 | test-3 + } catch { + $failure = $_ + } + + ## Exception is thrown from 'test-1.Process'. By that time, the 'test-2.Begin' has run, + ## so 'test-2.Clean' will run. However, 'test-3.End' won't run, so 'test-3.Clean' won't run. + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -BeExactly 'test-1-process-error' + Test-Path $filePath | Should -BeTrue + $content = Get-Content $filePath + $content | Should -Be @('test-1-clean', 'test-2-clean') + } + + It "'Clean' block runs in pipeline - advanced function" { + function test-1 { + [CmdletBinding()] + param([switch] $EmitError) + process { + if ($EmitError) { + throw 'test-1-process-error' + } else { + Write-Output 'test-1' + } + } + + clean { 'test-1-clean' >> $filePath } + } + + function test-2 { + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline)] + $pipeInput + ) + + begin { Write-Verbose -Verbose 'test-2-begin' } + process { $pipeInput } + clean { 'test-2-clean' >> $filePath } + } + + function test-3 { + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline)] + $pipeInput + ) + + end { Write-Verbose -Verbose 'test-3-end' } + clean { 'test-3-clean' >> $filePath } + } + + ## All command will run, so all 'Clean' blocks will run + test-1 | test-2 | test-3 + Test-Path $filePath | Should -BeTrue + $content = Get-Content $filePath + $content | Should -Be @('test-1-clean', 'test-2-clean', 'test-3-clean') + + + $failure = $null + Remove-Item $filePath -Force + ## Exception will be thrown from 'test-1.Process'. By that time, the 'test-2.Begin' has run, + ## so 'test-2.Clean' will run. However, 'test-3.End' won't run, so 'test-3.Clean' won't run. + try { + test-1 -EmitError | test-2 | test-3 + } catch { + $failure = $_ + } + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -BeExactly 'test-1-process-error' + Test-Path $filePath | Should -BeTrue + $content = Get-Content $filePath + $content | Should -Be @('test-1-clean', 'test-2-clean') + } + + It 'does not execute End {} if the pipeline is halted during Process {}' { + # We don't need Should -Not -Throw as if this reaches end{} and throws the test will fail anyway. + 1..10 | + & { + begin { "BEGIN" } + process { "PROCESS $_" } + end { "END"; throw "This should not be reached." } + } | + Select-Object -First 3 | + Should -Be @( "BEGIN", "PROCESS 1", "PROCESS 2" ) + } + + It "still executes 'Clean' block if the pipeline is halted" { + 1..10 | + & { + process { $_ } + clean { "Clean block hit" > $filePath } + } | + Select-Object -First 1 | + Should -Be 1 + + Test-Path $filePath | Should -BeTrue + Get-Content $filePath | Should -BeExactly 'Clean block hit' + } + + It "Select-Object in pipeline" { + function bar { + process { 'bar_' + $_ } end { 'bar_end' } clean { 'bar_clean' > $filePath } + } + + function zoo { + process { 'zoo_' + $_ } end { 'zoo_end' } clean { 'zoo_clean' >> $filePath } + } + + 1..10 | bar | Select-Object -First 2 | zoo | Should -Be @('zoo_bar_1', 'zoo_bar_2', 'zoo_end') + Test-Path $filePath | Should -BeTrue + $content = Get-Content $filePath + $content | Should -Be @('bar_clean', 'zoo_clean') + } + } + + Context 'Streams from Named Blocks' { + + It 'Permits output from named block: