diff --git a/src/PowerShellEditorServices/Session/ExecutionTarget.cs b/src/PowerShellEditorServices/Session/ExecutionTarget.cs new file mode 100644 index 000000000..3a11f48c9 --- /dev/null +++ b/src/PowerShellEditorServices/Session/ExecutionTarget.cs @@ -0,0 +1,23 @@ +namespace Microsoft.PowerShell.EditorServices.Session +{ + /// + /// Represents the different API's available for executing commands. + /// + internal enum ExecutionTarget + { + /// + /// Indicates that the command should be invoked through the PowerShell debugger. + /// + Debugger, + + /// + /// Indicates that the command should be invoked via an instance of the PowerShell class. + /// + PowerShell, + + /// + /// Indicates that the command should be invoked through the PowerShell engine's event manager. + /// + InvocationEvent + } +} diff --git a/src/PowerShellEditorServices/Session/IPromptContext.cs b/src/PowerShellEditorServices/Session/IPromptContext.cs new file mode 100644 index 000000000..cabc3cf48 --- /dev/null +++ b/src/PowerShellEditorServices/Session/IPromptContext.cs @@ -0,0 +1,62 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + /// + /// Provides methods for interacting with implementations of ReadLine. + /// + public interface IPromptContext + { + /// + /// Read a string that has been input by the user. + /// + /// Indicates if ReadLine should act like a command REPL. + /// + /// The cancellation token can be used to cancel reading user input. + /// + /// + /// A task object that represents the completion of reading input. The Result property will + /// return the input string. + /// + Task InvokeReadLine(bool isCommandLine, CancellationToken cancellationToken); + + /// + /// Performs any additional actions required to cancel the current ReadLine invocation. + /// + void AbortReadLine(); + + /// + /// Creates a task that completes when the current ReadLine invocation has been aborted. + /// + /// + /// A task object that represents the abortion of the current ReadLine invocation. + /// + Task AbortReadLineAsync(); + + /// + /// Blocks until the current ReadLine invocation has exited. + /// + void WaitForReadLineExit(); + + /// + /// Creates a task that completes when the current ReadLine invocation has exited. + /// + /// + /// A task object that represents the exit of the current ReadLine invocation. + /// + Task WaitForReadLineExitAsync(); + + /// + /// Adds the specified command to the history managed by the ReadLine implementation. + /// + /// The command to record. + void AddToHistory(string command); + + /// + /// Forces the prompt handler to trigger PowerShell event handling, reliquishing control + /// of the pipeline thread during event processing. + /// + void ForcePSEventHandling(); + } +} diff --git a/src/PowerShellEditorServices/Session/InvocationEventQueue.cs b/src/PowerShellEditorServices/Session/InvocationEventQueue.cs new file mode 100644 index 000000000..a3a72316d --- /dev/null +++ b/src/PowerShellEditorServices/Session/InvocationEventQueue.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation.Runspaces; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using System.Threading; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + using System.Management.Automation; + + /// + /// Provides the ability to take over the current pipeline in a runspace. + /// + internal class InvocationEventQueue + { + private readonly PromptNest _promptNest; + private readonly Runspace _runspace; + private readonly PowerShellContext _powerShellContext; + private InvocationRequest _invocationRequest; + private Task _currentWaitTask; + private SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + + internal InvocationEventQueue(PowerShellContext powerShellContext, PromptNest promptNest) + { + _promptNest = promptNest; + _powerShellContext = powerShellContext; + _runspace = powerShellContext.CurrentRunspace.Runspace; + CreateInvocationSubscriber(); + } + + /// + /// Executes a command on the main pipeline thread through + /// eventing. A event subscriber will + /// be created that creates a nested PowerShell instance for + /// to utilize. + /// + /// + /// Avoid using this method directly if possible. + /// will route commands + /// through this method if required. + /// + /// The expected result type. + /// The to be executed. + /// + /// Error messages from PowerShell will be written to the . + /// + /// Specifies options to be used when executing this command. + /// + /// An awaitable which will provide results once the command + /// execution completes. + /// + internal async Task> ExecuteCommandOnIdle( + PSCommand psCommand, + StringBuilder errorMessages, + ExecutionOptions executionOptions) + { + var request = new PipelineExecutionRequest( + _powerShellContext, + psCommand, + errorMessages, + executionOptions); + + await SetInvocationRequestAsync( + new InvocationRequest( + pwsh => request.Execute().GetAwaiter().GetResult())); + + try + { + return await request.Results; + } + finally + { + await SetInvocationRequestAsync(null); + } + } + + /// + /// Marshals a to run on the pipeline thread. A new + /// will be created for the invocation. + /// + /// + /// The to invoke on the pipeline thread. The nested + /// instance for the created + /// will be passed as an argument. + /// + /// + /// An awaitable that the caller can use to know when execution completes. + /// + internal async Task InvokeOnPipelineThread(Action invocationAction) + { + var request = new InvocationRequest(pwsh => + { + using (_promptNest.GetRunspaceHandle(CancellationToken.None, isReadLine: false)) + { + pwsh.Runspace = _runspace; + invocationAction(pwsh); + } + }); + + await SetInvocationRequestAsync(request); + try + { + await request.Task; + } + finally + { + await SetInvocationRequestAsync(null); + } + } + + private async Task WaitForExistingRequestAsync() + { + InvocationRequest existingRequest; + await _lock.WaitAsync(); + try + { + existingRequest = _invocationRequest; + if (existingRequest == null || existingRequest.Task.IsCompleted) + { + return; + } + } + finally + { + _lock.Release(); + } + + await existingRequest.Task; + } + + private async Task SetInvocationRequestAsync(InvocationRequest request) + { + await WaitForExistingRequestAsync(); + await _lock.WaitAsync(); + try + { + _invocationRequest = request; + } + finally + { + _lock.Release(); + } + + _powerShellContext.ForcePSEventHandling(); + } + + private void OnPowerShellIdle(object sender, EventArgs e) + { + if (!_lock.Wait(0)) + { + return; + } + + InvocationRequest currentRequest = null; + try + { + if (_invocationRequest == null || System.Console.KeyAvailable) + { + return; + } + + currentRequest = _invocationRequest; + } + finally + { + _lock.Release(); + } + + _promptNest.PushPromptContext(); + try + { + currentRequest.Invoke(_promptNest.GetPowerShell()); + } + finally + { + _promptNest.PopPromptContext(); + } + } + + private PSEventSubscriber CreateInvocationSubscriber() + { + PSEventSubscriber subscriber = _runspace.Events.SubscribeEvent( + source: null, + eventName: PSEngineEvent.OnIdle, + sourceIdentifier: PSEngineEvent.OnIdle, + data: null, + handlerDelegate: OnPowerShellIdle, + supportEvent: true, + forwardEvent: false); + + SetSubscriberExecutionThreadWithReflection(subscriber); + + subscriber.Unsubscribed += OnInvokerUnsubscribed; + + return subscriber; + } + + private void OnInvokerUnsubscribed(object sender, PSEventUnsubscribedEventArgs e) + { + CreateInvocationSubscriber(); + } + + private void SetSubscriberExecutionThreadWithReflection(PSEventSubscriber subscriber) + { + // We need to create the PowerShell object in the same thread so we can get a nested + // PowerShell. Without changes to PSReadLine directly, this is the only way to achieve + // that consistently. The alternative is to make the subscriber a script block and have + // that create and process the PowerShell object, but that puts us in a different + // SessionState and is a lot slower. + + // This should be safe as PSReadline should be waiting for pipeline input due to the + // OnIdle event sent along with it. + typeof(PSEventSubscriber) + .GetProperty( + "ShouldProcessInExecutionThread", + BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(subscriber, true); + } + + private class InvocationRequest : TaskCompletionSource + { + private readonly Action _invocationAction; + + internal InvocationRequest(Action invocationAction) + { + _invocationAction = invocationAction; + } + + internal void Invoke(PowerShell pwsh) + { + try + { + _invocationAction(pwsh); + + // Ensure the result is set in another thread otherwise the caller + // may take over the pipeline thread. + System.Threading.Tasks.Task.Run(() => SetResult(true)); + } + catch (Exception e) + { + System.Threading.Tasks.Task.Run(() => SetException(e)); + } + } + } + } +} diff --git a/src/PowerShellEditorServices/Session/LegacyReadLineContext.cs b/src/PowerShellEditorServices/Session/LegacyReadLineContext.cs new file mode 100644 index 000000000..5548f5d19 --- /dev/null +++ b/src/PowerShellEditorServices/Session/LegacyReadLineContext.cs @@ -0,0 +1,51 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Console; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + internal class LegacyReadLineContext : IPromptContext + { + private readonly ConsoleReadLine _legacyReadLine; + + internal LegacyReadLineContext(PowerShellContext powerShellContext) + { + _legacyReadLine = new ConsoleReadLine(powerShellContext); + } + + public Task AbortReadLineAsync() + { + return Task.FromResult(true); + } + + public async Task InvokeReadLine(bool isCommandLine, CancellationToken cancellationToken) + { + return await _legacyReadLine.InvokeLegacyReadLine(isCommandLine, cancellationToken); + } + + public Task WaitForReadLineExitAsync() + { + return Task.FromResult(true); + } + + public void AddToHistory(string command) + { + // Do nothing, history is managed completely by the PowerShell engine in legacy ReadLine. + } + + public void AbortReadLine() + { + // Do nothing, no additional actions are needed to cancel ReadLine. + } + + public void WaitForReadLineExit() + { + // Do nothing, ReadLine cancellation is instant or not appliciable. + } + + public void ForcePSEventHandling() + { + // Do nothing, the pipeline thread is not occupied by legacy ReadLine. + } + } +} diff --git a/src/PowerShellEditorServices/Session/PSReadLinePromptContext.cs b/src/PowerShellEditorServices/Session/PSReadLinePromptContext.cs new file mode 100644 index 000000000..2fe44f68f --- /dev/null +++ b/src/PowerShellEditorServices/Session/PSReadLinePromptContext.cs @@ -0,0 +1,216 @@ +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System; +using System.Management.Automation.Runspaces; +using Microsoft.PowerShell.EditorServices.Console; + +namespace Microsoft.PowerShell.EditorServices.Session { + using System.Management.Automation; + + internal class PSReadLinePromptContext : IPromptContext { + private const string ReadLineScript = @" + [System.Diagnostics.DebuggerHidden()] + [System.Diagnostics.DebuggerStepThrough()] + param() + return [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine( + $Host.Runspace, + $ExecutionContext, + $args[0])"; + + // private const string ReadLineScript = @" + // [System.Diagnostics.DebuggerHidden()] + // [System.Diagnostics.DebuggerStepThrough()] + // param( + // [Parameter(Mandatory)] + // [Threading.CancellationToken] $CancellationToken, + + // [ValidateNotNull()] + // [runspace] $Runspace = $Host.Runspace, + + // [ValidateNotNull()] + // [System.Management.Automation.EngineIntrinsics] $EngineIntrinsics = $ExecutionContext + // ) + // end { + // if ($CancellationToken.IsCancellationRequested) { + // return [string]::Empty + // } + + // return [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine( + // $Runspace, + // $EngineIntrinsics, + // $CancellationToken) + // }"; + + private const string ReadLineInitScript = @" + [System.Diagnostics.DebuggerHidden()] + [System.Diagnostics.DebuggerStepThrough()] + param() + end { + $module = Get-Module -ListAvailable PSReadLine | Select-Object -First 1 + if (-not $module -or $module.Version -lt ([version]'2.0.0')) { + return + } + + Import-Module -ModuleInfo $module + return 'Microsoft.PowerShell.PSConsoleReadLine' -as [type] + }"; + + private readonly PowerShellContext _powerShellContext; + + private PromptNest _promptNest; + + private InvocationEventQueue _invocationEventQueue; + + private ConsoleReadLine _consoleReadLine; + + private CancellationTokenSource _readLineCancellationSource; + + private PSReadLineProxy _readLineProxy; + + internal PSReadLinePromptContext( + PowerShellContext powerShellContext, + PromptNest promptNest, + InvocationEventQueue invocationEventQueue, + PSReadLineProxy readLineProxy) + { + _promptNest = promptNest; + _powerShellContext = powerShellContext; + _invocationEventQueue = invocationEventQueue; + _consoleReadLine = new ConsoleReadLine(powerShellContext); + _readLineProxy = readLineProxy; + + #if CoreCLR + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + _readLineProxy.OverrideReadKey( + intercept => ConsoleProxy.UnixReadKey( + intercept, + _readLineCancellationSource.Token)); + #endif + } + + internal static bool TryGetPSReadLineProxy(Runspace runspace, out PSReadLineProxy readLineProxy) + { + readLineProxy = null; + using (var pwsh = PowerShell.Create()) + { + pwsh.Runspace = runspace; + var psReadLineType = pwsh + .AddScript(ReadLineInitScript) + .Invoke() + .FirstOrDefault(); + + if (psReadLineType == null) + { + return false; + } + + try + { + readLineProxy = new PSReadLineProxy(psReadLineType); + } + catch (InvalidOperationException) + { + // The Type we got back from PowerShell doesn't have the members we expected. + // Could be an older version, a custom build, or something a newer version with + // breaking changes. + return false; + } + } + + return true; + } + + public async Task InvokeReadLine(bool isCommandLine, CancellationToken cancellationToken) + { + _readLineCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var localTokenSource = _readLineCancellationSource; + if (localTokenSource.Token.IsCancellationRequested) + { + throw new TaskCanceledException(); + } + + try + { + if (!isCommandLine) + { + return await _consoleReadLine.InvokeLegacyReadLine( + false, + _readLineCancellationSource.Token); + } + + var result = (await _powerShellContext.ExecuteCommand( + new PSCommand() + .AddScript(ReadLineScript) + .AddArgument(_readLineCancellationSource.Token), + null, + new ExecutionOptions() + { + WriteErrorsToHost = false, + WriteOutputToHost = false, + InterruptCommandPrompt = false, + AddToHistory = false, + IsReadLine = isCommandLine + })) + .FirstOrDefault(); + + return cancellationToken.IsCancellationRequested + ? string.Empty + : result; + } + finally + { + _readLineCancellationSource = null; + } + } + + public void AbortReadLine() + { + if (_readLineCancellationSource == null) + { + return; + } + + _readLineCancellationSource.Cancel(); + + WaitForReadLineExit(); + } + + public async Task AbortReadLineAsync() { + if (_readLineCancellationSource == null) + { + return; + } + + _readLineCancellationSource.Cancel(); + + await WaitForReadLineExitAsync(); + } + + public void WaitForReadLineExit() + { + using (_promptNest.GetRunspaceHandle(CancellationToken.None, isReadLine: true)) + { } + } + + public async Task WaitForReadLineExitAsync () { + using (await _promptNest.GetRunspaceHandleAsync(CancellationToken.None, isReadLine: true)) + { } + } + + public void AddToHistory(string command) + { + _readLineProxy.AddToHistory(command); + } + + public void ForcePSEventHandling() + { + _readLineProxy.ForcePSEventHandling(); + } + } +} diff --git a/src/PowerShellEditorServices/Session/PSReadLineProxy.cs b/src/PowerShellEditorServices/Session/PSReadLineProxy.cs new file mode 100644 index 000000000..165cd7e71 --- /dev/null +++ b/src/PowerShellEditorServices/Session/PSReadLineProxy.cs @@ -0,0 +1,103 @@ +using System; +using System.Reflection; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + internal class PSReadLineProxy + { + private const string AddToHistoryMethodName = "AddToHistory"; + + private const string SetKeyHandlerMethodName = "SetKeyHandler"; + + private const string ReadKeyOverrideFieldName = "_readKeyOverride"; + + private const string VirtualTerminalTypeName = "Microsoft.PowerShell.Internal.VirtualTerminal"; + + private const string ForcePSEventHandlingMethodName = "ForcePSEventHandling"; + + private static readonly Type[] s_setKeyHandlerTypes = new Type[4] + { + typeof(string[]), + typeof(Action), + typeof(string), + typeof(string) + }; + + private static readonly Type[] s_addToHistoryTypes = new Type[1] { typeof(string) }; + + private readonly FieldInfo _readKeyOverrideField; + + internal PSReadLineProxy(Type psConsoleReadLine) + { + ForcePSEventHandling = + (Action)GetMethod( + psConsoleReadLine, + ForcePSEventHandlingMethodName, + Type.EmptyTypes, + BindingFlags.Static | BindingFlags.NonPublic) + .CreateDelegate(typeof(Action)); + + AddToHistory = + (Action)GetMethod( + psConsoleReadLine, + AddToHistoryMethodName, + s_addToHistoryTypes) + .CreateDelegate(typeof(Action)); + + SetKeyHandler = + (Action, string, string>)GetMethod( + psConsoleReadLine, + SetKeyHandlerMethodName, + s_setKeyHandlerTypes) + .CreateDelegate(typeof(Action, string, string>)); + + _readKeyOverrideField = psConsoleReadLine.GetTypeInfo().Assembly + .GetType(VirtualTerminalTypeName) + ?.GetField(ReadKeyOverrideFieldName, BindingFlags.Static | BindingFlags.NonPublic); + + if (_readKeyOverrideField == null) + { + throw new InvalidOperationException(); + } + } + + internal Action AddToHistory { get; } + + internal Action, object>, string, string> SetKeyHandler { get; } + + internal Action ForcePSEventHandling { get; } + + internal void OverrideReadKey(Func readKeyFunc) + { + _readKeyOverrideField.SetValue(null, readKeyFunc); + } + + private static MethodInfo GetMethod( + Type psConsoleReadLine, + string name, + Type[] types, + BindingFlags flags = BindingFlags.Public | BindingFlags.Static) + { + // Shouldn't need this compiler directive after switching to netstandard2.0 + #if CoreCLR + var method = psConsoleReadLine.GetMethod( + name, + flags); + #else + var method = psConsoleReadLine.GetMethod( + name, + flags, + null, + types, + types.Length == 0 ? new ParameterModifier[0] : new[] { new ParameterModifier(types.Length) }); + #endif + + if (method == null) + { + throw new InvalidOperationException(); + } + + return method; + } + } +} diff --git a/src/PowerShellEditorServices/Session/PipelineExecutionRequest.cs b/src/PowerShellEditorServices/Session/PipelineExecutionRequest.cs new file mode 100644 index 000000000..ce9781229 --- /dev/null +++ b/src/PowerShellEditorServices/Session/PipelineExecutionRequest.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Management.Automation; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + internal interface IPipelineExecutionRequest + { + Task Execute(); + + Task WaitTask { get; } + } + + /// + /// Contains details relating to a request to execute a + /// command on the PowerShell pipeline thread. + /// + /// The expected result type of the execution. + internal class PipelineExecutionRequest : IPipelineExecutionRequest + { + private PowerShellContext _powerShellContext; + private PSCommand _psCommand; + private StringBuilder _errorMessages; + private ExecutionOptions _executionOptions; + private TaskCompletionSource> _resultsTask; + + public Task> Results + { + get { return this._resultsTask.Task; } + } + + public Task WaitTask { get { return Results; } } + + public PipelineExecutionRequest( + PowerShellContext powerShellContext, + PSCommand psCommand, + StringBuilder errorMessages, + bool sendOutputToHost) + : this( + powerShellContext, + psCommand, + errorMessages, + new ExecutionOptions() + { + WriteOutputToHost = sendOutputToHost + }) + { } + + + public PipelineExecutionRequest( + PowerShellContext powerShellContext, + PSCommand psCommand, + StringBuilder errorMessages, + ExecutionOptions executionOptions) + { + _powerShellContext = powerShellContext; + _psCommand = psCommand; + _errorMessages = errorMessages; + _executionOptions = executionOptions; + _resultsTask = new TaskCompletionSource>(); + } + + public async Task Execute() + { + var results = + await _powerShellContext.ExecuteCommand( + _psCommand, + _errorMessages, + _executionOptions); + + var unusedTask = Task.Run(() => _resultsTask.SetResult(results)); + // TODO: Deal with errors? + } + } +} diff --git a/src/PowerShellEditorServices/Session/PromptNest.cs b/src/PowerShellEditorServices/Session/PromptNest.cs new file mode 100644 index 000000000..91813544b --- /dev/null +++ b/src/PowerShellEditorServices/Session/PromptNest.cs @@ -0,0 +1,559 @@ +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + using System; + using System.Management.Automation; + + /// + /// Represents the stack of contexts in which PowerShell commands can be invoked. + /// + internal class PromptNest : IDisposable + { + private ConcurrentStack _frameStack; + + private PromptNestFrame _readLineFrame; + + private IHostInput _consoleReader; + + private PowerShellContext _powerShellContext; + + private IVersionSpecificOperations _versionSpecificOperations; + + private bool _isDisposed; + + private object _syncObject = new object(); + + /// + /// Initializes a new instance of the class. + /// + /// + /// The to track prompt status for. + /// + /// + /// The instance for the first frame. + /// + /// + /// The input handler. + /// + /// + /// The for the calling + /// instance. + /// + /// + /// This constructor should only be called when + /// is set to the initial runspace. + /// + internal PromptNest( + PowerShellContext powerShellContext, + PowerShell initialPowerShell, + IHostInput consoleReader, + IVersionSpecificOperations versionSpecificOperations) + { + _versionSpecificOperations = versionSpecificOperations; + _consoleReader = consoleReader; + _powerShellContext = powerShellContext; + _frameStack = new ConcurrentStack(); + _frameStack.Push( + new PromptNestFrame( + initialPowerShell, + NewHandleQueue())); + + var readLineShell = PowerShell.Create(); + readLineShell.Runspace = powerShellContext.CurrentRunspace.Runspace; + _readLineFrame = new PromptNestFrame( + readLineShell, + new AsyncQueue()); + + ReleaseRunspaceHandleImpl(isReadLine: true); + } + + /// + /// Gets a value indicating whether the current frame was created by a debugger stop event. + /// + internal bool IsInDebugger => CurrentFrame.FrameType.HasFlag(PromptNestFrameType.Debug); + + /// + /// Gets a value indicating whether the current frame was created for an out of process runspace. + /// + internal bool IsRemote => CurrentFrame.FrameType.HasFlag(PromptNestFrameType.Remote); + + /// + /// Gets a value indicating whether the current frame was created by PSHost.EnterNestedPrompt(). + /// + internal bool IsNestedPrompt => CurrentFrame.FrameType.HasFlag(PromptNestFrameType.NestedPrompt); + + /// + /// Gets a value indicating the current number of frames managed by this PromptNest. + /// + internal int NestedPromptLevel => _frameStack.Count; + + private PromptNestFrame CurrentFrame + { + get + { + _frameStack.TryPeek(out PromptNestFrame currentFrame); + return _isDisposed ? _readLineFrame : currentFrame; + } + } + + public void Dispose() + { + Dispose(true); + } + + protected virtual void Dispose(bool disposing) + { + lock (_syncObject) + { + if (_isDisposed || !disposing) + { + return; + } + + while (NestedPromptLevel > 1) + { + _consoleReader?.StopCommandLoop(); + var currentFrame = CurrentFrame; + if (currentFrame.FrameType.HasFlag(PromptNestFrameType.Debug)) + { + _versionSpecificOperations.StopCommandInDebugger(_powerShellContext); + currentFrame.ThreadController.StartThreadExit(DebuggerResumeAction.Stop); + currentFrame.WaitForFrameExit(CancellationToken.None); + continue; + } + + if (currentFrame.FrameType.HasFlag(PromptNestFrameType.NestedPrompt)) + { + _powerShellContext.ExitAllNestedPrompts(); + continue; + } + + currentFrame.PowerShell.BeginStop(null, null); + currentFrame.WaitForFrameExit(CancellationToken.None); + } + + _consoleReader?.StopCommandLoop(); + _readLineFrame.Dispose(); + CurrentFrame.Dispose(); + _frameStack.Clear(); + _powerShellContext = null; + _consoleReader = null; + _isDisposed = true; + } + } + + /// + /// Gets the for the current frame. + /// + /// + /// The for the current frame, or + /// if the current frame does not have one. + /// + internal ThreadController GetThreadController() + { + if (_isDisposed) + { + return null; + } + + return CurrentFrame.IsThreadController ? CurrentFrame.ThreadController : null; + } + + /// + /// Create a new and set it as the current frame. + /// + internal void PushPromptContext() + { + if (_isDisposed) + { + return; + } + + PushPromptContext(PromptNestFrameType.Normal); + } + + /// + /// Create a new and set it as the current frame. + /// + /// The frame type. + internal void PushPromptContext(PromptNestFrameType frameType) + { + if (_isDisposed) + { + return; + } + + _frameStack.Push( + new PromptNestFrame( + frameType.HasFlag(PromptNestFrameType.Remote) + ? PowerShell.Create() + : PowerShell.Create(RunspaceMode.CurrentRunspace), + NewHandleQueue(), + frameType)); + } + + /// + /// Dispose of the current and revert to the previous frame. + /// + internal void PopPromptContext() + { + PromptNestFrame currentFrame; + lock (_syncObject) + { + if (_isDisposed || _frameStack.Count == 1) + { + return; + } + + _frameStack.TryPop(out currentFrame); + } + + currentFrame.Dispose(); + } + + /// + /// Get the instance for the current + /// . + /// + /// Indicates whether this is for a PSReadLine command. + /// The instance for the current frame. + internal PowerShell GetPowerShell(bool isReadLine = false) + { + if (_isDisposed) + { + return null; + } + + // Typically we want to run PSReadLine on the current nest frame. + // The exception is when the current frame is remote, in which + // case we need to run it in it's own frame because we can't take + // over a remote pipeline through event invocation. + if (NestedPromptLevel > 1 && !IsRemote) + { + return CurrentFrame.PowerShell; + } + + return isReadLine ? _readLineFrame.PowerShell : CurrentFrame.PowerShell; + } + + /// + /// Get the for the current . + /// + /// + /// The that can be used to cancel the request. + /// + /// Indicates whether this is for a PSReadLine command. + /// The for the current frame. + internal RunspaceHandle GetRunspaceHandle(CancellationToken cancellationToken, bool isReadLine) + { + if (_isDisposed) + { + return null; + } + + // Also grab the main runspace handle if this is for a ReadLine pipeline and the runspace + // is in process. + if (isReadLine && !_powerShellContext.IsCurrentRunspaceOutOfProcess()) + { + GetRunspaceHandleImpl(cancellationToken, isReadLine: false); + } + + return GetRunspaceHandleImpl(cancellationToken, isReadLine); + } + + + /// + /// Get the for the current . + /// + /// + /// The that will be checked prior to + /// completing the returned task. + /// + /// Indicates whether this is for a PSReadLine command. + /// + /// A object representing the asynchronous operation. + /// The property will return the + /// for the current frame. + /// + internal async Task GetRunspaceHandleAsync(CancellationToken cancellationToken, bool isReadLine) + { + if (_isDisposed) + { + return null; + } + + // Also grab the main runspace handle if this is for a ReadLine pipeline and the runspace + // is in process. + if (isReadLine && !_powerShellContext.IsCurrentRunspaceOutOfProcess()) + { + await GetRunspaceHandleImplAsync(cancellationToken, isReadLine: false); + } + + return await GetRunspaceHandleImplAsync(cancellationToken, isReadLine); + } + + /// + /// Releases control of the aquired via the + /// . + /// + /// + /// The representing the control to release. + /// + internal void ReleaseRunspaceHandle(RunspaceHandle runspaceHandle) + { + if (_isDisposed) + { + return; + } + + ReleaseRunspaceHandleImpl(runspaceHandle.IsReadLine); + if (runspaceHandle.IsReadLine && !_powerShellContext.IsCurrentRunspaceOutOfProcess()) + { + ReleaseRunspaceHandleImpl(isReadLine: false); + } + } + + /// + /// Releases control of the aquired via the + /// . + /// + /// + /// The representing the control to release. + /// + /// + /// A object representing the release of the + /// . + /// + internal async Task ReleaseRunspaceHandleAsync(RunspaceHandle runspaceHandle) + { + if (_isDisposed) + { + return; + } + + await ReleaseRunspaceHandleImplAsync(runspaceHandle.IsReadLine); + if (runspaceHandle.IsReadLine && !_powerShellContext.IsCurrentRunspaceOutOfProcess()) + { + await ReleaseRunspaceHandleImplAsync(isReadLine: false); + } + } + + /// + /// Determines if the current frame is unavailable for commands. + /// + /// + /// A value indicating whether the current frame is unavailable for commands. + /// + internal bool IsMainThreadBusy() + { + return !_isDisposed && CurrentFrame.Queue.IsEmpty; + } + + /// + /// Determines if a PSReadLine command is currently running. + /// + /// + /// A value indicating whether a PSReadLine command is currently running. + /// + internal bool IsReadLineBusy() + { + return !_isDisposed && _readLineFrame.Queue.IsEmpty; + } + + /// + /// Blocks until the current frame has been disposed. + /// + /// + /// A delegate that when invoked initates the exit of the current frame. + /// + internal void WaitForCurrentFrameExit(Action initiator) + { + if (_isDisposed) + { + return; + } + + var currentFrame = CurrentFrame; + try + { + initiator.Invoke(currentFrame); + } + finally + { + currentFrame.WaitForFrameExit(CancellationToken.None); + } + } + + /// + /// Blocks until the current frame has been disposed. + /// + internal void WaitForCurrentFrameExit() + { + if (_isDisposed) + { + return; + } + + CurrentFrame.WaitForFrameExit(CancellationToken.None); + } + + /// + /// Blocks until the current frame has been disposed. + /// + /// + /// The used the exit the block prior to + /// the current frame being disposed. + /// + internal void WaitForCurrentFrameExit(CancellationToken cancellationToken) + { + if (_isDisposed) + { + return; + } + + CurrentFrame.WaitForFrameExit(cancellationToken); + } + + /// + /// Creates a task that is completed when the current frame has been disposed. + /// + /// + /// A delegate that when invoked initates the exit of the current frame. + /// + /// + /// A object representing the current frame being disposed. + /// + internal async Task WaitForCurrentFrameExitAsync(Func initiator) + { + if (_isDisposed) + { + return; + } + + var currentFrame = CurrentFrame; + try + { + await initiator.Invoke(currentFrame); + } + finally + { + await currentFrame.WaitForFrameExitAsync(CancellationToken.None); + } + } + + /// + /// Creates a task that is completed when the current frame has been disposed. + /// + /// + /// A delegate that when invoked initates the exit of the current frame. + /// + /// + /// A object representing the current frame being disposed. + /// + internal async Task WaitForCurrentFrameExitAsync(Action initiator) + { + if (_isDisposed) + { + return; + } + + var currentFrame = CurrentFrame; + try + { + initiator.Invoke(currentFrame); + } + finally + { + await currentFrame.WaitForFrameExitAsync(CancellationToken.None); + } + } + + /// + /// Creates a task that is completed when the current frame has been disposed. + /// + /// + /// A object representing the current frame being disposed. + /// + internal async Task WaitForCurrentFrameExitAsync() + { + if (_isDisposed) + { + return; + } + + await WaitForCurrentFrameExitAsync(CancellationToken.None); + } + + /// + /// Creates a task that is completed when the current frame has been disposed. + /// + /// + /// The used the exit the block prior to the current frame being disposed. + /// + /// + /// A object representing the current frame being disposed. + /// + internal async Task WaitForCurrentFrameExitAsync(CancellationToken cancellationToken) + { + if (_isDisposed) + { + return; + } + + await CurrentFrame.WaitForFrameExitAsync(cancellationToken); + } + + private AsyncQueue NewHandleQueue() + { + var queue = new AsyncQueue(); + queue.Enqueue(new RunspaceHandle(_powerShellContext)); + return queue; + } + + private RunspaceHandle GetRunspaceHandleImpl(CancellationToken cancellationToken, bool isReadLine) + { + if (isReadLine) + { + return _readLineFrame.Queue.Dequeue(cancellationToken); + } + + return CurrentFrame.Queue.Dequeue(cancellationToken); + } + + private async Task GetRunspaceHandleImplAsync(CancellationToken cancellationToken, bool isReadLine) + { + if (isReadLine) + { + return await _readLineFrame.Queue.DequeueAsync(cancellationToken); + } + + return await CurrentFrame.Queue.DequeueAsync(cancellationToken); + } + + private void ReleaseRunspaceHandleImpl(bool isReadLine) + { + if (isReadLine) + { + _readLineFrame.Queue.Enqueue(new RunspaceHandle(_powerShellContext, true)); + return; + } + + CurrentFrame.Queue.Enqueue(new RunspaceHandle(_powerShellContext, false)); + } + + private async Task ReleaseRunspaceHandleImplAsync(bool isReadLine) + { + if (isReadLine) + { + await _readLineFrame.Queue.EnqueueAsync(new RunspaceHandle(_powerShellContext, true)); + return; + } + + await CurrentFrame.Queue.EnqueueAsync(new RunspaceHandle(_powerShellContext, false)); + } + } +} diff --git a/src/PowerShellEditorServices/Session/PromptNestFrame.cs b/src/PowerShellEditorServices/Session/PromptNestFrame.cs new file mode 100644 index 000000000..7ced26e45 --- /dev/null +++ b/src/PowerShellEditorServices/Session/PromptNestFrame.cs @@ -0,0 +1,132 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + using System.Management.Automation; + + /// + /// Represents a single frame in the . + /// + internal class PromptNestFrame : IDisposable + { + private const PSInvocationState IndisposableStates = PSInvocationState.Stopping | PSInvocationState.Running; + + private SemaphoreSlim _frameExited = new SemaphoreSlim(initialCount: 0); + + private bool _isDisposed = false; + + /// + /// Gets the instance. + /// + internal PowerShell PowerShell { get; } + + /// + /// Gets the queue that controls command invocation order. + /// + internal AsyncQueue Queue { get; } + + /// + /// Gets the frame type. + /// + internal PromptNestFrameType FrameType { get; } + + /// + /// Gets the . + /// + internal ThreadController ThreadController { get; } + + /// + /// Gets a value indicating whether the frame requires command invocations + /// to be routed to a specific thread. + /// + internal bool IsThreadController { get; } + + internal PromptNestFrame(PowerShell powerShell, AsyncQueue handleQueue) + : this(powerShell, handleQueue, PromptNestFrameType.Normal) + { } + + internal PromptNestFrame( + PowerShell powerShell, + AsyncQueue handleQueue, + PromptNestFrameType frameType) + { + PowerShell = powerShell; + Queue = handleQueue; + FrameType = frameType; + IsThreadController = (frameType & (PromptNestFrameType.Debug | PromptNestFrameType.NestedPrompt)) != 0; + if (!IsThreadController) + { + return; + } + + ThreadController = new ThreadController(this); + } + + public void Dispose() + { + Dispose(true); + } + + protected virtual void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + if (disposing) + { + if (IndisposableStates.HasFlag(PowerShell.InvocationStateInfo.State)) + { + PowerShell.BeginStop( + asyncResult => + { + PowerShell.Runspace = null; + PowerShell.Dispose(); + }, + null); + } + else + { + PowerShell.Runspace = null; + PowerShell.Dispose(); + } + + _frameExited.Release(); + } + + _isDisposed = true; + } + + /// + /// Blocks until the frame has been disposed. + /// + /// + /// The that will exit the block when cancelled. + /// + internal void WaitForFrameExit(CancellationToken cancellationToken) + { + _frameExited.Wait(cancellationToken); + _frameExited.Release(); + } + + /// + /// Creates a task object that is completed when the frame has been disposed. + /// + /// + /// The that will be checked prior to completing + /// the returned task. + /// + /// + /// A object that represents this frame being disposed. + /// + internal async Task WaitForFrameExitAsync(CancellationToken cancellationToken) + { + await _frameExited.WaitAsync(cancellationToken); + _frameExited.Release(); + } + } +} diff --git a/src/PowerShellEditorServices/Session/PromptNestFrameType.cs b/src/PowerShellEditorServices/Session/PromptNestFrameType.cs new file mode 100644 index 000000000..55cf550b7 --- /dev/null +++ b/src/PowerShellEditorServices/Session/PromptNestFrameType.cs @@ -0,0 +1,16 @@ +using System; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + [Flags] + internal enum PromptNestFrameType + { + Normal = 0, + + NestedPrompt = 1, + + Debug = 2, + + Remote = 4 + } +} diff --git a/src/PowerShellEditorServices/Session/ThreadController.cs b/src/PowerShellEditorServices/Session/ThreadController.cs new file mode 100644 index 000000000..95fc85bb5 --- /dev/null +++ b/src/PowerShellEditorServices/Session/ThreadController.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + /// + /// Provides the ability to route PowerShell command invocations to a specific thread. + /// + internal class ThreadController + { + private PromptNestFrame _nestFrame; + + internal AsyncQueue PipelineRequestQueue { get; } + + internal TaskCompletionSource FrameExitTask { get; } + + internal int ManagedThreadId { get; } + + internal bool IsPipelineThread { get; } + + /// + /// Initializes an new instance of the ThreadController class. This constructor should only + /// ever been called from the thread it is meant to control. + /// + /// The parent PromptNestFrame object. + internal ThreadController(PromptNestFrame nestFrame) + { + _nestFrame = nestFrame; + PipelineRequestQueue = new AsyncQueue(); + FrameExitTask = new TaskCompletionSource(); + ManagedThreadId = Thread.CurrentThread.ManagedThreadId; + + // If the debugger stop is triggered on a thread with no default runspace we + // shouldn't attempt to route commands to it. + IsPipelineThread = Runspace.DefaultRunspace != null; + } + + /// + /// Determines if the caller is already on the thread that this object maintains. + /// + /// + /// A value indicating if the caller is already on the thread maintained by this object. + /// + internal bool IsCurrentThread() + { + return Thread.CurrentThread.ManagedThreadId == ManagedThreadId; + } + + /// + /// Requests the invocation of a PowerShell command on the thread maintained by this object. + /// + /// The execution request to send. + /// + /// A task object representing the asynchronous operation. The Result property will return + /// the output of the command invocation. + /// + internal async Task> RequestPipelineExecution( + PipelineExecutionRequest executionRequest) + { + await PipelineRequestQueue.EnqueueAsync(executionRequest); + return await executionRequest.Results; + } + + /// + /// Retrieves the first currently queued execution request. If there are no pending + /// execution requests then the task will be completed when one is requested. + /// + /// + /// A task object representing the asynchronous operation. The Result property will return + /// the retrieved pipeline execution request. + /// + internal async Task TakeExecutionRequest() + { + return await PipelineRequestQueue.DequeueAsync(); + } + + /// + /// Marks the thread to be exited. + /// + /// + /// The resume action for the debugger. If the frame is not a debugger frame this parameter + /// is ignored. + /// + internal void StartThreadExit(DebuggerResumeAction action) + { + StartThreadExit(action, waitForExit: false); + } + + /// + /// Marks the thread to be exited. + /// + /// + /// The resume action for the debugger. If the frame is not a debugger frame this parameter + /// is ignored. + /// + /// + /// Indicates whether the method should block until the exit is completed. + /// + internal void StartThreadExit(DebuggerResumeAction action, bool waitForExit) + { + Task.Run(() => FrameExitTask.TrySetResult(action)); + if (!waitForExit) + { + return; + } + + _nestFrame.WaitForFrameExit(CancellationToken.None); + } + + /// + /// Creates a task object that completes when the thread has be marked for exit. + /// + /// + /// A task object representing the frame receiving a request to exit. The Result property + /// will return the DebuggerResumeAction supplied with the request. + /// + internal async Task Exit() + { + return await FrameExitTask.Task.ConfigureAwait(false); + } + } +}