|
| 1 | +--- |
| 2 | +RFC: RFC0043 |
| 3 | +Author: Dongbo Wang |
| 4 | +Status: Withdrawn |
| 5 | +SupercededBy: N/A |
| 6 | +Version: 1.0 |
| 7 | +Area: Microsoft.PowerShell.Core |
| 8 | +Comments Due: 4/10/2019 |
| 9 | +Plan to implement: No |
| 10 | +--- |
| 11 | + |
| 12 | +# Loading Modules into Isolated AssemblyLoadContext |
| 13 | + |
| 14 | +Assembly isolation is a missing feature in PowerShell. |
| 15 | +Today, a PowerShell process loads all assemblies in the default load context. |
| 16 | +When an assembly is already loaded, it's not allowed to load the same assembly again with a different version. |
| 17 | +This results in the PowerShell issue [#2083](https://github.com/PowerShell/PowerShell/issues/2083). |
| 18 | +Basically, when two modules depend on different versions of the same assembly, |
| 19 | +if the module that depends on the lower version is loaded first, |
| 20 | +the other module cannot be loaded in the same session anymore. |
| 21 | + |
| 22 | +There are two layers of assembly isolation that we can consider to support |
| 23 | + |
| 24 | +- Runspace level: Load PowerShell assemblies into a custom `AssemblyLoadContext`, |
| 25 | + then create a Runspace via Reflection within that `AssemblyLoadContext`. |
| 26 | + So that Runspace works with its own set of PowerShell assemblies in that load context, |
| 27 | + and all assemblies loaded in that Runspace also get loaded into that load context |
| 28 | + (Except for `Assembly.LoadFrom` and `Assembly.LoadFile`. |
| 29 | + `LoadFrom` always loads an assembly to the default load context; |
| 30 | + `LoadFile` always loads an assembly into a new load context). |
| 31 | + |
| 32 | +- Module level: Load a module into a custom `AssemblyLoadContext`, |
| 33 | + the module required assemblies as well as all the assemblies loaded by `Add-Type` in the module script will be loaded into that load context. |
| 34 | + |
| 35 | +Note: _This RFC focuses on the module-level isolation, **but it's written with the purpose to be withdrawn**._ |
| 36 | + _While researching the feasibility of this idea, I realized that it's impossible to have it well supported due to the challenges discussed below._ |
| 37 | + _This RFC serves as a summary of the research to explain the reasons why we will not proceed with this idea._ |
| 38 | + |
| 39 | +## Motivation |
| 40 | + |
| 41 | + As a PowerShell user, I'm able to use modules that depend on different versions of the same assembly in the same session. |
| 42 | + |
| 43 | +## Specification |
| 44 | + |
| 45 | +### Import-Module -Isolated |
| 46 | + |
| 47 | +The idea is simple: Add the switch parameter `-Isolated` to `Import-Module`. |
| 48 | +When specified, PowerShell creates a custom `AssemblyLoadContext` (ALC) instance and makes sure all assembly loadings for and within that module, |
| 49 | +except for explicit user calls to `Assembly.LoadFrom` and `Assembly.LoadFile`, |
| 50 | +load the assemblies into that custom load context. |
| 51 | +The `ModuleInfo` object will hold a reference to the load context used for this module. |
| 52 | + |
| 53 | +PowerShell assembly loading code and `Add-Type` needs to be changed to not call `Assembly.LoadFrom`. |
| 54 | +Instead, PowerShell needs to call `LoadFromAssemblyPath` on the corresponding load context instance: |
| 55 | + |
| 56 | +- It queries for the current `EngineSessionState` to see if it's from an isolated module; |
| 57 | +- If yes, then the load context of that module is used, otherwise, default load context `AssemblyLoadContext.Default` is used. |
| 58 | + |
| 59 | +_This means we will have type identity issues within a session_ -- type C and C' are the same type in terms of the fully-qualified type name, |
| 60 | +but they are from two different assembly instances that are loaded into two load contexts. |
| 61 | +This would raise multiple folds of challenges that will be discussed below |
| 62 | + |
| 63 | +### Interoperability |
| 64 | + |
| 65 | +Imagine the assemblies A and B are loaded into ALC-M (isolated module M). |
| 66 | +Assembly A exposes a cmdlet `Demo-Cmdlet` that has a parameter accepting an object of type B::C. |
| 67 | +Let's say a different version of B is loaded into the default-ACL, and the result assembly instance is B'. |
| 68 | +So, from the default-ACL (default Runspace), one can create an object of B'::C' and call `Demo-Cmdlet` with it. |
| 69 | +That call will result in a type casting exception with the confusing error like "cannot cast B::C to B::C". |
| 70 | + |
| 71 | +This is because `B'::C'` and `B::C` are two different types, |
| 72 | +even though they have the same fully-qualified names and maybe members. |
| 73 | +This is the interoperability issue that we will face once PowerShell allow modules to be loaded into different load contexts. |
| 74 | + |
| 75 | +This problem can be even worse when it comes to how `Import-Module M -Isolated` deals with M's nested modules and required modules. |
| 76 | +Unless having all nested and required modules loaded into the same custom load context, |
| 77 | +there could be interoperability issues when M calls cmdlets from its nested modules or required modules. |
| 78 | + |
| 79 | +#### Mitigation by updating Type Resolution |
| 80 | + |
| 81 | +We can mitigate the interoperability issue by enhance the `TypeResolver` to make it aware of the current `EngineSessionState` for a type resolution operation. |
| 82 | +If it happens in the scope of a isolated module, |
| 83 | +then `TypeResolver` first searches assemblies in the associated custom-ALC, |
| 84 | +followed by searching in the default-ALC. |
| 85 | + |
| 86 | +We also need to support a new syntax for the type reference in PowerShell: |
| 87 | + |
| 88 | +```powershell |
| 89 | +[ModuleName\TypeName] |
| 90 | +``` |
| 91 | + |
| 92 | +When the `ModuleName` part is specified, PowerShell can check if such a module exists and whether it has a custom load context. |
| 93 | +If it's a module with a custom load context, then the type resolution will search assemblies from the custom-ALC and then the default-ALC to find the `TypeName`. |
| 94 | +Otherwise, the type resolution will search the default-ALC only. |
| 95 | + |
| 96 | +So in the above imagined scenario, it's possible to make it work as expected if you construct the object using `[M\B::C]` from the default-ALC. |
| 97 | +But it's non-intuitive and I think many existing scripts may fail when modules started to be loaded in separate load contexts. |
| 98 | + |
| 99 | +### Caching Problem |
| 100 | + |
| 101 | +PowerShell has a lot caches related to `System.Type`. |
| 102 | +Some have `Type` as the value, such as the cache in `TypeResolver` and the type cache for `TypeNameAst`. |
| 103 | +Some have `Type` as the key, such as the member tables in `DotNetAdapter` and the binder caches in DLR. |
| 104 | + |
| 105 | +For the former category, the cache data structure needs to be updated so as to make sure the cache is associated with a load context. |
| 106 | +So for example, when type resolution happens for `[C]` that is running from an isolated module M, |
| 107 | +then `TypeResolver` should use the cache associated with ALC-M. |
| 108 | + |
| 109 | +Another example, if a `TypeNameAst` has resolve the type via reflection in default-ALC, |
| 110 | +and the `ScriptBlock` containing that `TypeNameAst` is being executed in the context of an isolated module M, |
| 111 | +then the cached type referred by `TypeNameAst` should be voided and another resolution should be triggered. |
| 112 | +Similarly, if the `ScriptBlock` has already been compiled into LINQ expression tree and have the delegate generated, |
| 113 | +then the cached delegate needs to be voided and another compilation needs to be enforced to make sure type resolution is correct. |
| 114 | + |
| 115 | +For the latter category, theoretically they will continue to work without change, |
| 116 | +because types loaded into different load contexts are different. |
| 117 | +Initially, I think we still need to replace the cache data structure with something like `ConditionalWeakTable<AssemblyLoadContext, ...>`, |
| 118 | +so that a load context can be GC collected after the corresponding module is removed. |
| 119 | +However, it turns out it's unlikely that the module load contexts can be reclaimed at all in PowerShell. |
| 120 | +We will visit that topic in the next section. |
| 121 | + |
| 122 | +As you can imagine, it will be a huge work item to just getting the current caching correct. |
| 123 | +And besides, the bookkeeping to make sure caches from the first category are properly voided and rebuilt will be expensive and hurt performance. |
| 124 | + |
| 125 | +### Cannot GC Module's AssemblyLoadContext |
| 126 | + |
| 127 | +.NET Core 3.0 supports unloading an `AssemblyLoadContext` and all assemblies in it. |
| 128 | +Ideally, we would like to reclaim the load context associated with an isolated module when the module is removed. |
| 129 | +However, it's very likely not possible no matter how we update our caches to avoid holding an `AssemblyLoadContext` instance indefinitely. |
| 130 | +This is because Dynamic Language Runtime (DLR) also holds types in its cache, indefinitely. |
| 131 | + |
| 132 | +The DLR types live in the default-ALC, and it will generate a `DynamicMetaObject` object for a late-binding operation, |
| 133 | +which is something like a key-value pair `<restrictions, expression>` that will be held in the DLR caches. |
| 134 | +This is a perf improvement feature for dynamic languages like PowerShell because it allows you to skip an expensive late-binding operation when the `restrictions` matches. |
| 135 | +That key-value pair will be held indefinitely, so once types from a custom load context is involved in a late binding operation, such as property access, method invocation and etc, |
| 136 | +it will be rooted in the GC Heap by the DLR cache, and the custom load context cannot be unloaded. |
| 137 | + |
| 138 | +## Summary |
| 139 | + |
| 140 | +To summarize, supporting the module-level isolation doesn't seem to be the right way to go. |
| 141 | +The interoperability issues caused by type identity would result in confusing errors that are not intuitive to resolve. |
| 142 | +The caching issues caused by the necessary changes to `TypeResolver` would make the code hard to maintain and slower to run. |
| 143 | + |
| 144 | +## Alternate Proposals and Considerations |
| 145 | + |
| 146 | +### Runspace-level Isolation |
| 147 | + |
| 148 | +The Runspace-level isolation seems to be more promising. |
| 149 | + |
| 150 | +- The whole Runspace and all assemblies get loaded in the Runspace are isolated in a custom load context. |
| 151 | + So interoperability will not be a problem unless you receive an object by invoking something on another isolated Runspace. |
| 152 | +- The existing caching needs no change. |
| 153 | + The `TypeResolver` implementation needs to be updated to first search assemblies in the Runspace load context, |
| 154 | + then search in the default load context. |
| 155 | +- Runspace load context can be reclaimed after the Runspace is closed, |
| 156 | + because the Runspace is not held on by anything other than the user code. |
| 157 | +- It fits in the server-application model better. |
| 158 | + Imagine we want to make a server process for PowerShell, |
| 159 | + and any hosts can connect to the server and request for a new session. |
| 160 | + It would be a requirement that an isolated session is created for each host so they are not interfered with each other. |
| 161 | + The Runspace-level isolation would be perfect in this scenario, |
| 162 | + since one server process would be able to satisfy multiple hosts by creating isolated sessions for them. |
| 163 | + |
| 164 | +### About the scenario called out in Motivation |
| 165 | + |
| 166 | +However, the Runspace-level isolation doesn't help the motivation called out above, |
| 167 | +because it's still not possible to load same assemblies with different versions in the same Runspace load context. |
| 168 | + |
| 169 | +There is probably an easier way to address that scenario -- register a handler to `AssemblyLoadContext.AssemblyResolve` event. |
| 170 | +The resolving process is as follows: |
| 171 | + |
| 172 | +- When `Assembly.Load` fails, the `AssemblyResolve` event will be triggered. |
| 173 | + - The event arg passed in contains the requesting assembly, |
| 174 | + so we can get the directory where the requesting assembly locates. |
| 175 | + - Search in that particular directory to find the exact match of the requested assembly. |
| 176 | + - if found, load it into the load context of the requesting assembly. |
| 177 | + |
| 178 | +Yes, this can also cause interoperability issues, |
| 179 | +but the chance that a user is affected by it should be low compared to supporting `Import-Module -Isolated`, |
| 180 | +because only the conflicting assembly is loaded into a separate load context. |
| 181 | + |
| 182 | +By far, only the assembly `Newtonsoft.Json` is reported as the conflicting assembly among different modules, |
| 183 | +so chances are only this assembly will be loaded into a separate load context for most cases. |
0 commit comments