From ffe7c619442a13ad9e25dddc854ba17194e25351 Mon Sep 17 00:00:00 2001 From: David Wilson Date: Thu, 22 Dec 2016 06:37:19 -0800 Subject: [PATCH 1/3] Add support for Enter-PSSession and Enter-PSHostProcess This change adds support for remote sessions (PSv4+) and attaching to processes on the same machine (PSv5+). --- .../PowerShellVersionRequest.cs | 10 +- .../LanguageServer/RunspaceChanged.cs | 37 +++ .../PowerShellEditorServices.Protocol.csproj | 1 + .../Server/LanguageServer.cs | 12 +- .../Language/CommandHelpers.cs | 4 +- .../Language/LanguageService.cs | 13 +- .../Nano.PowerShellEditorServices.csproj | 2 + .../PowerShellEditorServices.csproj | 1 + .../Session/PowerShell4Operations.cs | 5 +- .../Session/PowerShell5Operations.cs | 5 +- .../Session/PowerShellContext.cs | 222 ++++++++++++++++-- .../Session/RunspaceChangedEventArgs.cs | 75 ++++++ .../Session/RunspaceHandle.cs | 14 +- .../Session/SessionPSHost.cs | 76 +++++- .../LanguageServerTests.cs | 2 +- 15 files changed, 419 insertions(+), 60 deletions(-) create mode 100644 src/PowerShellEditorServices.Protocol/LanguageServer/RunspaceChanged.cs create mode 100644 src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs diff --git a/src/PowerShellEditorServices.Protocol/LanguageServer/PowerShellVersionRequest.cs b/src/PowerShellEditorServices.Protocol/LanguageServer/PowerShellVersionRequest.cs index 37f7ce1b0..87a7ac20e 100644 --- a/src/PowerShellEditorServices.Protocol/LanguageServer/PowerShellVersionRequest.cs +++ b/src/PowerShellEditorServices.Protocol/LanguageServer/PowerShellVersionRequest.cs @@ -11,11 +11,11 @@ namespace Microsoft.PowerShell.EditorServices.Protocol.LanguageServer public class PowerShellVersionRequest { public static readonly - RequestType Type = - RequestType.Create("powerShell/getVersion"); + RequestType Type = + RequestType.Create("powerShell/getVersion"); } - public class PowerShellVersionResponse + public class PowerShellVersion { public string Version { get; set; } @@ -25,11 +25,11 @@ public class PowerShellVersionResponse public string Architecture { get; set; } - public PowerShellVersionResponse() + public PowerShellVersion() { } - public PowerShellVersionResponse(PowerShellVersionDetails versionDetails) + public PowerShellVersion(PowerShellVersionDetails versionDetails) { this.Version = versionDetails.VersionString; this.DisplayVersion = $"{versionDetails.Version.Major}.{versionDetails.Version.Minor}"; diff --git a/src/PowerShellEditorServices.Protocol/LanguageServer/RunspaceChanged.cs b/src/PowerShellEditorServices.Protocol/LanguageServer/RunspaceChanged.cs new file mode 100644 index 000000000..e8de9c553 --- /dev/null +++ b/src/PowerShellEditorServices.Protocol/LanguageServer/RunspaceChanged.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices.Session; +using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.PowerShell.EditorServices.Protocol.LanguageServer +{ + public class RunspaceChangedEvent + { + public static readonly + EventType Type = + EventType.Create("powerShell/runspaceChanged"); + } + + public class RunspaceDetails + { + public PowerShellVersion PowerShellVersion { get; set; } + + public RunspaceType RunspaceType { get; set; } + + public string ConnectionString { get; set; } + + public RunspaceDetails() + { + } + + public RunspaceDetails(RunspaceChangedEventArgs eventArgs) + { + this.PowerShellVersion = new PowerShellVersion(eventArgs.RunspaceVersion); + this.RunspaceType = eventArgs.RunspaceType; + this.ConnectionString = eventArgs.ConnectionString; + } + } +} diff --git a/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj b/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj index d5e97d08f..5761e0841 100644 --- a/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj +++ b/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj @@ -61,6 +61,7 @@ + diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs index 6a7e81d81..d924da5b3 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs @@ -52,6 +52,7 @@ public LanguageServer(HostDetails hostDetails, ProfilePaths profilePaths, Channe this.editorSession = new EditorSession(); this.editorSession.StartSession(hostDetails, profilePaths); this.editorSession.ConsoleService.OutputWritten += this.powerShellContext_OutputWritten; + this.editorSession.PowerShellContext.RunspaceChanged += PowerShellContext_RunspaceChanged; // Attach to ExtensionService events this.editorSession.ExtensionService.CommandAdded += ExtensionService_ExtensionAdded; @@ -910,10 +911,10 @@ protected async Task HandleWorkspaceSymbolRequest( protected async Task HandlePowerShellVersionRequest( object noParams, - RequestContext requestContext) + RequestContext requestContext) { await requestContext.SendResult( - new PowerShellVersionResponse( + new PowerShellVersion( this.editorSession.PowerShellContext.PowerShellVersionDetails)); } @@ -988,6 +989,13 @@ protected Task HandleEvaluateRequest( #region Event Handlers + private async void PowerShellContext_RunspaceChanged(object sender, RunspaceChangedEventArgs e) + { + await this.SendEvent( + RunspaceChangedEvent.Type, + new RunspaceDetails(e)); + } + private async void powerShellContext_OutputWritten(object sender, OutputWrittenEventArgs e) { // Queue the output for writing diff --git a/src/PowerShellEditorServices/Language/CommandHelpers.cs b/src/PowerShellEditorServices/Language/CommandHelpers.cs index d3976dcab..75b027a59 100644 --- a/src/PowerShellEditorServices/Language/CommandHelpers.cs +++ b/src/PowerShellEditorServices/Language/CommandHelpers.cs @@ -28,8 +28,8 @@ public static async Task GetCommandInfo( command.AddCommand(@"Microsoft.PowerShell.Core\Get-Command"); command.AddArgument(commandName); - var results = await powerShellContext.ExecuteCommand(command, false, false); - return results.FirstOrDefault(); + var results = await powerShellContext.ExecuteCommand(command, false, false); + return results.OfType().FirstOrDefault(); } /// diff --git a/src/PowerShellEditorServices/Language/LanguageService.cs b/src/PowerShellEditorServices/Language/LanguageService.cs index e7e0a3769..b3fff7764 100644 --- a/src/PowerShellEditorServices/Language/LanguageService.cs +++ b/src/PowerShellEditorServices/Language/LanguageService.cs @@ -346,7 +346,7 @@ await CommandHelpers.GetCommandInfo( this.powerShellContext); foundDefinition = - await FindDeclarationForBuiltinCommand( + FindDeclarationForBuiltinCommand( cmdInfo, foundSymbol, workspace); @@ -532,22 +532,17 @@ private ScriptFile[] GetBuiltinCommandScriptFiles( return new List().ToArray(); } - private async Task FindDeclarationForBuiltinCommand( - CommandInfo cmdInfo, + private SymbolReference FindDeclarationForBuiltinCommand( + CommandInfo commandInfo, SymbolReference foundSymbol, Workspace workspace) { SymbolReference foundDefinition = null; - if (cmdInfo != null) + if (commandInfo != null) { int index = 0; ScriptFile[] nestedModuleFiles; - CommandInfo commandInfo = - await CommandHelpers.GetCommandInfo( - foundSymbol.SymbolName, - this.powerShellContext); - nestedModuleFiles = GetBuiltinCommandScriptFiles( commandInfo.Module, diff --git a/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj b/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj index 62c2244b5..a69017a45 100644 --- a/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj +++ b/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj @@ -113,6 +113,7 @@ + @@ -149,6 +150,7 @@ + diff --git a/src/PowerShellEditorServices/PowerShellEditorServices.csproj b/src/PowerShellEditorServices/PowerShellEditorServices.csproj index 62103ef88..1ee63f1e5 100644 --- a/src/PowerShellEditorServices/PowerShellEditorServices.csproj +++ b/src/PowerShellEditorServices/PowerShellEditorServices.csproj @@ -121,6 +121,7 @@ + diff --git a/src/PowerShellEditorServices/Session/PowerShell4Operations.cs b/src/PowerShellEditorServices/Session/PowerShell4Operations.cs index fd582f4bd..935e68495 100644 --- a/src/PowerShellEditorServices/Session/PowerShell4Operations.cs +++ b/src/PowerShellEditorServices/Session/PowerShell4Operations.cs @@ -16,7 +16,10 @@ internal class PowerShell4Operations : IVersionSpecificOperations public void ConfigureDebugger(Runspace runspace) { #if !PowerShellv3 - runspace.Debugger.SetDebugMode(DebugModes.LocalScript | DebugModes.RemoteScript); + if (runspace.Debugger != null) + { + runspace.Debugger.SetDebugMode(DebugModes.LocalScript | DebugModes.RemoteScript); + } #endif } diff --git a/src/PowerShellEditorServices/Session/PowerShell5Operations.cs b/src/PowerShellEditorServices/Session/PowerShell5Operations.cs index 65c7f4527..54f434cb8 100644 --- a/src/PowerShellEditorServices/Session/PowerShell5Operations.cs +++ b/src/PowerShellEditorServices/Session/PowerShell5Operations.cs @@ -12,7 +12,10 @@ internal class PowerShell5Operations : PowerShell4Operations public override void PauseDebugger(Runspace runspace) { #if !PowerShellv3 && !PowerShellv4 - runspace.Debugger.SetDebuggerStepMode(true); + if (runspace.Debugger != null) + { + runspace.Debugger.SetDebuggerStepMode(true); + } #endif } } diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index 280220372..fec813cdf 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -18,8 +18,10 @@ namespace Microsoft.PowerShell.EditorServices { + using CSharp.RuntimeBinder; using Session; using System.Management.Automation; + using System.Management.Automation.Host; using System.Management.Automation.Runspaces; using System.Reflection; @@ -28,15 +30,16 @@ namespace Microsoft.PowerShell.EditorServices /// Handles nested PowerShell prompts and also manages execution of /// commands whether inside or outside of the debugger. /// - public class PowerShellContext : IDisposable + public class PowerShellContext : IDisposable, IHostSupportsInteractiveSession { #region Fields private PowerShell powerShell; - private IConsoleHost consoleHost; private bool ownsInitialRunspace; + private IConsoleHost consoleHost; private Runspace initialRunspace; private Runspace currentRunspace; + private RunspaceType runspaceType; private ProfilePaths profilePaths; private ConsoleServicePSHost psHost; private InitialSessionState initialSessionState; @@ -50,6 +53,8 @@ public class PowerShellContext : IDisposable private object runspaceMutex = new object(); private AsyncQueue runspaceWaitQueue = new AsyncQueue(); + private Stack runspaceStack = new Stack(); + #endregion #region Properties @@ -135,7 +140,7 @@ public PowerShellContext(HostDetails hostDetails, ProfilePaths profilePaths) { hostDetails = hostDetails ?? HostDetails.Default; - this.psHost = new ConsoleServicePSHost(hostDetails); + this.psHost = new ConsoleServicePSHost(hostDetails, this); this.initialSessionState = InitialSessionState.CreateDefault2(); Runspace runspace = RunspaceFactory.CreateRunspace(psHost, this.initialSessionState); @@ -187,16 +192,12 @@ private void Initialize(ProfilePaths profilePaths, Runspace initialRunspace) this.initialRunspace = initialRunspace; this.currentRunspace = initialRunspace; - this.psHost.Runspace = initialRunspace; - - this.currentRunspace.Debugger.BreakpointUpdated += OnBreakpointUpdated; - this.currentRunspace.Debugger.DebuggerStop += OnDebuggerStop; this.powerShell = PowerShell.Create(); this.powerShell.Runspace = this.currentRunspace; // Get the PowerShell runtime version - this.PowerShellVersionDetails = GetPowerShellVersion(); + this.PowerShellVersionDetails = GetPowerShellVersion(this.currentRunspace); // Write out the PowerShell version for tracking purposes Logger.Write( @@ -231,9 +232,8 @@ private void Initialize(ProfilePaths profilePaths, Runspace initialRunspace) this.SetExecutionPolicy(ExecutionPolicy.RemoteSigned); } - // Configure the runspace's debugger - this.versionSpecificOperations.ConfigureDebugger( - this.currentRunspace); + // Set up the runspace + this.ConfigureRunspace(this.currentRunspace, false); // Set the $profile variable in the runspace this.profilePaths = profilePaths; @@ -248,11 +248,73 @@ private void Initialize(ProfilePaths profilePaths, Runspace initialRunspace) this.SessionState = PowerShellContextState.Ready; // Now that the runspace is ready, enqueue it for first use - RunspaceHandle runspaceHandle = new RunspaceHandle(this.currentRunspace, this); + RunspaceHandle runspaceHandle = new RunspaceHandle(this); this.runspaceWaitQueue.EnqueueAsync(runspaceHandle).Wait(); } - private PowerShellVersionDetails GetPowerShellVersion() + private void ConfigureRunspace(Runspace runspace, bool getVersion) + { + if (getVersion) + { + this.runspaceType = RunspaceType.Local; + this.PowerShellVersionDetails = this.GetPowerShellVersion(this.currentRunspace); + + string connectionString = null; + + if (runspace.ConnectionInfo != null) + { + // Use 'dynamic' to avoid missing NamedPipeRunspaceConnectionInfo + // on PS v3 and v4 + try + { + dynamic connectionInfo = runspace.ConnectionInfo; + if (connectionInfo.ProcessId != null) + { + this.runspaceType = RunspaceType.Process; + connectionString = connectionInfo.ProcessId.ToString(); + } + } + catch (RuntimeBinderException) + { + // ProcessId property isn't on the object, move on. + } + + if (connectionString == null && runspace.ConnectionInfo.ComputerName != "localhost") + { + this.runspaceType = RunspaceType.Remote; + connectionString = runspace.ConnectionInfo.ComputerName; + } + } + + this.OnRunspaceChanged( + this, + new RunspaceChangedEventArgs( + this.PowerShellVersionDetails, + this.runspaceType, + connectionString)); + } + + // Subscribe to runspace events + if (runspace.Debugger != null) + { + runspace.Debugger.BreakpointUpdated += OnBreakpointUpdated; + runspace.Debugger.DebuggerStop += OnDebuggerStop; + } + + // Configure the runspace's debugger + this.versionSpecificOperations.ConfigureDebugger(runspace); + } + + private void CleanupRunspace(Runspace runspace) + { + if (runspace.Debugger != null) + { + runspace.Debugger.BreakpointUpdated -= OnBreakpointUpdated; + runspace.Debugger.DebuggerStop -= OnDebuggerStop; + } + } + + private PowerShellVersionDetails GetPowerShellVersion(Runspace runspace) { Version powerShellVersion = new Version(5, 0); string versionString = null; @@ -261,7 +323,7 @@ private PowerShellVersionDetails GetPowerShellVersion() try { - var psVersionTable = this.currentRunspace.SessionStateProxy.GetVariable("PSVersionTable") as Hashtable; + var psVersionTable = this.ExecuteScriptAndGetItem("$PSVersionTable", runspace); if (psVersionTable != null) { var edition = psVersionTable["PSEdition"] as string; @@ -294,7 +356,7 @@ private PowerShellVersionDetails GetPowerShellVersion() versionString = powerShellVersion.ToString(); } - var arch = this.currentRunspace.SessionStateProxy.GetVariable("env:PROCESSOR_ARCHITECTURE") as string; + var arch = this.ExecuteScriptAndGetItem("$env:PROCESSOR_ARCHITECTURE", runspace); if (string.Equals(arch, "AMD64", StringComparison.CurrentCultureIgnoreCase)) { architecture = PowerShellProcessArchitecture.X64; @@ -614,6 +676,50 @@ public async Task ExecuteScriptAtPath(string scriptPath, string arguments = null await this.ExecuteCommand(command, true); } + private TResult ExecuteScriptAndGetItem(string scriptToExecute, Runspace runspace, TResult defaultValue = default(TResult)) + { + Pipeline pipeline = null; + + try + { + if (runspace.RunspaceAvailability == RunspaceAvailability.AvailableForNestedCommand) + { + pipeline = runspace.CreateNestedPipeline(scriptToExecute, false); + } + else + { + pipeline = runspace.CreatePipeline(scriptToExecute, false); + } + + Collection results = pipeline.Invoke(); + + if (results.Count == 0) + { + return defaultValue; + } + + if (typeof(TResult) != typeof(PSObject)) + { + return + results + .Select(pso => pso.BaseObject) + .OfType() + .FirstOrDefault(); + } + else + { + return + results + .OfType() + .FirstOrDefault(); + } + } + finally + { + pipeline.Dispose(); + } + } + /// /// Loads PowerShell profiles for the host from the specified /// profile locations. Only the profile paths which exist are @@ -728,7 +834,7 @@ internal void ReleaseRunspaceHandle(RunspaceHandle runspaceHandle) if (this.runspaceWaitQueue.IsEmpty) { - var newRunspaceHandle = new RunspaceHandle(this.currentRunspace, this); + var newRunspaceHandle = new RunspaceHandle(this); this.runspaceWaitQueue.EnqueueAsync(newRunspaceHandle).Wait(); } else @@ -805,11 +911,17 @@ private void OnSessionStateChanged(object sender, SessionStateChangedEventArgs e e.ExecutionResult)); this.SessionState = e.NewSessionState; + this.SessionStateChanged?.Invoke(sender, e); + } - if (this.SessionStateChanged != null) - { - this.SessionStateChanged(sender, e); - } + /// + /// Raised when the runspace changes by entering a remote session or one in a different process. + /// + public event EventHandler RunspaceChanged; + + private void OnRunspaceChanged(object sender, RunspaceChangedEventArgs e) + { + this.RunspaceChanged?.Invoke(sender, e); } #endregion @@ -1097,12 +1209,25 @@ private void WritePromptWithRunspace(Runspace runspace) this.WritePromptToHost( command => { + this.powerShell.Runspace = runspace; this.powerShell.Commands = command; - return + var promptString = this.powerShell .Invoke() .FirstOrDefault(); + + if (this.runspaceType == RunspaceType.Remote) + { + promptString = + string.Format( + CultureInfo.InvariantCulture, + "[{0}]: {1}", + runspace.ConnectionInfo.ComputerName, + promptString); + } + + return promptString; }); } @@ -1311,6 +1436,61 @@ await this.powerShellContext.ExecuteCommand( } #endregion + + #region IHostSupportsInteractiveSession Implementation + + bool IHostSupportsInteractiveSession.IsRunspacePushed + { + get + { + return this.runspaceStack.Count > 0; + } + } + + Runspace IHostSupportsInteractiveSession.Runspace + { + get + { + return this.currentRunspace; + } + } + + void IHostSupportsInteractiveSession.PushRunspace(Runspace runspace) + { + Logger.Write( + LogLevel.Verbose, + $"Pushing runspace {this.currentRunspace.Id}, new runspace is {runspace.Id} (remote: {runspace.ConnectionInfo != null})"); + + this.CleanupRunspace(this.currentRunspace); + this.runspaceStack.Push(this.currentRunspace); + + this.currentRunspace = runspace; + this.ConfigureRunspace(runspace, true); + } + + void IHostSupportsInteractiveSession.PopRunspace() + { + if (this.runspaceStack.Count > 0) + { + var oldRunspace = this.currentRunspace; + this.currentRunspace = this.runspaceStack.Pop(); + + Logger.Write( + LogLevel.Verbose, + $"Popping runspace {oldRunspace.Id}, new runspace is {this.currentRunspace.Id} (remote: {this.currentRunspace.ConnectionInfo != null})"); + + this.CleanupRunspace(oldRunspace); + this.ConfigureRunspace(this.currentRunspace, true); + } + else + { + Logger.Write( + LogLevel.Error, + "Caller attempted to pop a runspace when no runspaces are on the stack."); + } + } + + #endregion } } diff --git a/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs b/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs new file mode 100644 index 000000000..980ce8aa8 --- /dev/null +++ b/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs @@ -0,0 +1,75 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices.Session +{ + /// + /// Specifies the possible types of a runspace. + /// + public enum RunspaceType + { + /// + /// A local runspace in the current process. + /// + Local, + + /// + /// A local runspace in a different process. + /// + Process, + + /// + /// A runspace in a process on a another machine. + /// + Remote, + } + + /// + /// Provides arguments for the PowerShellContext.RunspaceChanged + /// event. + /// + public class RunspaceChangedEventArgs + { + /// + /// Gets the PowerShell version of the new runspace. + /// + public PowerShellVersionDetails RunspaceVersion { get; private set; } + + /// + /// Gets the runspace type. + /// + public RunspaceType RunspaceType { get; private set; } + + /// + /// Gets the "connection string" for the runspace, generally the + /// ComputerName for a remote runspace or the ProcessId of an + /// "Attach" runspace. + /// + public string ConnectionString { get; private set; } + + /// + /// Creates a new instance of the RunspaceChangedEventArgs + /// class. + /// + /// + /// The PowerShellVersionDetails of the new runspace. + /// + /// + /// The RunspaceType of the new runspace. + /// + /// + /// The connection string of the new runspace. + /// + public RunspaceChangedEventArgs( + PowerShellVersionDetails runspaceVersion, + RunspaceType runspaceType, + string connectionString) + { + this.RunspaceVersion = runspaceVersion; + this.RunspaceType = runspaceType; + this.ConnectionString = connectionString; + } + } +} diff --git a/src/PowerShellEditorServices/Session/RunspaceHandle.cs b/src/PowerShellEditorServices/Session/RunspaceHandle.cs index 0c9ae0ab5..b7fc0e8f1 100644 --- a/src/PowerShellEditorServices/Session/RunspaceHandle.cs +++ b/src/PowerShellEditorServices/Session/RunspaceHandle.cs @@ -4,6 +4,7 @@ // using System; +using System.Management.Automation.Host; using System.Management.Automation.Runspaces; namespace Microsoft.PowerShell.EditorServices @@ -19,17 +20,21 @@ public class RunspaceHandle : IDisposable /// /// Gets the runspace that is held by this handle. /// - public Runspace Runspace { get; private set; } + public Runspace Runspace + { + get + { + return ((IHostSupportsInteractiveSession)this.powerShellContext).Runspace; + } + } /// /// Initializes a new instance of the RunspaceHandle class using the /// given runspace. /// - /// The runspace instance which is temporarily owned by this handle. /// The PowerShellContext instance which manages the runspace. - public RunspaceHandle(Runspace runspace, PowerShellContext powerShellContext) + public RunspaceHandle(PowerShellContext powerShellContext) { - this.Runspace = runspace; this.powerShellContext = powerShellContext; } @@ -42,7 +47,6 @@ public void Dispose() // Release the handle and clear the runspace so that // no further operations can be performed on it. this.powerShellContext.ReleaseRunspaceHandle(this); - this.Runspace = null; } } } diff --git a/src/PowerShellEditorServices/Session/SessionPSHost.cs b/src/PowerShellEditorServices/Session/SessionPSHost.cs index 85a650858..a13ab22be 100644 --- a/src/PowerShellEditorServices/Session/SessionPSHost.cs +++ b/src/PowerShellEditorServices/Session/SessionPSHost.cs @@ -25,6 +25,7 @@ internal class ConsoleServicePSHost : PSHost, IHostSupportsInteractiveSession private IConsoleHost consoleHost; private Guid instanceId = Guid.NewGuid(); private ConsoleServicePSHostUserInterface hostUserInterface; + private IHostSupportsInteractiveSession hostSupportsInteractiveSession; #endregion @@ -51,10 +52,16 @@ internal IConsoleHost ConsoleHost /// /// Provides details about the host application. /// - public ConsoleServicePSHost(HostDetails hostDetails) + /// + /// An implementation of IHostSupportsInteractiveSession for runspace management. + /// + public ConsoleServicePSHost( + HostDetails hostDetails, + IHostSupportsInteractiveSession hostSupportsInteractiveSession) { this.hostDetails = hostDetails; this.hostUserInterface = new ConsoleServicePSHostUserInterface(); + this.hostSupportsInteractiveSession = hostSupportsInteractiveSession; } #endregion @@ -93,16 +100,6 @@ public override PSHostUserInterface UI get { return this.hostUserInterface; } } - public bool IsRunspacePushed - { - get { return false; } - } - - public Runspace Runspace - { - get; internal set; - } - public override void EnterNestedPrompt() { Logger.Write(LogLevel.Verbose, "EnterNestedPrompt() called."); @@ -128,17 +125,70 @@ public override void SetShouldExit(int exitCode) if (this.consoleHost != null) { this.consoleHost.ExitSession(exitCode); + + if (this.IsRunspacePushed) + { + this.PopRunspace(); + } + } + } + + #endregion + + #region IHostSupportsInteractiveSession Implementation + + public bool IsRunspacePushed + { + get + { + if (this.hostSupportsInteractiveSession != null) + { + return this.hostSupportsInteractiveSession.IsRunspacePushed; + } + else + { + throw new NotImplementedException(); + } + } + } + + public Runspace Runspace + { + get + { + if (this.hostSupportsInteractiveSession != null) + { + return this.hostSupportsInteractiveSession.Runspace; + } + else + { + throw new NotImplementedException(); + } } } public void PushRunspace(Runspace runspace) { - throw new NotImplementedException(); + if (this.hostSupportsInteractiveSession != null) + { + this.hostSupportsInteractiveSession.PushRunspace(runspace); + } + else + { + throw new NotImplementedException(); + } } public void PopRunspace() { - throw new NotImplementedException(); + if (this.hostSupportsInteractiveSession != null) + { + this.hostSupportsInteractiveSession.PopRunspace(); + } + else + { + throw new NotImplementedException(); + } } #endregion diff --git a/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs b/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs index 844006803..05009ce5d 100644 --- a/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs +++ b/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs @@ -705,7 +705,7 @@ await this.languageServiceClient.SendEvent( [Fact] public async Task ServiceReturnsPowerShellVersionDetails() { - PowerShellVersionResponse versionDetails = + PowerShellVersion versionDetails = await this.SendRequest( PowerShellVersionRequest.Type, new PowerShellVersionRequest()); From 5fe47a41e41e6089798826ba5b2fcc55a552cff5 Mon Sep 17 00:00:00 2001 From: David Wilson Date: Thu, 29 Dec 2016 16:15:04 -0800 Subject: [PATCH 2/3] Enable debugging of remote and attached runspaces This change finishes support for Enter-PSSession and Enter-PSHostProcess by wiring up the AttachRequest so that both of these commands can be used to attach to a runspace in a process on a local or remote machine. It also enables the LaunchRequest to be executed without a script file path to enable an interactive debugging session experience. --- .../DebugAdapter/AttachRequest.cs | 6 +- .../DebugAdapter/ContinuedEvent.cs | 20 + .../DebugAdapter/TerminatedEvent.cs | 6 +- .../LanguageServer/RunspaceChanged.cs | 8 +- .../Channel/TcpSocketServerChannel.cs | 1 + .../MessageProtocol/MessageDispatcher.cs | 1 + .../MessageProtocol/ProtocolEndpoint.cs | 1 - .../PowerShellEditorServices.Protocol.csproj | 1 + .../Server/DebugAdapter.cs | 204 ++++-- .../Server/DebugAdapterBase.cs | 5 - .../Server/LanguageServer.cs | 6 +- .../Debugging/DebugService.cs | 218 ++++++- .../Debugging/DebuggerStoppedEventArgs.cs | 117 ++++ .../Debugging/StackFrameDetails.cs | 34 +- .../Debugging/VariableDetails.cs | 15 + .../Language/CommandHelpers.cs | 8 +- .../Language/LanguageService.cs | 2 +- .../Nano.PowerShellEditorServices.csproj | 3 + .../PowerShellEditorServices.csproj | 2 + .../Session/EditorSession.cs | 4 +- .../Session/PowerShell3Operations.cs | 18 +- .../Session/PowerShell4Operations.cs | 18 +- .../Session/PowerShellContext.cs | 591 +++++++++++------- .../Session/PowerShellVersionDetails.cs | 87 +++ .../Session/RunspaceChangedEventArgs.cs | 66 +- .../Session/RunspaceDetails.cs | 203 ++++++ .../Session/SessionPSHost.cs | 8 +- .../Workspace/Workspace.cs | 49 +- .../Debugging/DebugServiceTests.cs | 15 +- .../Language/LanguageServiceTests.cs | 2 +- 30 files changed, 1330 insertions(+), 389 deletions(-) create mode 100644 src/PowerShellEditorServices.Protocol/DebugAdapter/ContinuedEvent.cs create mode 100644 src/PowerShellEditorServices/Debugging/DebuggerStoppedEventArgs.cs create mode 100644 src/PowerShellEditorServices/Session/RunspaceDetails.cs diff --git a/src/PowerShellEditorServices.Protocol/DebugAdapter/AttachRequest.cs b/src/PowerShellEditorServices.Protocol/DebugAdapter/AttachRequest.cs index 4b8faa9c3..902abd990 100644 --- a/src/PowerShellEditorServices.Protocol/DebugAdapter/AttachRequest.cs +++ b/src/PowerShellEditorServices.Protocol/DebugAdapter/AttachRequest.cs @@ -16,8 +16,10 @@ public static readonly public class AttachRequestArguments { - public string Address { get; set; } + public string ComputerName { get; set; } - public int Port { get; set; } + public int ProcessId { get; set; } + + public int RunspaceId { get; set; } } } diff --git a/src/PowerShellEditorServices.Protocol/DebugAdapter/ContinuedEvent.cs b/src/PowerShellEditorServices.Protocol/DebugAdapter/ContinuedEvent.cs new file mode 100644 index 000000000..8e307500a --- /dev/null +++ b/src/PowerShellEditorServices.Protocol/DebugAdapter/ContinuedEvent.cs @@ -0,0 +1,20 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.PowerShell.EditorServices.Protocol.DebugAdapter +{ + public class ContinuedEvent + { + public static readonly + EventType Type = + EventType.Create("continued"); + + public int ThreadId { get; set; } + + public bool AllThreadsContinued { get; set; } + } +} diff --git a/src/PowerShellEditorServices.Protocol/DebugAdapter/TerminatedEvent.cs b/src/PowerShellEditorServices.Protocol/DebugAdapter/TerminatedEvent.cs index 2756899fd..39202676d 100644 --- a/src/PowerShellEditorServices.Protocol/DebugAdapter/TerminatedEvent.cs +++ b/src/PowerShellEditorServices.Protocol/DebugAdapter/TerminatedEvent.cs @@ -10,8 +10,10 @@ namespace Microsoft.PowerShell.EditorServices.Protocol.DebugAdapter public class TerminatedEvent { public static readonly - EventType Type = - EventType.Create("terminated"); + EventType Type = + EventType.Create("terminated"); + + public bool Restart { get; set; } } } diff --git a/src/PowerShellEditorServices.Protocol/LanguageServer/RunspaceChanged.cs b/src/PowerShellEditorServices.Protocol/LanguageServer/RunspaceChanged.cs index e8de9c553..77a0148dc 100644 --- a/src/PowerShellEditorServices.Protocol/LanguageServer/RunspaceChanged.cs +++ b/src/PowerShellEditorServices.Protocol/LanguageServer/RunspaceChanged.cs @@ -19,7 +19,7 @@ public class RunspaceDetails { public PowerShellVersion PowerShellVersion { get; set; } - public RunspaceType RunspaceType { get; set; } + public RunspaceLocation RunspaceType { get; set; } public string ConnectionString { get; set; } @@ -27,10 +27,10 @@ public RunspaceDetails() { } - public RunspaceDetails(RunspaceChangedEventArgs eventArgs) + public RunspaceDetails(Session.RunspaceDetails eventArgs) { - this.PowerShellVersion = new PowerShellVersion(eventArgs.RunspaceVersion); - this.RunspaceType = eventArgs.RunspaceType; + this.PowerShellVersion = new PowerShellVersion(eventArgs.PowerShellVersion); + this.RunspaceType = eventArgs.Location; this.ConnectionString = eventArgs.ConnectionString; } } diff --git a/src/PowerShellEditorServices.Protocol/MessageProtocol/Channel/TcpSocketServerChannel.cs b/src/PowerShellEditorServices.Protocol/MessageProtocol/Channel/TcpSocketServerChannel.cs index 483a90976..6f79c28c2 100755 --- a/src/PowerShellEditorServices.Protocol/MessageProtocol/Channel/TcpSocketServerChannel.cs +++ b/src/PowerShellEditorServices.Protocol/MessageProtocol/Channel/TcpSocketServerChannel.cs @@ -51,6 +51,7 @@ protected override void Shutdown() { if (this.tcpListener != null) { + this.networkStream.Dispose(); this.tcpListener.Stop(); this.tcpListener = null; diff --git a/src/PowerShellEditorServices.Protocol/MessageProtocol/MessageDispatcher.cs b/src/PowerShellEditorServices.Protocol/MessageProtocol/MessageDispatcher.cs index 964488ecc..40f494fd6 100644 --- a/src/PowerShellEditorServices.Protocol/MessageProtocol/MessageDispatcher.cs +++ b/src/PowerShellEditorServices.Protocol/MessageProtocol/MessageDispatcher.cs @@ -89,6 +89,7 @@ public void Stop() { this.messageLoopCancellationToken.Cancel(); this.messageLoopThread.Stop(); + SynchronizationContext.SetSynchronizationContext(null); } } diff --git a/src/PowerShellEditorServices.Protocol/MessageProtocol/ProtocolEndpoint.cs b/src/PowerShellEditorServices.Protocol/MessageProtocol/ProtocolEndpoint.cs index 1993cf899..92aed5ecb 100644 --- a/src/PowerShellEditorServices.Protocol/MessageProtocol/ProtocolEndpoint.cs +++ b/src/PowerShellEditorServices.Protocol/MessageProtocol/ProtocolEndpoint.cs @@ -335,7 +335,6 @@ private void MessageDispatcher_UnhandledException(object sender, Exception e) { this.endpointExitedTask.SetException(e); } - else if (this.originalSynchronizationContext != null) { this.originalSynchronizationContext.Post(o => { throw e; }, null); diff --git a/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj b/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj index 5761e0841..3bb8e254e 100644 --- a/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj +++ b/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj @@ -52,6 +52,7 @@ + diff --git a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs index 86a87cc9b..d6974eb7c 100644 --- a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs +++ b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs @@ -14,6 +14,7 @@ using System.IO; using System.Linq; using System.Management.Automation; +using System.Text; using System.Threading.Tasks; namespace Microsoft.PowerShell.EditorServices.Protocol.Server @@ -22,9 +23,8 @@ public class DebugAdapter : DebugAdapterBase { private EditorSession editorSession; private OutputDebouncer outputDebouncer; - private bool isConfigurationDoneRequestComplete; - private bool isLaunchRequestComplete; private bool noDebug; + private bool waitingForAttach; private string scriptPathToLaunch; private string arguments; @@ -38,6 +38,7 @@ public DebugAdapter(HostDetails hostDetails, ProfilePaths profilePaths, ChannelB { this.editorSession = new EditorSession(); this.editorSession.StartDebugSession(hostDetails, profilePaths); + this.editorSession.PowerShellContext.RunspaceChanged += this.powerShellContext_RunspaceChanged; this.editorSession.DebugService.DebuggerStopped += this.DebugService_DebuggerStopped; this.editorSession.ConsoleService.OutputWritten += this.powerShellContext_OutputWritten; @@ -86,7 +87,7 @@ protected Task LaunchScript(RequestContext requestContext) await requestContext.SendEvent( TerminatedEvent.Type, - null); + new TerminatedEvent()); // Stop the server await this.Stop(); @@ -113,16 +114,14 @@ protected async Task HandleConfigurationDoneRequest( object args, RequestContext requestContext) { - // The order of debug protocol messages apparently isn't as guaranteed as we might like. - // Need to be able to handle the case where we get the configurationDone request after the - // launch request. - if (this.isLaunchRequestComplete) + if (!string.IsNullOrEmpty(this.scriptPathToLaunch)) { - this.LaunchScript(requestContext); + // Configuration is done, launch the script + var nonAwaitedTask = + this.LaunchScript(requestContext) + .ConfigureAwait(false); } - this.isConfigurationDoneRequestComplete = true; - await requestContext.SendResult(null); } @@ -133,19 +132,31 @@ protected async Task HandleLaunchRequest( // Set the working directory for the PowerShell runspace to the cwd passed in via launch.json. // In case that is null, use the the folder of the script to be executed. If the resulting // working dir path is a file path then extract the directory and use that. - string workingDir = launchParams.Cwd ?? launchParams.Script ?? launchParams.Program; - workingDir = PowerShellContext.UnescapePath(workingDir); - try + string workingDir = + launchParams.Cwd ?? + launchParams.Script ?? + launchParams.Program; + + if (workingDir != null) { - if ((File.GetAttributes(workingDir) & FileAttributes.Directory) != FileAttributes.Directory) + workingDir = PowerShellContext.UnescapePath(workingDir); + try + { + if ((File.GetAttributes(workingDir) & FileAttributes.Directory) != FileAttributes.Directory) + { + workingDir = Path.GetDirectoryName(workingDir); + } + } + catch (Exception ex) { - workingDir = Path.GetDirectoryName(workingDir); + Logger.Write(LogLevel.Error, "cwd path is invalid: " + ex.Message); + + workingDir = null; } } - catch (Exception ex) - { - Logger.Write(LogLevel.Error, "cwd path is invalid: " + ex.Message); + if (workingDir == null) + { #if NanoServer workingDir = AppContext.BaseDirectory; #else @@ -164,37 +175,107 @@ protected async Task HandleLaunchRequest( Logger.Write(LogLevel.Verbose, "Script arguments are: " + arguments); } - // We may not actually launch the script in response to this - // request unless it comes after the configurationDone request. - // If the launch request comes first, then stash the launch - // params so that the subsequent configurationDone request handler - // can launch the script. + // Store the launch parameters so that they can be used later this.noDebug = launchParams.NoDebug; this.scriptPathToLaunch = launchParams.Script ?? launchParams.Program; this.arguments = arguments; - // The order of debug protocol messages apparently isn't as guaranteed as we might like. - // Need to be able to handle the case where we get the launch request after the - // configurationDone request. - if (this.isConfigurationDoneRequestComplete) + await requestContext.SendResult(null); + + // If no script is being launched, execute an empty script to + // cause the prompt string to be evaluated and displayed + if (!string.IsNullOrEmpty(this.scriptPathToLaunch)) { - this.LaunchScript(requestContext); + await this.editorSession.PowerShellContext.ExecuteScriptString( + "", false, true); } - this.isLaunchRequestComplete = true; - - await requestContext.SendResult(null); + // Send the InitializedEvent so that the debugger will continue + // sending configuration requests + await requestContext.SendEvent( + InitializedEvent.Type, + null); } - protected Task HandleAttachRequest( + protected async Task HandleAttachRequest( AttachRequestArguments attachParams, RequestContext requestContext) { - // TODO: Implement this once we support attaching to processes - throw new NotImplementedException(); + StringBuilder errorMessages = new StringBuilder(); + + if (attachParams.ComputerName != null) + { + PowerShellVersionDetails runspaceVersion = + this.editorSession.PowerShellContext.CurrentRunspace.PowerShellVersion; + + if (runspaceVersion.Version.Major < 4) + { + await requestContext.SendError( + $"Remote sessions are only available with PowerShell 4 and higher (current session is {runspaceVersion.Version})."); + + return; + } + + await this.editorSession.PowerShellContext.ExecuteScriptString( + $"Enter-PSSession -ComputerName \"{attachParams.ComputerName}\"", + errorMessages); + + if (errorMessages.Length > 0) + { + await requestContext.SendError( + $"Could not establish remote session to computer '{attachParams.ComputerName}'"); + + return; + } + } + + if (attachParams.ProcessId > 0) + { + PowerShellVersionDetails runspaceVersion = + this.editorSession.PowerShellContext.CurrentRunspace.PowerShellVersion; + + if (runspaceVersion.Version.Major < 5) + { + await requestContext.SendError( + $"Attaching to a process is only available with PowerShell 5 and higher (current session is {runspaceVersion.Version})."); + + return; + } + + await this.editorSession.PowerShellContext.ExecuteScriptString( + $"Enter-PSHostProcess -Id {attachParams.ProcessId}", + errorMessages); + + if (errorMessages.Length > 0) + { + await requestContext.SendError( + $"Could not attach to process '{attachParams.ProcessId}'"); + + return; + } + + // 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 + // event gets fired with the attached runspace. + int runspaceId = attachParams.RunspaceId > 0 ? attachParams.RunspaceId : 1; + this.waitingForAttach = true; + Task nonAwaitedTask = + this.editorSession.PowerShellContext.ExecuteScriptString( + $"\nDebug-Runspace -Id {runspaceId}"); + } + else + { + await requestContext.SendError( + "A positive integer must be specified for the processId field."); + + return; + } + + await requestContext.SendResult(null); } - protected Task HandleDisconnectRequest( + protected async Task HandleDisconnectRequest( object disconnectParams, RequestContext requestContext) { @@ -217,11 +298,18 @@ protected Task HandleDisconnectRequest( // so we shouldn't try to abort because PowerShellContext will be null if (this.editorSession != null && this.editorSession.PowerShellContext != null) { - this.editorSession.PowerShellContext.SessionStateChanged += handler; - this.editorSession.PowerShellContext.AbortExecution(); + if (this.editorSession.PowerShellContext.SessionState == PowerShellContextState.Running || + this.editorSession.PowerShellContext.IsDebuggerStopped) + { + this.editorSession.PowerShellContext.SessionStateChanged += handler; + this.editorSession.PowerShellContext.AbortExecution(); + } + else + { + await requestContext.SendResult(null); + await this.Stop(); + } } - - return Task.FromResult(true); } protected async Task HandleSetBreakpointsRequest( @@ -568,7 +656,7 @@ await requestContext.SendResult( #region Event Handlers - async void DebugService_DebuggerStopped(object sender, DebuggerStopEventArgs e) + async void DebugService_DebuggerStopped(object sender, DebuggerStoppedEventArgs e) { // Flush pending output before sending the event await this.outputDebouncer.Flush(); @@ -580,10 +668,10 @@ async void DebugService_DebuggerStopped(object sender, DebuggerStopEventArgs e) // We don't support exception breakpoints and for "pause", we can't distinguish // between stepping and the user pressing the pause/break button in the debug toolbar. string debuggerStoppedReason = "step"; - if (e.Breakpoints.Count > 0) + if (e.OriginalEvent.Breakpoints.Count > 0) { debuggerStoppedReason = - e.Breakpoints[0] is CommandBreakpoint + e.OriginalEvent.Breakpoints[0] is CommandBreakpoint ? "function breakpoint" : "breakpoint"; } @@ -594,15 +682,39 @@ await this.SendEvent( { Source = new Source { - Path = e.InvocationInfo.ScriptName, + Path = e.ScriptPath, }, - Line = e.InvocationInfo.ScriptLineNumber, - Column = e.InvocationInfo.OffsetInLine, - ThreadId = 1, // TODO: Change this based on context + Line = e.LineNumber, + Column = e.ColumnNumber, + ThreadId = 1, Reason = debuggerStoppedReason }); } + async void powerShellContext_RunspaceChanged(object sender, RunspaceChangedEventArgs e) + { + if (this.waitingForAttach && e.ChangeAction == RunspaceChangeAction.Enter && e.NewRunspace.IsAttached) + { + // Send the InitializedEvent so that the debugger will continue + // sending configuration requests + this.waitingForAttach = false; + await this.SendEvent(InitializedEvent.Type, null); + } + else if (e.ChangeAction == RunspaceChangeAction.Exit && this.editorSession.PowerShellContext.IsDebuggerStopped) + { + // Exited the session while the debugger is stopped, + // send a ContinuedEvent so that the client changes the + // UI to appear to be running again + await this.SendEvent( + ContinuedEvent.Type, + new ContinuedEvent + { + ThreadId = 1, + AllThreadsContinued = true + }); + } + } + async void powerShellContext_OutputWritten(object sender, OutputWrittenEventArgs e) { // Queue the output for writing diff --git a/src/PowerShellEditorServices.Protocol/Server/DebugAdapterBase.cs b/src/PowerShellEditorServices.Protocol/Server/DebugAdapterBase.cs index 4f324d269..d2b7c5e6a 100644 --- a/src/PowerShellEditorServices.Protocol/Server/DebugAdapterBase.cs +++ b/src/PowerShellEditorServices.Protocol/Server/DebugAdapterBase.cs @@ -63,11 +63,6 @@ await requestContext.SendResult( SupportsHitConditionalBreakpoints = true, SupportsSetVariable = true }); - - // Send the Initialized event so that we get breakpoints - await requestContext.SendEvent( - InitializedEvent.Type, - null); } } } diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs index d924da5b3..821fcdac7 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs @@ -915,7 +915,7 @@ protected async Task HandlePowerShellVersionRequest( { await requestContext.SendResult( new PowerShellVersion( - this.editorSession.PowerShellContext.PowerShellVersionDetails)); + this.editorSession.PowerShellContext.LocalPowerShellVersion)); } private bool IsQueryMatch(string query, string symbolName) @@ -989,11 +989,11 @@ protected Task HandleEvaluateRequest( #region Event Handlers - private async void PowerShellContext_RunspaceChanged(object sender, RunspaceChangedEventArgs e) + private async void PowerShellContext_RunspaceChanged(object sender, Session.RunspaceChangedEventArgs e) { await this.SendEvent( RunspaceChangedEvent.Type, - new RunspaceDetails(e)); + new Protocol.LanguageServer.RunspaceDetails(e.NewRunspace)); } private async void powerShellContext_OutputWritten(object sender, OutputWrittenEventArgs e) diff --git a/src/PowerShellEditorServices/Debugging/DebugService.cs b/src/PowerShellEditorServices/Debugging/DebugService.cs index 9b7df6e61..8b22ff480 100644 --- a/src/PowerShellEditorServices/Debugging/DebugService.cs +++ b/src/PowerShellEditorServices/Debugging/DebugService.cs @@ -12,6 +12,8 @@ using System.Threading.Tasks; using Microsoft.PowerShell.EditorServices.Debugging; using Microsoft.PowerShell.EditorServices.Utility; +using System.IO; +using Microsoft.PowerShell.EditorServices.Session; namespace Microsoft.PowerShell.EditorServices { @@ -23,12 +25,17 @@ public class DebugService { #region Fields + private const string PsesGlobalVariableNamePrefix = "__psEditorServices_"; + private PowerShellContext powerShellContext; // TODO: This needs to be managed per nested session private Dictionary> breakpointsPerFile = new Dictionary>(); + private Dictionary> remoteFileMappings = + new Dictionary>(); + private int nextVariableId; private List variables; private VariableContainerDetails globalScopeVariables; @@ -82,10 +89,31 @@ public async Task SetLineBreakpoints( if (breakpoints.Length > 0) { + // Make sure we're using the remote script path + string scriptPath = scriptFile.FilePath; + if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote) + { + string mappedPath = + this.GetRemotePathMapping( + this.powerShellContext.CurrentRunspace, + scriptPath); + + if (mappedPath == null) + { + Logger.Write( + LogLevel.Error, + $"Could not map local path '{scriptPath}' to a remote path."); + + return resultBreakpointDetails.ToArray(); + } + + scriptPath = mappedPath; + } + // Fix for issue #123 - file paths that contain wildcard chars [ and ] need to // quoted and have those wildcard chars escaped. string escapedScriptPath = - PowerShellContext.EscapePath(scriptFile.FilePath, escapeSpaces: false); + PowerShellContext.EscapePath(scriptPath, escapeSpaces: false); foreach (BreakpointDetails breakpoint in breakpoints) { @@ -556,7 +584,7 @@ private async Task ClearBreakpointsInFile(ScriptFile scriptFile) { PSCommand psCommand = new PSCommand(); psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint"); - psCommand.AddParameter("Breakpoint", breakpoints.ToArray()); + psCommand.AddParameter("Id", breakpoints.Select(b => b.Id).ToArray()); await this.powerShellContext.ExecuteCommand(psCommand); @@ -612,16 +640,16 @@ private async Task FetchVariableContainer( new VariableContainerDetails(this.nextVariableId++, "Scope: " + scope); this.variables.Add(scopeVariableContainer); - var results = await this.powerShellContext.ExecuteCommand(psCommand, sendErrorToHost: false); + var results = await this.powerShellContext.ExecuteCommand(psCommand, sendErrorToHost: false); if (results != null) { - foreach (PSVariable psvariable in results) + foreach (PSObject psVariableObject in results) { - var variableDetails = new VariableDetails(psvariable) {Id = this.nextVariableId++}; + var variableDetails = new VariableDetails(psVariableObject) {Id = this.nextVariableId++}; this.variables.Add(variableDetails); scopeVariableContainer.Children.Add(variableDetails.Name, variableDetails); - if ((autoVariables != null) && AddToAutoVariables(psvariable, scope)) + if ((autoVariables != null) && AddToAutoVariables(psVariableObject, scope)) { autoVariables.Children.Add(variableDetails.Name, variableDetails); } @@ -631,9 +659,30 @@ private async Task FetchVariableContainer( return scopeVariableContainer; } - private bool AddToAutoVariables(PSVariable psvariable, string scope) + private bool AddToAutoVariables(PSObject psvariable, string scope) { - if ((scope == VariableContainerDetails.GlobalScopeName) || + string variableName = psvariable.Properties["Name"].Value as string; + object variableValue = psvariable.Properties["Value"].Value; + + ScopedItemOptions variableScope = ScopedItemOptions.None; + PSPropertyInfo optionsProperty = psvariable.Properties["Options"]; + if (string.Equals(optionsProperty.TypeNameOfValue, "System.String")) + { + if (!Enum.TryParse( + optionsProperty.Value as string, + out variableScope)) + { + Logger.Write( + LogLevel.Warning, + $"Could not parse a variable's ScopedItemOptions value of '{optionsProperty.Value}'"); + } + } + else if (optionsProperty.Value is ScopedItemOptions) + { + variableScope = (ScopedItemOptions)optionsProperty.Value; + } + + if ((scope == VariableContainerDetails.GlobalScopeName) || (scope == VariableContainerDetails.ScriptScopeName)) { // We don't A) have a good way of distinguishing built-in from user created variables @@ -646,36 +695,36 @@ private bool AddToAutoVariables(PSVariable psvariable, string scope) var readonlyAllScope = ScopedItemOptions.AllScope | ScopedItemOptions.ReadOnly; // Some local variables, if they exist, should be displayed by default - if (psvariable.GetType().Name == "LocalVariable") + if (psvariable.TypeNames.Any(typeName => typeName.EndsWith("LocalVariable"))) { - if (psvariable.Name.Equals("_")) + if (variableName.Equals("_")) { return true; } - else if (psvariable.Name.Equals("args", StringComparison.OrdinalIgnoreCase)) + else if (variableName.Equals("args", StringComparison.OrdinalIgnoreCase)) { - var array = psvariable.Value as Array; + var array = variableValue as Array; return array != null ? array.Length > 0 : false; } return false; } - else if (psvariable.GetType() != typeof(PSVariable)) + else if (!psvariable.TypeNames.Any(typeName => typeName.EndsWith("PSVariable"))) { return false; } - if (((psvariable.Options | constantAllScope) == constantAllScope) || - ((psvariable.Options | readonlyAllScope) == readonlyAllScope)) + if (((variableScope | constantAllScope) == constantAllScope) || + ((variableScope | readonlyAllScope) == readonlyAllScope)) { - string prefixedVariableName = VariableDetails.DollarPrefix + psvariable.Name; + string prefixedVariableName = VariableDetails.DollarPrefix + variableName; if (this.globalScopeVariables.Children.ContainsKey(prefixedVariableName)) { return false; } } - if ((psvariable.Value != null) && (psvariable.Value.GetType() == typeof(PSDebugContext))) + if (variableValue != null && variableValue.GetType().Name.EndsWith(nameof(PSDebugContext))) { return false; } @@ -686,9 +735,15 @@ private bool AddToAutoVariables(PSVariable psvariable, string scope) private async Task FetchStackFrames() { PSCommand psCommand = new PSCommand(); - psCommand.AddCommand("Get-PSCallStack"); - var results = await this.powerShellContext.ExecuteCommand(psCommand); + // This glorious hack ensures that Get-PSCallStack returns a list of CallStackFrame + // objects (or "deserialized" CallStackFrames) when attached to a runspace in another + // process. Without the intermediate variable Get-PSCallStack inexplicably returns + // an array of strings containing the formatted output of the CallStackFrame list. + var callStackVarName = $"$global:{PsesGlobalVariableNamePrefix}CallStack"; + psCommand.AddScript($"{callStackVarName} = Get-PSCallStack; {callStackVarName}"); + + var results = await this.powerShellContext.ExecuteCommand(psCommand); var callStackFrames = results.ToArray(); this.stackFrameDetails = new StackFrameDetails[callStackFrames.Length]; @@ -707,6 +762,16 @@ private async Task FetchStackFrames() this.stackFrameDetails[i] = StackFrameDetails.Create(callStackFrames[i], autoVariables, localVariables); + + string stackFrameScriptPath = this.stackFrameDetails[i].ScriptPath; + if (this.powerShellContext.CurrentRunspace.Location == Session.RunspaceLocation.Remote && + !string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath)) + { + this.stackFrameDetails[i].ScriptPath = + Workspace.MapRemotePathToLocal( + stackFrameScriptPath, + this.powerShellContext.CurrentRunspace.ConnectionString); + } } } @@ -784,7 +849,7 @@ private ScriptBlock GetBreakpointActionScriptBlock( if (hitCount.HasValue) { string globalHitCountVarName = - $"$global:__psEditorServices_BreakHitCounter_{breakpointHitCounter++}"; + $"$global:{PsesGlobalVariableNamePrefix}BreakHitCounter_{breakpointHitCounter++}"; wrappedCondition = $"if ({breakpoint.Condition}) {{ if (++{globalHitCountVarName} -eq {hitCount}) {{ break }} }}"; @@ -895,6 +960,34 @@ private string FormatInvalidBreakpointConditionMessage(string condition, string return $"'{condition}' is not a valid PowerShell expression. {message}"; } + private void AddRemotePathMapping(RunspaceDetails runspaceDetails, string remotePath, string localPath) + { + Dictionary runspaceMappings = null; + + if (!this.remoteFileMappings.TryGetValue(runspaceDetails, out runspaceMappings)) + { + runspaceMappings = new Dictionary(); + this.remoteFileMappings.Add(runspaceDetails, runspaceMappings); + } + + // Add mappings in both directions + runspaceMappings[localPath.ToLower()] = remotePath; + runspaceMappings[remotePath.ToLower()] = localPath; + } + + private string GetRemotePathMapping(RunspaceDetails runspaceDetails, string localPath) + { + string remotePath = null; + Dictionary runspaceMappings = null; + + if (this.remoteFileMappings.TryGetValue(runspaceDetails, out runspaceMappings)) + { + runspaceMappings.TryGetValue(localPath.ToLower(), out remotePath); + } + + return remotePath; + } + #endregion #region Events @@ -902,18 +995,73 @@ private string FormatInvalidBreakpointConditionMessage(string condition, string /// /// Raised when the debugger stops execution at a breakpoint or when paused. /// - public event EventHandler DebuggerStopped; + public event EventHandler DebuggerStopped; private async void OnDebuggerStop(object sender, DebuggerStopEventArgs e) { // Get call stack and variables. await this.FetchStackFramesAndVariables(); - // Notify the host that the debugger is stopped - if (this.DebuggerStopped != null) + // If this is a remote connection, get the file content + string localScriptPath = null; + if (this.powerShellContext.CurrentRunspace.Location == Session.RunspaceLocation.Remote) { - this.DebuggerStopped(sender, e); + string remoteScriptPath = e.InvocationInfo.ScriptName; + if (!string.IsNullOrEmpty(remoteScriptPath)) + { + localScriptPath = + Workspace.MapRemotePathToLocal( + remoteScriptPath, + this.powerShellContext.CurrentRunspace.ConnectionString); + + // TODO: Catch filesystem exceptions! + + // Does the local file already exist? + if (!File.Exists(localScriptPath)) + { + // Load the file contents from the remote machine and create the buffer + PSCommand command = new PSCommand(); + command.AddCommand("Microsoft.PowerShell.Management\\Get-Content"); + command.AddParameter("Path", remoteScriptPath); + command.AddParameter("Raw"); + command.AddParameter("Encoding", "Byte"); + + byte[] fileContent = + (await this.powerShellContext.ExecuteCommand(command, false, false)) + .FirstOrDefault(); + + if (fileContent != null) + { + File.WriteAllBytes(localScriptPath, fileContent); + } + else + { + Logger.Write( + LogLevel.Warning, + $"Could not load contents of remote file '{remoteScriptPath}'"); + } + + // Add the file mapping so that breakpoints can be passed through + // to the real file in the remote session + this.AddRemotePathMapping( + this.powerShellContext.CurrentRunspace, + remoteScriptPath, + localScriptPath); + this.AddRemotePathMapping( + this.powerShellContext.CurrentRunspace, + localScriptPath, + remoteScriptPath); + } + } } + + // Notify the host that the debugger is stopped + this.DebuggerStopped?.Invoke( + sender, + new DebuggerStoppedEventArgs( + e, + this.powerShellContext.CurrentRunspace, + localScriptPath)); } /// @@ -933,8 +1081,28 @@ private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e) { List breakpoints; + string scriptPath = lineBreakpoint.Script; + if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote) + { + string mappedPath = + this.GetRemotePathMapping( + this.powerShellContext.CurrentRunspace, + scriptPath); + + if (mappedPath == null) + { + Logger.Write( + LogLevel.Error, + $"Could not map remote path '{scriptPath}' to a local path."); + + return; + } + + scriptPath = mappedPath; + } + // Normalize the script filename for proper indexing - string normalizedScriptName = lineBreakpoint.Script.ToLower(); + string normalizedScriptName = scriptPath.ToLower(); // Get the list of breakpoints for this file if (!this.breakpointsPerFile.TryGetValue(normalizedScriptName, out breakpoints)) diff --git a/src/PowerShellEditorServices/Debugging/DebuggerStoppedEventArgs.cs b/src/PowerShellEditorServices/Debugging/DebuggerStoppedEventArgs.cs new file mode 100644 index 000000000..f53ab8323 --- /dev/null +++ b/src/PowerShellEditorServices/Debugging/DebuggerStoppedEventArgs.cs @@ -0,0 +1,117 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices.Session; +using Microsoft.PowerShell.EditorServices.Utility; +using System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Debugging +{ + /// + /// Provides event arguments for the DebugService.DebuggerStopped event. + /// + public class DebuggerStoppedEventArgs + { + #region Properties + + /// + /// Gets the path of the script where the debugger has stopped execution. + /// If 'IsRemoteSession' returns true, this path will be a local filesystem + /// path containing the contents of the script that is executing remotely. + /// + public string ScriptPath { get; private set; } + + /// + /// Returns true if the breakpoint was raised from a remote debugging session. + /// + public bool IsRemoteSession + { + get { return this.RunspaceDetails.Location == RunspaceLocation.Remote; } + } + + /// + /// Gets the original script path if 'IsRemoteSession' returns true. + /// + public string RemoteScriptPath { get; private set; } + + /// + /// Gets the RunspaceDetails for the current runspace. + /// + public RunspaceDetails RunspaceDetails { get; private set; } + + /// + /// Gets the line number at which the debugger stopped execution. + /// + public int LineNumber + { + get + { + return this.OriginalEvent.InvocationInfo.ScriptLineNumber; + } + } + + /// + /// Gets the column number at which the debugger stopped execution. + /// + public int ColumnNumber + { + get + { + return this.OriginalEvent.InvocationInfo.OffsetInLine; + } + } + + /// + /// Gets the original DebuggerStopEventArgs from the PowerShell engine. + /// + public DebuggerStopEventArgs OriginalEvent { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the DebuggerStoppedEventArgs class. + /// + /// The original DebuggerStopEventArgs instance from which this instance is based. + /// The RunspaceDetails of the runspace which raised this event. + public DebuggerStoppedEventArgs( + DebuggerStopEventArgs originalEvent, + RunspaceDetails runspaceDetails) + : this(originalEvent, runspaceDetails, null) + { + } + + /// + /// Creates a new instance of the DebuggerStoppedEventArgs class. + /// + /// The original DebuggerStopEventArgs instance from which this instance is based. + /// The RunspaceDetails of the runspace which raised this event. + /// The local path of the remote script being debugged. + public DebuggerStoppedEventArgs( + DebuggerStopEventArgs originalEvent, + RunspaceDetails runspaceDetails, + string localScriptPath) + { + Validate.IsNotNull(nameof(originalEvent), originalEvent); + Validate.IsNotNull(nameof(runspaceDetails), runspaceDetails); + + if (!string.IsNullOrEmpty(localScriptPath)) + { + this.ScriptPath = localScriptPath; + this.RemoteScriptPath = originalEvent.InvocationInfo.ScriptName; + } + else + { + this.ScriptPath = originalEvent.InvocationInfo.ScriptName; + } + + this.OriginalEvent = originalEvent; + this.RunspaceDetails = runspaceDetails; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Debugging/StackFrameDetails.cs b/src/PowerShellEditorServices/Debugging/StackFrameDetails.cs index fe4a24f32..8b8a07eed 100644 --- a/src/PowerShellEditorServices/Debugging/StackFrameDetails.cs +++ b/src/PowerShellEditorServices/Debugging/StackFrameDetails.cs @@ -13,10 +13,22 @@ namespace Microsoft.PowerShell.EditorServices /// public class StackFrameDetails { + #region Fields + + /// + /// A constant string used in the ScriptPath field to represent a + /// stack frame with no associated script file. + /// + public const string NoFileScriptPath = ""; + + #endregion + + #region Properties + /// /// Gets the path to the script where the stack frame occurred. /// - public string ScriptPath { get; private set; } + public string ScriptPath { get; internal set; } /// /// Gets the name of the function where the stack frame occurred. @@ -43,12 +55,16 @@ public class StackFrameDetails /// public VariableContainerDetails LocalVariables { get; private set; } + #endregion + + #region Constructors + /// /// Creates an instance of the StackFrameDetails class from a /// CallStackFrame instance provided by the PowerShell engine. /// - /// - /// The original CallStackFrame instance from which details will be obtained. + /// + /// A PSObject representing the CallStackFrame instance from which details will be obtained. /// /// /// A variable container with all the filtered, auto variables for this stack frame. @@ -58,19 +74,21 @@ public class StackFrameDetails /// /// A new instance of the StackFrameDetails class. static internal StackFrameDetails Create( - CallStackFrame callStackFrame, + PSObject callStackFrameObject, VariableContainerDetails autoVariables, VariableContainerDetails localVariables) { return new StackFrameDetails { - ScriptPath = callStackFrame.ScriptName ?? "", - FunctionName = callStackFrame.FunctionName, - LineNumber = callStackFrame.Position.StartLineNumber, - ColumnNumber = callStackFrame.Position.StartColumnNumber, + ScriptPath = (callStackFrameObject.Properties["ScriptName"].Value as string) ?? NoFileScriptPath, + FunctionName = callStackFrameObject.Properties["FunctionName"].Value as string, + LineNumber = (int)(callStackFrameObject.Properties["ScriptLineNumber"].Value ?? 0), + ColumnNumber = 0, // Column number isn't given in PowerShell stack frames AutoVariables = autoVariables, LocalVariables = localVariables }; } + + #endregion } } diff --git a/src/PowerShellEditorServices/Debugging/VariableDetails.cs b/src/PowerShellEditorServices/Debugging/VariableDetails.cs index 8a76e0899..5c3d34c74 100644 --- a/src/PowerShellEditorServices/Debugging/VariableDetails.cs +++ b/src/PowerShellEditorServices/Debugging/VariableDetails.cs @@ -47,6 +47,21 @@ public VariableDetails(PSVariable psVariable) { } + /// + /// Initializes an instance of the VariableDetails class from + /// the name and value pair stored inside of a PSObject which + /// represents a PSVariable. + /// + /// + /// The PSObject which represents a PSVariable. + /// + public VariableDetails(PSObject psVariableObject) + : this( + DollarPrefix + psVariableObject.Properties["Name"].Value as string, + psVariableObject.Properties["Value"].Value) + { + } + /// /// Initializes an instance of the VariableDetails class from /// the details contained in a PSPropertyInfo instance. diff --git a/src/PowerShellEditorServices/Language/CommandHelpers.cs b/src/PowerShellEditorServices/Language/CommandHelpers.cs index 75b027a59..527fc3ac8 100644 --- a/src/PowerShellEditorServices/Language/CommandHelpers.cs +++ b/src/PowerShellEditorServices/Language/CommandHelpers.cs @@ -28,8 +28,12 @@ public static async Task GetCommandInfo( command.AddCommand(@"Microsoft.PowerShell.Core\Get-Command"); command.AddArgument(commandName); - var results = await powerShellContext.ExecuteCommand(command, false, false); - return results.OfType().FirstOrDefault(); + return + (await powerShellContext + .ExecuteCommand(command, false, false)) + .Select(o => o.BaseObject) + .OfType() + .FirstOrDefault(); } /// diff --git a/src/PowerShellEditorServices/Language/LanguageService.cs b/src/PowerShellEditorServices/Language/LanguageService.cs index b3fff7764..0679ba429 100644 --- a/src/PowerShellEditorServices/Language/LanguageService.cs +++ b/src/PowerShellEditorServices/Language/LanguageService.cs @@ -231,7 +231,7 @@ public FindOccurrencesResult FindSymbolsInFile(ScriptFile scriptFile) IEnumerable symbolReferencesinFile = AstOperations - .FindSymbolsInDocument(scriptFile.ScriptAst, this.powerShellContext.PowerShellVersion) + .FindSymbolsInDocument(scriptFile.ScriptAst, this.powerShellContext.LocalPowerShellVersion.Version) .Select( reference => { reference.SourceLine = diff --git a/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj b/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj index a69017a45..5d396a782 100644 --- a/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj +++ b/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj @@ -56,6 +56,7 @@ + @@ -113,6 +114,7 @@ + @@ -144,6 +146,7 @@ + diff --git a/src/PowerShellEditorServices/PowerShellEditorServices.csproj b/src/PowerShellEditorServices/PowerShellEditorServices.csproj index 1ee63f1e5..af65c1869 100644 --- a/src/PowerShellEditorServices/PowerShellEditorServices.csproj +++ b/src/PowerShellEditorServices/PowerShellEditorServices.csproj @@ -69,6 +69,7 @@ + @@ -122,6 +123,7 @@ + diff --git a/src/PowerShellEditorServices/Session/EditorSession.cs b/src/PowerShellEditorServices/Session/EditorSession.cs index d2fb0c447..ae32b796a 100644 --- a/src/PowerShellEditorServices/Session/EditorSession.cs +++ b/src/PowerShellEditorServices/Session/EditorSession.cs @@ -87,7 +87,7 @@ public void StartSession(HostDetails hostDetails, ProfilePaths profilePaths) this.InstantiateAnalysisService(); // Create a workspace to contain open files - this.Workspace = new Workspace(this.PowerShellContext.PowerShellVersion); + this.Workspace = new Workspace(this.PowerShellContext.LocalPowerShellVersion.Version); } /// @@ -108,7 +108,7 @@ public void StartDebugSession(HostDetails hostDetails, ProfilePaths profilePaths this.ConsoleService = new ConsoleService(this.PowerShellContext); // Create a workspace to contain open files - this.Workspace = new Workspace(this.PowerShellContext.PowerShellVersion); + this.Workspace = new Workspace(this.PowerShellContext.LocalPowerShellVersion.Version); } internal void InstantiateAnalysisService(string settingsPath = null) diff --git a/src/PowerShellEditorServices/Session/PowerShell3Operations.cs b/src/PowerShellEditorServices/Session/PowerShell3Operations.cs index faa4996c9..7b4ac4a9c 100644 --- a/src/PowerShellEditorServices/Session/PowerShell3Operations.cs +++ b/src/PowerShellEditorServices/Session/PowerShell3Operations.cs @@ -39,11 +39,19 @@ public IEnumerable ExecuteCommandInDebugger( nestedPipeline.Commands.Add(command); } - executionResult = - nestedPipeline - .Invoke() - .Select(pso => pso.BaseObject) - .Cast(); + var results = nestedPipeline.Invoke(); + + if (typeof(TResult) != typeof(PSObject)) + { + executionResult = + results + .Select(pso => pso.BaseObject) + .Cast(); + } + else + { + executionResult = results.Cast(); + } } // Write the output to the host if necessary diff --git a/src/PowerShellEditorServices/Session/PowerShell4Operations.cs b/src/PowerShellEditorServices/Session/PowerShell4Operations.cs index 935e68495..4551ef996 100644 --- a/src/PowerShellEditorServices/Session/PowerShell4Operations.cs +++ b/src/PowerShellEditorServices/Session/PowerShell4Operations.cs @@ -58,10 +58,20 @@ public IEnumerable ExecuteCommandInDebugger( outputCollection); #endif - return - outputCollection - .Select(pso => pso.BaseObject) - .Cast(); + IEnumerable results = null; + if (typeof(TResult) != typeof(PSObject)) + { + results = + outputCollection + .Select(pso => pso.BaseObject) + .Cast(); + } + else + { + results = outputCollection.Cast(); + } + + return results; } } } diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index fec813cdf..16d6b64b5 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -6,7 +6,6 @@ using Microsoft.PowerShell.EditorServices.Console; using Microsoft.PowerShell.EditorServices.Utility; using System; -using System.Collections; using System.Globalization; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -18,7 +17,6 @@ namespace Microsoft.PowerShell.EditorServices { - using CSharp.RuntimeBinder; using Session; using System.Management.Automation; using System.Management.Automation.Host; @@ -36,16 +34,15 @@ public class PowerShellContext : IDisposable, IHostSupportsInteractiveSession private PowerShell powerShell; private bool ownsInitialRunspace; + private RunspaceDetails initialRunspace; + private IConsoleHost consoleHost; - private Runspace initialRunspace; - private Runspace currentRunspace; - private RunspaceType runspaceType; private ProfilePaths profilePaths; private ConsoleServicePSHost psHost; - private InitialSessionState initialSessionState; + private IVersionSpecificOperations versionSpecificOperations; - private int pipelineThreadId; + private int pipelineThreadId; private TaskCompletionSource debuggerStoppedTask; private TaskCompletionSource pipelineExecutionTask; private TaskCompletionSource pipelineResultTask; @@ -53,7 +50,7 @@ public class PowerShellContext : IDisposable, IHostSupportsInteractiveSession private object runspaceMutex = new object(); private AsyncQueue runspaceWaitQueue = new AsyncQueue(); - private Stack runspaceStack = new Stack(); + private Stack runspaceStack = new Stack(); #endregion @@ -81,27 +78,12 @@ public PowerShellContextState SessionState } /// - /// Gets the PowerShell version details for the current runspace. + /// Gets the PowerShell version details for the initial local runspace. /// - public PowerShellVersionDetails PowerShellVersionDetails + public PowerShellVersionDetails LocalPowerShellVersion { - get; private set; - } - - /// - /// Gets the PowerShell version of the current runspace. - /// - public Version PowerShellVersion - { - get { return this.PowerShellVersionDetails.Version; } - } - - /// - /// Gets the PowerShell edition of the current runspace. - /// - public string PowerShellEdition - { - get { return this.PowerShellVersionDetails.Edition; } + get; + private set; } /// @@ -118,6 +100,15 @@ internal IConsoleHost ConsoleHost } } + /// + /// Gets details pertaining to the current runspace. + /// + public RunspaceDetails CurrentRunspace + { + get; + private set; + } + #endregion #region Constructors @@ -141,9 +132,9 @@ public PowerShellContext(HostDetails hostDetails, ProfilePaths profilePaths) hostDetails = hostDetails ?? HostDetails.Default; this.psHost = new ConsoleServicePSHost(hostDetails, this); - this.initialSessionState = InitialSessionState.CreateDefault2(); + var initialSessionState = InitialSessionState.CreateDefault2(); - Runspace runspace = RunspaceFactory.CreateRunspace(psHost, this.initialSessionState); + Runspace runspace = RunspaceFactory.CreateRunspace(psHost, initialSessionState); #if !NanoServer runspace.ApartmentState = ApartmentState.STA; #endif @@ -190,32 +181,40 @@ private void Initialize(ProfilePaths profilePaths, Runspace initialRunspace) this.SessionState = PowerShellContextState.NotStarted; - this.initialRunspace = initialRunspace; - this.currentRunspace = initialRunspace; + // Get the PowerShell runtime version + this.LocalPowerShellVersion = + PowerShellVersionDetails.GetVersionDetails( + initialRunspace); + + this.initialRunspace = + new RunspaceDetails( + initialRunspace, + this.LocalPowerShellVersion, + RunspaceLocation.Local, + null); + this.CurrentRunspace = this.initialRunspace; this.powerShell = PowerShell.Create(); - this.powerShell.Runspace = this.currentRunspace; - - // Get the PowerShell runtime version - this.PowerShellVersionDetails = GetPowerShellVersion(this.currentRunspace); + this.powerShell.Runspace = initialRunspace; // Write out the PowerShell version for tracking purposes Logger.Write( LogLevel.Normal, string.Format( "PowerShell runtime version: {0}, edition: {1}", - this.PowerShellVersion, - this.PowerShellEdition)); + this.LocalPowerShellVersion.Version, + this.LocalPowerShellVersion.Edition)); - if (PowerShellVersion >= new Version(5,0)) + Version powerShellVersion = this.LocalPowerShellVersion.Version; + if (powerShellVersion >= new Version(5,0)) { this.versionSpecificOperations = new PowerShell5Operations(); } - else if (PowerShellVersion.Major == 4) + else if (powerShellVersion.Major == 4) { this.versionSpecificOperations = new PowerShell4Operations(); } - else if (PowerShellVersion.Major == 3) + else if (powerShellVersion.Major == 3) { this.versionSpecificOperations = new PowerShell3Operations(); } @@ -223,17 +222,17 @@ private void Initialize(ProfilePaths profilePaths, Runspace initialRunspace) { throw new NotSupportedException( "This computer has an unsupported version of PowerShell installed: " + - PowerShellVersion.ToString()); + powerShellVersion.ToString()); } - if (this.PowerShellEdition != "Linux") + if (this.LocalPowerShellVersion.Edition != "Linux") { // TODO: Should this be configurable? this.SetExecutionPolicy(ExecutionPolicy.RemoteSigned); } // Set up the runspace - this.ConfigureRunspace(this.currentRunspace, false); + this.ConfigureRunspace(this.CurrentRunspace); // Set the $profile variable in the runspace this.profilePaths = profilePaths; @@ -252,131 +251,32 @@ private void Initialize(ProfilePaths profilePaths, Runspace initialRunspace) this.runspaceWaitQueue.EnqueueAsync(runspaceHandle).Wait(); } - private void ConfigureRunspace(Runspace runspace, bool getVersion) + private void ConfigureRunspace(RunspaceDetails runspaceDetails) { - if (getVersion) + if (!runspaceDetails.IsAttached) { - this.runspaceType = RunspaceType.Local; - this.PowerShellVersionDetails = this.GetPowerShellVersion(this.currentRunspace); - - string connectionString = null; - - if (runspace.ConnectionInfo != null) + runspaceDetails.Runspace.StateChanged += this.HandleRunspaceStateChanged; + if (runspaceDetails.Runspace.Debugger != null) { - // Use 'dynamic' to avoid missing NamedPipeRunspaceConnectionInfo - // on PS v3 and v4 - try - { - dynamic connectionInfo = runspace.ConnectionInfo; - if (connectionInfo.ProcessId != null) - { - this.runspaceType = RunspaceType.Process; - connectionString = connectionInfo.ProcessId.ToString(); - } - } - catch (RuntimeBinderException) - { - // ProcessId property isn't on the object, move on. - } - - if (connectionString == null && runspace.ConnectionInfo.ComputerName != "localhost") - { - this.runspaceType = RunspaceType.Remote; - connectionString = runspace.ConnectionInfo.ComputerName; - } + runspaceDetails.Runspace.Debugger.BreakpointUpdated += OnBreakpointUpdated; + runspaceDetails.Runspace.Debugger.DebuggerStop += OnDebuggerStop; } - this.OnRunspaceChanged( - this, - new RunspaceChangedEventArgs( - this.PowerShellVersionDetails, - this.runspaceType, - connectionString)); - } - - // Subscribe to runspace events - if (runspace.Debugger != null) - { - runspace.Debugger.BreakpointUpdated += OnBreakpointUpdated; - runspace.Debugger.DebuggerStop += OnDebuggerStop; - } - - // Configure the runspace's debugger - this.versionSpecificOperations.ConfigureDebugger(runspace); - } - - private void CleanupRunspace(Runspace runspace) - { - if (runspace.Debugger != null) - { - runspace.Debugger.BreakpointUpdated -= OnBreakpointUpdated; - runspace.Debugger.DebuggerStop -= OnDebuggerStop; + this.versionSpecificOperations.ConfigureDebugger(runspaceDetails.Runspace); } } - private PowerShellVersionDetails GetPowerShellVersion(Runspace runspace) + private void CleanupRunspace(RunspaceDetails runspaceDetails) { - Version powerShellVersion = new Version(5, 0); - string versionString = null; - string powerShellEdition = "Desktop"; - var architecture = PowerShellProcessArchitecture.Unknown; - - try + if (!runspaceDetails.IsAttached) { - var psVersionTable = this.ExecuteScriptAndGetItem("$PSVersionTable", runspace); - if (psVersionTable != null) + runspaceDetails.Runspace.StateChanged -= this.HandleRunspaceStateChanged; + if (runspaceDetails.Runspace.Debugger != null) { - var edition = psVersionTable["PSEdition"] as string; - if (edition != null) - { - powerShellEdition = edition; - } - - // The PSVersion value will either be of Version or SemanticVersion. - // In the former case, take the value directly. In the latter case, - // generate a Version from its string representation. - var version = psVersionTable["PSVersion"]; - if (version is Version) - { - powerShellVersion = (Version)version; - } - else if (string.Equals(powerShellEdition, "Core", StringComparison.CurrentCultureIgnoreCase)) - { - // Expected version string format is 6.0.0-alpha so build a simpler version from that - powerShellVersion = new Version(version.ToString().Split('-')[0]); - } - - var gitCommitId = psVersionTable["GitCommitId"] as string; - if (gitCommitId != null) - { - versionString = gitCommitId; - } - else - { - versionString = powerShellVersion.ToString(); - } - - var arch = this.ExecuteScriptAndGetItem("$env:PROCESSOR_ARCHITECTURE", runspace); - if (string.Equals(arch, "AMD64", StringComparison.CurrentCultureIgnoreCase)) - { - architecture = PowerShellProcessArchitecture.X64; - } - else if (string.Equals(arch, "x86", StringComparison.CurrentCultureIgnoreCase)) - { - architecture = PowerShellProcessArchitecture.X86; - } + runspaceDetails.Runspace.Debugger.BreakpointUpdated -= OnBreakpointUpdated; + runspaceDetails.Runspace.Debugger.DebuggerStop -= OnDebuggerStop; } } - catch (Exception ex) - { - Logger.Write(LogLevel.Warning, "Failed to look up PowerShell version. Defaulting to version 5. " + ex.Message); - } - - return new PowerShellVersionDetails( - powerShellVersion, - versionString, - powerShellEdition, - architecture); } #endregion @@ -436,8 +336,7 @@ public async Task> ExecuteCommand( /// /// The expected result type. /// The PSCommand to be executed. - /// Error messages from PowerShell will be written to the StringBuilder. - /// You must set sendErrorToHost to false for errors to be written to the StringBuilder. This value can be null. + /// Error messages from PowerShell will be written to the StringBuilder. /// /// If true, causes any output written during command execution to be written to the host. /// @@ -491,15 +390,9 @@ public async Task> ExecuteCommand( endOfStatement: false)); } - if (this.currentRunspace.RunspaceAvailability == RunspaceAvailability.AvailableForNestedCommand || + if (this.CurrentRunspace.Runspace.RunspaceAvailability == RunspaceAvailability.AvailableForNestedCommand || this.debuggerStoppedTask != null) { - Logger.Write( - LogLevel.Verbose, - string.Format( - "Attempting to execute nested pipeline command(s):\r\n\r\n{0}", - GetStringForPSCommand(psCommand))); - executionResult = this.ExecuteCommandInDebugger( psCommand, @@ -526,8 +419,21 @@ public async Task> ExecuteCommand( await Task.Factory.StartNew>( () => { - this.powerShell.Commands = psCommand; - Collection result = this.powerShell.Invoke(); + Collection result = null; + try + { + this.powerShell.Commands = psCommand; + result = this.powerShell.Invoke(); + } + catch (RemoteException e) + { + if (!e.SerializedRemoteException.TypeNames[0].EndsWith("PipelineStoppedException")) + { + // Rethrow anything that isn't a PipelineStoppedException + throw e; + } + } + return result; }, CancellationToken.None, // Might need a cancellation token @@ -557,6 +463,14 @@ await Task.Factory.StartNew>( return executionResult; } } + catch (PipelineStoppedException e) + { + Logger.Write( + LogLevel.Error, + "Popeline stopped while executing command:\r\n\r\n" + e.ToString()); + + errorMessages?.Append(e.Message); + } catch (RuntimeException e) { Logger.Write( @@ -581,6 +495,12 @@ await Task.Factory.StartNew>( { this.WritePromptWithRunspace(runspaceHandle.Runspace); } + else if (this.IsDebuggerStopped) + { + // Check for RunspaceAvailability.RemoteDebug (4) in a way that's safe in PSv3 + //if ((int)this.currentRunspace.RunspaceAvailability == 4) + this.WritePromptInDebugger(); + } else { this.WritePromptWithNestedPipeline(); @@ -623,6 +543,19 @@ public Task> ExecuteScriptString( return this.ExecuteScriptString(scriptString, false, true); } + /// + /// Executes a script string in the session's runspace. + /// + /// The script string to execute. + /// Error messages from PowerShell will be written to the StringBuilder. + /// A Task that can be awaited for the script completion. + public Task> ExecuteScriptString( + string scriptString, + StringBuilder errorMessages) + { + return this.ExecuteScriptString(scriptString, errorMessages, false, true); + } + /// /// Executes a script string in the session's runspace. /// @@ -630,8 +563,25 @@ public Task> ExecuteScriptString( /// If true, causes the script string to be written to the host. /// If true, causes the script output to be written to the host. /// A Task that can be awaited for the script completion. + public Task> ExecuteScriptString( + string scriptString, + bool writeInputToHost, + bool writeOutputToHost) + { + return this.ExecuteScriptString(scriptString, null, writeInputToHost, writeOutputToHost); + } + + /// + /// Executes a script string in the session's runspace. + /// + /// The script string to execute. + /// Error messages from PowerShell will be written to the StringBuilder. + /// If true, causes the script string to be written to the host. + /// If true, causes the script output to be written to the host. + /// A Task that can be awaited for the script completion. public async Task> ExecuteScriptString( string scriptString, + StringBuilder errorMessages, bool writeInputToHost, bool writeOutputToHost) { @@ -645,7 +595,11 @@ public async Task> ExecuteScriptString( PSCommand psCommand = new PSCommand(); psCommand.AddScript(scriptString); - return await this.ExecuteCommand(psCommand, writeOutputToHost); + return + await this.ExecuteCommand( + psCommand, + errorMessages, + writeOutputToHost); } /// @@ -676,7 +630,7 @@ public async Task ExecuteScriptAtPath(string scriptPath, string arguments = null await this.ExecuteCommand(command, true); } - private TResult ExecuteScriptAndGetItem(string scriptToExecute, Runspace runspace, TResult defaultValue = default(TResult)) + internal static TResult ExecuteScriptAndGetItem(string scriptToExecute, Runspace runspace, TResult defaultValue = default(TResult)) { Pipeline pipeline = null; @@ -780,7 +734,7 @@ internal void BreakExecution() // Pause the debugger this.versionSpecificOperations.PauseDebugger( - this.currentRunspace); + this.CurrentRunspace.Runspace); } internal void ResumeDebugger(DebuggerResumeAction resumeAction) @@ -819,15 +773,85 @@ public void Dispose() this.powerShell = null; } + // Clean up the active runspace + this.CleanupRunspace(this.CurrentRunspace); + + // Drain the runspace stack if any have been pushed + if (this.runspaceStack.Count > 0) + { + // Push the active runspace so it will be included in the loop + this.runspaceStack.Push(this.CurrentRunspace); + + while (this.runspaceStack.Count > 1) + { + RunspaceDetails poppedRunspace = this.runspaceStack.Pop(); + this.CloseRunspace(poppedRunspace); + } + } + if (this.ownsInitialRunspace && this.initialRunspace != null) { - // TODO: Detach from events - this.initialRunspace.Close(); - this.initialRunspace.Dispose(); + this.CloseRunspace(this.initialRunspace); this.initialRunspace = null; } } + private void CloseRunspace(RunspaceDetails runspaceDetails) + { + // An attached runspace will be detached when the + // running pipeline is aborted + if (!runspaceDetails.IsAttached) + { + string exitCommand = null; + + switch (runspaceDetails.Location) + { + case RunspaceLocation.Local: + runspaceDetails.Runspace.Close(); + runspaceDetails.Runspace.Dispose(); + break; + + case RunspaceLocation.LocalProcess: + exitCommand = "Exit-PSHostProcess"; + break; + + case RunspaceLocation.Remote: + exitCommand = "Exit-PSSession"; + break; + } + + if (exitCommand != null) + { + Exception exitException = null; + + try + { + using (PowerShell ps = PowerShell.Create()) + { + ps.Runspace = runspaceDetails.Runspace; + ps.AddCommand(exitCommand); + ps.Invoke(); + } + } + catch (RemoteException e) + { + exitException = e; + } + catch (RuntimeException e) + { + exitException = e; + } + + if (exitException != null) + { + Logger.Write( + LogLevel.Error, + $"Caught {exitException.GetType().Name} while exiting {runspaceDetails.Location} runspace:\r\n{exitException.ToString()}"); + } + } + } + } + internal void ReleaseRunspaceHandle(RunspaceHandle runspaceHandle) { Validate.IsNotNull("runspaceHandle", runspaceHandle); @@ -853,7 +877,7 @@ internal void ReleaseRunspaceHandle(RunspaceHandle runspaceHandle) /// public void SetWorkingDirectory(string path) { - this.currentRunspace.SessionStateProxy.Path.SetLocation(path); + this.CurrentRunspace.Runspace.SessionStateProxy.Path.SetLocation(path); } /// @@ -902,16 +926,25 @@ public static string UnescapePath(string path) private void OnSessionStateChanged(object sender, SessionStateChangedEventArgs e) { - Logger.Write( - LogLevel.Verbose, - string.Format( - "Session state changed --\r\n\r\n Old state: {0}\r\n New state: {1}\r\n Result: {2}", - this.SessionState.ToString(), - e.NewSessionState.ToString(), - e.ExecutionResult)); + if (this.SessionState != PowerShellContextState.Disposed) + { + Logger.Write( + LogLevel.Verbose, + string.Format( + "Session state changed --\r\n\r\n Old state: {0}\r\n New state: {1}\r\n Result: {2}", + this.SessionState.ToString(), + e.NewSessionState.ToString(), + e.ExecutionResult)); - this.SessionState = e.NewSessionState; - this.SessionStateChanged?.Invoke(sender, e); + this.SessionState = e.NewSessionState; + this.SessionStateChanged?.Invoke(sender, e); + } + else + { + Logger.Write( + LogLevel.Warning, + $"Received session state change to {e.NewSessionState} when already disposed"); + } } /// @@ -930,9 +963,15 @@ private void OnRunspaceChanged(object sender, RunspaceChangedEventArgs e) private IEnumerable ExecuteCommandInDebugger(PSCommand psCommand, bool sendOutputToHost) { + Logger.Write( + LogLevel.Verbose, + string.Format( + "Attempting to execute command(s) in the debugger:\r\n\r\n{0}", + GetStringForPSCommand(psCommand))); + return this.versionSpecificOperations.ExecuteCommandInDebugger( this, - this.currentRunspace, + this.CurrentRunspace.Runspace, psCommand, sendOutputToHost); } @@ -1217,7 +1256,7 @@ private void WritePromptWithRunspace(Runspace runspace) .Invoke() .FirstOrDefault(); - if (this.runspaceType == RunspaceType.Remote) + if (this.CurrentRunspace.Location == RunspaceLocation.Remote) { promptString = string.Format( @@ -1231,9 +1270,20 @@ private void WritePromptWithRunspace(Runspace runspace) }); } + private void WritePromptInDebugger() + { + this.WritePromptToHost( + command => + { + return + this.ExecuteCommandInDebugger(command, false) + .FirstOrDefault(); + }); + } + private void WritePromptWithNestedPipeline() { - using (var pipeline = this.currentRunspace.CreateNestedPipeline()) + using (var pipeline = this.CurrentRunspace.Runspace.CreateNestedPipeline()) { this.WritePromptToHost( command => @@ -1293,6 +1343,24 @@ private void SetProfileVariableInCurrentRunspace(ProfilePaths profilePaths) this.powerShell.Commands.Clear(); } + private void HandleRunspaceStateChanged(object sender, RunspaceStateEventArgs args) + { + switch (args.RunspaceStateInfo.State) + { + case RunspaceState.Opening: + case RunspaceState.Opened: + // These cases don't matter, just return + return; + + case RunspaceState.Closing: + case RunspaceState.Closed: + case RunspaceState.Broken: + // If the runspace closes or fails, pop the runspace + ((IHostSupportsInteractiveSession)this).PopRunspace(); + break; + } + } + #endregion #region Events @@ -1321,15 +1389,34 @@ private void OnDebuggerStop(object sender, DebuggerStopEventArgs e) PowerShellExecutionResult.Stopped, null)); + // Did we attach to a runspace? + if (this.CurrentRunspace.Location != RunspaceLocation.Local && + this.CurrentRunspace.IsAttached == false) + { + // Check the current runspace ID + PSCommand command = new PSCommand(); + command.AddScript("$host.Runspace.InstanceId"); + Guid currentRunspaceId = + this.ExecuteCommandInDebugger(command, false) + .FirstOrDefault(); + + // If the reported unspace ID is different it means we're + // attached to a different runspace in the process + if (currentRunspaceId != Guid.Empty && currentRunspaceId != this.CurrentRunspace.Id) + { + // Push the details about the attached runspace + this.PushRunspace( + RunspaceDetails.CreateAttached( + this.CurrentRunspace, + currentRunspaceId)); + } + } + // Write out the debugger prompt - // TODO: Eventually re-enable this and put it behind a setting, #133 - //this.WritePromptWithNestedPipeline(); + this.WritePromptInDebugger(); // Raise the event for the debugger service - if (this.DebuggerStop != null) - { - this.DebuggerStop(sender, e); - } + this.DebuggerStop?.Invoke(sender, e); Logger.Write(LogLevel.Verbose, "Starting pipeline thread message loop..."); @@ -1343,8 +1430,7 @@ private void OnDebuggerStop(object sender, DebuggerStopEventArgs e) if (taskIndex == 0) { // Write a new output line before continuing - // TODO: Re-enable this with fix for #133 - //this.WriteOutput("", true); + this.WriteOutput("", true); e.ResumeAction = this.debuggerStoppedTask.Task.Result; Logger.Write(LogLevel.Verbose, "Received debugger resume action " + e.ResumeAction.ToString()); @@ -1365,6 +1451,19 @@ private void OnDebuggerStop(object sender, DebuggerStopEventArgs e) Logger.Write(LogLevel.Verbose, "Pipeline thread execution completed."); this.pipelineResultTask.SetResult(executionRequest); + + if (this.CurrentRunspace.Runspace.RunspaceAvailability == RunspaceAvailability.Available) + { + if (this.CurrentRunspace.IsAttached) + { + // We're detached from the runspace now, send a runspace update. + this.PopRunspace(); + } + + // If the executed command caused the debugger to exit, break + // from the pipeline loop + break; + } } else { @@ -1374,6 +1473,7 @@ private void OnDebuggerStop(object sender, DebuggerStopEventArgs e) // Clear the task so that it won't be used again this.debuggerStoppedTask = null; + this.pipelineExecutionTask = null; } // NOTE: This event is 'internal' because the DebugService provides @@ -1382,10 +1482,7 @@ private void OnDebuggerStop(object sender, DebuggerStopEventArgs e) private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e) { - if (this.BreakpointUpdated != null) - { - this.BreakpointUpdated(sender, e); - } + this.BreakpointUpdated?.Invoke(sender, e); } #endregion @@ -1435,6 +1532,74 @@ await this.powerShellContext.ExecuteCommand( } } + private void PushRunspace(RunspaceDetails newRunspaceDetails) + { + Logger.Write( + LogLevel.Verbose, + $"Pushing {this.CurrentRunspace.Location} runspace {this.CurrentRunspace.Id}, new runspace is {newRunspaceDetails.Id} (connection: {newRunspaceDetails.ConnectionString})"); + + RunspaceDetails previousRunspace = this.CurrentRunspace; + + // If the new runspace is just attaching to another runspace in + // the same process, don't clean up the previous runspace because + // we need to maintain its event handler registrations. + if (!newRunspaceDetails.IsAttached) + { + this.CleanupRunspace(previousRunspace); + } + + this.runspaceStack.Push(previousRunspace); + + this.CurrentRunspace = newRunspaceDetails; + this.ConfigureRunspace(newRunspaceDetails); + + this.OnRunspaceChanged( + this, + new RunspaceChangedEventArgs( + RunspaceChangeAction.Enter, + previousRunspace, + this.CurrentRunspace)); + } + + private void PopRunspace() + { + if (this.SessionState != PowerShellContextState.Disposed) + { + if (this.runspaceStack.Count > 0) + { + RunspaceDetails previousRunspace = this.CurrentRunspace; + this.CurrentRunspace = this.runspaceStack.Pop(); + + Logger.Write( + LogLevel.Verbose, + $"Popping {previousRunspace.Location} runspace {previousRunspace.Id}, new runspace is {this.CurrentRunspace.Id} (connection: {this.CurrentRunspace.ConnectionString})"); + + this.CleanupRunspace(previousRunspace); + + // If the old runspace is just attached to another runspace in + // the same process, don't configure the popped runspace because + // its event handlers are already registered. + if (!previousRunspace.IsAttached) + { + this.ConfigureRunspace(this.CurrentRunspace); + } + + this.OnRunspaceChanged( + this, + new RunspaceChangedEventArgs( + RunspaceChangeAction.Exit, + previousRunspace, + this.CurrentRunspace)); + } + else + { + Logger.Write( + LogLevel.Error, + "Caller attempted to pop a runspace when no runspaces are on the stack."); + } + } + } + #endregion #region IHostSupportsInteractiveSession Implementation @@ -1451,43 +1616,19 @@ Runspace IHostSupportsInteractiveSession.Runspace { get { - return this.currentRunspace; + return this.CurrentRunspace.Runspace; } } void IHostSupportsInteractiveSession.PushRunspace(Runspace runspace) { - Logger.Write( - LogLevel.Verbose, - $"Pushing runspace {this.currentRunspace.Id}, new runspace is {runspace.Id} (remote: {runspace.ConnectionInfo != null})"); - - this.CleanupRunspace(this.currentRunspace); - this.runspaceStack.Push(this.currentRunspace); - - this.currentRunspace = runspace; - this.ConfigureRunspace(runspace, true); + this.PushRunspace( + RunspaceDetails.Create(runspace)); } void IHostSupportsInteractiveSession.PopRunspace() { - if (this.runspaceStack.Count > 0) - { - var oldRunspace = this.currentRunspace; - this.currentRunspace = this.runspaceStack.Pop(); - - Logger.Write( - LogLevel.Verbose, - $"Popping runspace {oldRunspace.Id}, new runspace is {this.currentRunspace.Id} (remote: {this.currentRunspace.ConnectionInfo != null})"); - - this.CleanupRunspace(oldRunspace); - this.ConfigureRunspace(this.currentRunspace, true); - } - else - { - Logger.Write( - LogLevel.Error, - "Caller attempted to pop a runspace when no runspaces are on the stack."); - } + this.PopRunspace(); } #endregion diff --git a/src/PowerShellEditorServices/Session/PowerShellVersionDetails.cs b/src/PowerShellEditorServices/Session/PowerShellVersionDetails.cs index 9f8a596e0..e25cc33b0 100644 --- a/src/PowerShellEditorServices/Session/PowerShellVersionDetails.cs +++ b/src/PowerShellEditorServices/Session/PowerShellVersionDetails.cs @@ -3,7 +3,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using Microsoft.PowerShell.EditorServices.Utility; using System; +using System.Collections; +using System.Management.Automation.Runspaces; namespace Microsoft.PowerShell.EditorServices.Session { @@ -33,6 +36,8 @@ public enum PowerShellProcessArchitecture /// public class PowerShellVersionDetails { + #region Properties + /// /// Gets the version of the PowerShell runtime. /// @@ -54,6 +59,10 @@ public class PowerShellVersionDetails /// public PowerShellProcessArchitecture Architecture { get; private set; } + #endregion + + #region Constructors + /// /// Creates an instance of the PowerShellVersionDetails class. /// @@ -72,5 +81,83 @@ public PowerShellVersionDetails( this.Edition = editionString; this.Architecture = architecture; } + + #endregion + + #region Public Methods + + /// + /// Gets the PowerShell version details for the given runspace. + /// + /// The runspace for which version details will be gathered. + /// A new PowerShellVersionDetails instance. + public static PowerShellVersionDetails GetVersionDetails(Runspace runspace) + { + Version powerShellVersion = new Version(5, 0); + string versionString = null; + string powerShellEdition = "Desktop"; + var architecture = PowerShellProcessArchitecture.Unknown; + + try + { + var psVersionTable = PowerShellContext.ExecuteScriptAndGetItem("$PSVersionTable", runspace); + if (psVersionTable != null) + { + var edition = psVersionTable["PSEdition"] as string; + if (edition != null) + { + powerShellEdition = edition; + } + + // The PSVersion value will either be of Version or SemanticVersion. + // In the former case, take the value directly. In the latter case, + // generate a Version from its string representation. + var version = psVersionTable["PSVersion"]; + if (version is Version) + { + powerShellVersion = (Version)version; + } + else if (string.Equals(powerShellEdition, "Core", StringComparison.CurrentCultureIgnoreCase)) + { + // Expected version string format is 6.0.0-alpha so build a simpler version from that + powerShellVersion = new Version(version.ToString().Split('-')[0]); + } + + var gitCommitId = psVersionTable["GitCommitId"] as string; + if (gitCommitId != null) + { + versionString = gitCommitId; + } + else + { + versionString = powerShellVersion.ToString(); + } + + var arch = PowerShellContext.ExecuteScriptAndGetItem("$env:PROCESSOR_ARCHITECTURE", runspace); + if (string.Equals(arch, "AMD64", StringComparison.CurrentCultureIgnoreCase)) + { + architecture = PowerShellProcessArchitecture.X64; + } + else if (string.Equals(arch, "x86", StringComparison.CurrentCultureIgnoreCase)) + { + architecture = PowerShellProcessArchitecture.X86; + } + } + } + catch (Exception ex) + { + Logger.Write( + LogLevel.Warning, + "Failed to look up PowerShell version. Defaulting to version 5. " + ex.Message); + } + + return new PowerShellVersionDetails( + powerShellVersion, + versionString, + powerShellEdition, + architecture); + } + + #endregion } } diff --git a/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs b/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs index 980ce8aa8..12f51f2a5 100644 --- a/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs +++ b/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs @@ -3,73 +3,63 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using Microsoft.PowerShell.EditorServices.Utility; + namespace Microsoft.PowerShell.EditorServices.Session { /// - /// Specifies the possible types of a runspace. + /// Defines the set of actions that will cause the runspace to be changed. /// - public enum RunspaceType + public enum RunspaceChangeAction { /// - /// A local runspace in the current process. + /// The runspace change was caused by entering a new session. /// - Local, + Enter, /// - /// A local runspace in a different process. + /// The runspace change was caused by exiting the current session. /// - Process, - - /// - /// A runspace in a process on a another machine. - /// - Remote, + Exit } /// - /// Provides arguments for the PowerShellContext.RunspaceChanged - /// event. + /// Provides arguments for the PowerShellContext.RunspaceChanged event. /// public class RunspaceChangedEventArgs { /// - /// Gets the PowerShell version of the new runspace. + /// Gets the RunspaceChangeAction which caused this event. /// - public PowerShellVersionDetails RunspaceVersion { get; private set; } + public RunspaceChangeAction ChangeAction { get; private set; } /// - /// Gets the runspace type. + /// Gets a RunspaceDetails object describing the previous runspace. /// - public RunspaceType RunspaceType { get; private set; } + public RunspaceDetails PreviousRunspace { get; private set; } /// - /// Gets the "connection string" for the runspace, generally the - /// ComputerName for a remote runspace or the ProcessId of an - /// "Attach" runspace. + /// Gets a RunspaceDetails object describing the new runspace. /// - public string ConnectionString { get; private set; } + public RunspaceDetails NewRunspace { get; private set; } /// - /// Creates a new instance of the RunspaceChangedEventArgs - /// class. + /// Creates a new instance of the RunspaceChangedEventArgs class. /// - /// - /// The PowerShellVersionDetails of the new runspace. - /// - /// - /// The RunspaceType of the new runspace. - /// - /// - /// The connection string of the new runspace. - /// + /// The action which caused the runspace to change. + /// The previously active runspace. + /// The newly active runspace. public RunspaceChangedEventArgs( - PowerShellVersionDetails runspaceVersion, - RunspaceType runspaceType, - string connectionString) + RunspaceChangeAction changeAction, + RunspaceDetails previousRunspace, + RunspaceDetails newRunspace) { - this.RunspaceVersion = runspaceVersion; - this.RunspaceType = runspaceType; - this.ConnectionString = connectionString; + Validate.IsNotNull(nameof(previousRunspace), previousRunspace); + Validate.IsNotNull(nameof(newRunspace), newRunspace); + + this.ChangeAction = changeAction; + this.PreviousRunspace = previousRunspace; + this.NewRunspace = newRunspace; } } } diff --git a/src/PowerShellEditorServices/Session/RunspaceDetails.cs b/src/PowerShellEditorServices/Session/RunspaceDetails.cs new file mode 100644 index 000000000..dffe9cfee --- /dev/null +++ b/src/PowerShellEditorServices/Session/RunspaceDetails.cs @@ -0,0 +1,203 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.CSharp.RuntimeBinder; +using Microsoft.PowerShell.EditorServices.Utility; +using System; +using System.Management.Automation.Runspaces; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + /// + /// Specifies the possible types of a runspace. + /// + public enum RunspaceLocation + { + /// + /// A runspace in the current process on the same machine. + /// + Local, + + /// + /// A runspace in a different process on the same machine. + /// + LocalProcess, + + /// + /// A runspace on a different machine. + /// + Remote, + + // NOTE: We don't have a RemoteProcess variable here because there's + // no reliable way to know when the user has used Enter-PSHostProcess + // to jump into a different PowerShell process on the remote machine. + // Even if we check the PID every time the prompt gets written, there's + // still a chance the user is running a script that contains the + // Enter-PSHostProcess command and it won't be caught until after the + // script finishes. + } + + /// + /// Provides details about a runspace being used in the current + /// editing session. + /// + public class RunspaceDetails + { + #region Properties + + /// + /// Gets the id of the underlying Runspace object. + /// + public Guid Id { get; private set; } + + /// + /// Gets the Runspace instance for which this class contains details. + /// + internal Runspace Runspace { get; private set; } + + /// + /// Gets the PowerShell version of the new runspace. + /// + public PowerShellVersionDetails PowerShellVersion { get; private set; } + + /// + /// Gets the runspace location, either Local or Remote. + /// + public RunspaceLocation Location { get; private set; } + + /// + /// Gets a boolean which indicates whether this runspace is the result + /// of attaching with Debug-Runspace. + /// + public bool IsAttached { get; private set; } + + /// + /// Gets the "connection string" for the runspace, generally the + /// ComputerName for a remote runspace or the ProcessId of an + /// "Attach" runspace. + /// + public string ConnectionString { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the RunspaceDetails class. + /// + /// + /// The runspace for which this instance contains details. + /// + /// + /// The PowerShellVersionDetails of the runspace. + /// + /// + /// The RunspaceLocale of the runspace. + /// + /// + /// The connection string of the runspace. + /// + public RunspaceDetails( + Runspace runspace, + PowerShellVersionDetails powerShellVersion, + RunspaceLocation runspaceLocation, + string connectionString) + { + this.Id = runspace.InstanceId; + this.Runspace = runspace; + this.PowerShellVersion = powerShellVersion; + this.Location = runspaceLocation; + this.ConnectionString = connectionString; + } + + #endregion + + #region Public Methods + + /// + /// Creates and populates a new RunspaceDetails instance for the given runspace. + /// + /// The runspace for which details will be gathered. + /// A new RunspaceDetails instance. + public static RunspaceDetails Create(Runspace runspace) + { + Validate.IsNotNull(nameof(runspace), runspace); + + var runspaceLocation = RunspaceLocation.Local; + var versionDetails = PowerShellVersionDetails.GetVersionDetails(runspace); + + string connectionString = null; + + if (runspace.ConnectionInfo != null) + { + // Use 'dynamic' to avoid missing NamedPipeRunspaceConnectionInfo + // on PS v3 and v4 + try + { + dynamic connectionInfo = runspace.ConnectionInfo; + if (connectionInfo.ProcessId != null) + { + runspaceLocation = RunspaceLocation.LocalProcess; + connectionString = connectionInfo.ProcessId.ToString(); + } + } + catch (RuntimeBinderException) + { + // ProcessId property isn't on the object, move on. + } + + if (runspace.ConnectionInfo.ComputerName != "localhost") + { + runspaceLocation = RunspaceLocation.Remote; + connectionString = + runspace.ConnectionInfo.ComputerName + + (connectionString != null ? $"-{connectionString}" : string.Empty); + } + } + + return + new RunspaceDetails( + runspace, + versionDetails, + runspaceLocation, + connectionString); + } + + /// + /// Creates a clone of the given runspace through which another + /// runspace was attached. Sets the IsAttached property of the + /// resulting RunspaceDetails object to true. + /// + /// + /// The RunspaceDetails object which the new object based. + /// + /// + /// The id of the runspace that has been attached. + /// + /// + /// A new RunspaceDetails instance for the attached runspace. + /// + public static RunspaceDetails CreateAttached( + RunspaceDetails runspaceDetails, + Guid attachedRunspaceId) + { + RunspaceDetails newRunspace = + new RunspaceDetails( + runspaceDetails.Runspace, + runspaceDetails.PowerShellVersion, + runspaceDetails.Location, + runspaceDetails.ConnectionString); + + // Since this is an attached runspace, set the IsAttached + // property and carry forward the ID of the attached runspace + newRunspace.IsAttached = true; + newRunspace.Id = attachedRunspaceId; + + return newRunspace; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Session/SessionPSHost.cs b/src/PowerShellEditorServices/Session/SessionPSHost.cs index a13ab22be..7f8678694 100644 --- a/src/PowerShellEditorServices/Session/SessionPSHost.cs +++ b/src/PowerShellEditorServices/Session/SessionPSHost.cs @@ -125,11 +125,11 @@ public override void SetShouldExit(int exitCode) if (this.consoleHost != null) { this.consoleHost.ExitSession(exitCode); + } - if (this.IsRunspacePushed) - { - this.PopRunspace(); - } + if (this.IsRunspacePushed) + { + this.PopRunspace(); } } diff --git a/src/PowerShellEditorServices/Workspace/Workspace.cs b/src/PowerShellEditorServices/Workspace/Workspace.cs index 8e66f4599..998ddcdb5 100644 --- a/src/PowerShellEditorServices/Workspace/Workspace.cs +++ b/src/PowerShellEditorServices/Workspace/Workspace.cs @@ -9,6 +9,7 @@ using System.Linq; using System.IO; using System.Text; +using System.Diagnostics; namespace Microsoft.PowerShell.EditorServices { @@ -93,13 +94,23 @@ public ScriptFile GetFile(string filePath) return scriptFile; } + /// + /// Gets a new ScriptFile instance which is identified by the given file path. + /// + /// The file path for which a buffer will be retrieved. + /// A ScriptFile instance if there is a buffer for the path, null otherwise. + public ScriptFile GetFileBuffer(string filePath) + { + return this.GetFileBuffer(filePath, null); + } + /// /// Gets a new ScriptFile instance which is identified by the given file /// path and initially contains the given buffer contents. /// - /// - /// - /// + /// The file path for which a buffer will be retrieved. + /// The initial buffer contents if there is not an existing ScriptFile for this path. + /// A ScriptFile instance for the specified path. public ScriptFile GetFileBuffer(string filePath, string initialBuffer) { Validate.IsNotNullOrEmptyString("filePath", filePath); @@ -110,7 +121,7 @@ public ScriptFile GetFileBuffer(string filePath, string initialBuffer) // Make sure the file isn't already loaded into the workspace ScriptFile scriptFile = null; - if (!this.workspaceFiles.TryGetValue(keyName, out scriptFile)) + if (!this.workspaceFiles.TryGetValue(keyName, out scriptFile) && initialBuffer != null) { scriptFile = new ScriptFile( @@ -202,6 +213,36 @@ public string GetRelativePath(string filePath) return resolvedPath; } + internal static string MapRemotePathToLocal(string remotePath, string connectionString) + { + string sessionTempPath = + Path.Combine( + Path.GetTempPath(), + "PSES-" + Process.GetCurrentProcess().Id, + "RemoteFiles"); + + // The path generated by this code will look something like + // %TEMP%\PSES-[PID]\RemoteFiles\1205823508\computer-name\MyFile.ps1 + // The "path hash" is just the hashed representation of the remote + // file's full path (sans directory) to try and ensure some amount of + // uniqueness across different files on the remote machine. We put + // the "connection string" after the path slug so that it can be used + // as the differentiator string in editors like VS Code when more than + // one tab has the same filename. + + var sessionDir = Directory.CreateDirectory(sessionTempPath); + var pathHashDir = + sessionDir.CreateSubdirectory( + Path.GetDirectoryName(remotePath).GetHashCode().ToString()); + + var remoteFileDir = pathHashDir.CreateSubdirectory(connectionString); + + return + Path.Combine( + remoteFileDir.FullName, + Path.GetFileName(remotePath)); + } + #endregion #region Private Methods diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 4e8aa6ec4..51a46bab3 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using Microsoft.PowerShell.EditorServices.Debugging; using Microsoft.PowerShell.EditorServices.Utility; using System; using System.Collections.Generic; @@ -23,8 +24,8 @@ public class DebugServiceTests : IDisposable private PowerShellContext powerShellContext; private SynchronizationContext runnerContext; - private AsyncQueue debuggerStoppedQueue = - new AsyncQueue(); + private AsyncQueue debuggerStoppedQueue = + new AsyncQueue(); private AsyncQueue sessionStateQueue = new AsyncQueue(); @@ -33,7 +34,7 @@ public DebugServiceTests() this.powerShellContext = new PowerShellContext(); this.powerShellContext.SessionStateChanged += powerShellContext_SessionStateChanged; - this.workspace = new Workspace(this.powerShellContext.PowerShellVersion); + this.workspace = new Workspace(this.powerShellContext.LocalPowerShellVersion.Version); // Load the test debug file this.debugScriptFile = @@ -69,7 +70,7 @@ void debugService_BreakpointUpdated(object sender, BreakpointUpdatedEventArgs e) // TODO: Needed? } - async void debugService_DebuggerStopped(object sender, DebuggerStopEventArgs e) + async void debugService_DebuggerStopped(object sender, DebuggerStoppedEventArgs e) { await this.debuggerStoppedQueue.EnqueueAsync(e); } @@ -889,13 +890,13 @@ public async Task AssertDebuggerStopped( { SynchronizationContext syncContext = SynchronizationContext.Current; - DebuggerStopEventArgs eventArgs = + DebuggerStoppedEventArgs eventArgs = await this.debuggerStoppedQueue.DequeueAsync(); - Assert.Equal(scriptPath, eventArgs.InvocationInfo.ScriptName); + Assert.Equal(scriptPath, eventArgs.ScriptPath); if (lineNumber > -1) { - Assert.Equal(lineNumber, eventArgs.InvocationInfo.ScriptLineNumber); + Assert.Equal(lineNumber, eventArgs.LineNumber); } } diff --git a/test/PowerShellEditorServices.Test/Language/LanguageServiceTests.cs b/test/PowerShellEditorServices.Test/Language/LanguageServiceTests.cs index c303e11a4..b0de1205d 100644 --- a/test/PowerShellEditorServices.Test/Language/LanguageServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Language/LanguageServiceTests.cs @@ -33,7 +33,7 @@ public class LanguageServiceTests : IDisposable public LanguageServiceTests() { this.powerShellContext = new PowerShellContext(); - this.workspace = new Workspace(this.powerShellContext.PowerShellVersion); + this.workspace = new Workspace(this.powerShellContext.LocalPowerShellVersion.Version); this.languageService = new LanguageService(this.powerShellContext); } From 22f17e3bceeba735fd1dd672c8be34bf3498d112 Mon Sep 17 00:00:00 2001 From: David Wilson Date: Mon, 9 Jan 2017 14:06:51 -0800 Subject: [PATCH 3/3] Add RemoteFileManager to manage remote files This change adds a new RemoteFileManager class which manages files which are opened when a remote session is active. It handles the gathering of remote file contents and mapping remote files paths to a local cache. It also keeps track of the remote files which were opened in the editor and closes them once the remote session has been exited. --- .../WebsocketServerChannel.cs | 2 +- .../EditorServicesHost.cs | 3 +- .../LanguageServer/EditorCommands.cs | 7 + .../Server/DebugAdapter.cs | 14 +- .../Server/LanguageServer.cs | 6 + .../Server/LanguageServerEditorOperations.cs | 9 + .../Debugging/DebugService.cs | 134 +++----- .../Extensions/IEditorOperations.cs | 7 + .../Nano.PowerShellEditorServices.csproj | 1 + .../PowerShellEditorServices.csproj | 1 + .../Session/EditorSession.cs | 13 +- .../Session/PowerShellContext.cs | 7 + .../Session/RemoteFileManager.cs | 299 ++++++++++++++++++ .../Session/RunspaceChangedEventArgs.cs | 8 +- .../Workspace/Workspace.cs | 30 -- .../Extensions/ExtensionServiceTests.cs | 5 + 16 files changed, 414 insertions(+), 132 deletions(-) create mode 100644 src/PowerShellEditorServices/Session/RemoteFileManager.cs diff --git a/src/PowerShellEditorServices.Channel.WebSocket/WebsocketServerChannel.cs b/src/PowerShellEditorServices.Channel.WebSocket/WebsocketServerChannel.cs index f3c0741cc..07acf953a 100644 --- a/src/PowerShellEditorServices.Channel.WebSocket/WebsocketServerChannel.cs +++ b/src/PowerShellEditorServices.Channel.WebSocket/WebsocketServerChannel.cs @@ -151,7 +151,7 @@ public class DebugAdapterWebSocketConnection : EditorServiceWebSocketConnection { public DebugAdapterWebSocketConnection() { - Server = new DebugAdapter(null, null, Channel); + Server = new DebugAdapter(null, null, Channel, null); } } } diff --git a/src/PowerShellEditorServices.Host/EditorServicesHost.cs b/src/PowerShellEditorServices.Host/EditorServicesHost.cs index 4fb304e07..081ec2b2d 100644 --- a/src/PowerShellEditorServices.Host/EditorServicesHost.cs +++ b/src/PowerShellEditorServices.Host/EditorServicesHost.cs @@ -164,7 +164,8 @@ public void StartDebugService(int debugServicePort, ProfilePaths profilePaths) new DebugAdapter( hostDetails, profilePaths, - new TcpSocketServerChannel(debugServicePort)); + new TcpSocketServerChannel(debugServicePort), + this.languageServer?.EditorOperations); this.debugAdapter.SessionEnded += (obj, args) => diff --git a/src/PowerShellEditorServices.Protocol/LanguageServer/EditorCommands.cs b/src/PowerShellEditorServices.Protocol/LanguageServer/EditorCommands.cs index f97b26ee1..ee39820bd 100644 --- a/src/PowerShellEditorServices.Protocol/LanguageServer/EditorCommands.cs +++ b/src/PowerShellEditorServices.Protocol/LanguageServer/EditorCommands.cs @@ -108,6 +108,13 @@ public static readonly RequestType.Create("editor/openFile"); } + public class CloseFileRequest + { + public static readonly + RequestType Type = + RequestType.Create("editor/closeFile"); + } + public class ShowInformationMessageRequest { public static readonly diff --git a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs index d6974eb7c..15bd5e671 100644 --- a/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs +++ b/src/PowerShellEditorServices.Protocol/Server/DebugAdapter.cs @@ -4,6 +4,7 @@ // using Microsoft.PowerShell.EditorServices.Debugging; +using Microsoft.PowerShell.EditorServices.Extensions; using Microsoft.PowerShell.EditorServices.Protocol.DebugAdapter; using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol; using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol.Channel; @@ -23,21 +24,26 @@ public class DebugAdapter : DebugAdapterBase { private EditorSession editorSession; private OutputDebouncer outputDebouncer; + private bool noDebug; private bool waitingForAttach; private string scriptPathToLaunch; private string arguments; public DebugAdapter(HostDetails hostDetails, ProfilePaths profilePaths) - : this(hostDetails, profilePaths, new StdioServerChannel()) + : this(hostDetails, profilePaths, new StdioServerChannel(), null) { } - public DebugAdapter(HostDetails hostDetails, ProfilePaths profilePaths, ChannelBase serverChannel) + public DebugAdapter( + HostDetails hostDetails, + ProfilePaths profilePaths, + ChannelBase serverChannel, + IEditorOperations editorOperations) : base(serverChannel) { this.editorSession = new EditorSession(); - this.editorSession.StartDebugSession(hostDetails, profilePaths); + this.editorSession.StartDebugSession(hostDetails, profilePaths, editorOperations); this.editorSession.PowerShellContext.RunspaceChanged += this.powerShellContext_RunspaceChanged; this.editorSession.DebugService.DebuggerStopped += this.DebugService_DebuggerStopped; this.editorSession.ConsoleService.OutputWritten += this.powerShellContext_OutputWritten; @@ -184,7 +190,7 @@ protected async Task HandleLaunchRequest( // If no script is being launched, execute an empty script to // cause the prompt string to be evaluated and displayed - if (!string.IsNullOrEmpty(this.scriptPathToLaunch)) + if (string.IsNullOrEmpty(this.scriptPathToLaunch)) { await this.editorSession.PowerShellContext.ExecuteScriptString( "", false, true); diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs index 821fcdac7..9301d9a18 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs @@ -32,9 +32,15 @@ public class LanguageServer : LanguageServerBase private OutputDebouncer outputDebouncer; private LanguageServerEditorOperations editorOperations; private LanguageServerSettings currentSettings = new LanguageServerSettings(); + private Dictionary> codeActionsPerFile = new Dictionary>(); + public IEditorOperations EditorOperations + { + get { return this.editorOperations; } + } + /// /// Provides details about the host application. /// diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServerEditorOperations.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServerEditorOperations.cs index b8c1da92c..c8648660d 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServerEditorOperations.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServerEditorOperations.cs @@ -110,6 +110,15 @@ public Task OpenFile(string filePath) true); } + public Task CloseFile(string filePath) + { + return + this.messageSender.SendRequest( + CloseFileRequest.Type, + filePath, + true); + } + public string GetWorkspacePath() { return this.editorSession.Workspace.WorkspacePath; diff --git a/src/PowerShellEditorServices/Debugging/DebugService.cs b/src/PowerShellEditorServices/Debugging/DebugService.cs index 8b22ff480..220d9f2bc 100644 --- a/src/PowerShellEditorServices/Debugging/DebugService.cs +++ b/src/PowerShellEditorServices/Debugging/DebugService.cs @@ -28,14 +28,12 @@ public class DebugService private const string PsesGlobalVariableNamePrefix = "__psEditorServices_"; private PowerShellContext powerShellContext; + private RemoteFileManager remoteFileManager; // TODO: This needs to be managed per nested session private Dictionary> breakpointsPerFile = new Dictionary>(); - private Dictionary> remoteFileMappings = - new Dictionary>(); - private int nextVariableId; private List variables; private VariableContainerDetails globalScopeVariables; @@ -56,12 +54,31 @@ public class DebugService /// The PowerShellContext to use for all debugging operations. /// public DebugService(PowerShellContext powerShellContext) + : this(powerShellContext, null) + { + } + + /// + /// Initializes a new instance of the DebugService class and uses + /// the given PowerShellContext for all future operations. + /// + /// + /// The PowerShellContext to use for all debugging operations. + /// + /// + /// A RemoteFileManager instance to use for accessing files in remote sessions. + /// + public DebugService( + PowerShellContext powerShellContext, + RemoteFileManager remoteFileManager) { - Validate.IsNotNull("powerShellContext", powerShellContext); + Validate.IsNotNull(nameof(powerShellContext), powerShellContext); this.powerShellContext = powerShellContext; this.powerShellContext.DebuggerStop += this.OnDebuggerStop; this.powerShellContext.BreakpointUpdated += this.OnBreakpointUpdated; + + this.remoteFileManager = remoteFileManager; } #endregion @@ -91,12 +108,13 @@ public async Task SetLineBreakpoints( { // Make sure we're using the remote script path string scriptPath = scriptFile.FilePath; - if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote) + if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote && + this.remoteFileManager != null) { string mappedPath = - this.GetRemotePathMapping( - this.powerShellContext.CurrentRunspace, - scriptPath); + this.remoteFileManager.GetMappedPath( + scriptPath, + this.powerShellContext.CurrentRunspace); if (mappedPath == null) { @@ -764,13 +782,14 @@ private async Task FetchStackFrames() StackFrameDetails.Create(callStackFrames[i], autoVariables, localVariables); string stackFrameScriptPath = this.stackFrameDetails[i].ScriptPath; - if (this.powerShellContext.CurrentRunspace.Location == Session.RunspaceLocation.Remote && + if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote && + this.remoteFileManager != null && !string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath)) { this.stackFrameDetails[i].ScriptPath = - Workspace.MapRemotePathToLocal( + this.remoteFileManager.GetMappedPath( stackFrameScriptPath, - this.powerShellContext.CurrentRunspace.ConnectionString); + this.powerShellContext.CurrentRunspace); } } } @@ -960,34 +979,6 @@ private string FormatInvalidBreakpointConditionMessage(string condition, string return $"'{condition}' is not a valid PowerShell expression. {message}"; } - private void AddRemotePathMapping(RunspaceDetails runspaceDetails, string remotePath, string localPath) - { - Dictionary runspaceMappings = null; - - if (!this.remoteFileMappings.TryGetValue(runspaceDetails, out runspaceMappings)) - { - runspaceMappings = new Dictionary(); - this.remoteFileMappings.Add(runspaceDetails, runspaceMappings); - } - - // Add mappings in both directions - runspaceMappings[localPath.ToLower()] = remotePath; - runspaceMappings[remotePath.ToLower()] = localPath; - } - - private string GetRemotePathMapping(RunspaceDetails runspaceDetails, string localPath) - { - string remotePath = null; - Dictionary runspaceMappings = null; - - if (this.remoteFileMappings.TryGetValue(runspaceDetails, out runspaceMappings)) - { - runspaceMappings.TryGetValue(localPath.ToLower(), out remotePath); - } - - return remotePath; - } - #endregion #region Events @@ -1003,56 +994,14 @@ private async void OnDebuggerStop(object sender, DebuggerStopEventArgs e) await this.FetchStackFramesAndVariables(); // If this is a remote connection, get the file content - string localScriptPath = null; - if (this.powerShellContext.CurrentRunspace.Location == Session.RunspaceLocation.Remote) + string localScriptPath = e.InvocationInfo.ScriptName; + if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote && + this.remoteFileManager != null) { - string remoteScriptPath = e.InvocationInfo.ScriptName; - if (!string.IsNullOrEmpty(remoteScriptPath)) - { - localScriptPath = - Workspace.MapRemotePathToLocal( - remoteScriptPath, - this.powerShellContext.CurrentRunspace.ConnectionString); - - // TODO: Catch filesystem exceptions! - - // Does the local file already exist? - if (!File.Exists(localScriptPath)) - { - // Load the file contents from the remote machine and create the buffer - PSCommand command = new PSCommand(); - command.AddCommand("Microsoft.PowerShell.Management\\Get-Content"); - command.AddParameter("Path", remoteScriptPath); - command.AddParameter("Raw"); - command.AddParameter("Encoding", "Byte"); - - byte[] fileContent = - (await this.powerShellContext.ExecuteCommand(command, false, false)) - .FirstOrDefault(); - - if (fileContent != null) - { - File.WriteAllBytes(localScriptPath, fileContent); - } - else - { - Logger.Write( - LogLevel.Warning, - $"Could not load contents of remote file '{remoteScriptPath}'"); - } - - // Add the file mapping so that breakpoints can be passed through - // to the real file in the remote session - this.AddRemotePathMapping( - this.powerShellContext.CurrentRunspace, - remoteScriptPath, - localScriptPath); - this.AddRemotePathMapping( - this.powerShellContext.CurrentRunspace, - localScriptPath, - remoteScriptPath); - } - } + localScriptPath = + await this.remoteFileManager.FetchRemoteFile( + e.InvocationInfo.ScriptName, + this.powerShellContext.CurrentRunspace); } // Notify the host that the debugger is stopped @@ -1082,12 +1031,13 @@ private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e) List breakpoints; string scriptPath = lineBreakpoint.Script; - if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote) + if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote && + this.remoteFileManager != null) { string mappedPath = - this.GetRemotePathMapping( - this.powerShellContext.CurrentRunspace, - scriptPath); + this.remoteFileManager.GetMappedPath( + scriptPath, + this.powerShellContext.CurrentRunspace); if (mappedPath == null) { diff --git a/src/PowerShellEditorServices/Extensions/IEditorOperations.cs b/src/PowerShellEditorServices/Extensions/IEditorOperations.cs index 8e9358e1b..9471aec80 100644 --- a/src/PowerShellEditorServices/Extensions/IEditorOperations.cs +++ b/src/PowerShellEditorServices/Extensions/IEditorOperations.cs @@ -41,6 +41,13 @@ public interface IEditorOperations /// A Task that can be tracked for completion. Task OpenFile(string filePath); + /// + /// Causes a file to be closed in the editor. + /// + /// The path of the file to be closed. + /// A Task that can be tracked for completion. + Task CloseFile(string filePath); + /// /// Inserts text into the specified range for the file at the specified path. /// diff --git a/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj b/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj index 5d396a782..15100124c 100644 --- a/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj +++ b/src/PowerShellEditorServices/Nano.PowerShellEditorServices.csproj @@ -110,6 +110,7 @@ + diff --git a/src/PowerShellEditorServices/PowerShellEditorServices.csproj b/src/PowerShellEditorServices/PowerShellEditorServices.csproj index af65c1869..0d388e23c 100644 --- a/src/PowerShellEditorServices/PowerShellEditorServices.csproj +++ b/src/PowerShellEditorServices/PowerShellEditorServices.csproj @@ -144,6 +144,7 @@ + diff --git a/src/PowerShellEditorServices/Session/EditorSession.cs b/src/PowerShellEditorServices/Session/EditorSession.cs index ae32b796a..ad9df1451 100644 --- a/src/PowerShellEditorServices/Session/EditorSession.cs +++ b/src/PowerShellEditorServices/Session/EditorSession.cs @@ -100,12 +100,21 @@ public void StartSession(HostDetails hostDetails, ProfilePaths profilePaths) /// /// An object containing the profile paths for the session. /// - public void StartDebugSession(HostDetails hostDetails, ProfilePaths profilePaths) + /// + /// An IEditorOperations implementation used to interact with the editor. + /// + public void StartDebugSession( + HostDetails hostDetails, + ProfilePaths profilePaths, + IEditorOperations editorOperations) { // Initialize all services this.PowerShellContext = new PowerShellContext(hostDetails, profilePaths); - this.DebugService = new DebugService(this.PowerShellContext); this.ConsoleService = new ConsoleService(this.PowerShellContext); + this.DebugService = + new DebugService( + this.PowerShellContext, + new RemoteFileManager(this.PowerShellContext, editorOperations)); // Create a workspace to contain open files this.Workspace = new Workspace(this.PowerShellContext.LocalPowerShellVersion.Version); diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index 16d6b64b5..b4f7bed7d 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -786,6 +786,13 @@ public void Dispose() { RunspaceDetails poppedRunspace = this.runspaceStack.Pop(); this.CloseRunspace(poppedRunspace); + + this.OnRunspaceChanged( + this, + new RunspaceChangedEventArgs( + RunspaceChangeAction.Shutdown, + poppedRunspace, + null)); } } diff --git a/src/PowerShellEditorServices/Session/RemoteFileManager.cs b/src/PowerShellEditorServices/Session/RemoteFileManager.cs new file mode 100644 index 000000000..a2818f1d3 --- /dev/null +++ b/src/PowerShellEditorServices/Session/RemoteFileManager.cs @@ -0,0 +1,299 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices.Extensions; +using Microsoft.PowerShell.EditorServices.Utility; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Session +{ + /// + /// Manages files that are accessed from a remote PowerShell session. + /// Also manages the registration and handling of the 'psedit' function + /// in 'LocalProcess' and 'Remote' runspaces. + /// + public class RemoteFileManager + { + #region Fields + + private string processTempPath; + private PowerShellContext powerShellContext; + private IEditorOperations editorOperations; + + private Dictionary filesPerRunspace = + new Dictionary(); + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the RemoteFileManager class. + /// + /// + /// The PowerShellContext to use for file loading operations. + /// + /// + /// The IEditorOperations instance to use for opening/closing files in the editor. + /// + public RemoteFileManager( + PowerShellContext powerShellContext, + IEditorOperations editorOperations) + { + Validate.IsNotNull(nameof(powerShellContext), powerShellContext); + Validate.IsNotNull(nameof(editorOperations), editorOperations); + + this.powerShellContext = powerShellContext; + this.powerShellContext.RunspaceChanged += PowerShellContext_RunspaceChanged; + + this.editorOperations = editorOperations; + + this.processTempPath = + Path.Combine( + Path.GetTempPath(), + "PSES-" + Process.GetCurrentProcess().Id, + "RemoteFiles"); + + // Delete existing session file cache path if it already exists + try + { + if (Directory.Exists(this.processTempPath)) + { + Directory.Delete(this.processTempPath, true); + } + } + catch (IOException e) + { + Logger.Write( + LogLevel.Error, + $"Could not delete existing remote files folder for current session: {this.processTempPath}\r\n\r\n{e.ToString()}"); + } + } + + #endregion + + #region Public Methods + + /// + /// Opens a remote file, fetching its contents if necessary. + /// + /// + /// The remote file path to be opened. + /// + /// + /// The runspace from which where the remote file will be fetched. + /// + /// + /// The local file path where the remote file's contents have been stored. + /// + public async Task FetchRemoteFile( + string remoteFilePath, + RunspaceDetails runspaceDetails) + { + string localFilePath = null; + + if (!string.IsNullOrEmpty(remoteFilePath)) + { + try + { + RemotePathMappings pathMappings = this.GetPathMappings(runspaceDetails); + localFilePath = this.GetMappedPath(remoteFilePath, runspaceDetails); + + if (!pathMappings.IsRemotePathOpened(remoteFilePath)) + { + // Does the local file already exist? + if (!File.Exists(localFilePath)) + { + // Load the file contents from the remote machine and create the buffer + PSCommand command = new PSCommand(); + command.AddCommand("Microsoft.PowerShell.Management\\Get-Content"); + command.AddParameter("Path", remoteFilePath); + command.AddParameter("Raw"); + command.AddParameter("Encoding", "Byte"); + + byte[] fileContent = + (await this.powerShellContext.ExecuteCommand(command, false, false)) + .FirstOrDefault(); + + if (fileContent != null) + { + File.WriteAllBytes(localFilePath, fileContent); + } + else + { + Logger.Write( + LogLevel.Warning, + $"Could not load contents of remote file '{remoteFilePath}'"); + } + + pathMappings.AddOpenedLocalPath(localFilePath); + } + } + } + catch (IOException e) + { + Logger.Write( + LogLevel.Error, + $"Caught {e.GetType().Name} while attempting to get remote file at path '{remoteFilePath}'\r\n\r\n{e.ToString()}"); + } + } + + return localFilePath; + } + + + /// + /// For a remote or local cache path, get the corresponding local or + /// remote file path. + /// + /// + /// The remote or local file path. + /// + /// + /// The runspace from which the remote file was fetched. + /// + /// The mapped file path. + public string GetMappedPath( + string filePath, + RunspaceDetails runspaceDetails) + { + RemotePathMappings remotePathMappings = this.GetPathMappings(runspaceDetails); + return remotePathMappings.GetMappedPath(filePath); + } + + #endregion + + #region Private Methods + + private RemotePathMappings GetPathMappings(RunspaceDetails runspaceDetails) + { + RemotePathMappings remotePathMappings = null; + + if (!this.filesPerRunspace.TryGetValue(runspaceDetails, out remotePathMappings)) + { + remotePathMappings = new RemotePathMappings(runspaceDetails, this); + this.filesPerRunspace.Add(runspaceDetails, remotePathMappings); + } + + return remotePathMappings; + } + + private async void PowerShellContext_RunspaceChanged(object sender, RunspaceChangedEventArgs e) + { + if (e.ChangeAction == RunspaceChangeAction.Enter) + { + // TODO: Register psedit function and event handler + } + else + { + // Close any remote files that were opened + RemotePathMappings remotePathMappings; + if (this.filesPerRunspace.TryGetValue(e.PreviousRunspace, out remotePathMappings)) + { + foreach (string remotePath in remotePathMappings.OpenedPaths) + { + await this.editorOperations.CloseFile(remotePath); + } + } + + // TODO: Clean up psedit registration + } + } + + #endregion + + #region Nested Classes + + private class RemotePathMappings + { + private RunspaceDetails runspaceDetails; + private RemoteFileManager remoteFileManager; + private HashSet openedPaths = new HashSet(); + private Dictionary pathMappings = new Dictionary(); + + public IEnumerable OpenedPaths + { + get { return openedPaths; } + } + + public RemotePathMappings( + RunspaceDetails runspaceDetails, + RemoteFileManager remoteFileManager) + { + this.runspaceDetails = runspaceDetails; + this.remoteFileManager = remoteFileManager; + } + + public void AddPathMapping(string remotePath, string localPath) + { + // Add mappings in both directions + this.pathMappings[localPath.ToLower()] = remotePath; + this.pathMappings[remotePath.ToLower()] = localPath; + } + + public void AddOpenedLocalPath(string openedLocalPath) + { + this.openedPaths.Add(openedLocalPath); + } + + public bool IsRemotePathOpened(string remotePath) + { + return this.openedPaths.Contains(remotePath); + } + + public string GetMappedPath(string filePath) + { + string mappedPath = null; + + if (!this.pathMappings.TryGetValue(filePath.ToLower(), out mappedPath)) + { + // If the path isn't mapped yet, generate it + if (!filePath.StartsWith(this.remoteFileManager.processTempPath)) + { + mappedPath = + this.MapRemotePathToLocal( + filePath, + runspaceDetails.ConnectionString); + + this.AddPathMapping(filePath, mappedPath); + } + } + + return mappedPath; + } + + private string MapRemotePathToLocal(string remotePath, string connectionString) + { + // The path generated by this code will look something like + // %TEMP%\PSES-[PID]\RemoteFiles\1205823508\computer-name\MyFile.ps1 + // The "path hash" is just the hashed representation of the remote + // file's full path (sans directory) to try and ensure some amount of + // uniqueness across different files on the remote machine. We put + // the "connection string" after the path slug so that it can be used + // as the differentiator string in editors like VS Code when more than + // one tab has the same filename. + + var sessionDir = Directory.CreateDirectory(this.remoteFileManager.processTempPath); + var pathHashDir = + sessionDir.CreateSubdirectory( + Path.GetDirectoryName(remotePath).GetHashCode().ToString()); + + var remoteFileDir = pathHashDir.CreateSubdirectory(connectionString); + + return + Path.Combine( + remoteFileDir.FullName, + Path.GetFileName(remotePath)); + } + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs b/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs index 12f51f2a5..12dc76ade 100644 --- a/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs +++ b/src/PowerShellEditorServices/Session/RunspaceChangedEventArgs.cs @@ -20,7 +20,12 @@ public enum RunspaceChangeAction /// /// The runspace change was caused by exiting the current session. /// - Exit + Exit, + + /// + /// The runspace change was caused by shutting down the service. + /// + Shutdown } /// @@ -55,7 +60,6 @@ public RunspaceChangedEventArgs( RunspaceDetails newRunspace) { Validate.IsNotNull(nameof(previousRunspace), previousRunspace); - Validate.IsNotNull(nameof(newRunspace), newRunspace); this.ChangeAction = changeAction; this.PreviousRunspace = previousRunspace; diff --git a/src/PowerShellEditorServices/Workspace/Workspace.cs b/src/PowerShellEditorServices/Workspace/Workspace.cs index 998ddcdb5..db1affd8f 100644 --- a/src/PowerShellEditorServices/Workspace/Workspace.cs +++ b/src/PowerShellEditorServices/Workspace/Workspace.cs @@ -213,36 +213,6 @@ public string GetRelativePath(string filePath) return resolvedPath; } - internal static string MapRemotePathToLocal(string remotePath, string connectionString) - { - string sessionTempPath = - Path.Combine( - Path.GetTempPath(), - "PSES-" + Process.GetCurrentProcess().Id, - "RemoteFiles"); - - // The path generated by this code will look something like - // %TEMP%\PSES-[PID]\RemoteFiles\1205823508\computer-name\MyFile.ps1 - // The "path hash" is just the hashed representation of the remote - // file's full path (sans directory) to try and ensure some amount of - // uniqueness across different files on the remote machine. We put - // the "connection string" after the path slug so that it can be used - // as the differentiator string in editors like VS Code when more than - // one tab has the same filename. - - var sessionDir = Directory.CreateDirectory(sessionTempPath); - var pathHashDir = - sessionDir.CreateSubdirectory( - Path.GetDirectoryName(remotePath).GetHashCode().ToString()); - - var remoteFileDir = pathHashDir.CreateSubdirectory(connectionString); - - return - Path.Combine( - remoteFileDir.FullName, - Path.GetFileName(remotePath)); - } - #endregion #region Private Methods diff --git a/test/PowerShellEditorServices.Test/Extensions/ExtensionServiceTests.cs b/test/PowerShellEditorServices.Test/Extensions/ExtensionServiceTests.cs index 3ac6a22f3..8adfe64d1 100644 --- a/test/PowerShellEditorServices.Test/Extensions/ExtensionServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Extensions/ExtensionServiceTests.cs @@ -183,6 +183,11 @@ public Task OpenFile(string filePath) throw new NotImplementedException(); } + public Task CloseFile(string filePath) + { + throw new NotImplementedException(); + } + public Task InsertText(string filePath, string text, BufferRange insertRange) { throw new NotImplementedException();