From 4be89f5644047ea55cb010e50a3928bc4642a6f4 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 3 Oct 2026 21:59:16 +0300 Subject: [PATCH 1/3] Improve ALC --- resources/RegisterPnPAssemblyResolver.ps1 | 27 ++++++++++------- .../Base/PnPPowerShellModuleInitializer.cs | 30 ++++++++++++++----- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/resources/RegisterPnPAssemblyResolver.ps1 b/resources/RegisterPnPAssemblyResolver.ps1 index f07ed8b45..c5047d975 100644 --- a/resources/RegisterPnPAssemblyResolver.ps1 +++ b/resources/RegisterPnPAssemblyResolver.ps1 @@ -11,19 +11,24 @@ # We load the module assembly by explicit path (so no resolver is needed to find it) and invoke its public # registration entry point, which wires up the resolver on the default AssemblyLoadContext. Registration is # idempotent, so running this on every import is harmless. +# +# ScriptsToProcess runs in the caller's session state, so the body runs in a child scope to keep its variables +# out of the user's session. -$moduleRoot = $PSScriptRoot -$coreAssemblyPath = Join-Path -Path $moduleRoot -ChildPath 'Core/PnP.PowerShell.dll' +& { + $moduleRoot = $PSScriptRoot + $coreAssemblyPath = Join-Path -Path $moduleRoot -ChildPath 'Core/PnP.PowerShell.dll' -if (Test-Path -LiteralPath $coreAssemblyPath) { - $assembly = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath($coreAssemblyPath) - $initializerType = $assembly.GetType('PnP.PowerShell.Commands.Base.PnPPowerShellModuleInitializer') - if ($null -ne $initializerType) { - $registerMethod = $initializerType.GetMethod( - 'EnsureDependencyResolverRegistered', - [System.Reflection.BindingFlags]'Public, Static') - if ($null -ne $registerMethod) { - [void]$registerMethod.Invoke($null, $null) + if (Test-Path -LiteralPath $coreAssemblyPath) { + $assembly = [System.Runtime.Loader.AssemblyLoadContext]::Default.LoadFromAssemblyPath($coreAssemblyPath) + $initializerType = $assembly.GetType('PnP.PowerShell.Commands.Base.PnPPowerShellModuleInitializer') + if ($null -ne $initializerType) { + $registerMethod = $initializerType.GetMethod( + 'EnsureDependencyResolverRegistered', + [System.Reflection.BindingFlags]'Public, Static') + if ($null -ne $registerMethod) { + [void]$registerMethod.Invoke($null, $null) + } } } } diff --git a/src/Commands/Base/PnPPowerShellModuleInitializer.cs b/src/Commands/Base/PnPPowerShellModuleInitializer.cs index f5a58291a..d1d351508 100644 --- a/src/Commands/Base/PnPPowerShellModuleInitializer.cs +++ b/src/Commands/Base/PnPPowerShellModuleInitializer.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Management.Automation; using System.Reflection; using System.Runtime.CompilerServices; @@ -16,9 +18,9 @@ namespace PnP.PowerShell.Commands.Base /// default so its cmdlet types remain discoverable. Every other /// assembly we ship lives in the sibling "Common" folder and is loaded into a dedicated, fully /// isolated . A single - /// handler on the default context routes any assembly we ship into that private context; from there the - /// private context resolves the entire transitive graph internally, so our Microsoft.Extensions.* (and - /// friends) can never bind against a version the host process already loaded. + /// handler on the default context routes the private dependencies this assembly references into that + /// private context; from there the private context resolves the entire transitive graph internally, so our + /// Microsoft.Extensions.* (and friends) can never bind against a version the host process already loaded. /// /// The handler MUST be registered before PowerShell reflects over this assembly to discover cmdlets, /// because the cmdlet base types statically reference PnP.Framework/PnP.Core and reflection therefore @@ -42,6 +44,14 @@ public sealed class PnPPowerShellModuleInitializer : IModuleAssemblyInitializer /// private static readonly string s_dependencyPath; + /// + /// Simple names of the assemblies this assembly references directly. These are the only requests from the + /// default context that are ours to answer: everything they depend on is resolved inside the private + /// context by . The resolver is process-wide, so without this filter it + /// would hand our private copies to any other module that fails to resolve an assembly we happen to ship. + /// + private static readonly HashSet s_referencedAssemblyNames; + /// /// Guards against registering the resolver more than once (module initializer + OnImport + re-import). /// @@ -56,8 +66,12 @@ static PnPPowerShellModuleInitializer() { // This assembly (PnP.PowerShell.dll) ships in "/Core"; the private dependency graph ships // in the sibling "/Common" folder. - string executingDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + Assembly executingAssembly = Assembly.GetExecutingAssembly(); + string executingDirectory = Path.GetDirectoryName(executingAssembly.Location); s_dependencyPath = Path.GetFullPath(Path.Combine(executingDirectory, "..", "Common")); + s_referencedAssemblyNames = new HashSet( + executingAssembly.GetReferencedAssemblies().Select(reference => reference.Name), + StringComparer.OrdinalIgnoreCase); // In-IDE (Visual Studio F5) debugging imports the raw build output, where every dependency sits in // the same folder as this assembly. PowerShell's own directory probing already resolves that whole @@ -96,13 +110,13 @@ public void OnImport() } /// - /// Default-context resolver. When the default context cannot satisfy an assembly, we check whether we - /// ship it. If so, we hand it to the private context; otherwise we return null and let the runtime - /// continue its normal resolution (shared framework, PowerShell, host). + /// Default-context resolver. When the default context cannot satisfy an assembly this assembly references, + /// we check whether we ship it. If so, we hand it to the private context; otherwise we return null + /// and let the runtime continue its normal resolution (shared framework, PowerShell, host). /// private static Assembly ResolveDependency(AssemblyLoadContext defaultContext, AssemblyName assemblyName) { - if (string.IsNullOrEmpty(assemblyName?.Name)) + if (string.IsNullOrEmpty(assemblyName?.Name) || !s_referencedAssemblyNames.Contains(assemblyName.Name)) { return null; } From a29fecf52410029f1b36438574fa7fff4f744692 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 3 Oct 2026 22:27:56 +0300 Subject: [PATCH 2/3] Refactor assembly reference handling to use versioned dictionary for better dependency resolution --- .../Base/PnPPowerShellModuleInitializer.cs | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/Commands/Base/PnPPowerShellModuleInitializer.cs b/src/Commands/Base/PnPPowerShellModuleInitializer.cs index 933341c1a..0f2d2dbb9 100644 --- a/src/Commands/Base/PnPPowerShellModuleInitializer.cs +++ b/src/Commands/Base/PnPPowerShellModuleInitializer.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Management.Automation; using System.Reflection; using System.Runtime.CompilerServices; @@ -45,12 +44,14 @@ public sealed class PnPPowerShellModuleInitializer : IModuleAssemblyInitializer private static readonly string s_dependencyPath; /// - /// Simple names of the assemblies this assembly references directly. These are the only requests from the - /// default context that are ours to answer: everything they depend on is resolved inside the private - /// context by . The resolver is process-wide, so without this filter it - /// would hand our private copies to any other module that fails to resolve an assembly we happen to ship. + /// Versions of the assemblies this assembly references directly, keyed by simple name. These references are the + /// only requests from the default context that are ours to answer: everything they depend on is resolved inside + /// the private context by . The resolver is process-wide and its event does + /// not say which assembly made the request, so a request is only answered when both its name and its version + /// match one of our references. Other modules shipping the same assemblies (MSAL above all) build against their + /// own versions, so their requests fall through to their own resolution instead of receiving our private copies. /// - private static readonly HashSet s_referencedAssemblyNames; + private static readonly Dictionary s_referencedAssemblyVersions; /// /// Guards against registering the resolver more than once (module initializer + OnImport + re-import). @@ -69,9 +70,11 @@ static PnPPowerShellModuleInitializer() Assembly executingAssembly = Assembly.GetExecutingAssembly(); string executingDirectory = Path.GetDirectoryName(executingAssembly.Location); s_dependencyPath = Path.GetFullPath(Path.Combine(executingDirectory, "..", "Common")); - s_referencedAssemblyNames = new HashSet( - executingAssembly.GetReferencedAssemblies().Select(reference => reference.Name), - StringComparer.OrdinalIgnoreCase); + s_referencedAssemblyVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (AssemblyName reference in executingAssembly.GetReferencedAssemblies()) + { + s_referencedAssemblyVersions.TryAdd(reference.Name, reference.Version); + } // In-IDE (Visual Studio F5) debugging imports the raw build output, where every dependency sits in // the same folder as this assembly. PowerShell's own directory probing already resolves that whole @@ -117,7 +120,7 @@ public void OnImport() /// private static Assembly ResolveDependency(AssemblyLoadContext defaultContext, AssemblyName assemblyName) { - if (string.IsNullOrEmpty(assemblyName?.Name) || !s_referencedAssemblyNames.Contains(assemblyName.Name)) + if (!IsReferencedByThisAssembly(assemblyName)) { return null; } @@ -143,5 +146,16 @@ private static Assembly ResolveDependency(AssemblyLoadContext defaultContext, As // copies, isolated from whatever the host already loaded into the default context. return s_dependencyContext.LoadFromAssemblyName(assemblyName); } + + /// + /// True when the requested assembly is one this assembly references, at the version it references. A request + /// for the same name at another version comes from another module and is left to that module's resolution. + /// + private static bool IsReferencedByThisAssembly(AssemblyName assemblyName) + { + return !string.IsNullOrEmpty(assemblyName?.Name) + && s_referencedAssemblyVersions.TryGetValue(assemblyName.Name, out Version referencedVersion) + && assemblyName.Version == referencedVersion; + } } } From 5bcb2df14b8eca19563e848f42f54ef19ae00623 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 3 Oct 2026 22:36:52 +0300 Subject: [PATCH 3/3] Clarify comments on assembly resolution and interception behavior in PnPPowerShellModuleInitializer --- src/Commands/Base/PnPPowerShellModuleInitializer.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Commands/Base/PnPPowerShellModuleInitializer.cs b/src/Commands/Base/PnPPowerShellModuleInitializer.cs index 0f2d2dbb9..2b5bbea7f 100644 --- a/src/Commands/Base/PnPPowerShellModuleInitializer.cs +++ b/src/Commands/Base/PnPPowerShellModuleInitializer.cs @@ -48,8 +48,9 @@ public sealed class PnPPowerShellModuleInitializer : IModuleAssemblyInitializer /// only requests from the default context that are ours to answer: everything they depend on is resolved inside /// the private context by . The resolver is process-wide and its event does /// not say which assembly made the request, so a request is only answered when both its name and its version - /// match one of our references. Other modules shipping the same assemblies (MSAL above all) build against their - /// own versions, so their requests fall through to their own resolution instead of receiving our private copies. + /// match one of our references. This narrows the interception rather than isolating it: another module asking + /// for one of these assemblies (MSAL above all) at another version falls through to its own resolution, but one + /// asking for the very same version cannot be told apart from us and still receives our private copy. /// private static readonly Dictionary s_referencedAssemblyVersions; @@ -149,7 +150,7 @@ private static Assembly ResolveDependency(AssemblyLoadContext defaultContext, As /// /// True when the requested assembly is one this assembly references, at the version it references. A request - /// for the same name at another version comes from another module and is left to that module's resolution. + /// for the same name at another version is not ours and is left to the requester's own resolution. /// private static bool IsReferencedByThisAssembly(AssemblyName assemblyName) {