-
Notifications
You must be signed in to change notification settings - Fork 236
WIP Move to runspace synchronizer from completions #980
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a19bfcf
35ed20b
849015e
f4fbcf1
1cbeb60
2fdff5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,282 @@ | ||
// | ||
// Copyright (c) Microsoft. All rights reserved. | ||
// Licensed under the MIT license. See LICENSE file in the project root for full license information. | ||
// | ||
|
||
using System; | ||
using System.Collections; | ||
using System.Collections.Generic; | ||
using System.Collections.ObjectModel; | ||
using System.Management.Automation.Runspaces; | ||
using System.Reflection; | ||
using Microsoft.PowerShell.Commands; | ||
|
||
namespace Microsoft.PowerShell.EditorServices | ||
{ | ||
using System.Management.Automation; | ||
|
||
/// <summary> | ||
/// This class is used to sync the state of one runspace to another. | ||
/// It's done by copying over variables and reimporting modules into the target runspace. | ||
/// It doesn't rely on the pipeline of the source runspace at all, instead leverages Reflection | ||
/// to access internal properties and methods on the Runspace type. | ||
/// Lastly, in order to trigger the synchronizing, you must call the Activate method. This will go | ||
/// in the PSReadLine key handler for ENTER. | ||
/// </summary> | ||
public class RunspaceSynchronizer | ||
{ | ||
private static readonly Version versionZero = new Version(0, 0); | ||
// Determines whether the HandleRunspaceStateChange event should attempt to sync the runspaces. | ||
private static bool SourceActionEnabled = false; | ||
|
||
// 'moduleCache' keeps track of all modules imported in the source Runspace. | ||
// when there is a `Import-Module -Force`, the new module object would be a | ||
// different instance with different hashcode, so we can tell if there is a | ||
// force loading of an already loaded module. | ||
private static HashSet<PSModuleInfo> moduleCache = new HashSet<PSModuleInfo>(); | ||
|
||
// 'variableCache' keeps all global scope variable names and their value type. | ||
// As long as the value type doesn't change, we don't need to update the variable | ||
// in the target Runspace, because all tab completion needs is the type information. | ||
private static Dictionary<string, object> variableCache = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase); | ||
|
||
private static Runspace sourceRunspace; | ||
private static Runspace targetRunspace; | ||
private static EngineIntrinsics sourceEngineIntrinsics; | ||
private static EngineIntrinsics targetEngineIntrinsics; | ||
|
||
private readonly static HashSet<string> POWERSHELL_MAGIC_VARIABLES = new HashSet<string>(StringComparer.OrdinalIgnoreCase) | ||
{ | ||
"PID", | ||
"PSVersionTable", | ||
"PSEdition", | ||
"PSHOME", | ||
"HOST", | ||
"true", | ||
"false", | ||
"null", | ||
"Error", | ||
"IsMacOS", | ||
"IsLinux", | ||
"IsWindows" | ||
}; | ||
|
||
/// <summary> | ||
/// Determines if the RunspaceSynchronizer has been initialized. | ||
/// </summary> | ||
public static bool IsReadyForEvents { get; private set; } | ||
|
||
#region Public methods | ||
|
||
/// <summary> | ||
/// Does a thing | ||
/// </summary> | ||
public static void InitializeRunspaces(Runspace runspaceSource, Runspace runspaceTarget) | ||
{ | ||
sourceRunspace = runspaceSource; | ||
sourceEngineIntrinsics = sourceRunspace.GetEngineIntrinsics(); | ||
targetRunspace = runspaceTarget; | ||
targetEngineIntrinsics = runspaceTarget.GetEngineIntrinsics(); | ||
IsReadyForEvents = true; | ||
|
||
sourceEngineIntrinsics.Events.SubscribeEvent( | ||
source: null, | ||
eventName: null, | ||
sourceIdentifier: PSEngineEvent.OnIdle.ToString(), | ||
data: null, | ||
handlerDelegate: HandleRunspaceStateChange, | ||
supportEvent: true, | ||
forwardEvent: false); | ||
|
||
Activate(); | ||
// Trigger events | ||
HandleRunspaceStateChange(sender: null, args: null); | ||
} | ||
|
||
/// <summary> | ||
/// Does a thing | ||
/// </summary> | ||
public static void Activate() | ||
{ | ||
SourceActionEnabled = true; | ||
} | ||
|
||
#endregion | ||
|
||
#region Private Methods | ||
|
||
private static void HandleRunspaceStateChange(object sender, PSEventArgs args) | ||
{ | ||
if (!SourceActionEnabled || sourceRunspace.Debugger.IsActive) | ||
{ | ||
return; | ||
} | ||
|
||
SourceActionEnabled = false; | ||
|
||
var newOrChangedModules = new List<PSModuleInfo>(); | ||
List<PSModuleInfo> modules = sourceRunspace.GetModules(); | ||
foreach (PSModuleInfo module in modules) | ||
{ | ||
if (moduleCache.Add(module)) | ||
{ | ||
newOrChangedModules.Add(module); | ||
} | ||
} | ||
|
||
|
||
var newOrChangedVars = new List<PSVariable>(); | ||
|
||
var variables = sourceEngineIntrinsics.GetVariables(); | ||
foreach (var variable in variables) | ||
{ | ||
// If the variable is a magic variable or it's type has not changed, then skip it. | ||
if(POWERSHELL_MAGIC_VARIABLES.Contains(variable.Name) || | ||
(variableCache.TryGetValue(variable.Name, out object value) && value == variable.Value)) | ||
{ | ||
continue; | ||
} | ||
|
||
// Add the variable to the cache and mark it as a newOrChanged variable. | ||
variableCache[variable.Name] = variable.Value; | ||
newOrChangedVars.Add(variable); | ||
} | ||
|
||
if (newOrChangedModules.Count > 0) | ||
{ | ||
// Import the modules in the targetRunspace with -Force | ||
using (PowerShell pwsh = PowerShell.Create()) | ||
{ | ||
pwsh.Runspace = targetRunspace; | ||
|
||
foreach (PSModuleInfo moduleInfo in newOrChangedModules) | ||
{ | ||
if(moduleInfo.Path != null) | ||
{ | ||
string nameParameterValue = moduleInfo.Path; | ||
// If the version is greater than zero, the module info was probably imported by the psd1 or module base. | ||
// If so, we can just import from the module base which is the root of the module folder. | ||
if (moduleInfo.Version > versionZero) | ||
{ | ||
nameParameterValue = moduleInfo.ModuleBase; | ||
} | ||
|
||
pwsh.AddCommand("Import-Module") | ||
.AddParameter("Name", nameParameterValue) | ||
.AddParameter("Force") | ||
.AddStatement(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't this make the psm1 run twice? Also in Windows PowerShell, static class methods have runspace affinity to the last runspace that processed the type definition. This second import will change the runspace that static class methods run in, even when invoked in the origin runspace. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This affinity issue has been fixed in PowerShell Core. See PowerShell/PowerShell#4209 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I did qualify it, I should have been more clear though. That said, afaik there aren't plans to drop support for it anytime soon right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, there is no plan to port the fix back to Windows PowerShell. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What I'm getting at is PSES still has to support Windows PowerShell. So it seems to me that the choices are:
Or am I missing something? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or, 4. Only apply this optimization to PowerShell Core. On Windows PowerShell, keep calling into the integrated console for intellisense operations There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to clarify, that's what I meant by item 2. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm currently now doing 2. If you're using PowerShell Core, you will get this optimization. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will only import the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Can you elaborate on this? When a module is imported through a manifest, the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @daxian-dbw for some reason... for script modules, the path points to the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I see. The A bit more logic is needed here to find out if there is a module manifest file. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can do something similar to: $psmoduleinfo = Get-Module PowerShellGet
Join-Path $psmoduleinfo.ModuleBase "$($psmoduleinfo.Name).psd1" There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Problem is you can't tell whether they've imported the One could, for instance import a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alternatively, we could override Import-Module in order to get the exact parameters used for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm now importing the module base if the version is greater than There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This won't work for argument completers that depend on module state. Also not related to this line but argument completers not loaded by a module won't be transferred either. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will cause some modules to throw. iirc a commonly installed version of the VMWare module does not handle being imported into multiple runspaces well. There's a few modules like that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Can you elaborate the scenario for this one? The assembly that implements a argument completer not getting loaded by the module? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is a valid concern. Normally a module shouldn't be made only work when importing once in a process ... but we need to evaluate how bad it would be (a black list?) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
@rjmholt thought of the idea of overriding There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Sorry that was confusing in the context of this line, I mean more generally. Like someone just dot sourcing a script or manually running There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Considering they have to run code in order to register an argument completer I don't think it's a security issue. We do automatically import modules during intellisense which could register an argument completer. That might be a security issue, but more because loading a module runs code, not necessarily related to the fact that a completer could be loaded. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I may override this cmdlet. It's not as complicated and high profile as |
||
} | ||
} | ||
|
||
pwsh.Invoke(); | ||
} | ||
} | ||
|
||
if (newOrChangedVars.Count > 0) | ||
{ | ||
// Set or update the variables. | ||
foreach (PSVariable variable in newOrChangedVars) | ||
{ | ||
targetEngineIntrinsics.SetVariable(variable); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the debugger is currently stopped in a different session state (like a break point in a module function), this will throw all of the module's state into the global state for the completion runspace. Also stepping through something like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point. Need to try out this in debugging scenario. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @rjmholt and I have brainstormed about this. For starters, we will skip synchronizing while the initial runspace is debugging. The belief here is that your main focus in debugging is reading, not writing. Unfortunately, this means stuff like Eventually, it would be nice to get to a point where after you finish debugging, it triggers a "resync -force" which blows away the target runspace and remakes it with a clean sync so we're back to what we would expect after debugging. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Writing at a breakpoint, along with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've disabled syncing while debugging. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry for the sadness, like I said, eventually I'd like to do the "trigger resync force" - both for debugging and nested prompts but it's not the highest priority for the majority of customers and I 'm not quite sure how I would approach it just yet. Any ideas? |
||
} | ||
} | ||
} | ||
|
||
#endregion | ||
} | ||
|
||
internal static class RunspaceExtensions | ||
{ | ||
private static BindingFlags bindingFlags = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Default; | ||
|
||
// Gets the modules loaded in a runspace. | ||
// This exists in runspace.ExecutionContext.Modules.GetModule(string[] patterns, bool all) | ||
internal static List<PSModuleInfo> GetModules(this Runspace runspace) | ||
{ | ||
var executionContext = typeof(Runspace) | ||
.GetProperty("ExecutionContext", bindingFlags) | ||
.GetValue(runspace); | ||
var ModuleIntrinsics = executionContext.GetType() | ||
.GetProperty("Modules", bindingFlags) | ||
.GetValue(executionContext); | ||
var modules = ModuleIntrinsics.GetType() | ||
.GetMethod("GetModules", bindingFlags, null, new Type[] { typeof(string[]), typeof(bool) }, null) | ||
.Invoke(ModuleIntrinsics, new object[] { new string[] { "*" }, false }) as List<PSModuleInfo>; | ||
return modules; | ||
} | ||
|
||
// Gets the engine intrinsics object on a Runspace. | ||
// This exists in runspace.ExecutionContext.EngineIntrinsics. | ||
internal static EngineIntrinsics GetEngineIntrinsics(this Runspace runspace) | ||
{ | ||
var executionContext = typeof(Runspace) | ||
.GetProperty("ExecutionContext", bindingFlags) | ||
.GetValue(runspace); | ||
var engineIntrinsics = executionContext.GetType() | ||
.GetProperty("EngineIntrinsics", bindingFlags) | ||
.GetValue(executionContext) as EngineIntrinsics; | ||
return engineIntrinsics; | ||
} | ||
} | ||
|
||
// Extension methods on EngineIntrinsics to streamline some setters and setters. | ||
internal static class EngineIntrinsicsExtensions | ||
{ | ||
private const int RETRY_ATTEMPTS = 3; | ||
internal static List<PSVariable> GetVariables(this EngineIntrinsics engineIntrinsics) | ||
{ | ||
List<PSVariable> variables = new List<PSVariable>(); | ||
foreach (PSObject psobject in engineIntrinsics.GetItems(ItemProviderType.Variable)) | ||
{ | ||
var variable = (PSVariable) psobject.BaseObject; | ||
variables.Add(variable); | ||
} | ||
return variables; | ||
} | ||
|
||
internal static void SetVariable(this EngineIntrinsics engineIntrinsics, PSVariable variable) | ||
{ | ||
engineIntrinsics.SetItem(ItemProviderType.Variable, variable.Name, variable.Value); | ||
} | ||
|
||
private static Collection<PSObject> GetItems(this EngineIntrinsics engineIntrinsics, ItemProviderType itemType) | ||
{ | ||
for (int i = 0; i < RETRY_ATTEMPTS; i++) | ||
{ | ||
try | ||
{ | ||
return engineIntrinsics.InvokeProvider.Item.Get($@"{itemType.ToString()}:\*"); | ||
} | ||
catch(Exception) | ||
{ | ||
// InvokeProvider.Item.Get is not threadsafe so let's try a couple times | ||
// to get results from it. | ||
} | ||
} | ||
return new Collection<PSObject>(); | ||
} | ||
|
||
private static void SetItem(this EngineIntrinsics engineIntrinsics, ItemProviderType itemType, string name, object value) | ||
{ | ||
for (int i = 0; i < RETRY_ATTEMPTS; i++) | ||
{ | ||
try | ||
{ | ||
engineIntrinsics.InvokeProvider.Item.Set($@"{itemType}:\{name}", value); | ||
return; | ||
} | ||
catch (Exception) | ||
{ | ||
// InvokeProvider.Item.Set is not threadsafe so let's try a couple times to set. | ||
} | ||
} | ||
} | ||
|
||
private enum ItemProviderType | ||
{ | ||
Variable, | ||
Function, | ||
Alias | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe this should go somewhere else...