diff --git a/PowerShellEditorServices.build.ps1 b/PowerShellEditorServices.build.ps1 index 474457b97..2a4cff2eb 100644 --- a/PowerShellEditorServices.build.ps1 +++ b/PowerShellEditorServices.build.ps1 @@ -202,6 +202,11 @@ task TestE2E Build, SetupHelpForTests, { # Run E2E tests in ConstrainedLanguage mode. if (!$script:IsNix) { + if (-not [Security.Principal.WindowsIdentity]::GetCurrent().Owner.IsWellKnown("BuiltInAdministratorsSid")) { + Write-Warning 'Skipping E2E CLM tests as they must be ran in an elevated process.' + return + } + try { [System.Environment]::SetEnvironmentVariable("__PSLockdownPolicy", "0x80000007", [System.EnvironmentVariableTarget]::Machine); exec { & dotnet $script:dotnetTestArgs $script:NetRuntime.PS7 } diff --git a/src/PowerShellEditorServices/Server/PsesDebugServer.cs b/src/PowerShellEditorServices/Server/PsesDebugServer.cs index c058f9460..789c727b4 100644 --- a/src/PowerShellEditorServices/Server/PsesDebugServer.cs +++ b/src/PowerShellEditorServices/Server/PsesDebugServer.cs @@ -121,7 +121,7 @@ public void Dispose() // It represents the debugger on the PowerShell process we're in, // while a new debug server is spun up for every debugging session _psesHost.DebugContext.IsDebugServerActive = false; - _debugAdapterServer.Dispose(); + _debugAdapterServer?.Dispose(); _inputStream.Dispose(); _outputStream.Dispose(); _loggerFactory.Dispose(); diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs index 3b7e75ec6..4f806ce39 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -12,6 +12,7 @@ using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; using Microsoft.PowerShell.EditorServices.Services.PowerShell; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; namespace Microsoft.PowerShell.EditorServices.Services { @@ -43,6 +44,7 @@ public async Task> GetBreakpointsAsync() { if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace)) { + _editorServicesHost.Runspace.ThrowCancelledIfUnusable(); return BreakpointApiUtils.GetBreakpoints( _editorServicesHost.Runspace.Debugger, _debugStateService.RunspaceId); diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index e0854cb2b..5d32451ed 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -16,6 +16,7 @@ using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; using Microsoft.PowerShell.EditorServices.Services.TextDocument; using Microsoft.PowerShell.EditorServices.Utility; @@ -74,6 +75,15 @@ internal class DebugService /// public DebuggerStoppedEventArgs CurrentDebuggerStoppedEventArgs { get; private set; } + /// + /// Tracks whether we are running Debug-Runspace in an out-of-process runspace. + /// + public bool IsDebuggingRemoteRunspace + { + get => _debugContext.IsDebuggingRemoteRunspace; + set => _debugContext.IsDebuggingRemoteRunspace = value; + } + #endregion #region Constructors @@ -128,6 +138,8 @@ public async Task SetLineBreakpointsAsync( DscBreakpointCapability dscBreakpoints = await _debugContext.GetDscBreakpointCapabilityAsync(CancellationToken.None).ConfigureAwait(false); string scriptPath = scriptFile.FilePath; + + _psesHost.Runspace.ThrowCancelledIfUnusable(); // Make sure we're using the remote script path if (_psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null) { @@ -771,22 +783,23 @@ private async Task FetchStackFramesAsync(string scriptNameOverride) const string callStackVarName = $"$global:{PsesGlobalVariableNamePrefix}CallStack"; const string getPSCallStack = $"Get-PSCallStack | ForEach-Object {{ [void]{callStackVarName}.Add(@($PSItem, $PSItem.GetFrameVariables())) }}"; + _psesHost.Runspace.ThrowCancelledIfUnusable(); // If we're attached to a remote runspace, we need to serialize the list prior to // transport because the default depth is too shallow. From testing, we determined the - // correct depth is 3. The script always calls `Get-PSCallStack`. On a local machine, we - // just return its results. On a remote machine we serialize it first and then later + // correct depth is 3. The script always calls `Get-PSCallStack`. In a local runspace, we + // just return its results. In a remote runspace we serialize it first and then later // deserialize it. - bool isOnRemoteMachine = _psesHost.CurrentRunspace.IsOnRemoteMachine; - string returnSerializedIfOnRemoteMachine = isOnRemoteMachine + bool isRemoteRunspace = _psesHost.CurrentRunspace.Runspace.RunspaceIsRemote; + string returnSerializedIfInRemoteRunspace = isRemoteRunspace ? $"[Management.Automation.PSSerializer]::Serialize({callStackVarName}, 3)" : callStackVarName; // PSObject is used here instead of the specific type because we get deserialized // objects from remote sessions and want a common interface. - PSCommand psCommand = new PSCommand().AddScript($"[Collections.ArrayList]{callStackVarName} = @(); {getPSCallStack}; {returnSerializedIfOnRemoteMachine}"); + PSCommand psCommand = new PSCommand().AddScript($"[Collections.ArrayList]{callStackVarName} = @(); {getPSCallStack}; {returnSerializedIfInRemoteRunspace}"); IReadOnlyList results = await _executionService.ExecutePSCommandAsync(psCommand, CancellationToken.None).ConfigureAwait(false); - IEnumerable callStack = isOnRemoteMachine + IEnumerable callStack = isRemoteRunspace ? (PSSerializer.Deserialize(results[0].BaseObject as string) as PSObject)?.BaseObject as IList : results; @@ -797,7 +810,7 @@ private async Task FetchStackFramesAsync(string scriptNameOverride) // We have to use reflection to get the variable dictionary. IList callStackFrameComponents = (callStackFrameItem as PSObject)?.BaseObject as IList; PSObject callStackFrame = callStackFrameComponents[0] as PSObject; - IDictionary callStackVariables = isOnRemoteMachine + IDictionary callStackVariables = isRemoteRunspace ? (callStackFrameComponents[1] as PSObject)?.BaseObject as IDictionary : callStackFrameComponents[1] as IDictionary; @@ -861,7 +874,7 @@ private async Task FetchStackFramesAsync(string scriptNameOverride) { stackFrameDetailsEntry.ScriptPath = scriptNameOverride; } - else if (isOnRemoteMachine + else if (_psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null && !string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath)) { @@ -905,83 +918,98 @@ private static string TrimScriptListingLine(PSObject scriptLineObj, ref int pref internal async void OnDebuggerStopAsync(object sender, DebuggerStopEventArgs e) { - bool noScriptName = false; - string localScriptPath = e.InvocationInfo.ScriptName; - - // If there's no ScriptName, get the "list" of the current source - if (_remoteFileManager is not null && string.IsNullOrEmpty(localScriptPath)) + try { - // Get the current script listing and create the buffer - PSCommand command = new PSCommand().AddScript($"list 1 {int.MaxValue}"); + bool noScriptName = false; + string localScriptPath = e.InvocationInfo.ScriptName; - IReadOnlyList scriptListingLines = - await _executionService.ExecutePSCommandAsync( - command, CancellationToken.None).ConfigureAwait(false); - - if (scriptListingLines is not null) + // If there's no ScriptName, get the "list" of the current source + if (_remoteFileManager is not null && string.IsNullOrEmpty(localScriptPath)) { - int linePrefixLength = 0; + // Get the current script listing and create the buffer + PSCommand command = new PSCommand().AddScript($"list 1 {int.MaxValue}"); - string scriptListing = - string.Join( - Environment.NewLine, - scriptListingLines - .Select(o => TrimScriptListingLine(o, ref linePrefixLength)) - .Where(s => s is not null)); + IReadOnlyList scriptListingLines = + await _executionService.ExecutePSCommandAsync( + command, CancellationToken.None).ConfigureAwait(false); - temporaryScriptListingPath = - _remoteFileManager.CreateTemporaryFile( - $"[{_psesHost.CurrentRunspace.SessionDetails.ComputerName}] {TemporaryScriptFileName}", - scriptListing, - _psesHost.CurrentRunspace); + if (scriptListingLines is not null) + { + int linePrefixLength = 0; + + string scriptListing = + string.Join( + Environment.NewLine, + scriptListingLines + .Select(o => TrimScriptListingLine(o, ref linePrefixLength)) + .Where(s => s is not null)); + + temporaryScriptListingPath = + _remoteFileManager.CreateTemporaryFile( + $"[{_psesHost.CurrentRunspace.SessionDetails.ComputerName}] {TemporaryScriptFileName}", + scriptListing, + _psesHost.CurrentRunspace); + + localScriptPath = + temporaryScriptListingPath + ?? StackFrameDetails.NoFileScriptPath; + + noScriptName = localScriptPath is not null; + } + else + { + _logger.LogWarning("Could not load script context"); + } + } - localScriptPath = - temporaryScriptListingPath - ?? StackFrameDetails.NoFileScriptPath; + // Get call stack and variables. + await FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null).ConfigureAwait(false); - noScriptName = localScriptPath is not null; + // If this is a remote connection and the debugger stopped at a line + // in a script file, get the file contents + if (_psesHost.CurrentRunspace.IsOnRemoteMachine + && _remoteFileManager is not null + && !noScriptName) + { + localScriptPath = + await _remoteFileManager.FetchRemoteFileAsync( + e.InvocationInfo.ScriptName, + _psesHost.CurrentRunspace).ConfigureAwait(false); } - else + + if (stackFrameDetails.Length > 0) { - _logger.LogWarning("Could not load script context"); + // Augment the top stack frame with details from the stop event + if (invocationTypeScriptPositionProperty.GetValue(e.InvocationInfo) is IScriptExtent scriptExtent) + { + stackFrameDetails[0].StartLineNumber = scriptExtent.StartLineNumber; + stackFrameDetails[0].EndLineNumber = scriptExtent.EndLineNumber; + stackFrameDetails[0].StartColumnNumber = scriptExtent.StartColumnNumber; + stackFrameDetails[0].EndColumnNumber = scriptExtent.EndColumnNumber; + } } - } - // Get call stack and variables. - await FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null).ConfigureAwait(false); + CurrentDebuggerStoppedEventArgs = + new DebuggerStoppedEventArgs( + e, + _psesHost.CurrentRunspace, + localScriptPath); - // If this is a remote connection and the debugger stopped at a line - // in a script file, get the file contents - if (_psesHost.CurrentRunspace.IsOnRemoteMachine - && _remoteFileManager is not null - && !noScriptName) + // Notify the host that the debugger is stopped. + DebuggerStopped?.Invoke(sender, CurrentDebuggerStoppedEventArgs); + } + catch (OperationCanceledException) { - localScriptPath = - await _remoteFileManager.FetchRemoteFileAsync( - e.InvocationInfo.ScriptName, - _psesHost.CurrentRunspace).ConfigureAwait(false); + // Ignore, likely means that a remote runspace has closed. } - - if (stackFrameDetails.Length > 0) + catch (Exception exception) { - // Augment the top stack frame with details from the stop event - if (invocationTypeScriptPositionProperty.GetValue(e.InvocationInfo) is IScriptExtent scriptExtent) - { - stackFrameDetails[0].StartLineNumber = scriptExtent.StartLineNumber; - stackFrameDetails[0].EndLineNumber = scriptExtent.EndLineNumber; - stackFrameDetails[0].StartColumnNumber = scriptExtent.StartColumnNumber; - stackFrameDetails[0].EndColumnNumber = scriptExtent.EndColumnNumber; - } + // Log in a catch all so we don't crash the process. + _logger.LogError( + exception, + "Error occurred while obtaining debug info. Message: {message}", + exception.Message); } - - CurrentDebuggerStoppedEventArgs = - new DebuggerStoppedEventArgs( - e, - _psesHost.CurrentRunspace, - localScriptPath); - - // Notify the host that the debugger is stopped. - DebuggerStopped?.Invoke(sender, CurrentDebuggerStoppedEventArgs); } private void OnDebuggerResuming(object sender, DebuggerResumingEventArgs debuggerResumingEventArgs) => CurrentDebuggerStoppedEventArgs = null; diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs index 6c3db9dab..2849fee4d 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Management.Automation; +using System.Management.Automation.Remoting; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -18,6 +19,7 @@ using Microsoft.PowerShell.EditorServices.Services.PowerShell; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Context; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; namespace Microsoft.PowerShell.EditorServices.Handlers { @@ -249,7 +251,11 @@ public async Task Handle(PsesAttachRequestArguments request, Can try { - await _executionService.ExecutePSCommandAsync(enterPSSessionCommand, cancellationToken).ConfigureAwait(false); + await _executionService.ExecutePSCommandAsync( + enterPSSessionCommand, + cancellationToken, + PowerShellExecutionOptions.ImmediateInteractive) + .ConfigureAwait(false); } catch (Exception e) { @@ -261,6 +267,19 @@ public async Task Handle(PsesAttachRequestArguments request, Can _debugStateService.IsRemoteAttach = true; } + // Set up a temporary runspace changed event handler so we can ensure + // that the context switch is complete before attempting to debug + // a runspace in the target. + TaskCompletionSource runspaceChanged = new(); + + void RunspaceChangedHandler(object s, RunspaceChangedEventArgs _) + { + ((IInternalPowerShellExecutionService)s).RunspaceChanged -= RunspaceChangedHandler; + runspaceChanged.TrySetResult(true); + } + + _executionService.RunspaceChanged += RunspaceChangedHandler; + if (processIdIsSet && int.TryParse(request.ProcessId, out int processId) && (processId > 0)) { if (runspaceVersion.Version.Major < 5) @@ -274,11 +293,15 @@ public async Task Handle(PsesAttachRequestArguments request, Can try { - await _executionService.ExecutePSCommandAsync(enterPSHostProcessCommand, cancellationToken).ConfigureAwait(false); + await _executionService.ExecutePSCommandAsync( + enterPSHostProcessCommand, + cancellationToken, + PowerShellExecutionOptions.ImmediateInteractive) + .ConfigureAwait(false); } catch (Exception e) { - string msg = $"Could not attach to process '{processId}'"; + string msg = $"Could not attach to process with Id: '{request.ProcessId}'"; _logger.LogError(e, msg); throw new RpcErrorException(0, msg); } @@ -296,7 +319,11 @@ public async Task Handle(PsesAttachRequestArguments request, Can try { - await _executionService.ExecutePSCommandAsync(enterPSHostProcessCommand, cancellationToken).ConfigureAwait(false); + await _executionService.ExecutePSCommandAsync( + enterPSHostProcessCommand, + cancellationToken, + PowerShellExecutionOptions.ImmediateInteractive) + .ConfigureAwait(false); } catch (Exception e) { @@ -313,6 +340,8 @@ public async Task Handle(PsesAttachRequestArguments request, Can throw new RpcErrorException(0, "A positive integer must be specified for the processId field."); } + await runspaceChanged.Task.ConfigureAwait(false); + // Execute the Debug-Runspace command but don't await it because it // will block the debug adapter initialization process. The // InitializedEvent will be sent as soon as the RunspaceChanged @@ -327,13 +356,27 @@ public async Task Handle(PsesAttachRequestArguments request, Can .AddCommand("Microsoft.PowerShell.Utility\\Select-Object") .AddParameter("ExpandProperty", "Id"); - IEnumerable ids = await _executionService.ExecutePSCommandAsync(getRunspaceIdCommand, cancellationToken).ConfigureAwait(false); - foreach (int? id in ids) + try { - _debugStateService.RunspaceId = id; - break; + IEnumerable ids = await _executionService.ExecutePSCommandAsync( + getRunspaceIdCommand, + cancellationToken) + .ConfigureAwait(false); + + foreach (int? id in ids) + { + _debugStateService.RunspaceId = id; + break; - // TODO: If we don't end up setting this, we should throw + // TODO: If we don't end up setting this, we should throw + } + } + catch (Exception getRunspaceException) + { + _logger.LogError( + getRunspaceException, + "Unable to determine runspace to attach to. Message: {message}", + getRunspaceException.Message); } // TODO: We have the ID, why not just use that? @@ -363,10 +406,11 @@ public async Task Handle(PsesAttachRequestArguments request, Can // Clear any existing breakpoints before proceeding await _breakpointService.RemoveAllBreakpointsAsync().ConfigureAwait(continueOnCapturedContext: false); + _debugService.IsDebuggingRemoteRunspace = true; _debugStateService.WaitingForAttach = true; Task nonAwaitedTask = _executionService - .ExecutePSCommandAsync(debugRunspaceCmd, CancellationToken.None) - .ContinueWith(OnExecutionCompletedAsync); + .ExecutePSCommandAsync(debugRunspaceCmd, CancellationToken.None, PowerShellExecutionOptions.ImmediateInteractive) + .ContinueWith( OnExecutionCompletedAsync, TaskScheduler.Default); if (runspaceVersion.Version.Major >= 7) { @@ -395,10 +439,15 @@ public async Task OnStarted(IDebugAdapterServer server, CancellationToken cancel private async Task OnExecutionCompletedAsync(Task executeTask) { + bool isRunspaceClosed = false; try { await executeTask.ConfigureAwait(false); } + catch (PSRemotingTransportException) + { + isRunspaceClosed = true; + } catch (Exception e) { _logger.LogError( @@ -411,14 +460,20 @@ private async Task OnExecutionCompletedAsync(Task executeTask) _debugEventHandlerService.UnregisterEventHandlers(); - if (_debugStateService.IsAttachSession) + _debugService.IsDebuggingRemoteRunspace = false; + + if (!isRunspaceClosed && _debugStateService.IsAttachSession) { // Pop the sessions if (_runspaceContext.CurrentRunspace.RunspaceOrigin == RunspaceOrigin.EnteredProcess) { try { - await _executionService.ExecutePSCommandAsync(new PSCommand().AddCommand("Exit-PSHostProcess"), CancellationToken.None).ConfigureAwait(false); + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddCommand("Exit-PSHostProcess"), + CancellationToken.None, + PowerShellExecutionOptions.ImmediateInteractive) + .ConfigureAwait(false); if (_debugStateService.IsRemoteAttach) { diff --git a/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellContextFrame.cs b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellContextFrame.cs index b3007a0e7..2db852c6e 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellContextFrame.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellContextFrame.cs @@ -1,13 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; +using System.Diagnostics; +using System.Text; using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; -using System; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; using SMA = System.Management.Automation; namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Context { + [DebuggerDisplay("{ToDebuggerDisplayString()}")] internal class PowerShellContextFrame : IDisposable { public static PowerShellContextFrame CreateForPowerShellInstance( @@ -35,13 +39,34 @@ public PowerShellContextFrame(SMA.PowerShell powerShell, RunspaceInfo runspaceIn public PowerShellFrameType FrameType { get; } + public bool IsRepl => (FrameType & PowerShellFrameType.Repl) is not 0; + + public bool IsRemote => (FrameType & PowerShellFrameType.Remote) is not 0; + + public bool IsNested => (FrameType & PowerShellFrameType.Nested) is not 0; + + public bool IsDebug => (FrameType & PowerShellFrameType.Debug) is not 0; + + public bool IsAwaitingPop { get; set; } + + public bool SessionExiting { get; set; } + protected virtual void Dispose(bool disposing) { if (!disposedValue) { if (disposing) { - PowerShell.Dispose(); + // When runspace is popping from `Exit-PSHostProcess` or similar, attempting + // to dispose directly in the same frame would dead lock. + if (SessionExiting) + { + PowerShell.DisposeWhenCompleted(); + } + else + { + PowerShell.Dispose(); + } } disposedValue = true; @@ -54,5 +79,40 @@ public void Dispose() Dispose(disposing: true); GC.SuppressFinalize(this); } + +#if DEBUG + private string ToDebuggerDisplayString() + { + StringBuilder text = new(); + + if ((FrameType & PowerShellFrameType.Nested) is not 0) + { + text.Append("Ne-"); + } + + if ((FrameType & PowerShellFrameType.Debug) is not 0) + { + text.Append("De-"); + } + + if ((FrameType & PowerShellFrameType.Remote) is not 0) + { + text.Append("Rem-"); + } + + if ((FrameType & PowerShellFrameType.NonInteractive) is not 0) + { + text.Append("NI-"); + } + + if ((FrameType & PowerShellFrameType.Repl) is not 0) + { + text.Append("Repl-"); + } + + text.Append(PowerShellDebugDisplay.ToDebuggerString(PowerShell)); + return text.ToString(); + } +#endif } } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellFrameType.cs b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellFrameType.cs index cb20ff8ff..9bf2fca02 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellFrameType.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Context/PowerShellFrameType.cs @@ -8,10 +8,11 @@ namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Context [Flags] internal enum PowerShellFrameType { - Normal = 0x0, - Nested = 0x1, - Debug = 0x2, - Remote = 0x4, - NonInteractive = 0x8, + Normal = 0 << 0, + Nested = 1 << 0, + Debug = 1 << 1, + Remote = 1 << 2, + NonInteractive = 1 << 3, + Repl = 1 << 4, } } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Debugging/IPowerShellDebugContext.cs b/src/PowerShellEditorServices/Services/PowerShell/Debugging/IPowerShellDebugContext.cs index bf78d80b1..173e5992b 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Debugging/IPowerShellDebugContext.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Debugging/IPowerShellDebugContext.cs @@ -14,6 +14,8 @@ internal interface IPowerShellDebugContext DebuggerStopEventArgs LastStopEventArgs { get; } + public bool IsDebuggingRemoteRunspace { get; set; } + public event Action DebuggerStopped; public event Action DebuggerResuming; diff --git a/src/PowerShellEditorServices/Services/PowerShell/Debugging/PowerShellDebugContext.cs b/src/PowerShellEditorServices/Services/PowerShell/Debugging/PowerShellDebugContext.cs index 60a858a4c..878caf18d 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Debugging/PowerShellDebugContext.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Debugging/PowerShellDebugContext.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging { @@ -68,19 +69,32 @@ public PowerShellDebugContext( /// public bool IsDebugServerActive { get; set; } + /// + /// Tracks whether we are running Debug-Runspace in an out-of-process runspace. + /// + public bool IsDebuggingRemoteRunspace { get; set; } + public DebuggerStopEventArgs LastStopEventArgs { get; private set; } public event Action DebuggerStopped; public event Action DebuggerResuming; public event Action BreakpointUpdated; - public Task GetDscBreakpointCapabilityAsync(CancellationToken cancellationToken) => _psesHost.CurrentRunspace.GetDscBreakpointCapabilityAsync(_logger, _psesHost, cancellationToken); + public Task GetDscBreakpointCapabilityAsync(CancellationToken cancellationToken) + { + _psesHost.Runspace.ThrowCancelledIfUnusable(); + return _psesHost.CurrentRunspace.GetDscBreakpointCapabilityAsync(_logger, _psesHost, cancellationToken); + } // This is required by the PowerShell API so that remote debugging works. Without it, a // runspace may not have these options set and attempting to set breakpoints remotely fails. - public void EnableDebugMode() => _psesHost.Runspace.Debugger.SetDebugMode(DebugModes.LocalScript | DebugModes.RemoteScript); + public void EnableDebugMode() + { + _psesHost.Runspace.ThrowCancelledIfUnusable(); + _psesHost.Runspace.Debugger.SetDebugMode(DebugModes.LocalScript | DebugModes.RemoteScript); + } - public void Abort() => SetDebugResuming(DebuggerResumeAction.Stop); + public void Abort() => SetDebugResuming(DebuggerResumeAction.Stop, isDisconnect: true); public void BreakExecution() => _psesHost.Runspace.Debugger.SetDebuggerStepMode(enabled: true); @@ -92,7 +106,7 @@ public PowerShellDebugContext( public void StepOver() => SetDebugResuming(DebuggerResumeAction.StepOver); - public void SetDebugResuming(DebuggerResumeAction debuggerResumeAction) + public void SetDebugResuming(DebuggerResumeAction debuggerResumeAction, bool isDisconnect = false) { // NOTE: We exit because the paused/stopped debugger is currently in a prompt REPL, and // to resume the debugger we must exit that REPL. @@ -108,7 +122,26 @@ public void SetDebugResuming(DebuggerResumeAction debuggerResumeAction) // then we'd accidentally cancel the debugged task since no prompt is running. We can // test this by checking if the UI's type is NullPSHostUI which is used specifically in // this scenario. This mostly applies to unit tests. - if (_psesHost.UI is not NullPSHostUI) + if (_psesHost.UI is NullPSHostUI) + { + return; + } + + if (debuggerResumeAction is DebuggerResumeAction.Stop) + { + // If we're disconnecting we want to unwind all the way back to the default, local + // state. So we use UnwindCallStack here to ensure every context frame is cancelled. + if (isDisconnect) + { + _psesHost.UnwindCallStack(); + return; + } + + _psesHost.CancelIdleParentTask(); + return; + } + + if (_psesHost.CurrentFrame.IsRepl) { _psesHost.CancelCurrentTask(); } @@ -134,7 +167,22 @@ public void ProcessDebuggerResult(DebuggerCommandResults debuggerResult) if (debuggerResult?.ResumeAction is not null) { SetDebugResuming(debuggerResult.ResumeAction.Value); + + // If a debugging command like `c` is specified in a nested remote + // debugging prompt we need to unwind the nested execution loop. + if (_psesHost.CurrentFrame.IsRemote) + { + _psesHost.ForceSetExit(); + } + RaiseDebuggerResumingEvent(new DebuggerResumingEventArgs(debuggerResult.ResumeAction.Value)); + + // The Terminate exception is used by the engine for flow control + // when it needs to unwind the callstack out of the debugger. + if (debuggerResult.ResumeAction is DebuggerResumeAction.Stop) + { + throw new TerminateException(); + } } } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Execution/ExecutionOptions.cs b/src/PowerShellEditorServices/Services/PowerShell/Execution/ExecutionOptions.cs index af5d9e7e9..27bb8935a 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Execution/ExecutionOptions.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Execution/ExecutionOptions.cs @@ -22,6 +22,13 @@ public record ExecutionOptions public record PowerShellExecutionOptions : ExecutionOptions { + internal static PowerShellExecutionOptions ImmediateInteractive = new() + { + Priority = ExecutionPriority.Next, + MustRunInForeground = true, + InterruptCurrentForeground = true, + }; + public bool WriteOutputToHost { get; init; } public bool WriteInputToHost { get; init; } public bool ThrowOnError { get; init; } = true; diff --git a/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs index 56239947f..636f18ce6 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Execution/SynchronousPowerShellTask.cs @@ -8,6 +8,7 @@ using System.Management.Automation.Remoting; using System.Threading; using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Context; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; using Microsoft.PowerShell.EditorServices.Utility; @@ -17,6 +18,8 @@ namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution { internal class SynchronousPowerShellTask : SynchronousTask> { + private static readonly PowerShellExecutionOptions s_defaultPowerShellExecutionOptions = new(); + private readonly ILogger _logger; private readonly PsesInternalHost _psesHost; @@ -25,7 +28,7 @@ internal class SynchronousPowerShellTask : SynchronousTask Run(CancellationToken cancellationToken) { - _pwsh = _psesHost.CurrentPowerShell; + _psesHost.Runspace.ThrowCancelledIfUnusable(); + PowerShellContextFrame frame = _psesHost.PushPowerShellForExecution(); + try + { + _pwsh = _psesHost.CurrentPowerShell; + + if (PowerShellExecutionOptions.WriteInputToHost) + { + _psesHost.WriteWithPrompt(_psCommand, cancellationToken); + } - if (PowerShellExecutionOptions.WriteInputToHost) + return _pwsh.Runspace.Debugger.InBreakpoint + && (IsDebuggerCommand(_psCommand) || _pwsh.Runspace.RunspaceIsRemote) + ? ExecuteInDebugger(cancellationToken) + : ExecuteNormally(cancellationToken); + } + finally { - _psesHost.WriteWithPrompt(_psCommand, cancellationToken); + _psesHost.PopPowerShellForExecution(frame); } - - return _pwsh.Runspace.Debugger.InBreakpoint - && Array.Exists( - DebuggerCommands, - c => c.Equals(_psCommand.GetInvocationText(), StringComparison.CurrentCultureIgnoreCase)) - ? ExecuteInDebugger(cancellationToken) - : ExecuteNormally(cancellationToken); } public override string ToString() => _psCommand.GetInvocationText(); + private static bool IsDebuggerCommand(PSCommand command) + { + if (command.Commands.Count is not 1 + || command.Commands[0] is { IsScript: false } or { Parameters.Count: > 0 }) + { + return false; + } + + string commandText = command.Commands[0].CommandText; + foreach (string knownCommand in DebuggerCommands) + { + if (commandText.Equals(knownCommand, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + private IReadOnlyList ExecuteNormally(CancellationToken cancellationToken) { + _frame = _psesHost.CurrentFrame; if (PowerShellExecutionOptions.WriteOutputToHost) { _psCommand.AddOutputCommand(); @@ -93,6 +124,11 @@ private IReadOnlyList ExecuteNormally(CancellationToken cancellationTok result = _pwsh.InvokeCommand(_psCommand, invocationSettings); cancellationToken.ThrowIfCancellationRequested(); } + // Allow terminate exceptions to propogate for flow control. + catch (TerminateException) + { + throw; + } // Test if we've been cancelled. If we're remoting, PSRemotingDataStructureException // effectively means the pipeline was stopped. catch (Exception e) when (cancellationToken.IsCancellationRequested || e is PipelineStoppedException || e is PSRemotingDataStructureException) @@ -108,6 +144,17 @@ private IReadOnlyList ExecuteNormally(CancellationToken cancellationTok // Other errors are bubbled up to the caller catch (RuntimeException e) { + if (e is PSRemotingTransportException) + { + _ = System.Threading.Tasks.Task.Run( + () => _psesHost.UnwindCallStack(), + CancellationToken.None) + .HandleErrorsAsync(_logger); + + _psesHost.WaitForExternalDebuggerStops(); + throw new OperationCanceledException("The operation was canceled.", e); + } + Logger.LogWarning($"Runtime exception occurred while executing command:{Environment.NewLine}{Environment.NewLine}{e}"); if (PowerShellExecutionOptions.ThrowOnError) @@ -119,7 +166,14 @@ private IReadOnlyList ExecuteNormally(CancellationToken cancellationTok .AddOutputCommand() .AddParameter("InputObject", e.ErrorRecord.AsPSObject()); - _pwsh.InvokeCommand(command); + if (_pwsh.Runspace.RunspaceStateInfo.IsUsable()) + { + _pwsh.InvokeCommand(command); + } + else + { + _psesHost.UI.WriteErrorLine(e.ToString()); + } } finally { @@ -173,7 +227,11 @@ private IReadOnlyList ExecuteInDebugger(CancellationToken cancellationT debuggerResult = _pwsh.Runspace.Debugger.ProcessCommand(_psCommand, outputCollection); cancellationToken.ThrowIfCancellationRequested(); } - + // Allow terminate exceptions to propogate for flow control. + catch (TerminateException) + { + throw; + } // Test if we've been cancelled. If we're remoting, PSRemotingDataStructureException // effectively means the pipeline was stopped. catch (Exception e) when (cancellationToken.IsCancellationRequested || e is PipelineStoppedException || e is PSRemotingDataStructureException) @@ -185,6 +243,17 @@ private IReadOnlyList ExecuteInDebugger(CancellationToken cancellationT // Other errors are bubbled up to the caller catch (RuntimeException e) { + if (e is PSRemotingTransportException) + { + _ = System.Threading.Tasks.Task.Run( + () => _psesHost.UnwindCallStack(), + CancellationToken.None) + .HandleErrorsAsync(_logger); + + _psesHost.WaitForExternalDebuggerStops(); + throw new OperationCanceledException("The operation was canceled.", e); + } + Logger.LogWarning($"Runtime exception occurred while executing command:{Environment.NewLine}{Environment.NewLine}{e}"); if (PowerShellExecutionOptions.ThrowOnError) @@ -192,14 +261,9 @@ private IReadOnlyList ExecuteInDebugger(CancellationToken cancellationT throw; } - PSDataCollection errorOutputCollection = new(); - errorOutputCollection.DataAdded += (object sender, DataAddedEventArgs args) => - { - for (int i = args.Index; i < outputCollection.Count; i++) - { - _psesHost.UI.WriteLine(outputCollection[i].ToString()); - } - }; + using PSDataCollection errorOutputCollection = new(); + errorOutputCollection.DataAdding += (object sender, DataAddingEventArgs args) + => _psesHost.UI.WriteLine(args.ItemAdded?.ToString()); PSCommand command = new PSCommand() .AddDebugOutputCommand() @@ -249,6 +313,7 @@ private void StopDebuggerIfRemoteDebugSessionFailed() // Instead we have to query the remote directly if (_pwsh.Runspace.RunspaceIsRemote) { + _pwsh.Runspace.ThrowCancelledIfUnusable(); PSCommand assessDebuggerCommand = new PSCommand().AddScript("$Host.Runspace.Debugger.InBreakpoint"); PSDataCollection outputCollection = new(); @@ -266,8 +331,42 @@ private void StopDebuggerIfRemoteDebugSessionFailed() } } - private void CancelNormalExecution() => _pwsh.Stop(); + private void CancelNormalExecution() + { + if (_pwsh.Runspace.RunspaceStateInfo.IsUsable()) + { + return; + } - private void CancelDebugExecution() => _pwsh.Runspace.Debugger.StopProcessCommand(); + // If we're signaled to exit a runspace then that'll trigger a stop, + // if we block on that stop we'll never exit the runspace ( + // and essentially deadlock). + if (_frame?.SessionExiting is true) + { + _pwsh.BeginStop(null, null); + return; + } + + try + { + _pwsh.Stop(); + } + catch (NullReferenceException nre) + { + _logger.LogError( + nre, + "Null reference exception from PowerShell.Stop received."); + } + } + + private void CancelDebugExecution() + { + if (_pwsh.Runspace.RunspaceStateInfo.IsUsable()) + { + return; + } + + _pwsh.Runspace.Debugger.StopProcessCommand(); + } } } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs index 3bb797054..1917e3432 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs @@ -154,13 +154,15 @@ public PsesInternalHost( IRunspaceInfo IRunspaceContext.CurrentRunspace => CurrentRunspace; - private PowerShellContextFrame CurrentFrame => _psFrameStack.Peek(); + internal PowerShellContextFrame CurrentFrame => _psFrameStack.Peek(); public event Action RunspaceChanged; private bool ShouldExitExecutionLoop => _shouldExit || _shuttingDown != 0; - public override void EnterNestedPrompt() => PushPowerShellAndRunLoop(CreateNestedPowerShell(CurrentRunspace), PowerShellFrameType.Nested); + public override void EnterNestedPrompt() => PushPowerShellAndRunLoop( + CreateNestedPowerShell(CurrentRunspace), + PowerShellFrameType.Nested | PowerShellFrameType.Repl); public override void ExitNestedPrompt() => SetExit(); @@ -170,18 +172,38 @@ public PsesInternalHost( public void PopRunspace() { + if (!Runspace.RunspaceIsRemote) + { + return; + } + IsRunspacePushed = false; + CurrentFrame.SessionExiting = true; + PopPowerShell(); SetExit(); } public void PushRunspace(Runspace runspace) { IsRunspacePushed = true; - PushPowerShellAndRunLoop(CreatePowerShellForRunspace(runspace), PowerShellFrameType.Remote); + PushPowerShellAndMaybeRunLoop( + CreatePowerShellForRunspace(runspace), + PowerShellFrameType.Remote | PowerShellFrameType.Repl, + skipRunLoop: true); } // TODO: Handle exit code if needed - public override void SetShouldExit(int exitCode) => SetExit(); + public override void SetShouldExit(int exitCode) + { + if (CurrentFrame.IsRemote) + { + // PopRunspace also calls SetExit. + PopRunspace(); + return; + } + + SetExit(); + } /// /// Try to start the PowerShell loop in the host. @@ -241,7 +263,8 @@ public void SetExit() { // Can't exit from the top level of PSES // since if you do, you lose all LSP services - if (_psFrameStack.Count <= 1) + PowerShellContextFrame frame = CurrentFrame; + if (!frame.IsRepl || _psFrameStack.Count <= 1) { return; } @@ -249,6 +272,8 @@ public void SetExit() _shouldExit = true; } + internal void ForceSetExit() => _shouldExit = true; + public Task InvokeTaskOnPipelineThreadAsync( SynchronousTask task) { @@ -287,6 +312,10 @@ public Task InvokeTaskOnPipelineThreadAsync( public void CancelCurrentTask() => _cancellationContext.CancelCurrentTask(); + public void CancelIdleParentTask() => _cancellationContext.CancelIdleParentTask(); + + public void UnwindCallStack() => _cancellationContext.CancelCurrentTaskStack(); + public Task ExecuteDelegateAsync( string representation, ExecutionOptions executionOptions, @@ -399,7 +428,7 @@ private void Run() _mainRunspaceEngineIntrinsics = engineIntrinsics; _localComputerName = localRunspaceInfo.SessionDetails.ComputerName; _runspaceStack.Push(new RunspaceFrame(pwsh.Runspace, localRunspaceInfo)); - PushPowerShellAndRunLoop(pwsh, PowerShellFrameType.Normal, localRunspaceInfo); + PushPowerShellAndRunLoop(pwsh, PowerShellFrameType.Normal | PowerShellFrameType.Repl, localRunspaceInfo); } catch (Exception e) { @@ -415,7 +444,28 @@ private void Run() return (pwsh, localRunspaceInfo, engineIntrinsics); } + internal PowerShellContextFrame PushPowerShellForExecution() + { + PowerShellContextFrame frame = CurrentFrame; + PowerShellFrameType currentFrameType = frame.FrameType; + currentFrameType &= ~PowerShellFrameType.Repl; + PowerShellContextFrame newFrame = new( + frame.PowerShell.CloneForNewFrame(), + frame.RunspaceInfo, + currentFrameType); + + PushPowerShell(newFrame); + return newFrame; + } + private void PushPowerShellAndRunLoop(PowerShell pwsh, PowerShellFrameType frameType, RunspaceInfo newRunspaceInfo = null) + => PushPowerShellAndMaybeRunLoop(pwsh, frameType, newRunspaceInfo, skipRunLoop: false); + + private void PushPowerShellAndMaybeRunLoop( + PowerShell pwsh, + PowerShellFrameType frameType, + RunspaceInfo newRunspaceInfo = null, + bool skipRunLoop = false) { // TODO: Improve runspace origin detection here if (newRunspaceInfo is null) @@ -430,7 +480,7 @@ private void PushPowerShellAndRunLoop(PowerShell pwsh, PowerShellFrameType frame } } - PushPowerShellAndRunLoop(new PowerShellContextFrame(pwsh, newRunspaceInfo, frameType)); + PushPowerShellAndMaybeRunLoop(new PowerShellContextFrame(pwsh, newRunspaceInfo, frameType), skipRunLoop); } private RunspaceInfo GetRunspaceInfoForPowerShell(PowerShell pwsh, out bool isNewRunspace, out RunspaceFrame oldRunspaceFrame) @@ -455,9 +505,13 @@ private RunspaceInfo GetRunspaceInfoForPowerShell(PowerShell pwsh, out bool isNe return RunspaceInfo.CreateFromPowerShell(_logger, pwsh, _localComputerName); } - private void PushPowerShellAndRunLoop(PowerShellContextFrame frame) + private void PushPowerShellAndMaybeRunLoop(PowerShellContextFrame frame, bool skipRunLoop = false) { PushPowerShell(frame); + if (skipRunLoop) + { + return; + } try { @@ -465,7 +519,7 @@ private void PushPowerShellAndRunLoop(PowerShellContextFrame frame) { RunTopLevelExecutionLoop(); } - else if ((frame.FrameType & PowerShellFrameType.Debug) != 0) + else if (frame.IsDebug) { RunDebugExecutionLoop(); } @@ -476,7 +530,14 @@ private void PushPowerShellAndRunLoop(PowerShellContextFrame frame) } finally { - PopPowerShell(); + if (CurrentFrame != frame) + { + frame.IsAwaitingPop = true; + } + else + { + PopPowerShell(); + } } } @@ -484,6 +545,12 @@ private void PushPowerShell(PowerShellContextFrame frame) { if (_psFrameStack.Count > 0) { + if (frame.PowerShell.Runspace == CurrentFrame.PowerShell.Runspace) + { + _psFrameStack.Push(frame); + return; + } + RemoveRunspaceEventHandlers(CurrentFrame.PowerShell.Runspace); } @@ -492,15 +559,25 @@ private void PushPowerShell(PowerShellContextFrame frame) _psFrameStack.Push(frame); } + internal void PopPowerShellForExecution(PowerShellContextFrame expectedFrame) + { + if (CurrentFrame != expectedFrame) + { + expectedFrame.IsAwaitingPop = true; + return; + } + + PopPowerShellImpl(); + } + private void PopPowerShell(RunspaceChangeAction runspaceChangeAction = RunspaceChangeAction.Exit) { _shouldExit = false; - PowerShellContextFrame frame = _psFrameStack.Pop(); - try + PopPowerShellImpl(_ => { // If we're changing runspace, make sure we move the handlers over. If we just // popped the last frame, then we're exiting and should pop the runspace too. - if (_psFrameStack.Count == 0 || CurrentRunspace.Runspace != CurrentPowerShell.Runspace) + if (_psFrameStack.Count == 0 || Runspace != CurrentPowerShell.Runspace) { RunspaceFrame previousRunspaceFrame = _runspaceStack.Pop(); RemoveRunspaceEventHandlers(previousRunspaceFrame.Runspace); @@ -519,11 +596,24 @@ private void PopPowerShell(RunspaceChangeAction runspaceChangeAction = RunspaceC newRunspaceFrame.RunspaceInfo)); } } - } - finally + }); + } + + private void PopPowerShellImpl(Action action = null) + { + do { - frame.Dispose(); + PowerShellContextFrame frame = _psFrameStack.Pop(); + try + { + action?.Invoke(frame); + } + finally + { + frame.Dispose(); + } } + while (_psFrameStack.Count > 0 && CurrentFrame.IsAwaitingPop); } private void RunTopLevelExecutionLoop() @@ -539,7 +629,21 @@ private void RunTopLevelExecutionLoop() // Signal that we are ready for outside services to use _started.TrySetResult(true); - RunExecutionLoop(); + // While loop is purely so we can recover gracefully from a + // terminate exception. + while (true) + { + try + { + RunExecutionLoop(); + break; + } + catch (TerminateException) + { + // Do nothing, since we are at the top level of the loop + // the call stack has been unwound successfully. + } + } } catch (Exception e) { @@ -557,7 +661,7 @@ private void RunDebugExecutionLoop() try { DebugContext.EnterDebugLoop(); - RunExecutionLoop(); + RunExecutionLoop(isForDebug: true); } finally { @@ -565,11 +669,17 @@ private void RunDebugExecutionLoop() } } - private void RunExecutionLoop() + private void RunExecutionLoop(bool isForDebug = false) { + Runspace initialRunspace = Runspace; while (!ShouldExitExecutionLoop) { - using CancellationScope cancellationScope = _cancellationContext.EnterScope(isIdleScope: false); + if (isForDebug && !initialRunspace.RunspaceStateInfo.IsUsable()) + { + return; + } + + using CancellationScope cancellationScope = _cancellationContext.EnterScope(false); DoOneRepl(cancellationScope.CancellationToken); while (!ShouldExitExecutionLoop @@ -577,6 +687,18 @@ private void RunExecutionLoop() && _taskQueue.TryTake(out ISynchronousTask task)) { task.ExecuteSynchronously(cancellationScope.CancellationToken); + while (Runspace is { RunspaceIsRemote: true } remoteRunspace + && !remoteRunspace.RunspaceStateInfo.IsUsable()) + { + PopPowerShell(RunspaceChangeAction.Exit); + } + } + + if (_shouldExit + && CurrentFrame is { IsRemote: true, IsRepl: true, IsNested: false }) + { + _shouldExit = false; + PopPowerShell(); } } } @@ -595,7 +717,9 @@ private void DoOneRepl(CancellationToken cancellationToken) // the debugger (instead of using a Code launch configuration) via Wait-Debugger or // simply hitting a PSBreakpoint. We need to synchronize the state and stop the debug // context (and likely the debug server). - if (DebugContext.IsActive && !CurrentRunspace.Runspace.Debugger.InBreakpoint) + if (!DebugContext.IsDebuggingRemoteRunspace + && DebugContext.IsActive + && !CurrentRunspace.Runspace.Debugger.InBreakpoint) { StopDebugContext(); } @@ -637,6 +761,15 @@ private void DoOneRepl(CancellationToken cancellationToken) { // Do nothing, since we were just cancelled } + // Propagate exceptions thrown from the debugger when quitting. + catch (TerminateException) + { + throw; + } + catch (FlowControlException) + { + // Do nothing, a break or continue statement was used outside of a loop. + } catch (Exception e) { UI.WriteErrorLine($"An error occurred while running the REPL loop:{Environment.NewLine}{e}"); @@ -655,13 +788,14 @@ private void DoOneRepl(CancellationToken cancellationToken) private string GetPrompt(CancellationToken cancellationToken) { + Runspace.ThrowCancelledIfUnusable(); string prompt = DefaultPrompt; try { // TODO: Should we cache PSCommands like this as static members? PSCommand command = new PSCommand().AddCommand("prompt"); IReadOnlyList results = InvokePSCommand(command, executionOptions: null, cancellationToken); - if (results.Count > 0) + if (results?.Count > 0) { prompt = results[0]; } @@ -934,28 +1068,59 @@ private void StopDebugContext() } } + private readonly object _replFromAnotherThread = new(); + + internal void WaitForExternalDebuggerStops() + { + lock (_replFromAnotherThread) + { + } + } + private void OnDebuggerStopped(object sender, DebuggerStopEventArgs debuggerStopEventArgs) { // The debugger has officially started. We use this to later check if we should stop it. DebugContext.IsActive = true; - // If the debug server is NOT active, we need to synchronize state and start it. - if (!DebugContext.IsDebugServerActive) + // The local debugging architecture works mostly because we control the pipeline thread, + // but remote runspaces will trigger debugger stops on a separate thread. We lock here + // if we're on a different thread so in then event of a transport error, we can + // safely wind down REPL loops in a different thread. + bool isExternal = Environment.CurrentManagedThreadId != _pipelineThread.ManagedThreadId; + if (!isExternal) { - _languageServer?.SendNotification("powerShell/startDebugger"); + OnDebuggerStoppedImpl(sender, debuggerStopEventArgs); + return; } - DebugContext.SetDebuggerStopped(debuggerStopEventArgs); - - try + lock (_replFromAnotherThread) { - CurrentPowerShell.WaitForRemoteOutputIfNeeded(); - PushPowerShellAndRunLoop(CreateNestedPowerShell(CurrentRunspace), PowerShellFrameType.Debug | PowerShellFrameType.Nested); - CurrentPowerShell.ResumeRemoteOutputIfNeeded(); + OnDebuggerStoppedImpl(sender, debuggerStopEventArgs); } - finally + + void OnDebuggerStoppedImpl(object sender, DebuggerStopEventArgs debuggerStopEventArgs) { - DebugContext.SetDebuggerResumed(); + // If the debug server is NOT active, we need to synchronize state and start it. + if (!DebugContext.IsDebugServerActive) + { + _languageServer?.SendNotification("powerShell/startDebugger"); + } + + DebugContext.SetDebuggerStopped(debuggerStopEventArgs); + + try + { + CurrentPowerShell.WaitForRemoteOutputIfNeeded(); + PowerShellFrameType frameBase = CurrentFrame.FrameType & PowerShellFrameType.Remote; + PushPowerShellAndRunLoop( + CreateNestedPowerShell(CurrentRunspace), + frameBase | PowerShellFrameType.Debug | PowerShellFrameType.Nested | PowerShellFrameType.Repl); + CurrentPowerShell.ResumeRemoteOutputIfNeeded(); + } + finally + { + DebugContext.SetDebuggerResumed(); + } } } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellDebugDisplay.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellDebugDisplay.cs new file mode 100644 index 000000000..4a1536ff0 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellDebugDisplay.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#if DEBUG +using System.Diagnostics; +using SMA = System.Management.Automation; + +[assembly: DebuggerDisplay("{Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility.PowerShellDebugDisplay.ToDebuggerString(this)}", Target = typeof(SMA.PowerShell))] +[assembly: DebuggerDisplay("{Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility.PSCommandDebugDisplay.ToDebuggerString(this)}", Target = typeof(SMA.PSCommand))] + +namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; + +internal static class PowerShellDebugDisplay +{ + public static string ToDebuggerString(SMA.PowerShell pwsh) + { + if (pwsh.Commands.Commands.Count == 0) + { + return "{}"; + } + + return $"{{{pwsh.Commands.Commands[0].CommandText}}}"; + } +} + +internal static class PSCommandDebugDisplay +{ + public static string ToDebuggerString(SMA.PSCommand command) + { + if (command.Commands.Count == 0) + { + return "{}"; + } + + return $"{{{command.Commands[0].CommandText}}}"; + } +} +#endif diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs index 78748a91f..173d1d6ae 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs @@ -37,6 +37,38 @@ static PowerShellExtensions() typeof(PowerShell).GetMethod("ResumeIncomingData", BindingFlags.Instance | BindingFlags.NonPublic)); } + public static PowerShell CloneForNewFrame(this PowerShell pwsh) + { + if (pwsh.IsNested) + { + return PowerShell.Create(RunspaceMode.CurrentRunspace); + } + + PowerShell newPwsh = PowerShell.Create(); + newPwsh.Runspace = pwsh.Runspace; + return newPwsh; + } + + public static void DisposeWhenCompleted(this PowerShell pwsh) + { + static void handler(object self, PSInvocationStateChangedEventArgs e) + { + if (e.InvocationStateInfo.State is + not PSInvocationState.Completed + and not PSInvocationState.Failed + and not PSInvocationState.Stopped) + { + return; + } + + PowerShell pwsh = (PowerShell)self; + pwsh.InvocationStateChanged -= handler; + pwsh.Dispose(); + } + + pwsh.InvocationStateChanged += handler; + } + public static Collection InvokeAndClear(this PowerShell pwsh, PSInvocationSettings invocationSettings = null) { try diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/RunspaceExtensions.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/RunspaceExtensions.cs index 681e16d8f..adb4bf99a 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Utility/RunspaceExtensions.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/RunspaceExtensions.cs @@ -50,6 +50,17 @@ static RunspaceExtensions() /// A prompt string decorated with remote connection details. public static string GetRemotePrompt(this Runspace runspace, string basePrompt) => s_getRemotePromptFunc(runspace, basePrompt); + public static void ThrowCancelledIfUnusable(this Runspace runspace) + => runspace.RunspaceStateInfo.ThrowCancelledIfUnusable(); + + public static void ThrowCancelledIfUnusable(this RunspaceStateInfo runspaceStateInfo) + { + if (!IsUsable(runspaceStateInfo)) + { + throw new OperationCanceledException(); + } + } + public static bool IsUsable(this RunspaceStateInfo runspaceStateInfo) { return runspaceStateInfo.State switch diff --git a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs index 562f3c764..1cafd5f56 100644 --- a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs @@ -962,7 +962,7 @@ public async Task CanSendCompletionAndCompletionResolveRequestAsync() }); CompletionItem completionItem = Assert.Single(completionItems, - completionItem1 => completionItem1.Label == "Write-Host"); + completionItem1 => completionItem1.FilterText == "Write-Host"); CompletionItem updatedCompletionItem = await PsesLanguageClient .SendRequest("completionItem/resolve", completionItem) @@ -1102,9 +1102,8 @@ await PsesLanguageClient }) .Returning(CancellationToken.None).ConfigureAwait(true); - Assert.Collection(getProjectTemplatesResponse.Templates.OrderBy(t => t.Title), - template1 => Assert.Equal("AddPSScriptAnalyzerSettings", template1.Title), - template2 => Assert.Equal("New PowerShell Manifest Module", template2.Title)); + Assert.Contains(getProjectTemplatesResponse.Templates, t => t.Title is "AddPSScriptAnalyzerSettings"); + Assert.Contains(getProjectTemplatesResponse.Templates, t => t.Title is "New PowerShell Manifest Module"); } [SkippableFact]